diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ebbba136..d485f0d20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,10 +61,12 @@ jobs: wheels=("$RUNNER_TEMP"/wheelhouse/pops-*.whl) test "${#wheels[@]}" -eq 1 evidence="$RUNNER_TEMP/pops-final-evidence.json" + public_api_evidence="$RUNNER_TEMP/pops-final-evidence-public-api.json" + python scripts/run_final_gate.py --wheel "${wheels[0]}" --evidence "$evidence" python scripts/prove_public_api_parity.py \ --wheel "${wheels[0]}" \ - --evidence "$RUNNER_TEMP/pops-final-evidence-public-api.json" - python scripts/run_final_gate.py --wheel "${wheels[0]}" --evidence "$evidence" + --installed \ + --evidence "$public_api_evidence" python - <<'PY' from pops.runtime_environment import runtime_environment_report report = runtime_environment_report() @@ -72,7 +74,8 @@ jobs: assert report["mpi_compiled"] is False, report PY python scripts/release_preflight.py \ - --release --tag "$GITHUB_REF_NAME" --installed --evidence "$evidence" + --release --tag "$GITHUB_REF_NAME" --installed --evidence "$evidence" \ + --public-api-evidence "$public_api_evidence" - name: Retain authenticated release evidence uses: actions/upload-artifact@v7 diff --git a/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md b/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md index 5633c7c83..daff31dfe 100644 --- a/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md +++ b/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md @@ -1536,14 +1536,23 @@ dans `examples/final/`. Chaque script doit : ## 14. Gate de conformance finale -Le job de release commence par -`scripts/prove_public_api_parity.py --wheel --evidence `. -Cette preuve compare octet par octet tous les fichiers Python et de typage (`*.py`, `*.pyi`, -`py.typed`) du checkout et du wheel retenu, puis importe séparément les deux arbres dans des -interpréteurs isolés. Les deux snapshots doivent exposer la même racine publique, les mêmes -signatures et annotations, un `Case` explicite, des handles qualifiés distincts et +Le job de release exécute d'abord +`scripts/run_final_gate.py --wheel --evidence `. Ce gate installe +l'artefact exact avant que +`scripts/prove_public_api_parity.py --wheel --installed --evidence ` +ne résolve la distribution installée avec `importlib.metadata`, sans importer `pops` dans le +processus du gate. Le chemin résolu doit être extérieur au checkout. La preuve compare octet par +octet tous les fichiers Python et de typage (`*.py`, `*.pyi`, `py.typed`) du checkout, du wheel +retenu et du package installé, puis importe séparément les trois arbres dans des interpréteurs +isolés. Les trois snapshots doivent exposer la même racine publique, les mêmes signatures et +annotations, un `Case` explicite, des handles qualifiés distincts et authoring/validation/inspection sans chargement de `_pops`. Un ancien nom public, un fichier de -typage absent ou une divergence source/wheel bloque la publication. +typage absent, un chemin provenant du checkout ou une divergence source/wheel/installé bloque la +publication. La preuve authentifie aussi le `Name`, la `Version` et le digest du `METADATA` de la +distribution installée contre ceux du wheel. Enfin `release_preflight.py` reçoit cette evidence via +`--public-api-evidence` et vérifie son producteur, le SHA-256 du wheel et le chemin du package contre +le même runtime installé que l'evidence finale ; une evidence de parité issue d'un autre wheel ou +d'une autre installation ne peut donc pas être réutilisée. Une release ne peut être déclarée conforme que par `scripts/run_final_gate.py --evidence `. La commande exige un checkout propre, diff --git a/docs/docmap.toml b/docs/docmap.toml index c5663ad22..659c54816 100644 --- a/docs/docmap.toml +++ b/docs/docmap.toml @@ -85,6 +85,7 @@ depends_on = [ "python/pops/problem/problem.py", "python/pops/time/_program/api.py", "scripts/prove_public_api_parity.py", + "scripts/release_preflight.py", ".github/workflows/release.yml", "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py", "examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py", diff --git a/scripts/prove_public_api_parity.py b/scripts/prove_public_api_parity.py index 013806506..256b02c4b 100644 --- a/scripts/prove_public_api_parity.py +++ b/scripts/prove_public_api_parity.py @@ -5,7 +5,10 @@ import argparse from collections.abc import Mapping, Sequence +from email import policy +from email.parser import BytesParser import hashlib +import importlib.metadata import json from pathlib import Path, PurePosixPath import subprocess @@ -17,7 +20,7 @@ ROOT = Path(__file__).resolve().parents[1] SOURCE_PACKAGE = ROOT / "python" / "pops" -PROOF_SCHEMA_VERSION = 1 +PROOF_SCHEMA_VERSION = 3 TYPED_PAYLOAD_SUFFIXES = (".py", ".pyi") PUBLIC_ROOT = ( "Model", @@ -148,6 +151,7 @@ def _symbol(name): snapshot = { "public": public, "symbols": {name: _symbol(name) for name in public}, + "package_version": pops.__version__, "case_is_explicit_type": True, "qualified_handles": True, "pure_authoring": True, @@ -174,9 +178,9 @@ def _is_typed_payload(relative: str) -> bool: return path.name == "py.typed" or path.suffix in TYPED_PAYLOAD_SUFFIXES -def _source_manifest(package: Path = SOURCE_PACKAGE) -> dict[str, str]: +def _typed_manifest(package: Path, *, label: str) -> dict[str, str]: if not package.is_dir(): - raise PublicApiParityError("source package is absent: %s" % package) + raise PublicApiParityError("%s package is absent: %s" % (label, package)) manifest = { path.relative_to(package).as_posix(): _sha256(path) for path in sorted(package.rglob("*")) @@ -186,7 +190,7 @@ def _source_manifest(package: Path = SOURCE_PACKAGE) -> dict[str, str]: } required = {"__init__.py", "_pops.pyi", "py.typed"} if not required.issubset(manifest): - raise PublicApiParityError("source package lacks its root API or typing payload") + raise PublicApiParityError("%s package lacks its root API or typing payload" % label) return manifest @@ -210,6 +214,38 @@ def _wheel_manifest(archive: zipfile.ZipFile) -> dict[str, str]: return manifest +def _distribution_identity(payload: bytes, *, label: str) -> dict[str, str]: + try: + metadata = BytesParser(policy=policy.default).parsebytes(payload) + except (TypeError, ValueError) as exc: + raise PublicApiParityError("%s distribution METADATA is unreadable" % label) from exc + name = metadata.get("Name") + version = metadata.get("Version") + if not isinstance(name, str) or not name.strip() \ + or not isinstance(version, str) or not version.strip(): + raise PublicApiParityError( + "%s distribution METADATA has no exact Name/Version" % label) + normalized = name.strip().lower().replace("_", "-").replace(".", "-") + if normalized != "pops": + raise PublicApiParityError("%s distribution name is not PoPS" % label) + return { + "name": name.strip(), + "version": version.strip(), + "metadata_sha256": _sha256_bytes(payload), + } + + +def _wheel_distribution_identity(archive: zipfile.ZipFile) -> dict[str, str]: + names = [info.filename for info in archive.infolist() if not info.is_dir()] + if len(names) != len(set(names)): + raise PublicApiParityError("release wheel contains duplicate members") + metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise PublicApiParityError("release wheel has no unique distribution METADATA") + return _distribution_identity( + archive.read(metadata_names[0]), label="wheel") + + def _safe_extract(archive: zipfile.ZipFile, destination: Path) -> None: for info in archive.infolist(): relative = PurePosixPath(info.filename) @@ -251,43 +287,131 @@ def _canonical_sha256(payload: Mapping[str, Any]) -> str: return _sha256_bytes(encoded) -def build_proof(wheel: Path) -> dict[str, Any]: +def _require_manifest_parity( + reference: Mapping[str, str], + candidate: Mapping[str, str], + *, + label: str, +) -> None: + if candidate == reference: + return + missing = sorted(set(reference) - set(candidate)) + extra = sorted(set(candidate) - set(reference)) + changed = sorted( + name + for name in set(reference) & set(candidate) + if reference[name] != candidate[name] + ) + raise PublicApiParityError( + "%s Python/typing payload differs from source " + "(missing=%s, extra=%s, changed=%s)" + % (label, missing[:8], extra[:8], changed[:8]) + ) + + +def _installed_package_from_distribution() -> tuple[Path, dict[str, str]]: + try: + distribution = importlib.metadata.distribution("PoPS") + except importlib.metadata.PackageNotFoundError as exc: + raise PublicApiParityError("the PoPS distribution is not installed") from exc + files = distribution.files + if files is None: + raise PublicApiParityError("the installed PoPS distribution has no file inventory") + metadata_files = [ + row + for row in files + if PurePosixPath(str(row)).as_posix().endswith(".dist-info/METADATA") + ] + if len(metadata_files) != 1: + raise PublicApiParityError( + "the installed PoPS distribution has no unique METADATA") + metadata_path = Path(distribution.locate_file(metadata_files[0])).resolve() + if not metadata_path.is_file(): + raise PublicApiParityError("the installed PoPS distribution METADATA is absent") + identity = _distribution_identity(metadata_path.read_bytes(), label="installed") + package_initializers = [ + row for row in files if PurePosixPath(str(row)).as_posix() == "pops/__init__.py" + ] + if len(package_initializers) != 1: + raise PublicApiParityError( + "the installed PoPS distribution has no unique pops/__init__.py") + package = Path(distribution.locate_file(package_initializers[0])).resolve().parent + if not package.is_dir(): + raise PublicApiParityError("the installed PoPS package directory is absent") + try: + package.relative_to(ROOT) + except ValueError: + return package, identity + raise PublicApiParityError( + "the installed-package proof resolved inside the source checkout: %s" % package) + + +def build_proof( + wheel: Path, + *, + installed_package: Path | None = None, + installed_distribution: Mapping[str, str] | None = None, +) -> dict[str, Any]: """Compare one exact wheel archive with the current source checkout.""" retained = wheel.expanduser().resolve() if retained.suffix != ".whl" or not retained.is_file(): raise PublicApiParityError("release artifact is not one readable wheel") - source_manifest = _source_manifest() + source_manifest = _typed_manifest(SOURCE_PACKAGE, label="source") + installed = None if installed_package is None else installed_package.expanduser().resolve() + if installed_distribution is not None and installed is None: + raise PublicApiParityError( + "installed distribution identity requires an installed package") + if installed is not None: + try: + installed.relative_to(ROOT) + except ValueError: + pass + else: + raise PublicApiParityError( + "the installed-package proof resolved inside the source checkout: %s" % installed) try: with tempfile.TemporaryDirectory(prefix="pops-public-api-") as temporary: extracted = Path(temporary) with zipfile.ZipFile(retained) as archive: + wheel_distribution = _wheel_distribution_identity(archive) wheel_manifest = _wheel_manifest(archive) - if wheel_manifest != source_manifest: - missing = sorted(set(source_manifest) - set(wheel_manifest)) - extra = sorted(set(wheel_manifest) - set(source_manifest)) - changed = sorted( - name - for name in set(source_manifest) & set(wheel_manifest) - if source_manifest[name] != wheel_manifest[name] - ) - raise PublicApiParityError( - "wheel Python/typing payload differs from source " - "(missing=%s, extra=%s, changed=%s)" - % (missing[:8], extra[:8], changed[:8]) - ) + _require_manifest_parity( + source_manifest, wheel_manifest, label="wheel") _safe_extract(archive, extracted) source_snapshot = _snapshot(SOURCE_PACKAGE.parent) wheel_snapshot = _snapshot(extracted) + if installed is not None: + installed_manifest = _typed_manifest(installed, label="installed") + _require_manifest_parity( + source_manifest, installed_manifest, label="installed") + installed_snapshot = _snapshot(installed.parent) except (OSError, zipfile.BadZipFile) as exc: raise PublicApiParityError("release wheel is unreadable: %s" % exc) from exc if wheel_snapshot != source_snapshot: raise PublicApiParityError("wheel and source public API snapshots differ") + if source_snapshot.get("package_version") != wheel_distribution["version"]: + raise PublicApiParityError( + "source public API version differs from wheel distribution METADATA") + if installed is not None and installed_snapshot != source_snapshot: + raise PublicApiParityError("installed and source public API snapshots differ") + if installed_distribution is not None: + exact_installed_distribution = dict(installed_distribution) + if set(exact_installed_distribution) != {"name", "version", "metadata_sha256"}: + raise PublicApiParityError("installed distribution identity is malformed") + if exact_installed_distribution != wheel_distribution: + raise PublicApiParityError( + "installed distribution identity differs from wheel METADATA") if tuple(source_snapshot["public"]) != PUBLIC_ROOT: raise PublicApiParityError("public API snapshot differs from the final root contract") - return { + proof = { "schema_version": PROOF_SCHEMA_VERSION, + "producer": { + "script": "scripts/prove_public_api_parity.py", + "sha256": _sha256(Path(__file__).resolve()), + }, "wheel_path": str(retained), "wheel_sha256": _sha256(retained), + "distribution": wheel_distribution, "typed_payload_files": len(source_manifest), "typed_payload_sha256": _canonical_sha256(source_manifest), "public_api_sha256": _canonical_sha256(source_snapshot), @@ -295,7 +419,18 @@ def build_proof(wheel: Path) -> dict[str, Any]: "pure_authoring": source_snapshot["pure_authoring"], "qualified_handles": source_snapshot["qualified_handles"], "py_typed": source_snapshot["py_typed"], + "installed": installed is not None, + "installed_distribution": ( + None if installed_distribution is None else dict(installed_distribution) + ), } + if installed is not None: + proof.update({ + "installed_package": str(installed), + "installed_typed_payload_sha256": _canonical_sha256(installed_manifest), + "installed_public_api_sha256": _canonical_sha256(installed_snapshot), + }) + return proof def _write_evidence(path: Path, proof: Mapping[str, Any]) -> None: @@ -321,10 +456,23 @@ def _write_evidence(path: Path, proof: Mapping[str, Any]) -> None: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--wheel", required=True, type=Path) + parser.add_argument( + "--installed", + action="store_true", + help="also prove the importlib.metadata-resolved installed distribution outside checkout", + ) parser.add_argument("--evidence", type=Path) args = parser.parse_args(argv) try: - proof = build_proof(args.wheel) + if args.installed: + installed, installed_distribution = _installed_package_from_distribution() + else: + installed, installed_distribution = None, None + proof = build_proof( + args.wheel, + installed_package=installed, + installed_distribution=installed_distribution, + ) if args.evidence is not None: _write_evidence(args.evidence, proof) except (PublicApiParityError, OSError, ValueError) as exc: diff --git a/scripts/release_preflight.py b/scripts/release_preflight.py index d9401c8fd..17332cb5a 100644 --- a/scripts/release_preflight.py +++ b/scripts/release_preflight.py @@ -33,6 +33,7 @@ GENERATED = ROOT / "python" / "pops" / "_generated_release_contract.py" REQUIRED_GATES = REQUIRED_RELEASE_GATES EVIDENCE_SCHEMA_VERSION = 4 +PUBLIC_API_EVIDENCE_SCHEMA_VERSION = 3 class PreflightError(RuntimeError): @@ -236,6 +237,96 @@ def _wheel_evidence(directory: Path, gates: dict[str, Any], contract: Any) -> No raise PreflightError("release wheel name/version disagrees with the release contract") +def _public_api_evidence( + path: Path, + release_evidence: dict[str, Any], + contract: Any, +) -> None: + resolved = path.expanduser().resolve() + if _inside(ROOT, resolved) or not resolved.is_file(): + raise PreflightError( + "installed public API evidence must be one file outside the checkout") + try: + payload = json.loads(resolved.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise PreflightError("installed public API evidence is unreadable") from exc + expected = { + "schema_version", + "producer", + "wheel_path", + "wheel_sha256", + "distribution", + "typed_payload_files", + "typed_payload_sha256", + "public_api_sha256", + "public_names", + "pure_authoring", + "qualified_handles", + "py_typed", + "installed", + "installed_distribution", + "installed_package", + "installed_typed_payload_sha256", + "installed_public_api_sha256", + } + if not isinstance(payload, dict) or set(payload) != expected \ + or payload["schema_version"] != PUBLIC_API_EVIDENCE_SCHEMA_VERSION: + raise PreflightError("installed public API evidence has an unknown schema") + producer = { + "script": "scripts/prove_public_api_parity.py", + "sha256": hashlib.sha256( + (ROOT / "scripts" / "prove_public_api_parity.py").read_bytes() + ).hexdigest(), + } + if payload["producer"] != producer: + raise PreflightError("installed public API evidence has another producer") + wheel = release_evidence["gates"]["official_build"]["evidence"]["wheel"] + if payload["wheel_sha256"] != wheel["sha256"]: + raise PreflightError("installed public API evidence belongs to another wheel") + distribution = payload["distribution"] + installed_distribution = payload["installed_distribution"] + if not isinstance(distribution, dict) or set(distribution) != { + "name", "version", "metadata_sha256"}: + raise PreflightError("public API wheel distribution identity is malformed") + if installed_distribution != distribution: + raise PreflightError("installed distribution identity differs from the release wheel") + if not isinstance(distribution["name"], str) \ + or not isinstance(distribution["version"], str) \ + or distribution["name"].lower() != "pops" \ + or distribution["version"] != contract.PACKAGE_VERSION: + raise PreflightError("public API distribution identity disagrees with the release") + digests = ( + distribution["metadata_sha256"], + payload["wheel_sha256"], + payload["typed_payload_sha256"], + payload["installed_typed_payload_sha256"], + payload["public_api_sha256"], + payload["installed_public_api_sha256"], + ) + if any(not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None + for value in digests): + raise PreflightError("installed public API evidence contains an invalid digest") + if payload["installed_typed_payload_sha256"] != payload["typed_payload_sha256"] \ + or payload["installed_public_api_sha256"] != payload["public_api_sha256"]: + raise PreflightError("installed public API or typing digest differs from source") + if payload["installed"] is not True or payload["pure_authoring"] is not True \ + or payload["qualified_handles"] is not True or payload["py_typed"] is not True: + raise PreflightError("installed public API evidence did not prove the final contract") + if not isinstance(payload["typed_payload_files"], int) \ + or payload["typed_payload_files"] <= 0 \ + or not isinstance(payload["public_names"], list) \ + or not payload["public_names"] \ + or not all(isinstance(name, str) and name for name in payload["public_names"]): + raise PreflightError("installed public API evidence has an empty public surface") + if not isinstance(payload["installed_package"], str): + raise PreflightError("installed public API package path is malformed") + installed_package = Path(payload["installed_package"]).resolve() + runtime_package = Path(release_evidence["runtime"]["pops_file"]).resolve().parent + if installed_package != runtime_package: + raise PreflightError( + "public API parity was not proven on the authenticated installed runtime") + + def _examples_evidence(directory: Path, gates: dict[str, Any]) -> None: examples = gates["examples"]["evidence"] reopen = gates["artifact_reopen"]["evidence"] @@ -297,7 +388,12 @@ def _examples_evidence(directory: Path, gates: dict[str, Any]) -> None: raise PreflightError("release evidence restart proof markers drifted for %s" % key) -def _evidence(path: Path, contract: Any, commit: str, runtime: dict[str, str]) -> None: +def _evidence( + path: Path, + contract: Any, + commit: str, + runtime: dict[str, str], +) -> dict[str, Any]: payload = json.loads(path.read_text(encoding="utf-8")) expected = {"schema_version", "producer", "commit_sha", "package_version", "contract_sha256", "artifact_directory", "runtime", "gates"} @@ -361,6 +457,7 @@ def _evidence(path: Path, contract: Any, commit: str, runtime: dict[str, str]) - if gates["python_conformance"]["evidence"]["selection"] != PYTHON_REQUIRED_SELECTION: raise PreflightError("release evidence Python required-lane selection drifted") _examples_evidence(directory, gates) + return payload def main() -> int: @@ -369,10 +466,18 @@ def main() -> int: parser.add_argument("--tag") parser.add_argument("--installed", action="store_true") parser.add_argument("--evidence", type=Path) + parser.add_argument("--public-api-evidence", type=Path) args = parser.parse_args() try: - if args.release and (not args.tag or not args.installed or args.evidence is None): - raise PreflightError("--release requires --tag, --installed and --evidence") + if args.release and ( + not args.tag + or not args.installed + or args.evidence is None + or args.public_api_evidence is None + ): + raise PreflightError( + "--release requires --tag, --installed, --evidence and " + "--public-api-evidence") contract = _generated() checks = _static_contract(contract) if args.release: @@ -381,8 +486,16 @@ def main() -> int: if _run("git", "status", "--porcelain"): raise PreflightError("release checkout is dirty") runtime = _installed_contract(contract) - _evidence(args.evidence, contract, commit, runtime) - checks.extend(("tag", "changelog", "installed", "evidence", "clean")) + release_evidence = _evidence(args.evidence, contract, commit, runtime) + _public_api_evidence(args.public_api_evidence, release_evidence, contract) + checks.extend(( + "tag", + "changelog", + "installed", + "evidence", + "public_api_parity", + "clean", + )) elif args.tag: _tag_contract(contract.PACKAGE_VERSION, args.tag) checks.extend(("tag", "changelog")) diff --git a/tests/python/architecture/test_final_release_gate.py b/tests/python/architecture/test_final_release_gate.py index afb28b249..ba7aba224 100644 --- a/tests/python/architecture/test_final_release_gate.py +++ b/tests/python/architecture/test_final_release_gate.py @@ -1,7 +1,9 @@ """Source-only contract checks for the final release gate (ADC-695).""" from __future__ import annotations +import hashlib import importlib.util +import json from pathlib import Path import sys import zipfile @@ -191,6 +193,80 @@ def test_release_evidence_authenticates_the_exact_retained_wheel(tmp_path): preflight._wheel_evidence(tmp_path, gates, release) +def _write_public_api_evidence(tmp_path: Path) -> tuple[Path, dict, object]: + package = tmp_path / "site-packages" / "pops" + wheel_sha256 = "a" * 64 + typed_sha256 = "b" * 64 + public_sha256 = "c" * 64 + metadata_sha256 = "d" * 64 + payload = { + "schema_version": preflight.PUBLIC_API_EVIDENCE_SCHEMA_VERSION, + "producer": { + "script": "scripts/prove_public_api_parity.py", + "sha256": hashlib.sha256( + (SCRIPTS / "prove_public_api_parity.py").read_bytes() + ).hexdigest(), + }, + "wheel_path": str(tmp_path / "pops.whl"), + "wheel_sha256": wheel_sha256, + "distribution": { + "name": "PoPS", + "version": "1.0.0", + "metadata_sha256": metadata_sha256, + }, + "typed_payload_files": 3, + "typed_payload_sha256": typed_sha256, + "public_api_sha256": public_sha256, + "public_names": ["Model", "Program", "Case"], + "pure_authoring": True, + "qualified_handles": True, + "py_typed": True, + "installed": True, + "installed_distribution": { + "name": "PoPS", + "version": "1.0.0", + "metadata_sha256": metadata_sha256, + }, + "installed_package": str(package), + "installed_typed_payload_sha256": typed_sha256, + "installed_public_api_sha256": public_sha256, + } + path = tmp_path / "public-api-evidence.json" + path.write_text(json.dumps(payload), encoding="utf-8") + release_evidence = { + "runtime": {"pops_file": str(package / "__init__.py")}, + "gates": { + "official_build": { + "evidence": {"wheel": {"sha256": wheel_sha256}}, + }, + }, + } + release = type("ReleaseContract", (), {"PACKAGE_VERSION": "1.0.0"}) + return path, release_evidence, release + + +def test_release_preflight_binds_installed_public_api_to_wheel_and_runtime(tmp_path): + evidence, release_evidence, release = _write_public_api_evidence(tmp_path) + + preflight._public_api_evidence(evidence, release_evidence, release) + + payload = json.loads(evidence.read_text(encoding="utf-8")) + payload["wheel_sha256"] = "e" * 64 + evidence.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(preflight.PreflightError, match="another wheel"): + preflight._public_api_evidence(evidence, release_evidence, release) + + +def test_release_preflight_rejects_public_api_proven_on_another_install(tmp_path): + evidence, release_evidence, release = _write_public_api_evidence(tmp_path) + payload = json.loads(evidence.read_text(encoding="utf-8")) + payload["installed_package"] = str(tmp_path / "other" / "pops") + evidence.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(preflight.PreflightError, match="authenticated installed runtime"): + preflight._public_api_evidence(evidence, release_evidence, release) + + def test_tag_release_cannot_race_or_bypass_supported_matrix_wheel_and_final_gate(): release = (ROOT / ".github" / "workflows" / "release.yml").read_text() wheels = (ROOT / ".github" / "workflows" / "wheels.yml").read_text() diff --git a/tests/python/architecture/test_public_api_parity_proof.py b/tests/python/architecture/test_public_api_parity_proof.py index 53af6f9c8..f352cdfba 100644 --- a/tests/python/architecture/test_public_api_parity_proof.py +++ b/tests/python/architecture/test_public_api_parity_proof.py @@ -3,7 +3,11 @@ from __future__ import annotations import importlib.util +import json +import os from pathlib import Path +import shutil +import subprocess import sys import zipfile @@ -25,6 +29,12 @@ def _load(): proof = _load() +_METADATA = "Metadata-Version: 2.3\nName: PoPS\nVersion: 1.0.0\n" + + +def _distribution_identity() -> dict[str, str]: + return proof._distribution_identity(_METADATA.encode("utf-8"), label="test") + def _synthetic_wheel(path: Path, *, omit: str | None = None) -> None: with zipfile.ZipFile(path, "w") as archive: @@ -37,30 +47,138 @@ def _synthetic_wheel(path: Path, *, omit: str | None = None) -> None: archive.write(source, "pops/" + relative) archive.writestr( "pops-1.0.0.dist-info/METADATA", - "Metadata-Version: 2.3\nName: PoPS\nVersion: 1.0.0\n", + _METADATA, ) +def _installed_package(root: Path) -> Path: + package = root / "site-packages" / "pops" + shutil.copytree( + proof.SOURCE_PACKAGE, + package, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + return package + + +def _installed_distribution(root: Path) -> Path: + package = _installed_package(root) + distribution = package.parent / "pops-1.0.0.dist-info" + distribution.mkdir() + (distribution / "METADATA").write_text( + _METADATA, + encoding="utf-8", + ) + (distribution / "RECORD").write_text( + "pops/__init__.py,,\n" + "pops-1.0.0.dist-info/METADATA,,\n" + "pops-1.0.0.dist-info/RECORD,,\n", + encoding="utf-8", + ) + return package + + def test_exact_wheel_and_source_share_public_api_typing_and_lazy_authoring(tmp_path): wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" _synthetic_wheel(wheel) + installed = _installed_package(tmp_path) - evidence = proof.build_proof(wheel) + evidence = proof.build_proof( + wheel, + installed_package=installed, + installed_distribution=_distribution_identity(), + ) - assert evidence["schema_version"] == 1 + assert evidence["schema_version"] == 3 + assert evidence["producer"]["script"] == "scripts/prove_public_api_parity.py" + assert evidence["distribution"] == _distribution_identity() assert evidence["public_names"] == list(proof.PUBLIC_ROOT) assert evidence["pure_authoring"] is True assert evidence["qualified_handles"] is True assert evidence["py_typed"] is True assert evidence["typed_payload_files"] > 100 + assert evidence["installed"] is True + assert evidence["installed_distribution"] == evidence["distribution"] + assert evidence["installed_package"] == str(installed.resolve()) + assert evidence["installed_typed_payload_sha256"] == evidence["typed_payload_sha256"] + assert evidence["installed_public_api_sha256"] == evidence["public_api_sha256"] def test_wheel_proof_fails_closed_when_typing_payload_is_missing(tmp_path): wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" _synthetic_wheel(wheel, omit="_pops.pyi") + installed = _installed_package(tmp_path) with pytest.raises(proof.PublicApiParityError, match="typing payload"): - proof.build_proof(wheel) + proof.build_proof(wheel, installed_package=installed) + + +def test_installed_proof_rejects_payload_drift_and_source_checkout_alias(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + installed = _installed_package(tmp_path) + (installed / "__init__.py").write_text( + (installed / "__init__.py").read_text(encoding="utf-8") + "\nDRIFT = True\n", + encoding="utf-8", + ) + + with pytest.raises(proof.PublicApiParityError, match="installed Python/typing payload"): + proof.build_proof(wheel, installed_package=installed) + with pytest.raises(proof.PublicApiParityError, match="inside the source checkout"): + proof.build_proof(wheel, installed_package=proof.SOURCE_PACKAGE) + + +def test_installed_proof_rejects_distribution_identity_drift(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + installed = _installed_package(tmp_path) + drifted = {**_distribution_identity(), "version": "1.0.1"} + + with pytest.raises(proof.PublicApiParityError, match="distribution identity"): + proof.build_proof( + wheel, + installed_package=installed, + installed_distribution=drifted, + ) + + +def test_installed_cli_resolves_distribution_after_install_without_checkout_shadowing( + tmp_path, +): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + installed = _installed_distribution(tmp_path) + evidence = tmp_path / "installed-public-api.json" + environment = os.environ.copy() + environment["PYTHONPATH"] = str(installed.parent) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + + completed = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--wheel", + str(wheel), + "--installed", + "--evidence", + str(evidence), + ], + cwd=tmp_path, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + assert completed.returncode == 0, completed.stdout + payload = json.loads(evidence.read_text(encoding="utf-8")) + assert payload["installed"] is True + assert payload["distribution"] == _distribution_identity() + assert payload["installed_distribution"] == payload["distribution"] + assert payload["installed_package"] == str(installed.resolve()) + assert payload["installed_typed_payload_sha256"] == payload["typed_payload_sha256"] + assert payload["installed_public_api_sha256"] == payload["public_api_sha256"] def test_release_workflow_blocks_publication_on_source_wheel_api_parity(): @@ -71,7 +189,10 @@ def test_release_workflow_blocks_publication_on_source_wheel_api_parity(): assert "scripts/prove_public_api_parity.py" in validate assert '--wheel "${wheels[0]}"' in validate + assert "--installed" in validate assert 'pops-final-evidence-public-api.json' in validate + assert '--public-api-evidence "$public_api_evidence"' in validate + assert validate.index("scripts/run_final_gate.py") < validate.index( + "scripts/prove_public_api_parity.py") assert validate.index("scripts/prove_public_api_parity.py") < validate.index( - "scripts/run_final_gate.py" - ) + "scripts/release_preflight.py") diff --git a/tests/python/architecture/test_release_contract.py b/tests/python/architecture/test_release_contract.py index 96e565b6b..558415fbe 100644 --- a/tests/python/architecture/test_release_contract.py +++ b/tests/python/architecture/test_release_contract.py @@ -110,4 +110,7 @@ def test_release_mode_cannot_run_without_tag_install_and_authenticated_evidence( cwd=ROOT, text=True, capture_output=True, ) assert result.returncode != 0 - assert "requires --tag, --installed and --evidence" in result.stderr + assert ( + "requires --tag, --installed, --evidence and --public-api-evidence" + in result.stderr + )