| #!/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) -> None: |
| try: |
| tar_handle.extractall(extract_dir, filter="data") |
| except TypeError: |
| tar_handle.extractall(extract_dir) |
| |
| |
| 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(source_artifact: str, destination: str) -> Path: |
| with tarfile.open(source_artifact, "r:gz") as tar: |
| _safe_extract_tar(tar, destination) |
| |
| entries = [entry for entry in Path(destination).iterdir()] |
| if len(entries) == 1 and entries[0].is_dir(): |
| return entries[0] |
| return Path(destination) |
| |
| |
| 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, |
| ] |
| env = os.environ.copy() |
| env["SOURCE_DATE_EPOCH"] = str(source_epoch) |
| env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env.get('PATH', '')}" |
| result = subprocess.run( |
| command, |
| cwd=project_root, |
| capture_output=True, |
| text=True, |
| check=False, |
| env=env, |
| ) |
| 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: |
| project_root = _extract_project_root(source_artifact, temp_dir) |
| source_epoch = int(os.path.getmtime(source_artifact)) |
| 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) |
| |
| env = os.environ.copy() |
| env["FLIT_USE_VCS"] = "0" |
| env["SOURCE_DATE_EPOCH"] = str(source_epoch) |
| env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env.get('PATH', '')}" |
| sdist_result = subprocess.run( |
| ["flit", "build", "--format", "sdist"], |
| cwd=project_root, |
| capture_output=True, |
| text=True, |
| check=False, |
| env=env, |
| ) |
| 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() |