blob: a4a4bc5f8903db12f2714b0bd4caa3abab94a3b7 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Apache Artifacts Verification Script
Comprehensive verification tool for Apache release artifacts.
Checks signatures, checksums, archive integrity, license metadata,
reproducible rebuilds, and Apache RAT results.
Usage:
python scripts/verify_apache_artifacts.py --help
Show the available verification commands and flags.
python scripts/verify_apache_artifacts.py signatures
Verify detached GPG signatures, SHA512 checksums, and basic archive readability.
python scripts/verify_apache_artifacts.py artifacts
Verify required LICENSE/NOTICE/DISCLAIMER files in release artifacts.
python scripts/verify_apache_artifacts.py licenses --rat-jar /path/to/apache-rat.jar
Run Apache RAT and validate license-report results for extracted tarball contents.
python scripts/verify_apache_artifacts.py reproducible
Rebuild from the release source artifact and compare rebuilt outputs to release artifacts.
python scripts/verify_apache_artifacts.py all --rat-jar /path/to/apache-rat.jar --vote-email
Run the full verification flow and optionally render a vote email draft from the results.
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import importlib.util
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import textwrap
import xml.etree.ElementTree as ET
import zipfile
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
# Configuration
PROJECT_SHORT_NAME = "burr"
REQUIRED_TEXT_FILES = ("LICENSE", "NOTICE", "DISCLAIMER")
WHEEL_LICENSE_FILES = ("LICENSE-wheel",)
WHEEL_REQUIRED_TEXT_FILES = ("NOTICE", "DISCLAIMER") + WHEEL_LICENSE_FILES
PASS = "PASS"
FAIL = "FAIL"
SKIP = "SKIP"
@dataclass
class CheckResult:
name: str
status: str
details: str = ""
@dataclass
class VerificationSummary:
results: list[CheckResult] = field(default_factory=list)
def record(self, name: str, status: str, details: str = "") -> None:
self.results.append(CheckResult(name=name, status=status, details=details))
def pass_(self, name: str, details: str = "") -> None:
self.record(name, PASS, details)
def fail(self, name: str, details: str = "") -> None:
self.record(name, FAIL, details)
def skip(self, name: str, details: str = "") -> None:
self.record(name, SKIP, details)
@property
def ok(self) -> bool:
return all(result.status != FAIL for result in self.results)
def render(self) -> str:
lines = ["Results:"]
if not self.results:
lines.append(" (no checks executed)")
return "\n".join(lines)
width = max(len(result.name) for result in self.results)
for result in self.results:
symbol = {"PASS": "✅", "FAIL": "❌", "SKIP": "⊘"}[result.status]
line = f" {result.name:<{width}} {symbol} {result.status}"
if result.details:
line += f" {result.details}"
lines.append(line)
return "\n".join(lines)
def _fail(message: str) -> None:
print(f"\n❌ {message}")
sys.exit(1)
def _print_section(title: str) -> None:
print("\n" + "=" * 80)
print(f" {title}")
print("=" * 80 + "\n")
def _sha512_for_file(path: str) -> str:
sha512_hash = hashlib.sha512()
with open(path, "rb") as handle:
while chunk := handle.read(65536):
sha512_hash.update(chunk)
return sha512_hash.hexdigest()
def _read_expected_text_files() -> dict[str, bytes]:
project_root = Path(__file__).resolve().parent.parent
expected: dict[str, bytes] = {}
for filename in (*REQUIRED_TEXT_FILES, *WHEEL_LICENSE_FILES):
expected[filename] = (project_root / filename).read_bytes()
return expected
def _artifact_files(artifacts_dir: str) -> list[str]:
all_files = [
name
for name in os.listdir(artifacts_dir)
if os.path.isfile(os.path.join(artifacts_dir, name))
]
return sorted(
name
for name in all_files
if not name.endswith((".asc", ".sha512")) and not name.startswith("rat-report-")
)
def _top_level_prefix(paths: list[str]) -> str | None:
parts = [PurePosixPath(path).parts for path in paths if path]
if not parts:
return None
prefix = parts[0][0]
if all(item and item[0] == prefix for item in parts):
return prefix
return None
def _normalize_archive_member_names(names: list[str]) -> dict[str, str]:
prefix = _top_level_prefix(names)
normalized = {}
for name in names:
pure_name = PurePosixPath(name)
relative_parts = (
pure_name.parts[1:] if prefix and pure_name.parts[:1] == (prefix,) else pure_name.parts
)
normalized_name = str(PurePosixPath(*relative_parts)) if relative_parts else ""
normalized[normalized_name] = name
return normalized
def _tar_file_bytes(artifact_path: str) -> dict[str, bytes]:
with tarfile.open(artifact_path, "r:gz") as tar:
file_members = [member for member in tar.getmembers() if member.isfile()]
mapping = _normalize_archive_member_names([member.name for member in file_members])
contents: dict[str, bytes] = {}
for member in file_members:
normalized_name = next(
normalized for normalized, original in mapping.items() if original == member.name
)
extracted = tar.extractfile(member)
if extracted is None:
continue
contents[normalized_name] = extracted.read()
return contents
def _wheel_file_bytes(artifact_path: str) -> dict[str, bytes]:
with zipfile.ZipFile(artifact_path, "r") as wheel:
return {name: wheel.read(name) for name in wheel.namelist() if not name.endswith("/")}
def _wheel_content_hashes(wheel_path: str) -> dict[str, str]:
"""Return {member_path: sha256_hex} for all non-directory members of a wheel.
RECORD is excluded because it is a manifest that lists other files' hashes.
Two wheels built from identical source at different times will produce
different RECORD files, but their other content will be the same.
"""
result: dict[str, str] = {}
with zipfile.ZipFile(wheel_path, "r") as zf:
for name in zf.namelist():
if name.endswith("/"):
continue # directory entry — no content to hash
if PurePosixPath(name).name == "RECORD":
continue # manifest of other files' hashes — legitimately differs
result[name] = hashlib.sha256(zf.read(name)).hexdigest()
return result
def _compare_wheel_contents(wheel_a: str, wheel_b: str) -> tuple[bool, list[str]]:
"""Compare two wheels by file content hash, ignoring zip metadata and RECORD.
Returns (all_match, list_of_difference_descriptions). Uses content hashes
rather than whole-file SHA because zip timestamps make binary comparison
fail for wheels built from the same source at different times.
"""
hashes_a = _wheel_content_hashes(wheel_a)
hashes_b = _wheel_content_hashes(wheel_b)
name_a = os.path.basename(wheel_a)
name_b = os.path.basename(wheel_b)
diffs: list[str] = []
for key in sorted(set(hashes_a) | set(hashes_b)):
if key not in hashes_b:
diffs.append(f"only in {name_a}: {key}")
elif key not in hashes_a:
diffs.append(f"only in {name_b}: {key}")
elif hashes_a[key] != hashes_b[key]:
diffs.append(f"content differs: {key}")
return len(diffs) == 0, diffs
def _find_files_by_basename(file_bytes: dict[str, bytes], basename: str) -> list[str]:
matches = []
for path in file_bytes:
if PurePosixPath(path).name == basename:
matches.append(path)
return sorted(matches)
def _verify_artifact_exists(
artifact_path: str, summary: VerificationSummary, min_size: int = 1000
) -> bool:
name = f"Artifact exists: {os.path.basename(artifact_path)}"
if not os.path.exists(artifact_path):
print(f" ✗ Artifact not found: {os.path.basename(artifact_path)}")
summary.fail(name, "missing file")
return False
file_size = os.path.getsize(artifact_path)
if file_size < min_size:
print(
f" ✗ Artifact is suspiciously small ({file_size} bytes): {os.path.basename(artifact_path)}"
)
summary.fail(name, f"size {file_size} bytes")
return False
print(f" ✓ Artifact exists: {os.path.basename(artifact_path)} ({file_size:,} bytes)")
summary.pass_(name, f"{file_size:,} bytes")
return True
def _verify_artifact_signature(
artifact_path: str, signature_path: str, summary: VerificationSummary
) -> bool:
check_name = f"GPG signature: {os.path.basename(artifact_path)}"
print(f" Verifying GPG signature: {os.path.basename(signature_path)}")
if not os.path.exists(signature_path):
print(" ✗ Signature file not found")
summary.fail(check_name, "missing .asc")
return False
try:
result = subprocess.run(
["gpg", "--verify", signature_path, artifact_path],
capture_output=True,
check=False,
)
except OSError as exc:
print(f" ✗ Error running GPG: {exc}")
summary.fail(check_name, f"gpg unavailable: {exc}")
return False
if result.returncode == 0:
print(" ✓ GPG signature is valid")
summary.pass_(check_name)
return True
print(" ✗ GPG signature verification failed")
if result.stderr:
print(f" Error: {result.stderr.decode()}")
summary.fail(check_name, result.stderr.decode().strip() or "verification failed")
return False
def _verify_artifact_checksum(
artifact_path: str,
checksum_path: str,
summary: VerificationSummary,
) -> bool:
check_name = f"SHA512 checksum: {os.path.basename(artifact_path)}"
print(f" Verifying SHA512 checksum: {os.path.basename(checksum_path)}")
if not os.path.exists(checksum_path):
print(" ✗ Checksum file not found")
summary.fail(check_name, "missing .sha512")
return False
with open(checksum_path, "r", encoding="utf-8") as handle:
expected_checksum = handle.read().strip().split()[0]
actual_checksum = _sha512_for_file(artifact_path)
if actual_checksum == expected_checksum:
print(" ✓ SHA512 checksum is valid")
summary.pass_(check_name)
return True
print(" ✗ SHA512 checksum mismatch!")
print(f" Expected: {expected_checksum}")
print(f" Actual: {actual_checksum}")
summary.fail(check_name, "checksum mismatch")
return False
def _verify_tar_gz_readable(artifact_path: str, summary: VerificationSummary) -> bool:
check_name = f"Readable tar.gz: {os.path.basename(artifact_path)}"
print(f" Checking archive readability: {os.path.basename(artifact_path)}")
try:
with tarfile.open(artifact_path, "r:gz") as tar:
members = tar.getmembers()
if not members:
print(" ✗ Archive is empty (no files)")
summary.fail(check_name, "archive is empty")
return False
print(f" ✓ Archive is readable and contains {len(members)} files")
summary.pass_(check_name, f"{len(members)} members")
return True
except tarfile.TarError as exc:
print(f" ✗ Archive is corrupted or unreadable: {exc}")
summary.fail(check_name, str(exc))
return False
except Exception as exc: # pragma: no cover - defensive
print(f" ✗ Error reading archive: {exc}")
summary.fail(check_name, str(exc))
return False
def _verify_wheel_readable(wheel_path: str, summary: VerificationSummary) -> bool:
check_name = f"Readable wheel: {os.path.basename(wheel_path)}"
print(f" Checking wheel readability: {os.path.basename(wheel_path)}")
try:
with zipfile.ZipFile(wheel_path, "r") as wheel:
file_list = wheel.namelist()
if not file_list:
print(" ✗ Wheel is empty (no files)")
summary.fail(check_name, "wheel is empty")
return False
metadata_files = [name for name in file_list if "METADATA" in name or "WHEEL" in name]
if not metadata_files:
print(" ✗ Wheel missing required metadata files")
summary.fail(check_name, "missing METADATA/WHEEL")
return False
print(f" ✓ Wheel is readable and contains {len(file_list)} files")
summary.pass_(check_name, f"{len(file_list)} members")
return True
except zipfile.BadZipFile as exc:
print(" ✗ Wheel is corrupted or not a valid ZIP file")
summary.fail(check_name, str(exc))
return False
except Exception as exc: # pragma: no cover - defensive
print(f" ✗ Error reading wheel: {exc}")
summary.fail(check_name, str(exc))
return False
def _verify_required_text_files(
artifact_name: str,
file_bytes: dict[str, bytes],
required_files: tuple[str, ...],
expected_files: dict[str, bytes],
summary: VerificationSummary,
) -> bool:
all_valid = True
for required_name in required_files:
check_name = f"{artifact_name} contains {required_name}"
matches = _find_files_by_basename(file_bytes, required_name)
if not matches:
print(f" ✗ Missing {required_name}")
summary.fail(check_name, "missing")
all_valid = False
continue
matched_path = matches[0]
if file_bytes[matched_path] != expected_files[required_name]:
print(f" ✗ {required_name} content mismatch ({matched_path})")
summary.fail(check_name, f"content mismatch at {matched_path}")
all_valid = False
continue
print(f" ✓ {required_name} present and matches repository copy ({matched_path})")
summary.pass_(check_name, matched_path)
return all_valid
def verify_artifact_contents(
artifacts_dir: str, summary: VerificationSummary | None = None
) -> bool:
_print_section("Verifying Artifact Metadata Files")
if summary is None:
summary = VerificationSummary()
if not os.path.exists(artifacts_dir):
_fail(f"Artifacts directory not found: {artifacts_dir}")
artifacts = _artifact_files(artifacts_dir)
if not artifacts:
print(f"⚠️ No artifacts found in {artifacts_dir}")
summary.fail("Artifact metadata verification", "no artifacts found")
return False
expected_files = _read_expected_text_files()
all_valid = True
for artifact_name in artifacts:
artifact_path = os.path.join(artifacts_dir, artifact_name)
print(f"Inspecting metadata files: {artifact_name}")
print("-" * 80)
if artifact_name.endswith(".tar.gz"):
file_bytes = _tar_file_bytes(artifact_path)
if not _verify_required_text_files(
artifact_name,
file_bytes,
REQUIRED_TEXT_FILES,
expected_files,
summary,
):
all_valid = False
elif artifact_name.endswith(".whl"):
file_bytes = _wheel_file_bytes(artifact_path)
if not _verify_required_text_files(
artifact_name,
file_bytes,
WHEEL_REQUIRED_TEXT_FILES,
expected_files,
summary,
):
all_valid = False
else:
print(f" ⚠️ Skipping unsupported artifact type: {artifact_name}")
summary.skip(f"Artifact metadata: {artifact_name}", "unsupported type")
print()
return all_valid
def verify_signatures(artifacts_dir: str, summary: VerificationSummary | None = None) -> bool:
_print_section("Verifying Signatures and Checksums")
if summary is None:
summary = VerificationSummary()
if not os.path.exists(artifacts_dir):
_fail(f"Artifacts directory not found: {artifacts_dir}")
artifacts = _artifact_files(artifacts_dir)
if not artifacts:
print(f"⚠️ No artifacts found in {artifacts_dir}")
summary.fail("Signature verification", "no artifacts found")
return False
print(f"Found {len(artifacts)} artifact(s) to verify:\n")
all_valid = True
for artifact_name in artifacts:
artifact_path = os.path.join(artifacts_dir, artifact_name)
print(f"Verifying: {artifact_name}")
print("-" * 80)
if not _verify_artifact_exists(artifact_path, summary):
all_valid = False
continue
if not _verify_artifact_signature(artifact_path, f"{artifact_path}.asc", summary):
all_valid = False
if not _verify_artifact_checksum(artifact_path, f"{artifact_path}.sha512", summary):
all_valid = False
if artifact_name.endswith(".tar.gz"):
if not _verify_tar_gz_readable(artifact_path, summary):
all_valid = False
elif artifact_name.endswith(".whl"):
if not _verify_wheel_readable(artifact_path, summary):
all_valid = False
print()
return all_valid
def _safe_extract_tar(
tar_handle: tarfile.TarFile,
extract_dir: str,
members: list[tarfile.TarInfo] | None = None,
) -> None:
try:
tar_handle.extractall(extract_dir, members=members, filter="data")
except TypeError:
tar_handle.extractall(extract_dir, members=members)
def _build_rat_command(
rat_jar_path: str,
extract_dir: str,
rat_excludes: str | None,
output_style: str | None = None,
) -> list[str]:
command = ["java", "-jar", rat_jar_path]
if output_style:
command.extend(["--output-style", output_style])
if rat_excludes:
command.extend(["--input-exclude-file", rat_excludes])
command.extend(["--", extract_dir])
return command
def _rat_scan_target(extract_dir: str) -> tuple[str, str]:
extracted_root = Path(extract_dir)
entries = [entry for entry in extracted_root.iterdir()]
if len(entries) == 1 and entries[0].is_dir():
return str(extracted_root), entries[0].name
return str(extracted_root), "."
def _load_rat_xml_root(rat_report_xml: str) -> ET.Element:
raw_xml = Path(rat_report_xml).read_text(encoding="utf-8")
start_tag = "<rat-report"
end_tag = "</rat-report>"
xml_start = raw_xml.find(start_tag)
if xml_start == -1:
raise ValueError("RAT XML report does not contain XML content")
xml_end = raw_xml.find(end_tag, xml_start)
if xml_end == -1:
raise ValueError("RAT XML report is missing closing </rat-report> tag")
xml_content = raw_xml[xml_start : xml_end + len(end_tag)].strip()
if not xml_content:
raise ValueError("RAT XML report is empty")
return ET.fromstring(xml_content)
def _rat_license_state(resource: ET.Element) -> tuple[str, str]:
approval = resource.find("license-approval")
family = resource.find("license-family")
if approval is not None or family is not None:
license_approval = approval.get("name", "true") if approval is not None else "true"
license_family = family.get("name", "") if family is not None else ""
return license_approval, license_family
license_elem = resource.find("license")
if license_elem is not None:
license_approval = license_elem.get("approval", "true")
license_family = license_elem.get("family", "") or license_elem.get("name", "")
return license_approval, license_family
return "true", ""
def _check_licenses_with_rat(
artifact_path: str,
rat_jar_path: str,
report_name: str,
summary: VerificationSummary,
report_only: bool = False,
) -> bool:
check_name = f"Apache RAT: {os.path.basename(artifact_path)}"
print(f"\nRunning Apache RAT on: {os.path.basename(artifact_path)}")
print("-" * 80)
report_dir = "dist"
os.makedirs(report_dir, exist_ok=True)
rat_report_xml = os.path.join(report_dir, f"rat-report-{report_name}.xml")
rat_report_txt = os.path.join(report_dir, f"rat-report-{report_name}.txt")
with tempfile.TemporaryDirectory() as temp_dir:
extract_dir = os.path.join(temp_dir, "extracted")
os.makedirs(extract_dir)
print(" Extracting archive...")
try:
if artifact_path.endswith(".whl"):
with zipfile.ZipFile(artifact_path, "r") as whl:
whl.extractall(extract_dir)
else:
with tarfile.open(artifact_path, "r:gz") as tar:
_safe_extract_tar(tar, extract_dir)
print(" ✓ Extracted to temp directory")
except Exception as exc:
print(f" ✗ Error extracting archive: {exc}")
summary.fail(check_name, f"extract failed: {exc}")
return False
rat_excludes = ".rat-excludes"
if not os.path.exists(rat_excludes):
print(f" ⚠️ Warning: {rat_excludes} not found, running without excludes")
rat_excludes = None
else:
rat_excludes = os.path.abspath(rat_excludes)
rat_cwd, rat_target = _rat_scan_target(extract_dir)
print(" Running Apache RAT (XML format for parsing)...")
rat_cmd_xml = _build_rat_command(
rat_jar_path,
rat_target,
rat_excludes,
output_style="xml",
)
try:
with open(rat_report_xml, "w", encoding="utf-8") as report_file:
result = subprocess.run(
rat_cmd_xml,
cwd=rat_cwd,
stdout=report_file,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if result.returncode != 0:
# A nonzero exit means RAT crashed mid-scan (e.g. on a broken
# symlink). The XML report it produced will be truncated and
# parsing it gives a falsely clean result, so fail hard here.
print(f" ✗ RAT exited with code {result.returncode}")
if result.stderr:
print(" --- RAT stderr ---")
for line in result.stderr.splitlines()[-25:]:
print(f" {line}")
summary.fail(check_name, f"RAT exited with code {result.returncode}")
return False
print(f" ✓ RAT XML report: {rat_report_xml}")
except Exception as exc:
print(f" ✗ Error running RAT (XML): {exc}")
summary.fail(check_name, f"RAT execution failed: {exc}")
return False
print(" Running Apache RAT (text format for review)...")
rat_cmd_txt = _build_rat_command(rat_jar_path, rat_target, rat_excludes)
try:
with open(rat_report_txt, "w", encoding="utf-8") as report_file:
subprocess.run(
rat_cmd_txt,
cwd=rat_cwd,
stdout=report_file,
stderr=subprocess.PIPE,
text=True,
check=False,
)
print(f" ✓ RAT text report: {rat_report_txt}")
except Exception as exc:
print(f" ⚠️ Warning: Could not generate text report: {exc}")
print(" Parsing RAT report...")
try:
root = _load_rat_xml_root(rat_report_xml)
unapproved_licenses = []
unknown_licenses = []
for resource in root.findall(".//resource"):
name = resource.get("name", "unknown")
license_approval, license_family = _rat_license_state(resource)
if license_approval == "false" or license_family == "Unknown license":
if license_family == "Unknown license" or not license_family:
unknown_licenses.append(name)
else:
unapproved_licenses.append(name)
issues_count = len(unapproved_licenses) + len(unknown_licenses)
total_files = len(root.findall(".//resource"))
print(f" ✓ Scanned {total_files} files")
print(f" ✓ Found {issues_count} files with license issues")
if issues_count == 0:
print(" ✅ All files have approved licenses")
summary.pass_(check_name, "no RAT issues")
return True
if unknown_licenses:
print(f"\n Unknown/Missing Licenses ({len(unknown_licenses)} files):")
for file_name in unknown_licenses[:10]:
print(f" - {file_name}")
if unapproved_licenses:
print(f"\n Unapproved Licenses ({len(unapproved_licenses)} files):")
for file_name in unapproved_licenses[:10]:
print(f" - {file_name}")
if report_only:
print("\n ℹ️ Report-only mode: continuing despite license issues")
summary.pass_(check_name, f"report-only with {issues_count} issue(s)")
return True
print("\n ❌ License check failed!")
summary.fail(check_name, f"{issues_count} RAT issue(s)")
return False
except Exception as exc:
print(f" ✗ Error parsing RAT report: {exc}")
if report_only:
print(" ℹ️ Report-only mode: continuing despite parse error")
summary.pass_(check_name, "report-only despite parse error")
return True
summary.fail(check_name, f"report parse failed: {exc}")
return False
def verify_licenses(
artifacts_dir: str,
rat_jar_path: str,
summary: VerificationSummary | None = None,
report_only: bool = False,
) -> bool:
_print_section("Verifying Licenses with Apache RAT")
if summary is None:
summary = VerificationSummary()
if not os.path.exists(artifacts_dir):
_fail(f"Artifacts directory not found: {artifacts_dir}")
if not rat_jar_path or not os.path.exists(rat_jar_path):
_fail(
f"Apache RAT JAR not found: {rat_jar_path}\nDownload from: https://creadur.apache.org/rat/download_rat.cgi"
)
if shutil.which("java") is None:
_fail("Java not found. Required for Apache RAT.")
tar_artifacts = [name for name in _artifact_files(artifacts_dir) if name.endswith(".tar.gz")]
wheel_artifacts = [name for name in _artifact_files(artifacts_dir) if name.endswith(".whl")]
rat_artifacts = tar_artifacts + wheel_artifacts
if not rat_artifacts:
print(f"⚠️ No tar.gz or .whl artifacts found in {artifacts_dir}")
summary.fail("Apache RAT", "no tar.gz or .whl artifacts found")
return False
print(
f"Found {len(rat_artifacts)} artifact(s) to check ({len(tar_artifacts)} tarball(s), {len(wheel_artifacts)} wheel(s)):\n"
)
all_valid = True
for artifact_name in rat_artifacts:
artifact_path = os.path.join(artifacts_dir, artifact_name)
report_name = artifact_name.replace(".tar.gz", "").replace(".", "-")
if not _check_licenses_with_rat(
artifact_path,
rat_jar_path,
report_name,
summary,
report_only,
):
all_valid = False
return all_valid
def _release_artifact_map(artifacts_dir: str) -> dict[str, list[str]]:
artifacts = _artifact_files(artifacts_dir)
return {
"source": [name for name in artifacts if name.endswith("-src.tar.gz")],
"sdist": [name for name in artifacts if name.endswith("-sdist.tar.gz")],
"wheel": [name for name in artifacts if name.endswith(".whl")],
}
def _extract_project_root_and_epoch(source_artifact: str, destination: str) -> tuple[Path, int]:
with tarfile.open(source_artifact, "r:gz") as tar:
members = tar.getmembers()
if not members:
raise ValueError(f"source archive is empty: {source_artifact}")
source_date_epoch = int(members[0].mtime)
_safe_extract_tar(tar, destination, members=members)
entries = [entry for entry in Path(destination).iterdir()]
if len(entries) == 1 and entries[0].is_dir():
return entries[0], source_date_epoch
return Path(destination), source_date_epoch
def _reproducible_build_environment(source_date_epoch: int) -> dict[str, str]:
"""Return the shared environment for voter rebuilds."""
env = os.environ.copy()
env["FLIT_USE_VCS"] = "0"
env["SOURCE_DATE_EPOCH"] = str(source_date_epoch)
env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env.get('PATH', '')}"
return env
def _load_apache_release_module(project_root: Path):
module_path = project_root / "scripts" / "apache_release.py"
spec = importlib.util.spec_from_file_location("apache_release_for_verify", module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Unable to load release helper from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _build_reproducible_wheel(
project_root: Path, version: str, output_dir: str, source_epoch: int
) -> tuple[bool, str]:
command = [
sys.executable,
"-c",
(
"import importlib.util, pathlib, sys; "
"project_root = pathlib.Path(sys.argv[1]); "
"version = sys.argv[2]; "
"output_dir = sys.argv[3]; "
"module_path = project_root / 'scripts' / 'apache_release.py'; "
"spec = importlib.util.spec_from_file_location('apache_release_for_verify', module_path); "
"module = importlib.util.module_from_spec(spec); "
"spec.loader.exec_module(module); "
"module._build_wheel_from_current_dir(version, output_dir)"
),
str(project_root),
version,
output_dir,
]
result = subprocess.run(
command,
cwd=project_root,
capture_output=True,
text=True,
check=False,
env=_reproducible_build_environment(source_epoch),
)
output = "\n".join(item for item in [result.stdout, result.stderr] if item)
return result.returncode == 0, output
def _build_reproducible_artifacts(source_artifact: str, output_dir: str) -> tuple[bool, str]:
with tempfile.TemporaryDirectory() as temp_dir:
try:
project_root, source_epoch = _extract_project_root_and_epoch(source_artifact, temp_dir)
except (OSError, tarfile.TarError, ValueError) as exc:
return False, f"unable to read source epoch: {exc}"
version_match = re.search(r"(\d+\.\d+\.\d+)", os.path.basename(source_artifact))
if version_match is None:
return False, f"unable to determine version from {os.path.basename(source_artifact)}"
version = version_match.group(1)
dist_dir = os.path.join(project_root, "dist")
if os.path.exists(dist_dir):
shutil.rmtree(dist_dir)
sdist_result = subprocess.run(
["flit", "build", "--format", "sdist"],
cwd=project_root,
capture_output=True,
text=True,
check=False,
env=_reproducible_build_environment(source_epoch),
)
if sdist_result.returncode != 0:
return False, sdist_result.stderr or sdist_result.stdout
wheel_ok, wheel_output = _build_reproducible_wheel(
project_root, version, output_dir, source_epoch
)
if not wheel_ok:
return False, wheel_output
os.makedirs(output_dir, exist_ok=True)
for built_artifact in Path(dist_dir).glob("*"):
shutil.copy2(built_artifact, Path(output_dir) / built_artifact.name)
combined_output = "\n".join(
item for item in [sdist_result.stdout, sdist_result.stderr, wheel_output] if item
)
return True, combined_output
def _compare_rebuilt_artifacts(
artifacts_dir: str,
rebuilt_dir: str,
release_artifacts: dict[str, list[str]],
summary: VerificationSummary,
) -> bool:
all_valid = True
if release_artifacts["sdist"]:
release_sdist = os.path.join(artifacts_dir, release_artifacts["sdist"][0])
rebuilt_sdists = sorted(glob.glob(os.path.join(rebuilt_dir, "*.tar.gz")))
if not rebuilt_sdists:
summary.fail("Rebuilt sdist checksum", "no rebuilt sdist produced")
all_valid = False
else:
rebuilt_sdist = rebuilt_sdists[0]
if _sha512_for_file(release_sdist) == _sha512_for_file(rebuilt_sdist):
summary.pass_("Rebuilt sdist checksum", os.path.basename(rebuilt_sdist))
else:
summary.fail("Rebuilt sdist checksum", "rebuilt sdist differs from release")
all_valid = False
else:
summary.skip("Rebuilt sdist checksum", "no release sdist found")
if release_artifacts["wheel"]:
release_wheels = [os.path.join(artifacts_dir, name) for name in release_artifacts["wheel"]]
rebuilt_wheels = sorted(glob.glob(os.path.join(rebuilt_dir, "*.whl")))
if len(rebuilt_wheels) != len(release_wheels):
summary.fail(
"Rebuilt wheel checksum",
f"expected {len(release_wheels)} wheel(s), found {len(rebuilt_wheels)} rebuilt wheel(s)",
)
return False
for release_wheel in release_wheels:
release_name = os.path.basename(release_wheel)
matching_wheels = [
rebuilt for rebuilt in rebuilt_wheels if os.path.basename(rebuilt) == release_name
]
if not matching_wheels:
summary.fail(
f"Rebuilt wheel checksum: {release_name}", "matching rebuilt wheel not found"
)
all_valid = False
continue
rebuilt_wheel = matching_wheels[0]
match, diffs = _compare_wheel_contents(release_wheel, rebuilt_wheel)
if match:
summary.pass_(f"Rebuilt wheel contents: {release_name}")
else:
for diff in diffs[:5]:
print(f" {diff}")
summary.fail(
f"Rebuilt wheel contents: {release_name}",
f"{len(diffs)} file(s) differ between release and rebuilt wheel",
)
all_valid = False
else:
summary.skip("Rebuilt wheel contents", "no release wheel found")
return all_valid
def verify_reproducible_build(
artifacts_dir: str,
summary: VerificationSummary | None = None,
) -> bool:
_print_section("Verifying Reproducible Build")
if summary is None:
summary = VerificationSummary()
if not os.path.exists(artifacts_dir):
_fail(f"Artifacts directory not found: {artifacts_dir}")
release_artifacts = _release_artifact_map(artifacts_dir)
source_candidates = release_artifacts["source"] or release_artifacts["sdist"]
if not source_candidates:
summary.fail("Rebuild source artifact", "no source or sdist tarball found")
return False
if shutil.which("flit") is None:
summary.fail("Reproducible rebuild", "flit is required to rebuild release artifacts")
return False
source_artifact = os.path.join(artifacts_dir, source_candidates[0])
summary.pass_("Rebuild source artifact", os.path.basename(source_artifact))
with tempfile.TemporaryDirectory() as rebuilt_dir:
print(f"Rebuilding from: {os.path.basename(source_artifact)}")
ok, output = _build_reproducible_artifacts(source_artifact, rebuilt_dir)
if not ok:
summary.fail("Reproducible rebuild", output.strip() or "build failed")
return False
summary.pass_("Reproducible rebuild", "build completed")
return _compare_rebuilt_artifacts(artifacts_dir, rebuilt_dir, release_artifacts, summary)
def _extract_version_from_artifacts(artifacts_dir: str) -> str:
version_pattern = re.compile(r"(\d+\.\d+\.\d+)")
for artifact_name in _artifact_files(artifacts_dir):
match = version_pattern.search(artifact_name)
if match:
return match.group(1)
return "UNKNOWN_VERSION"
def render_vote_email(artifacts_dir: str, summary: VerificationSummary) -> str:
version = _extract_version_from_artifacts(artifacts_dir)
pass_count = sum(result.status == PASS for result in summary.results)
fail_count = sum(result.status == FAIL for result in summary.results)
skip_count = sum(result.status == SKIP for result in summary.results)
vote = "+1" if summary.ok else "-1"
result_lines = []
for result in summary.results:
result_lines.append(
f"- [{result.status}] {result.name}" + (f": {result.details}" if result.details else "")
)
return textwrap.dedent(
f"""\
Subject: [{vote}] Release Apache Burr (incubating) {version}
I verified the Apache Burr (incubating) {version} release artifacts.
Verification summary:
{os.linesep.join(result_lines)}
Totals:
- PASS: {pass_count}
- FAIL: {fail_count}
- SKIP: {skip_count}
Vote:
{vote} approve the release based on the checks above.
"""
).strip()
def _maybe_output_vote_email(args: argparse.Namespace, summary: VerificationSummary) -> None:
if not getattr(args, "vote_email", False):
return
email_text = render_vote_email(args.artifacts_dir, summary)
_print_section("Vote Email Draft")
print(email_text)
output_path = getattr(args, "vote_email_output", None)
if output_path:
Path(output_path).write_text(email_text + "\n", encoding="utf-8")
print(f"\nSaved vote email draft to: {output_path}")
def _list_tar_gz_contents(artifact_path: str) -> None:
print(f"\nContents of: {os.path.basename(artifact_path)}")
print("=" * 80)
try:
with tarfile.open(artifact_path, "r:gz") as tar:
members = tar.getmembers()
print(f"Total files: {len(members)}\n")
files = [member for member in members if member.isfile()]
dirs = [member for member in members if member.isdir()]
symlinks = [member for member in members if member.issym() or member.islnk()]
print(f"Files: {len(files)}, Directories: {len(dirs)}, Symlinks: {len(symlinks)}\n")
print("Files:\n")
for member in members:
size = f"{member.size:>12,}" if member.isfile() else " <dir>"
prefix = " "
if member.issym() or member.islnk():
prefix = "→ "
if member.linkname:
print(f"{prefix}{member.name} -> {member.linkname}")
continue
print(f"{prefix}{member.name:<70} {size}")
except Exception as exc:
print(f"Error reading archive: {exc}")
def _list_wheel_contents(wheel_path: str) -> None:
print(f"\nContents of: {os.path.basename(wheel_path)}")
print("=" * 80)
try:
with zipfile.ZipFile(wheel_path, "r") as wheel:
file_list = wheel.namelist()
print(f"Total files: {len(file_list)}\n")
top_level_dirs: dict[str, int] = {}
for file_name in file_list:
top_dir = file_name.split("/")[0]
top_level_dirs[top_dir] = top_level_dirs.get(top_dir, 0) + 1
print("Top-level structure:")
for dir_name, count in sorted(top_level_dirs.items()):
print(f" {dir_name:<50} ({count} files)")
print("\nFiles:\n")
for filename in sorted(file_list):
info = wheel.getinfo(filename)
size = f"{info.file_size:>12,}" if not filename.endswith("/") else " <dir>"
print(f" {filename:<70} {size}")
except Exception as exc:
print(f"Error reading wheel: {exc}")
def list_contents(artifact_path: str) -> None:
_print_section("Listing Artifact Contents")
if not os.path.exists(artifact_path):
_fail(f"Artifact not found: {artifact_path}")
if artifact_path.endswith(".tar.gz"):
_list_tar_gz_contents(artifact_path)
elif artifact_path.endswith(".whl"):
_list_wheel_contents(artifact_path)
else:
_fail(f"Unsupported file type: {artifact_path}\nSupported: .tar.gz, .whl")
def cmd_signatures(args: argparse.Namespace) -> bool:
summary = VerificationSummary()
verify_signatures(args.artifacts_dir, summary)
_print_section("Verification Summary")
print(summary.render())
return summary.ok
def cmd_artifacts(args: argparse.Namespace) -> bool:
summary = VerificationSummary()
verify_artifact_contents(args.artifacts_dir, summary)
_print_section("Verification Summary")
print(summary.render())
return summary.ok
def cmd_licenses(args: argparse.Namespace) -> bool:
if not args.rat_jar:
_fail("--rat-jar is required for license verification")
summary = VerificationSummary()
verify_licenses(args.artifacts_dir, args.rat_jar, summary, args.report_only)
_print_section("Verification Summary")
print(summary.render())
return summary.ok
def cmd_reproducible(args: argparse.Namespace) -> bool:
summary = VerificationSummary()
verify_reproducible_build(args.artifacts_dir, summary)
_print_section("Verification Summary")
print(summary.render())
_maybe_output_vote_email(args, summary)
return summary.ok
def cmd_all(args: argparse.Namespace) -> bool:
_print_section("Complete Apache Artifacts Verification")
summary = VerificationSummary()
print("\n[1/4] Verifying signatures and checksums...")
verify_signatures(args.artifacts_dir, summary)
print("\n[2/4] Verifying required metadata files...")
verify_artifact_contents(args.artifacts_dir, summary)
print("\n[3/4] Verifying reproducible rebuild...")
verify_reproducible_build(args.artifacts_dir, summary)
if args.rat_jar:
print("\n[4/4] Verifying licenses with Apache RAT...")
verify_licenses(args.artifacts_dir, args.rat_jar, summary, args.report_only)
else:
summary.skip("Apache RAT", "no --rat-jar provided")
_print_section("Verification Summary")
print(summary.render())
_maybe_output_vote_email(args, summary)
return summary.ok
def cmd_compare_wheels(args: argparse.Namespace) -> bool:
"""Handle 'compare-wheels' subcommand.
Compares two wheel files by their file content hashes, ignoring zip
metadata (timestamps) and the RECORD manifest. Exits non-zero on any
difference so it can be used as a CI gate.
"""
_print_section("Comparing Wheel Contents")
for path in [args.wheel_a, args.wheel_b]:
if not os.path.isfile(path):
_fail(f"Wheel not found: {path}")
print(f" Wheel A: {os.path.basename(args.wheel_a)}")
print(f" Wheel B: {os.path.basename(args.wheel_b)}")
match, diffs = _compare_wheel_contents(args.wheel_a, args.wheel_b)
if match:
print("\n✅ Wheel contents are equivalent (same files, same content)")
return True
print(f"\n❌ Wheel contents differ ({len(diffs)} difference(s)):")
for diff in diffs:
print(f" {diff}")
return False
def cmd_list_contents(args: argparse.Namespace) -> None:
list_contents(args.artifact)
def cmd_twine_check(args: argparse.Namespace) -> bool:
_print_section("Verifying Wheel Metadata with Twine")
summary = VerificationSummary()
wheel_pattern = f"{args.artifacts_dir}/apache_burr-*.whl"
wheel_files = glob.glob(wheel_pattern)
if not wheel_files:
print(f"❌ No wheel found matching: {wheel_pattern}")
summary.fail("Twine metadata check", "no wheel found")
_print_section("Verification Summary")
print(summary.render())
return False
for wheel_path in wheel_files:
print(f"\nChecking {os.path.basename(wheel_path)}...")
try:
subprocess.run(
["twine", "check", wheel_path],
check=True,
capture_output=True,
text=True,
)
print(f" ✓ {os.path.basename(wheel_path)} metadata is valid")
summary.pass_(f"Twine metadata: {os.path.basename(wheel_path)}")
except subprocess.CalledProcessError as exc:
print(f" ✗ Twine check failed: {exc.stderr}")
summary.fail(f"Twine metadata: {os.path.basename(wheel_path)}", exc.stderr.strip())
_print_section("Verification Summary")
print(summary.render())
return summary.ok
def _add_vote_email_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--vote-email",
action="store_true",
help="Render a vote email draft using the collected verification results",
)
parser.add_argument(
"--vote-email-output",
help="Optional path to write the generated vote email draft",
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Apache Artifacts Verification Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/verify_apache_artifacts.py list-contents dist/apache-burr-0.41.0-incubating-src.tar.gz
python scripts/verify_apache_artifacts.py signatures
python scripts/verify_apache_artifacts.py artifacts
python scripts/verify_apache_artifacts.py reproducible
python scripts/verify_apache_artifacts.py licenses --rat-jar /path/to/apache-rat.jar
python scripts/verify_apache_artifacts.py all --rat-jar /path/to/apache-rat.jar --vote-email
""",
)
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser(
"list-contents", help="List contents of a specific artifact"
)
list_parser.add_argument("artifact", help="Path to artifact file (.tar.gz or .whl)")
sig_parser = subparsers.add_parser(
"signatures", help="Verify GPG signatures and SHA512 checksums"
)
sig_parser.add_argument(
"--artifacts-dir", default="dist", help="Directory containing artifacts (default: dist)"
)
artifacts_parser = subparsers.add_parser(
"artifacts",
help="Verify required LICENSE/NOTICE/DISCLAIMER metadata in artifacts",
)
artifacts_parser.add_argument(
"--artifacts-dir", default="dist", help="Directory containing artifacts (default: dist)"
)
lic_parser = subparsers.add_parser("licenses", help="Verify licenses with Apache RAT")
lic_parser.add_argument(
"--artifacts-dir", default="dist", help="Directory containing artifacts (default: dist)"
)
lic_parser.add_argument("--rat-jar", required=True, help="Path to Apache RAT JAR file")
lic_parser.add_argument(
"--report-only", action="store_true", help="Generate report but don't fail on issues"
)
reproducible_parser = subparsers.add_parser(
"reproducible",
help="Rebuild from release source and compare rebuilt artifacts against release artifacts",
)
reproducible_parser.add_argument(
"--artifacts-dir", default="dist", help="Directory containing artifacts (default: dist)"
)
_add_vote_email_args(reproducible_parser)
all_parser = subparsers.add_parser(
"all",
help="Verify signatures, metadata files, reproducibility, and optionally Apache RAT results",
)
all_parser.add_argument(
"--artifacts-dir", default="dist", help="Directory containing artifacts (default: dist)"
)
all_parser.add_argument("--rat-jar", help="Path to Apache RAT JAR file (optional)")
all_parser.add_argument(
"--report-only", action="store_true", help="Generate report but don't fail on RAT issues"
)
_add_vote_email_args(all_parser)
twine_parser = subparsers.add_parser("twine-check", help="Verify wheel metadata with twine")
twine_parser.add_argument(
"--artifacts-dir", default="dist", help="Directory containing artifacts (default: dist)"
)
compare_wheels_parser = subparsers.add_parser(
"compare-wheels",
help="Compare two wheel files by content hash (ignores zip metadata and RECORD)",
)
compare_wheels_parser.add_argument("wheel_a", help="Path to first wheel")
compare_wheels_parser.add_argument("wheel_b", help="Path to second wheel")
args = parser.parse_args()
success = False
try:
if args.command == "list-contents":
cmd_list_contents(args)
sys.exit(0)
if args.command == "signatures":
success = cmd_signatures(args)
elif args.command == "artifacts":
success = cmd_artifacts(args)
elif args.command == "licenses":
success = cmd_licenses(args)
elif args.command == "reproducible":
success = cmd_reproducible(args)
elif args.command == "all":
success = cmd_all(args)
elif args.command == "twine-check":
success = cmd_twine_check(args)
elif args.command == "compare-wheels":
success = cmd_compare_wheels(args)
else:
_fail(f"Unknown command: {args.command}")
except KeyboardInterrupt:
print("\n\n⚠️ Interrupted by user")
sys.exit(130)
except Exception as exc:
print(f"\n❌ Unexpected error: {exc}")
import traceback
traceback.print_exc()
sys.exit(1)
if success:
print("\n✅ Verification completed successfully!")
sys.exit(0)
print("\n❌ Verification failed.")
sys.exit(1)
if __name__ == "__main__":
main()