| #!/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() |