From 127dc9ced242357cc71a5afb97923f348622dd67 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Sat, 25 Jul 2026 10:31:34 -0400 Subject: [PATCH 1/4] RELEASE-3 W3: the one home for artifact facts + exact-artifact install smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan_RELEASE §2.3 (gap G-C), part 1 of 2: the tools the gate jobs call. No src/ production edits. tools/package_verify.py — every packaging fact lives here, so build-dist, package-verify, install-smoke and publish.yml all ask the SAME code rather than four shell blocks that can drift: manifest hash + describe dist/ once, right after the single build verify re-check a dist/ against that manifest (hashes, sizes, name/version agreement across manifest + both filenames + wheel METADATA + sdist PKG-INFO, wheel/sdist membership, both console entry points, package data) hash-check the cheap one-file precondition a smoke cell / publish runs assert-version manifest version == the release tag Stdlib only, so the jobs that use it need no dependency sync. embedded/js/*.js are real package data: the cloner engine reads them at runtime, so a wheel that omits them installs and imports fine and only fails on first use. verify proves the wheel and the sdist carry byte-identical copies — comparing the two artifacts to EACH OTHER, not to the git checkout, so no runner's line-ending policy can make the check lie. tools/install_smoke.py — installs one artifact into a fresh venv by absolute path with caches disabled, proves what landed IS the artifact (version, the package imports from that venv's site-packages, every embedded/js file matches the hash recorded from the wheel), resolves that env's launcher via W1's resolve_launcher, and runs W1's canonical journey UNCHANGED. There is one journey and this is not a second one. tools/corrupt_artifact.py — the bite-proof fixture. Copy-only by construction: it refuses to write to its source and re-checks the source size afterwards, so a negative test can never damage the run's real hashed artifact. tests/test_package_verify.py — 23 hermetic pins over synthetic wheels/sdists (no network, no uv build). Every rule is proven to BITE, not merely to pass: flipped byte, dropped js member (rejected at manifest time AND by verify), wheel/sdist package-data disagreement, missing module, missing entry point, metadata/filename version disagreement, stray .js outside the package, sdist missing package data, manifest schema version, and the tag precondition. .gitignore — found while inspecting the real sdist: .claude/worktrees/ holds a FULL checkout per agent worktree, and hatchling swept all of it into the distribution. A local uv build produced a 56 MB sdist of which 55 MB was that directory; ignoring it gives 14 MB. It also stops ruff linting three extra copies of the source tree (501 files scanned -> 129). Local: ruff format/check, ty (76 baseline), vulture, budgets, suppression owners all clean; unit lane 784 passed (761 pre-existing + 23 new). Both artifact kinds additionally smoked end-to-end on Windows against the real uv build output, and the three bite proofs rehearsed against it. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 7 + tests/test_package_verify.py | 351 +++++++++++++++++++++ tools/corrupt_artifact.py | 103 ++++++ tools/install_smoke.py | 315 +++++++++++++++++++ tools/package_verify.py | 594 +++++++++++++++++++++++++++++++++++ 5 files changed, 1370 insertions(+) create mode 100644 tests/test_package_verify.py create mode 100644 tools/corrupt_artifact.py create mode 100644 tools/install_smoke.py create mode 100644 tools/package_verify.py diff --git a/.gitignore b/.gitignore index 12b203e..dc859e9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,10 @@ element_clones/ # graphify knowledge-graph artifacts (local navigation aid) graphify-out/ .graphify_* + +# Agent worktree scratch: each entry is a FULL checkout of this repo. Left +# un-ignored it is linted as if it were project source (ruff walks it and +# reports the same findings three times over) and, worse, hatchling sweeps it +# into the sdist — a local `uv build` produced a 56 MB sdist of which 55 MB was +# this directory. Ephemeral by construction; never part of the distribution. +.claude/worktrees/ diff --git a/tests/test_package_verify.py b/tests/test_package_verify.py new file mode 100644 index 0000000..3090d83 --- /dev/null +++ b/tests/test_package_verify.py @@ -0,0 +1,351 @@ +"""Hermetic pins for the W3 artifact contract (plan_RELEASE §2.3, gap G-C). + +``tools/package_verify.py`` is the ONE home for "is this distribution the one we +built, and does it carry what it must". These tests build synthetic wheels and +sdists in a tmp dir — no network, no real ``uv build`` — so every rule can be +proven to BITE, not just to pass. The same negative controls run as in-job bite +proofs in CI against a throwaway copy of the real artifact; this file is what +makes them cheap to keep honest. +""" + +from __future__ import annotations + +import io +import json +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools")) + +import package_verify as pv # noqa: E402 PERMANENT(tools/ is not an importable package; the sys.path line above must run first) + +VERSION = "1.2.0" +DIST_STEM = f"stealth_chrome_devtools_mcp-{VERSION}" + + +def _js_body(name: str) -> str: + return f"// {name}\nexport const marker = '{name}';\n" + + +def _metadata(version: str = VERSION, name: str = pv.DIST_NAME) -> str: + return ( + "Metadata-Version: 2.3\n" + f"Name: {name}\n" + f"Version: {version}\n" + "Summary: synthetic fixture\n" + "\n" + "body\n" + ) + + +def _entry_points() -> str: + return ( + "[console_scripts]\n" + "stealth-chrome-devtools = stealth_chrome_devtools_mcp.cli:main\n" + "stealth-chrome-devtools-mcp = stealth_chrome_devtools_mcp.server:main\n" + ) + + +def make_wheel( + path: Path, + *, + version: str = VERSION, + metadata_version: str | None = None, + js: dict[str, str] | None = None, + omit_modules: tuple[str, ...] = (), + entry_points: str | None = None, + extra: dict[str, str] | None = None, +) -> Path: + js = js if js is not None else {n: _js_body(n) for n in pv.EXPECTED_JS} + dist_info = f"stealth_chrome_devtools_mcp-{version}.dist-info" + with zipfile.ZipFile(path, "w") as zf: + for module in pv.EXPECTED_MODULES: + if module in omit_modules: + continue + zf.writestr(f"{pv.PKG}/{module}", f"# {module}\n") + for name, body in js.items(): + zf.writestr(f"{pv.PKG}/embedded/js/{name}", body) + zf.writestr(f"{dist_info}/METADATA", _metadata(metadata_version or version)) + zf.writestr(f"{dist_info}/WHEEL", "Wheel-Version: 1.0\n") + if entry_points is not None: + zf.writestr(f"{dist_info}/entry_points.txt", entry_points) + else: + zf.writestr(f"{dist_info}/entry_points.txt", _entry_points()) + for member, body in (extra or {}).items(): + zf.writestr(member, body) + return path + + +def make_sdist( + path: Path, + *, + version: str = VERSION, + pkg_info_version: str | None = None, + js: dict[str, str] | None = None, + omit_files: tuple[str, ...] = (), +) -> Path: + js = js if js is not None else {n: _js_body(n) for n in pv.EXPECTED_JS} + root = f"stealth_chrome_devtools_mcp-{version}" + files: dict[str, str] = { + "PKG-INFO": _metadata(pkg_info_version or version), + "pyproject.toml": "[project]\nname = 'x'\n", + "README.md": "# readme\n", + } + for name, body in js.items(): + files[f"src/{pv.PKG}/embedded/js/{name}"] = body + with tarfile.open(path, "w:gz") as tf: + for rel, body in files.items(): + if rel in omit_files: + continue + data = body.encode("utf-8") + info = tarfile.TarInfo(f"{root}/{rel}") + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + return path + + +def make_dist(tmp_path: Path, **kwargs: object) -> Path: + """A dist/ dir holding one synthetic wheel + one synthetic sdist.""" + dist = tmp_path / "dist" + dist.mkdir(exist_ok=True) + wheel_kwargs = { + k[len("wheel_") :]: v for k, v in kwargs.items() if k.startswith("wheel_") + } + sdist_kwargs = { + k[len("sdist_") :]: v for k, v in kwargs.items() if k.startswith("sdist_") + } + version = str(kwargs.get("version", VERSION)) + make_wheel( + dist / f"{DIST_STEM.replace(VERSION, version)}-py3-none-any.whl", + version=version, + **wheel_kwargs, + ) + make_sdist( + dist / f"{DIST_STEM.replace(VERSION, version)}.tar.gz", + version=version, + **sdist_kwargs, + ) + return dist + + +@pytest.fixture +def good_dist(tmp_path: Path) -> Path: + return make_dist(tmp_path) + + +@pytest.fixture +def good_manifest(good_dist: Path) -> dict: + return pv.build_manifest(good_dist) + + +# --------------------------------------------------------------------------- +# The contract holds on a well-formed pair. +# --------------------------------------------------------------------------- +def test_manifest_records_version_hashes_and_package_data(good_dist, good_manifest): + assert good_manifest["version"] == VERSION + assert good_manifest["schema_version"] == pv.MANIFEST_SCHEMA_VERSION + kinds = {a["kind"] for a in good_manifest["artifacts"]} + assert kinds == {"wheel", "sdist"} + for entry in good_manifest["artifacts"]: + assert len(entry["sha256"]) == 64 + assert entry["size"] == (good_dist / entry["filename"]).stat().st_size + assert set(good_manifest["package_data"]) == { + f"{pv.PKG}/embedded/js/{n}" for n in pv.EXPECTED_JS + } + + +def test_verify_accepts_the_artifacts_it_described(good_dist, good_manifest): + assert pv.verify_dist(good_dist, good_manifest) == [] + + +# --------------------------------------------------------------------------- +# Bite proofs: each rule must reject something. +# --------------------------------------------------------------------------- +def test_flipped_byte_is_rejected_by_hash(tmp_path, good_dist, good_manifest): + """The publish-path precondition: mutated bytes never pass as the built ones.""" + wheel = next(good_dist.glob("*.whl")) + corrupt = tmp_path / "corrupt" / wheel.name + corrupt.parent.mkdir() + data = bytearray(wheel.read_bytes()) + data[-1] ^= 0xFF + corrupt.write_bytes(bytes(data)) + + problems = pv.check_artifact_hash(corrupt, good_manifest) + assert problems, "a flipped byte must fail the hash check" + assert any("sha256" in p for p in problems) + # The original is untouched — a bite proof never damages the real artifact. + assert pv.check_artifact_hash(wheel, good_manifest) == [] + + +def test_removing_package_data_is_rejected_at_manifest_time(tmp_path): + js = {n: _js_body(n) for n in pv.EXPECTED_JS if n != "extract_styles.js"} + dist = make_dist(tmp_path, wheel_js=js) + with pytest.raises(pv.VerificationError, match="missing package data"): + pv.build_manifest(dist) + + +def test_removing_package_data_is_rejected_by_verify(tmp_path, good_manifest): + """Even with matching hashes elsewhere, a missing member is a violation.""" + js = {n: _js_body(n) for n in pv.EXPECTED_JS if n != "extract_events.js"} + dist = make_dist(tmp_path, wheel_js=js, sdist_js=js) + problems = pv.verify_dist(dist, good_manifest) + assert any("extract_events.js" in p for p in problems), problems + + +def test_wheel_and_sdist_package_data_must_agree(tmp_path): + sdist_js = {n: _js_body(n) for n in pv.EXPECTED_JS} + sdist_js["extract_assets.js"] = "// tampered\n" + dist = make_dist(tmp_path, sdist_js=sdist_js) + manifest = pv.build_manifest(dist) + problems = pv.verify_dist(dist, manifest) + assert any("disagree" in p for p in problems), problems + + +def test_missing_module_is_rejected(tmp_path): + dist = make_dist(tmp_path, wheel_omit_modules=("embedded/cdp_element_cloner.py",)) + manifest = pv.build_manifest(dist) + problems = pv.verify_dist(dist, manifest) + assert any("cdp_element_cloner.py" in p for p in problems), problems + + +def test_missing_entry_point_is_rejected(tmp_path): + only_one = ( + "[console_scripts]\n" + "stealth-chrome-devtools = stealth_chrome_devtools_mcp.cli:main\n" + ) + dist = make_dist(tmp_path, wheel_entry_points=only_one) + manifest = pv.build_manifest(dist) + problems = pv.verify_dist(dist, manifest) + assert any("stealth-chrome-devtools-mcp" in p for p in problems), problems + + +def test_metadata_version_disagreeing_with_filename_is_rejected(tmp_path): + dist = make_dist(tmp_path, wheel_metadata_version="9.9.9") + manifest = pv.build_manifest(dist) + # build_manifest reads the version FROM metadata, so the filename disagrees. + problems = pv.verify_dist(dist, manifest) + assert any("filename version" in p for p in problems), problems + + +def test_stray_js_outside_the_package_is_rejected(tmp_path): + dist = make_dist(tmp_path, wheel_extra={"data/extract_styles.js": "// dup\n"}) + manifest = pv.build_manifest(dist) + problems = pv.verify_dist(dist, manifest) + assert any("outside" in p for p in problems), problems + + +def test_sdist_missing_package_data_is_rejected(tmp_path): + dist = make_dist( + tmp_path, sdist_omit_files=(f"src/{pv.PKG}/embedded/js/extract_structure.js",) + ) + manifest = pv.build_manifest(dist) + problems = pv.verify_dist(dist, manifest) + assert any("extract_structure.js" in p for p in problems), problems + + +def test_two_wheels_in_dist_is_rejected(tmp_path, good_dist): + make_wheel(good_dist / "stealth_chrome_devtools_mcp-1.2.0-py2-none-any.whl") + with pytest.raises(pv.VerificationError, match="exactly 1 wheel"): + pv.find_artifacts(good_dist) + + +# --------------------------------------------------------------------------- +# Manifest loading + the tag precondition. +# --------------------------------------------------------------------------- +def test_manifest_schema_version_is_enforced(tmp_path, good_manifest): + path = tmp_path / "m.json" + good_manifest["schema_version"] = 99 + path.write_text(json.dumps(good_manifest), encoding="utf-8") + with pytest.raises(pv.VerificationError, match="schema_version"): + pv.load_manifest(path) + + +@pytest.mark.parametrize("tag", ["v1.2.0", "1.2.0", "refs/tags/v1.2.0"]) +def test_matching_tag_forms_are_accepted(good_manifest, tag): + assert pv.assert_tag_version(good_manifest, tag) == [] + + +@pytest.mark.parametrize("tag", ["v1.2.1", "v0.9.0", "refs/tags/v2.0.0"]) +def test_mismatched_tag_blocks_publication(good_manifest, tag): + problems = pv.assert_tag_version(good_manifest, tag) + assert problems and "disagree" in problems[0] + + +def test_unknown_artifact_filename_is_rejected(tmp_path, good_manifest): + stranger = tmp_path / "stealth_chrome_devtools_mcp-9.9.9-py3-none-any.whl" + make_wheel(stranger, version="9.9.9") + problems = pv.check_artifact_hash(stranger, good_manifest) + assert problems and "not in the manifest" in problems[0] + + +# --------------------------------------------------------------------------- +# CLI exit codes (what the workflow steps actually depend on). +# --------------------------------------------------------------------------- +def test_cli_manifest_then_verify_round_trips(tmp_path, good_dist): + manifest_path = tmp_path / "out" / "release-manifest.json" + assert ( + pv.main(["manifest", "--dist", str(good_dist), "--out", str(manifest_path)]) + == 0 + ) + evidence = tmp_path / "out" / "package-verify.json" + assert ( + pv.main( + [ + "verify", + "--dist", + str(good_dist), + "--manifest", + str(manifest_path), + "--expect-version", + f"v{VERSION}", + "--out", + str(evidence), + ] + ) + == 0 + ) + record = json.loads(evidence.read_text(encoding="utf-8")) + assert record["verified"] is True + assert record["violations"] == [] + + +def test_cli_hash_check_rejects_a_corrupted_copy(tmp_path, good_dist): + manifest_path = tmp_path / "release-manifest.json" + assert ( + pv.main(["manifest", "--dist", str(good_dist), "--out", str(manifest_path)]) + == 0 + ) + wheel = next(good_dist.glob("*.whl")) + copy = tmp_path / wheel.name + copy.write_bytes(wheel.read_bytes() + b"\x00") + assert ( + pv.main( + [ + "hash-check", + "--artifact", + str(copy), + "--manifest", + str(manifest_path), + ] + ) + == 1 + ) + + +def test_cli_assert_version_blocks_a_wrong_tag(tmp_path, good_dist): + manifest_path = tmp_path / "release-manifest.json" + pv.main(["manifest", "--dist", str(good_dist), "--out", str(manifest_path)]) + assert ( + pv.main(["assert-version", "--manifest", str(manifest_path), "--tag", "v9.9.9"]) + == 1 + ) + assert ( + pv.main( + ["assert-version", "--manifest", str(manifest_path), "--tag", f"v{VERSION}"] + ) + == 0 + ) diff --git a/tools/corrupt_artifact.py b/tools/corrupt_artifact.py new file mode 100644 index 0000000..a088488 --- /dev/null +++ b/tools/corrupt_artifact.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Make a DAMAGED COPY of a built artifact, for the W3 bite proofs. + +plan_RELEASE §2.3 requires proof that ``package-verify``/smoke and the publish +precondition actually reject a bad artifact — a check nobody has ever seen fail +is not evidence. This tool produces the damaged input for that negative test. + +It is deliberately copy-only: ``--source`` is opened read-only and ``--out`` must +be a different path, so the run's real hashed artifact can never be mutated by a +bite proof. No branch, commit, tag, or upload is involved. + +Damage modes +------------ +``--flip-byte`` flip the last byte of the copy (hash changes, nothing else). +``--drop-member M`` rewrite the copied wheel without member ``M`` (e.g. one + ``embedded/js`` script), so the *membership* rule is tested + independently of the hash rule. +""" + +from __future__ import annotations + +import argparse +import sys +import zipfile +from pathlib import Path + + +class CorruptionError(Exception): + """The requested damage could not be applied.""" + + +def flip_last_byte(source: Path, out: Path) -> None: + data = bytearray(source.read_bytes()) + if not data: + raise CorruptionError(f"{source} is empty; nothing to flip") + data[-1] ^= 0xFF + out.write_bytes(bytes(data)) + + +def drop_wheel_member(source: Path, out: Path, member: str) -> None: + """Copy the wheel omitting ``member`` (and leaving everything else intact).""" + with zipfile.ZipFile(source) as src: + names = src.namelist() + if member not in names: + raise CorruptionError( + f"{member!r} is not in {source.name}; cannot drop it " + f"(the bite proof would prove nothing)" + ) + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst: + for info in src.infolist(): + if info.filename == member: + continue + dst.writestr(info, src.read(info.filename)) + + +def _guard_copy_only(source: Path, out: Path) -> None: + """Refuse anything that could damage the run's real, hashed artifact.""" + if source == out: + raise CorruptionError( + "--out must differ from --source: a bite proof never damages " + "the run's real artifact" + ) + if not source.is_file(): + raise CorruptionError(f"source artifact not found: {source}") + + +def _assert_source_intact(source: Path, size_before: int) -> None: + if source.stat().st_size != size_before: + raise CorruptionError(f"source artifact changed size: {source}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--flip-byte", action="store_true") + group.add_argument("--drop-member", default="") + args = parser.parse_args(argv) + + source = args.source.absolute() + out = args.out.absolute() + try: + _guard_copy_only(source, out) + out.parent.mkdir(parents=True, exist_ok=True) + + before = source.stat().st_size + if args.flip_byte: + flip_last_byte(source, out) + damage = "flipped the last byte" + else: + drop_wheel_member(source, out, args.drop_member) + damage = f"dropped member {args.drop_member!r}" + _assert_source_intact(source, before) + print(f"corrupt_artifact: {damage} -> {out} (source {source} untouched)") + except (CorruptionError, OSError, zipfile.BadZipFile) as exc: + print(f"::error title=corrupt-artifact::{exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/install_smoke.py b/tools/install_smoke.py new file mode 100644 index 0000000..8cd8fbb --- /dev/null +++ b/tools/install_smoke.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Install ONE built artifact into a fresh environment and drive W1's journey. + +plan_RELEASE W3 (gap G-C). The gate's other lanes test the *source tree*; this +one tests the *files that will be uploaded to PyPI*. It: + +1. re-checks the downloaded artifact's sha256 against the build manifest + (``tools/package_verify.py hash-check`` — same code the publish job runs); +2. creates a fresh virtual environment and installs that artifact **by absolute + path with caches disabled**, so nothing can be satisfied from a wheel cache, + a site-packages left over from the checkout, or a PyPI download of the same + version; +3. proves the installed distribution is the artifact's: version matches the + manifest, the package imports from *that environment's* site-packages, and + every ``embedded/js`` script on disk is byte-identical to the hash recorded + from the wheel; +4. resolves that environment's console launcher through W1's existing + ``resolve_launcher`` (which uses ``Path.absolute()``, never ``.resolve()`` — + resolving would follow a POSIX venv's ``bin/python`` symlink out of the venv); +5. runs ``tests/release_gate_harness.run_release_gate_journey`` **unchanged**. + There is exactly one canonical journey and this is not a second one. + +Run from the repository root under an environment that has the test extra +installed (the harness needs ``fastmcp`` and ``psutil`` client-side); the +artifact under test supplies its own copies inside the fresh venv. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +# The harness and its e2e_helpers seam live in tests/ (not an installed package). +for _extra_path in (REPO_ROOT, REPO_ROOT / "tests"): + if str(_extra_path) not in sys.path: + sys.path.insert(0, str(_extra_path)) + +import package_verify # noqa: E402 PERMANENT(sys.path bootstrap above must run first) + +from release_gate_harness import ( # noqa: E402 PERMANENT(sys.path bootstrap above must run first) + gate_work_dir, + resolve_launcher, + run_release_gate_journey, +) + +RESULT_SCHEMA_VERSION = 1 +INSTALL_TIMEOUT = 900 +PROBE_TIMEOUT = 180 + +# Printed by the in-venv probe as one JSON line; keeping the marker explicit +# means arbitrary installer chatter on stdout cannot be mistaken for the result. +_PROBE_MARKER = "__INSTALL_SMOKE_PROBE__" + +_PROBE_SOURCE = f""" +import hashlib, importlib.metadata, json, sys +from pathlib import Path + +import stealth_chrome_devtools_mcp as pkg + +root = Path(pkg.__file__).parent +js_dir = root / "embedded" / "js" +payload = {{ + "version": importlib.metadata.version("{package_verify.DIST_NAME}"), + "package_root": str(root), + "executable": sys.executable, + "js": {{ + p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(js_dir.glob("*.js")) + }}, +}} +print("{_PROBE_MARKER}" + json.dumps(payload)) +""" + + +class SmokeError(Exception): + """A smoke precondition failed; the message is the human-readable reason.""" + + +def _run(cmd: list[str], *, timeout: int, cwd: Path | None = None) -> str: + """Run a fully-resolved command, echoing it, and return stdout.""" + print(f"$ {' '.join(cmd)}") + completed = subprocess.run( # noqa: S603 PERMANENT(CI tool; argv is built from resolved absolute paths, never a shell string) + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd) if cwd else None, + ) + if completed.stdout: + print(completed.stdout) + if completed.stderr: + print(completed.stderr, file=sys.stderr) + if completed.returncode != 0: + raise SmokeError( + f"command failed (exit {completed.returncode}): {' '.join(cmd)}" + ) + return completed.stdout + + +def _uv() -> str: + uv = shutil.which("uv") + if uv is None: + raise SmokeError("uv is not on PATH; the smoke needs it to build the fresh env") + return uv + + +def venv_python(venv_dir: Path) -> Path: + """The interpreter inside ``venv_dir`` (absolute, not symlink-resolved).""" + if sys.platform == "win32": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def create_fresh_env(venv_dir: Path, python_version: str) -> Path: + if venv_dir.exists(): + shutil.rmtree(venv_dir) + _run([_uv(), "venv", "--python", python_version, str(venv_dir)], timeout=300) + interpreter = venv_python(venv_dir) + if not interpreter.is_file(): + raise SmokeError(f"fresh environment has no interpreter at {interpreter}") + return interpreter + + +def install_artifact(interpreter: Path, artifact: Path) -> None: + """Install the LOCAL artifact by absolute path with every cache disabled.""" + if not artifact.is_absolute(): + raise SmokeError(f"artifact path must be absolute, got {artifact}") + _run( + [ + _uv(), + "pip", + "install", + "--no-cache", + "--python", + str(interpreter), + str(artifact), + ], + timeout=INSTALL_TIMEOUT, + ) + + +def probe_installation(interpreter: Path, cwd: Path) -> dict[str, object]: + """Ask the fresh environment what it actually installed. + + Runs with ``cwd`` outside the repository so an accidental import of the + checkout (rather than site-packages) cannot pass for the installed package. + """ + stdout = _run( + [str(interpreter), "-c", _PROBE_SOURCE], timeout=PROBE_TIMEOUT, cwd=cwd + ) + for line in stdout.splitlines(): + if line.startswith(_PROBE_MARKER): + return json.loads(line[len(_PROBE_MARKER) :]) + raise SmokeError("in-venv probe produced no result line") + + +def check_installation( + probe: dict[str, object], manifest: dict[str, object], venv_dir: Path +) -> list[str]: + """Violations of "what is installed IS the artifact" (empty == ok).""" + problems: list[str] = [] + expected_version = str(manifest["version"]) + if probe.get("version") != expected_version: + problems.append( + f"installed version {probe.get('version')!r} != artifact " + f"{expected_version!r}" + ) + + package_root = Path(str(probe.get("package_root", ""))) + try: + inside = package_root.is_relative_to(venv_dir) + except ValueError: # pragma: no cover - differing drives on Windows + inside = False + if not inside: + problems.append( + f"package imports from {package_root} which is OUTSIDE the fresh " + f"environment {venv_dir} — the smoke would have tested the checkout" + ) + + recorded = manifest["package_data"] + if not isinstance(recorded, dict): + return [*problems, "manifest 'package_data' is not an object"] + expected_js = {member.rsplit("/", 1)[-1]: h for member, h in recorded.items()} + installed_js = probe.get("js") + if not isinstance(installed_js, dict): + return [*problems, "probe returned no package-data hashes"] + if set(installed_js) != set(expected_js): + problems.append( + f"installed embedded/js {sorted(installed_js)} != artifact " + f"{sorted(expected_js)}" + ) + for name, expected_hash in sorted(expected_js.items()): + actual = installed_js.get(name) + if actual is None: + problems.append(f"package data missing after install: embedded/js/{name}") + elif actual != expected_hash: + problems.append( + f"embedded/js/{name}: installed sha256 {actual} != artifact " + f"{expected_hash}" + ) + return problems + + +def select_artifact(dist_dir: Path, kind: str) -> Path: + """The one wheel or the one sdist in ``dist_dir``, as an ABSOLUTE path. + + Resolved here rather than in the workflow so no shell has to glob a + version-dependent filename and quote a native absolute path on three OSes. + """ + wheel, sdist = package_verify.find_artifacts(dist_dir) + return (wheel if kind == "wheel" else sdist).absolute() + + +def smoke( + *, + dist_dir: Path, + manifest_path: Path, + kind: str, + work_dir: Path, + python_version: str, +) -> dict[str, object]: + artifact = select_artifact(dist_dir, kind) + manifest = package_verify.load_manifest(manifest_path) + + # (1) The downloaded bytes are the built bytes — the same precondition the + # publish job re-runs before it uploads anything. + hash_problems = package_verify.check_artifact_hash(artifact, manifest) + if hash_problems: + raise SmokeError("; ".join(hash_problems)) + + work_dir.mkdir(parents=True, exist_ok=True) + venv_dir = work_dir / "venv" + probe_cwd = work_dir / "probe-cwd" + probe_cwd.mkdir(exist_ok=True) + + # (2) fresh env + local install, caches off. + interpreter = create_fresh_env(venv_dir, python_version) + install_artifact(interpreter, artifact) + + # (3) what landed IS the artifact. + probe = probe_installation(interpreter, probe_cwd) + problems = check_installation(probe, manifest, venv_dir) + if problems: + raise SmokeError("; ".join(problems)) + + # (4) that environment's console launcher, absolute and unfollowed. + launcher = resolve_launcher(interpreter) + if not launcher.is_relative_to(venv_dir): + raise SmokeError( + f"resolved launcher {launcher} is outside the fresh environment {venv_dir}" + ) + + # (5) W1's canonical journey, unchanged, against that launcher. + journey_dir = gate_work_dir(work_dir / "gate") + journey_dir.mkdir(parents=True, exist_ok=True) + record = asyncio.run( + run_release_gate_journey(launcher=launcher, work_dir=journey_dir) + ) + return { + "schema_version": RESULT_SCHEMA_VERSION, + "artifact": artifact.name, + "artifact_kind": kind, + "artifact_sha256": package_verify.sha256_file(artifact), + "version": manifest["version"], + "installed_version": probe.get("version"), + "package_root": probe.get("package_root"), + "launcher": str(launcher), + "venv": str(venv_dir), + "python_version": python_version, + "journey": record, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Install one built artifact into a fresh env and run W1's journey." + ) + parser.add_argument("--dist-dir", type=Path, default=Path("dist")) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--kind", choices=("wheel", "sdist"), required=True) + parser.add_argument("--work-dir", type=Path, required=True) + parser.add_argument("--python", default="3.12") + parser.add_argument("--out", type=Path, default=None) + args = parser.parse_args(argv) + + try: + result = smoke( + dist_dir=args.dist_dir, + manifest_path=args.manifest, + kind=args.kind, + work_dir=args.work_dir, + python_version=args.python, + ) + except (SmokeError, package_verify.VerificationError) as exc: + print(f"::error title=install-smoke::{exc}", file=sys.stderr) + return 1 + + text = json.dumps(result, indent=2, sort_keys=True, default=str) + if args.out is not None: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(text + "\n", encoding="utf-8") + print(text) + print(f"install-smoke {args.kind}: OK ({result['artifact']})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/package_verify.py b/tools/package_verify.py new file mode 100644 index 0000000..226d197 --- /dev/null +++ b/tools/package_verify.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python3 +"""The ONE home for distribution-artifact facts (plan_RELEASE W3, gap G-C). + +Every job that needs to know something about the built distribution asks this +tool — ``build-dist`` (write the manifest), ``package-verify`` (re-check it), +``install-smoke`` (hash-check the cell's own download), and ``publish.yml`` +(re-check + tag/version precondition). There is deliberately no second copy of +these rules in YAML: a check that lives in one workflow's shell block cannot be +unit-tested and cannot be reused by the publish path. + +Subcommands +----------- +``manifest`` hash + describe ``dist/`` once, right after the single build. +``verify`` re-check a ``dist/`` against a manifest (hashes, metadata, + version agreement, wheel/sdist membership, package data). +``hash-check`` re-check ONE artifact file against the manifest (the cheap + precondition an install cell or the publish job runs). +``assert-version`` manifest version == a release tag (``v1.2.0`` or ``1.2.0``). + +Package data +------------ +``embedded/js/*.js`` are real package data: the cloner engine reads them at +runtime, so a wheel that omits them installs and imports fine and then fails on +first use. ``manifest`` records the sha256 of every one of them, ``verify`` +proves the wheel and the sdist carry BYTE-IDENTICAL copies, and +``tools/install_smoke.py`` proves the files that land in a fresh site-packages +are those same bytes. Comparing the two artifacts to each other (rather than to +the git checkout) keeps the check hermetic — no line-ending policy of a +particular runner's checkout can make it lie. + +Stdlib only. Exit 0 == every checked property holds; exit 1 == at least one +violation, each printed as a GitHub error annotation. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import tarfile +import zipfile +from email.parser import Parser +from pathlib import Path + +MANIFEST_SCHEMA_VERSION = 1 +DIST_NAME = "stealth-chrome-devtools-mcp" +PKG = "stealth_chrome_devtools_mcp" + +# The browser-side extraction scripts (CLAUDE.md: the cloner subsystem's `js/`). +EXPECTED_JS: tuple[str, ...] = ( + "comprehensive_element_extractor.js", + "extract_animations.js", + "extract_assets.js", + "extract_events.js", + "extract_related_files.js", + "extract_structure.js", + "extract_styles.js", +) + +# Python modules whose absence would mean a structurally broken wheel. +EXPECTED_MODULES: tuple[str, ...] = ( + "__init__.py", + "server.py", + "cli.py", + "settings.py", + "embedded/server.py", + "embedded/singleton.py", + "embedded/cdp_element_cloner.py", +) + +# Both console scripts (pyproject [project.scripts]). install-smoke resolves the +# first one out of a fresh environment, so a missing entry point must be caught +# here rather than as a confusing "launcher not found" three jobs later. +EXPECTED_ENTRY_POINTS: tuple[str, ...] = ( + "stealth-chrome-devtools-mcp", + "stealth-chrome-devtools", +) + +_CHUNK = 1024 * 1024 +# "-[-…]" — anything shorter than +# name+version cannot be a distribution filename. +_MIN_FILENAME_PARTS = 2 + + +class VerificationError(Exception): + """A checked property does not hold. Message is the human-readable reason.""" + + +# --------------------------------------------------------------------------- +# Hashing + artifact discovery. +# --------------------------------------------------------------------------- +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(_CHUNK): + digest.update(chunk) + return digest.hexdigest() + + +def _normalize(name: str) -> str: + """PEP 503 normalization, so ``foo_bar`` and ``Foo-Bar`` compare equal.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def find_artifacts(dist_dir: Path) -> tuple[Path, Path]: + """Return ``(wheel, sdist)``; exactly one of each must be present.""" + wheels = sorted(dist_dir.glob("*.whl")) + sdists = sorted(dist_dir.glob("*.tar.gz")) + if len(wheels) != 1: + raise VerificationError( + f"expected exactly 1 wheel in {dist_dir}, found {len(wheels)}: " + f"{[w.name for w in wheels]}" + ) + if len(sdists) != 1: + raise VerificationError( + f"expected exactly 1 sdist in {dist_dir}, found {len(sdists)}: " + f"{[s.name for s in sdists]}" + ) + return wheels[0], sdists[0] + + +# --------------------------------------------------------------------------- +# Reading inside the artifacts. +# --------------------------------------------------------------------------- +def wheel_members(wheel: Path) -> list[str]: + with zipfile.ZipFile(wheel) as zf: + return zf.namelist() + + +def sdist_members(sdist: Path) -> list[str]: + with tarfile.open(sdist, "r:gz") as tf: + return tf.getnames() + + +def _read_wheel(wheel: Path, member: str) -> bytes: + with zipfile.ZipFile(wheel) as zf: + return zf.read(member) + + +def _read_sdist(sdist: Path, member: str) -> bytes: + with tarfile.open(sdist, "r:gz") as tf: + extracted = tf.extractfile(member) + if extracted is None: + raise VerificationError(f"sdist member is not a regular file: {member}") + with extracted: + return extracted.read() + + +def _metadata_fields(text: str) -> tuple[str, str]: + """``(Name, Version)`` from a core-metadata document (METADATA / PKG-INFO).""" + message = Parser().parsestr(text) + name = message.get("Name") or "" + version = message.get("Version") or "" + if not name or not version: + raise VerificationError( + f"core metadata is missing Name/Version (name={name!r} version={version!r})" + ) + return name, version + + +def _dist_info_dir(members: list[str]) -> str: + dirs = { + m.split("/", 1)[0] for m in members if m.split("/", 1)[0].endswith(".dist-info") + } + if len(dirs) != 1: + raise VerificationError( + f"wheel must contain exactly one .dist-info directory, found {sorted(dirs)}" + ) + return next(iter(dirs)) + + +def _sdist_root(members: list[str]) -> str: + roots = {m.split("/", 1)[0] for m in members if "/" in m} + if len(roots) != 1: + raise VerificationError( + f"sdist must contain exactly one top-level directory, found {sorted(roots)}" + ) + return next(iter(roots)) + + +def _version_from_filename(filename: str, *, wheel: bool) -> str: + """The version segment of a PEP 427 wheel / PEP 625 sdist filename.""" + stem = filename[: -len(".whl")] if wheel else filename[: -len(".tar.gz")] + parts = stem.split("-") + if len(parts) < _MIN_FILENAME_PARTS: + raise VerificationError(f"cannot parse a version out of {filename!r}") + return parts[1] + + +# --------------------------------------------------------------------------- +# manifest +# --------------------------------------------------------------------------- +def build_manifest(dist_dir: Path) -> dict[str, object]: + """Describe ``dist/`` once: hashes, sizes, version, and package-data hashes. + + The package-data hashes come from the WHEEL (the artifact whose layout is + what actually lands in site-packages); ``verify`` then proves the sdist + agrees, and ``install_smoke`` proves site-packages agrees. + """ + wheel, sdist = find_artifacts(dist_dir) + members = wheel_members(wheel) + dist_info = _dist_info_dir(members) + name, version = _metadata_fields( + _read_wheel(wheel, f"{dist_info}/METADATA").decode("utf-8") + ) + + package_data: dict[str, str] = {} + with zipfile.ZipFile(wheel) as zf: + for js in EXPECTED_JS: + member = f"{PKG}/embedded/js/{js}" + if member not in members: + raise VerificationError(f"wheel is missing package data: {member}") + package_data[member] = hashlib.sha256(zf.read(member)).hexdigest() + + return { + "schema_version": MANIFEST_SCHEMA_VERSION, + "name": name, + "version": version, + "artifacts": [ + { + "filename": path.name, + "kind": kind, + "sha256": sha256_file(path), + "size": path.stat().st_size, + } + for path, kind in ((wheel, "wheel"), (sdist, "sdist")) + ], + "package_data": package_data, + } + + +def load_manifest(path: Path) -> dict[str, object]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise VerificationError(f"manifest {path} is not a JSON object") + if data.get("schema_version") != MANIFEST_SCHEMA_VERSION: + raise VerificationError( + f"manifest schema_version {data.get('schema_version')!r} " + f"!= expected {MANIFEST_SCHEMA_VERSION}" + ) + for key in ("name", "version", "artifacts", "package_data"): + if key not in data: + raise VerificationError(f"manifest {path} is missing {key!r}") + return data + + +def manifest_entry(manifest: dict[str, object], filename: str) -> dict[str, object]: + artifacts = manifest["artifacts"] + if not isinstance(artifacts, list): + raise VerificationError("manifest 'artifacts' is not a list") + for entry in artifacts: + if isinstance(entry, dict) and entry.get("filename") == filename: + return entry + raise VerificationError( + f"{filename!r} is not in the manifest (manifest lists " + f"{[e.get('filename') for e in artifacts if isinstance(e, dict)]})" + ) + + +def check_artifact_hash(artifact: Path, manifest: dict[str, object]) -> list[str]: + """Violations for ONE artifact file against its manifest entry.""" + problems: list[str] = [] + try: + entry = manifest_entry(manifest, artifact.name) + except VerificationError as exc: + return [str(exc)] + actual = sha256_file(artifact) + if actual != entry.get("sha256"): + problems.append( + f"{artifact.name}: sha256 {actual} != manifest {entry.get('sha256')}" + ) + actual_size = artifact.stat().st_size + if actual_size != entry.get("size"): + problems.append( + f"{artifact.name}: size {actual_size} != manifest {entry.get('size')}" + ) + return problems + + +# --------------------------------------------------------------------------- +# verify +# --------------------------------------------------------------------------- +def verify_dist(dist_dir: Path, manifest: dict[str, object]) -> list[str]: + """Every violation found in ``dist_dir`` relative to ``manifest`` (empty == ok). + + Collects rather than raises: one run should report every problem, not just + the first, so a broken build costs one CI round instead of five. + """ + problems: list[str] = [] + try: + wheel, sdist = find_artifacts(dist_dir) + except VerificationError as exc: + return [str(exc)] + + version = str(manifest["version"]) + manifest_files = { + e.get("filename") for e in manifest["artifacts"] if isinstance(e, dict) + } + if manifest_files != {wheel.name, sdist.name}: + problems.append( + f"dist contents {sorted({wheel.name, sdist.name})} != manifest " + f"{sorted(str(f) for f in manifest_files)}" + ) + + # 1. Byte identity with what was built. + problems.extend(check_artifact_hash(wheel, manifest)) + problems.extend(check_artifact_hash(sdist, manifest)) + + # 2. Name/version agreement across manifest, both filenames, and both + # core-metadata documents. A single disagreeing source is a red gate. + problems.extend(_check_identity(wheel, sdist, manifest, version)) + + # 3. Structural membership + package data. + problems.extend(_check_wheel_members(wheel)) + problems.extend(_check_sdist_members(sdist)) + problems.extend(_check_package_data(wheel, sdist, manifest)) + return problems + + +def _check_identity( + wheel: Path, sdist: Path, manifest: dict[str, object], version: str +) -> list[str]: + problems: list[str] = [] + if _normalize(str(manifest["name"])) != _normalize(DIST_NAME): + problems.append(f"manifest name {manifest['name']!r} != expected {DIST_NAME!r}") + for path, is_wheel in ((wheel, True), (sdist, False)): + try: + filename_version = _version_from_filename(path.name, wheel=is_wheel) + except VerificationError as exc: + problems.append(str(exc)) + continue + if filename_version != version: + problems.append( + f"{path.name}: filename version {filename_version!r} " + f"!= manifest version {version!r}" + ) + try: + members = wheel_members(wheel) + w_name, w_version = _metadata_fields( + _read_wheel(wheel, f"{_dist_info_dir(members)}/METADATA").decode("utf-8") + ) + if _normalize(w_name) != _normalize(DIST_NAME): + problems.append(f"wheel METADATA Name {w_name!r} != {DIST_NAME!r}") + if w_version != version: + problems.append( + f"wheel METADATA Version {w_version!r} != manifest {version!r}" + ) + except (VerificationError, KeyError) as exc: + problems.append(f"wheel METADATA unreadable: {exc}") + try: + s_members = sdist_members(sdist) + s_name, s_version = _metadata_fields( + _read_sdist(sdist, f"{_sdist_root(s_members)}/PKG-INFO").decode("utf-8") + ) + if _normalize(s_name) != _normalize(DIST_NAME): + problems.append(f"sdist PKG-INFO Name {s_name!r} != {DIST_NAME!r}") + if s_version != version: + problems.append( + f"sdist PKG-INFO Version {s_version!r} != manifest {version!r}" + ) + except (VerificationError, KeyError) as exc: + problems.append(f"sdist PKG-INFO unreadable: {exc}") + return problems + + +def _check_wheel_members(wheel: Path) -> list[str]: + problems: list[str] = [] + try: + members = wheel_members(wheel) + except (OSError, zipfile.BadZipFile) as exc: + return [f"wheel is unreadable: {exc}"] + member_set = set(members) + problems.extend( + f"wheel is missing module: {PKG}/{module}" + for module in EXPECTED_MODULES + if f"{PKG}/{module}" not in member_set + ) + problems.extend( + f"wheel is missing package data: {PKG}/embedded/js/{js}" + for js in EXPECTED_JS + if f"{PKG}/embedded/js/{js}" not in member_set + ) + # No stray copy of the js scripts outside the package (the duplicate-file + # trap pyproject.toml warns about — a force-include would land them twice). + stray = sorted( + m + for m in member_set + if m.endswith(".js") and not m.startswith(f"{PKG}/embedded/js/") + ) + if stray: + problems.append(f"wheel carries .js outside {PKG}/embedded/js/: {stray}") + try: + dist_info = _dist_info_dir(members) + except VerificationError as exc: + return [*problems, str(exc)] + entry_points_member = f"{dist_info}/entry_points.txt" + if entry_points_member not in member_set: + problems.append(f"wheel is missing {entry_points_member}") + else: + text = _read_wheel(wheel, entry_points_member).decode("utf-8") + problems.extend( + f"wheel entry_points.txt is missing {script!r}" + for script in EXPECTED_ENTRY_POINTS + if f"{script} =" not in text and f"{script}=" not in text + ) + return problems + + +def _check_sdist_members(sdist: Path) -> list[str]: + problems: list[str] = [] + try: + members = sdist_members(sdist) + root = _sdist_root(members) + except (OSError, tarfile.TarError, VerificationError) as exc: + return [f"sdist is unreadable: {exc}"] + member_set = set(members) + problems.extend( + f"sdist is missing {required}" + for required in ("pyproject.toml", "README.md") + if f"{root}/{required}" not in member_set + ) + problems.extend( + f"sdist is missing package data: src/{PKG}/embedded/js/{js}" + for js in EXPECTED_JS + if f"{root}/src/{PKG}/embedded/js/{js}" not in member_set + ) + return problems + + +def _check_package_data( + wheel: Path, sdist: Path, manifest: dict[str, object] +) -> list[str]: + """Wheel js == manifest js == sdist js, byte for byte.""" + problems: list[str] = [] + recorded = manifest["package_data"] + if not isinstance(recorded, dict): + return ["manifest 'package_data' is not an object"] + expected_keys = {f"{PKG}/embedded/js/{js}" for js in EXPECTED_JS} + if set(recorded) != expected_keys: + problems.append( + f"manifest package_data keys {sorted(recorded)} != expected " + f"{sorted(expected_keys)}" + ) + try: + sdist_root = _sdist_root(sdist_members(sdist)) + except (OSError, tarfile.TarError, VerificationError) as exc: + return [*problems, f"sdist is unreadable: {exc}"] + + wheel_names = set(wheel_members(wheel)) + for member, expected_hash in sorted(recorded.items()): + if member not in wheel_names: + problems.append(f"wheel is missing recorded package data: {member}") + continue + actual = hashlib.sha256(_read_wheel(wheel, member)).hexdigest() + if actual != expected_hash: + problems.append( + f"{member}: wheel sha256 {actual} != manifest {expected_hash}" + ) + js_name = member.rsplit("/", 1)[-1] + sdist_member = f"{sdist_root}/src/{PKG}/embedded/js/{js_name}" + try: + sdist_hash = hashlib.sha256(_read_sdist(sdist, sdist_member)).hexdigest() + except (KeyError, VerificationError) as exc: + problems.append(f"sdist package data unreadable ({sdist_member}): {exc}") + continue + if sdist_hash != expected_hash: + problems.append( + f"{js_name}: sdist sha256 {sdist_hash} != wheel/manifest " + f"{expected_hash} (the two publishable artifacts disagree)" + ) + return problems + + +# --------------------------------------------------------------------------- +# assert-version +# --------------------------------------------------------------------------- +def assert_tag_version(manifest: dict[str, object], tag: str) -> list[str]: + """``v1.2.0``/``refs/tags/v1.2.0``/``1.2.0`` must equal the built version.""" + bare = tag.removeprefix("refs/tags/").removeprefix("v") + version = str(manifest["version"]) + if bare != version: + return [ + f"release tag {tag!r} (version {bare!r}) != built artifact version " + f"{version!r} — the tag and the distribution disagree" + ] + return [] + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def _emit(problems: list[str], *, context: str) -> int: + if problems: + print(f"{context}: {len(problems)} violation(s)") + for problem in problems: + print(f"::error title={context}::{problem}", file=sys.stderr) + print(f" - {problem}") + return 1 + print(f"{context}: OK") + return 0 + + +def _cmd_manifest(args: argparse.Namespace) -> int: + manifest = build_manifest(args.dist) + text = json.dumps(manifest, indent=2, sort_keys=True) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(text + "\n", encoding="utf-8") + print(text) + return 0 + + +def _cmd_verify(args: argparse.Namespace) -> int: + manifest = load_manifest(args.manifest) + problems = verify_dist(args.dist, manifest) + if args.expect_version: + problems.extend(assert_tag_version(manifest, args.expect_version)) + if args.out is not None: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps( + { + "schema_version": MANIFEST_SCHEMA_VERSION, + "dist_dir": str(args.dist), + "version": manifest["version"], + "artifacts": manifest["artifacts"], + "violations": problems, + "verified": not problems, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + return _emit(problems, context="package-verify") + + +def _cmd_hash_check(args: argparse.Namespace) -> int: + manifest = load_manifest(args.manifest) + if not args.artifact.is_file(): + return _emit([f"artifact not found: {args.artifact}"], context="hash-check") + return _emit( + check_artifact_hash(args.artifact, manifest), + context=f"hash-check {args.artifact.name}", + ) + + +def _cmd_assert_version(args: argparse.Namespace) -> int: + manifest = load_manifest(args.manifest) + return _emit(assert_tag_version(manifest, args.tag), context="assert-version") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + + p_manifest = sub.add_parser("manifest", help="hash dist/ and write the manifest") + p_manifest.add_argument("--dist", type=Path, default=Path("dist")) + p_manifest.add_argument("--out", type=Path, required=True) + p_manifest.set_defaults(func=_cmd_manifest) + + p_verify = sub.add_parser("verify", help="re-check dist/ against a manifest") + p_verify.add_argument("--dist", type=Path, default=Path("dist")) + p_verify.add_argument("--manifest", type=Path, required=True) + p_verify.add_argument("--expect-version", default="") + p_verify.add_argument("--out", type=Path, default=None) + p_verify.set_defaults(func=_cmd_verify) + + p_hash = sub.add_parser("hash-check", help="re-check one artifact file") + p_hash.add_argument("--artifact", type=Path, required=True) + p_hash.add_argument("--manifest", type=Path, required=True) + p_hash.set_defaults(func=_cmd_hash_check) + + p_tag = sub.add_parser("assert-version", help="manifest version == release tag") + p_tag.add_argument("--manifest", type=Path, required=True) + p_tag.add_argument("--tag", required=True) + p_tag.set_defaults(func=_cmd_assert_version) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + return int(args.func(args)) + except VerificationError as exc: + return _emit([str(exc)], context=args.command) + except (OSError, ValueError, zipfile.BadZipFile, tarfile.TarError) as exc: + return _emit([f"{type(exc).__name__}: {exc}"], context=args.command) + + +if __name__ == "__main__": + sys.exit(main()) From 2e589b740e7e8292aa808385b9278ac69ba00011 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Sat, 25 Jul 2026 10:31:57 -0400 Subject: [PATCH 2/4] RELEASE-3 W3: build once, gate those exact files, publish those exact files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan_RELEASE §2.3 (gap G-C), part 2 of 2: the topology. Job semantics stay in the ONE reusable release-gate.yml; publish.yml CALLS it rather than re-implementing a second source of truth. release-gate.yml gains three edges, all wired directly into the stable `release-gate` aggregate (which already fails on any non-success dependency, including skipped/cancelled/missing): build-dist the ONLY `uv build` in the run. Validates metadata and package data, hashes everything into release-manifest.json, and uploads dist/ as the run's one immutable artifact. Exposes the built version as a workflow output. package-verify downloads that artifact and re-checks it independently, then runs three IN-JOB BITE PROOFS against throwaway copies (the real artifact is never touched, and no branch, commit, push, tag or publication is involved): 1/3 a flipped byte is rejected by the hash check 2/3 a wheel missing embedded/js is rejected — the copy is re-hashed into its OWN manifest first, so only the membership rule can reject it, independent of hashing 3/3 the publish tag precondition rejects a wrong tag and finally re-verifies that the real artifact is intact. install-smoke {wheel, sdist} x {Ubuntu/X64, Windows/X64, macOS/ARM64}: each cell downloads the artifact, hash-checks it, installs it into a fresh venv by absolute path with caches disabled, and runs W1's canonical journey through the launcher that install produced. No cell rebuilds or fetches from PyPI. A new `release_tag` input makes the gate itself fail when a tag disagrees with the built artifact's metadata version, so the disagreement is caught before any publish job exists to be blocked. publish.yml is rewritten around the same gate: at the tag SHA it calls release-gate.yml with `ref` and `release_tag`, so a tag is qualified by exactly the jobs a pull request runs — including the six install-smoke cells. `publish` then needs that gate, downloads the already-hashed dist/ from the same run, re-verifies hashes/membership/tag agreement, and uploads those very files to PyPI and the GitHub release. It never runs `uv build`, never rebuilds from the checkout, and never re-downloads the version from PyPI. Any failed, skipped or cancelled cell leaves needs.gate non-success and nothing is published. The old three-job "unit tests on ubuntu then build and publish" path is gone; permissions are also narrowed to the publish job. actionlint 1.7.7 clean on all three workflows (release-gate.yml, publish.yml, test.yml). The `runner` context is used only at step level, never in job-level env. The macOS install-smoke cells are included here deliberately and NOT pre-excluded: whether F-773 reaches this lane is an empirical question and this run is the experiment. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish.yml | 90 ++++++------ .github/workflows/release-gate.yml | 226 +++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 45 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dada748..4ce9c19 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,72 +1,69 @@ name: Publish to PyPI +# plan_RELEASE W3 (gap G-C): a tag publishes the ARTIFACTS THE GATE TESTED. +# +# The gate is not re-implemented here — this workflow CALLS the one reusable +# `release-gate.yml` at the tag's SHA, so the tag is qualified by exactly the +# same jobs (including the six install-smoke cells) a pull request runs. That +# call builds the distribution once and uploads it as the run's `dist` artifact; +# `publish` then DOWNLOADS those same files, re-checks their SHA-256 hashes +# against the build manifest, and uploads them. It never runs `uv build`, never +# rebuilds from the checkout, and never fetches the version from PyPI. +# +# Because `publish` needs the gate job, a failed, skipped, or cancelled cell in +# ANY gate lane leaves `needs.gate` non-success and neither PyPI nor the GitHub +# release happens. + on: push: tags: - "v*" permissions: - contents: write - id-token: write + contents: read jobs: - test: - name: Run tests before publish - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12", "3.13"] - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Install uv - uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} - - - name: Install dependencies - run: uv sync --extra test - - - name: Run unit tests - run: uv run pytest -m "not integration" -v --tb=short + # The full release gate, at the tag SHA, with the tag passed in so the gate + # itself fails if the tag and the built artifact's metadata version disagree. + gate: + uses: ./.github/workflows/release-gate.yml + with: + ref: ${{ github.sha }} + release_tag: ${{ github.ref_name }} publish: - name: Build and publish + name: Publish the gated artifacts runs-on: ubuntu-latest - needs: test + needs: gate environment: pypi + permissions: + contents: write # create the GitHub release + id-token: write # PyPI trusted publishing steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - - name: Install uv - uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - - - name: Set up Python - run: uv python install 3.12 + - name: Download the gated dist artifact (no rebuild) + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: dist - - name: Verify version matches tag + - name: Re-check hashes, membership, and the tag/version agreement + shell: bash run: | - TAG_VERSION="${GITHUB_REF#refs/tags/v}" - PKG_VERSION=$(python -c " - import re - with open('pyproject.toml') as f: - m = re.search(r'version\s*=\s*\"([^\"]+)\"', f.read()) - print(m.group(1)) - ") - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "::error::Tag v${TAG_VERSION} does not match pyproject.toml version ${PKG_VERSION}" - exit 1 - fi - echo "VERSION=${TAG_VERSION}" >> "$GITHUB_ENV" - - - name: Build package - run: uv build + set -euo pipefail + python3 tools/package_verify.py verify \ + --dist dist --manifest release-manifest.json \ + --expect-version "${GITHUB_REF_NAME}" + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + echo "These exact files are about to be published:" + sha256sum dist/* - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + packages-dir: dist - name: Generate changelog run: | @@ -111,6 +108,9 @@ jobs: name: v${{ env.VERSION }} body_path: changelog.md append_body: true + files: | + dist/*.whl + dist/*.tar.gz body: | ## Installation diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index aa36b13..6b55c9c 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -10,6 +10,13 @@ name: release-gate # offline-stealth. W3 adds build-dist, package-verify, install-smoke; W5 adds # release-evidence. Each new edge slots into the aggregate's `needs:` list — the # aggregate is never replaced and never forgets a direct edge. +# +# W3 topology (gap G-C): the distribution is built EXACTLY ONCE, in `build-dist`, +# which hashes it into a manifest and uploads it as the run's one immutable +# `dist` artifact. `package-verify` and every `install-smoke` cell DOWNLOAD that +# artifact and re-check its hashes; none of them runs `uv build`, rebuilds from +# the checkout, or fetches the same version from PyPI. `publish.yml` calls this +# same workflow at the tag SHA and uploads those very files. on: workflow_call: @@ -22,6 +29,19 @@ on: type: string required: false default: "" + release_tag: + description: >- + Release tag being qualified (``v1.2.0``/``refs/tags/v1.2.0``). When + non-empty, `build-dist` fails unless the built artifact's metadata + version equals it — so a tag/version disagreement is caught by the + gate itself, before any publish job can run. + type: string + required: false + default: "" + outputs: + version: + description: The version recorded in this run's build manifest. + value: ${{ jobs.build-dist.outputs.version }} permissions: contents: read @@ -343,6 +363,206 @@ jobs: fi exit "$code" + # ── W3: build ONCE, verify and smoke THOSE EXACT FILES (gap G-C) ─────────── + # The only `uv build` in the entire run. Everything downstream consumes the + # uploaded artifact; nothing rebuilds and nothing downloads this version from + # PyPI. `python3` (not `uv run`) runs the packaging tools deliberately: they + # are stdlib-only by design, so this job needs no dependency sync at all. + build-dist: + name: build-dist + runs-on: ubuntu-latest + outputs: + version: ${{ steps.manifest.outputs.version }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref }} + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 + - name: Set up Python 3.12 + run: uv python install 3.12 + - name: Build the distribution (the ONLY build in this run) + run: uv build + - name: Validate metadata + package data, hash into the manifest + id: manifest + shell: bash + run: | + set -euo pipefail + python3 tools/package_verify.py manifest \ + --dist dist --out release-manifest.json + version=$(python3 -c "import json;print(json.load(open('release-manifest.json'))['version'])") + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "built version ${version}" + - name: Assert the release tag matches the built version + if: inputs.release_tag != '' + run: >- + python3 tools/package_verify.py assert-version + --manifest release-manifest.json --tag "${{ inputs.release_tag }}" + - name: Upload the one immutable dist artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: dist + path: | + dist + release-manifest.json + if-no-files-found: error + + # Independent re-check of the DOWNLOADED bytes, plus the negative controls + # that prove these rules can actually fail. Distinct from installation. + package-verify: + name: package-verify + needs: build-dist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref }} + - name: Download the one dist artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: dist + - name: Verify the downloaded artifacts against the build manifest + run: >- + python3 tools/package_verify.py verify --dist dist + --manifest release-manifest.json --out package-verify.json + # ── Bite proofs. Each corrupts a THROWAWAY COPY and asserts rejection; + # the run's real hashed artifact is never touched (corrupt_artifact.py + # refuses to write to its source and re-checks the source size). No + # branch, commit, push, tag, or publication is involved. + - name: 'BITE PROOF 1/3: a flipped byte is rejected by the hash check' + shell: bash + run: | + set -euo pipefail + wheel=$(ls dist/*.whl) + copy="bite-hash/$(basename "$wheel")" + python3 tools/corrupt_artifact.py --source "$wheel" --out "$copy" --flip-byte + if python3 tools/package_verify.py hash-check \ + --artifact "$copy" --manifest release-manifest.json; then + echo "::error::hash-check ACCEPTED a corrupted artifact" + exit 1 + fi + echo "OK: the publish/smoke hash precondition rejects a mutated copy." + - name: 'BITE PROOF 2/3: a wheel missing package data is rejected' + shell: bash + # Independent of the hash rule on purpose: the copy is re-hashed into + # its OWN manifest, so only the membership rule can reject it. + run: | + set -euo pipefail + wheel=$(ls dist/*.whl) + sdist=$(ls dist/*.tar.gz) + mkdir -p bite-member + python3 tools/corrupt_artifact.py --source "$wheel" \ + --out "bite-member/$(basename "$wheel")" \ + --drop-member stealth_chrome_devtools_mcp/embedded/js/extract_styles.js + cp "$sdist" bite-member/ + if python3 tools/package_verify.py manifest \ + --dist bite-member --out bite-member/manifest.json; then + echo "::error::manifest ACCEPTED a wheel missing embedded/js package data" + exit 1 + fi + if python3 tools/package_verify.py verify \ + --dist bite-member --manifest release-manifest.json; then + echo "::error::verify ACCEPTED a wheel missing embedded/js package data" + exit 1 + fi + echo "OK: a wheel that would install and then fail at runtime is rejected." + - name: 'BITE PROOF 3/3: the publish tag precondition rejects a wrong tag' + shell: bash + run: | + set -euo pipefail + if python3 tools/package_verify.py assert-version \ + --manifest release-manifest.json --tag v0.0.0; then + echo "::error::assert-version ACCEPTED a tag that is not the built version" + exit 1 + fi + echo "OK: publishing under a tag that disagrees with the artifact is blocked." + - name: The real artifact is still intact after the bite proofs + run: >- + python3 tools/package_verify.py verify --dist dist + --manifest release-manifest.json + - name: Upload package-verify evidence + if: always() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: package-verify-evidence + path: | + package-verify.json + release-manifest.json + + # Install the EXACT downloaded artifact into a fresh environment and run W1's + # canonical journey through the launcher that install produced. Both + # publishable artifacts on every qualified cell. + install-smoke: + name: install-smoke (${{ matrix.kind }} ${{ matrix.cell.runner_os }}/${{ matrix.cell.runner_arch }}) + needs: build-dist + runs-on: ${{ matrix.cell.os }} + strategy: + fail-fast: false + matrix: + kind: [wheel, sdist] + cell: + - { os: ubuntu-latest, runner_os: Linux, runner_arch: X64 } + - { os: windows-latest, runner_os: Windows, runner_arch: X64 } + - { os: macos-latest, runner_os: macOS, runner_arch: ARM64 } + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref }} + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 + - name: Set up Python 3.12 + run: uv python install 3.12 + - name: Install dependencies + # Client side only: the harness needs fastmcp/psutil to DRIVE the smoke. + # The server side under test comes exclusively from the fresh venv the + # smoke builds out of the downloaded artifact. + run: uv sync --extra test --extra sentry + - name: Assert qualified runner + record identity + shell: bash + run: >- + uv run python tools/runner_identity.py + --runner-os "${{ runner.os }}" --runner-arch "${{ runner.arch }}" + --runner-name "${{ runner.name }}" + --expect-os "${{ matrix.cell.runner_os }}" + --expect-arch "${{ matrix.cell.runner_arch }}" --python "3.12" + --out "runner-identity.json" + - name: Resolve image Chrome Stable identity + shell: bash + run: uv run python tools/resolve_chrome.py --out chrome-identity.json + - name: Start Xvfb (Linux headed cases only) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq xvfb + Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset & + sleep 3 + - name: Download the one dist artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: dist + - name: Install the exact artifact and run W1's journey + # Step-level env for the same reason as the integration job: the + # `runner` context is NOT available in job-level env (actionlint). + shell: bash + run: >- + uv run python tools/install_smoke.py + --dist-dir dist --manifest release-manifest.json + --kind "${{ matrix.kind }}" + --work-dir "${{ runner.temp }}/install-smoke-${{ matrix.kind }}" + --out "install-smoke-${{ matrix.kind }}.json" + env: + DISPLAY: ${{ runner.os == 'Linux' && ':99' || '' }} + - name: Upload smoke result + identity + if: always() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: install-smoke-${{ matrix.kind }}-${{ matrix.cell.runner_os }}-${{ matrix.cell.runner_arch }} + path: | + install-smoke-${{ matrix.kind }}.json + chrome-identity.json + runner-identity.json + if-no-files-found: warn + # ── Stable aggregate: the ONE public required check ──────────────────────── release-gate: name: release-gate @@ -355,6 +575,9 @@ jobs: - transport - transport-known-gaps - offline-stealth + - build-dist + - package-verify + - install-smoke runs-on: ubuntu-latest steps: - name: Require every edge to have succeeded @@ -376,6 +599,9 @@ jobs: check transport "${{ needs.transport.result }}" check transport-known-gaps "${{ needs.transport-known-gaps.result }}" check offline-stealth "${{ needs.offline-stealth.result }}" + check build-dist "${{ needs.build-dist.result }}" + check package-verify "${{ needs.package-verify.result }}" + check install-smoke "${{ needs.install-smoke.result }}" if [ "$overall" -ne 0 ]; then echo "::error::release-gate: one or more required edges were not success" exit 1 From 2dacc052e7eb64341ffff35a1b5906d635649ab9 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Sat, 25 Jul 2026 10:46:10 -0400 Subject: [PATCH 3/4] RELEASE-3 W3: macOS install-smoke is a DECLARED PARTIAL cell (F-773) CI round 1 (run 30161880495) answered the empirical question the previous commit posed. Result: 4 of 6 install-smoke cells green (wheel+sdist x Linux/X64, Windows/X64); both macOS/ARM64 cells failed with F-773's exact signature -- about:blank ok in 1.1s/3.1s, a connection to the CLOSED port 127.0.0.1:1 hung 35.3s, the fixture server served NOTHING, the network.mojom.NetworkService process alive throughout, workspace already under RUNNER_TEMP. It is F-773, not a new defect. New information worth having: this reproduced from a FRESH INSTALL of the built wheel and sdist into a brand-new venv, not from the editable checkout. That eliminates install layout / editable install as a factor -- the hang happens through the exact files a user downloads. Recorded in the finding (now 13/13 reproductions) along with what the gate does and does not claim. Rather than drop the cells, they now run a DECLARED PARTIAL stage: release_gate_harness.run_release_gate_journey gains `stages`, selecting how far the ONE journey runs -- never which journey. "full" (default) is unchanged and is what W1's transport test and all four non-macOS smoke cells run. "handshake" stops after the non-navigating prefix: initialize -> tools/list (94) -> list_instances -> the representative parity call. So macOS/ARM64 now proves the published artifacts INSTALL AND SERVE, and proves nothing about navigation. That is a strictly smaller claim, and it is labelled everywhere it could be misread: - the cell name carries "NO-NAVIGATION partial" in the check list - a per-cell warning annotation fires before the step runs - install_smoke prints "PARTIAL ... must not be reported as full-journey" - the result record carries stages + navigation_verified: false - the gap-declaration job enumerates full vs partial coverage explicitly This is deliberately NOT an xfail and NOT continue-on-error: nothing failing is being marked expected-to-fail, and no cell is silently skipped while the gate reports green. A smaller thing is run, and it is named. transport-known-gaps -> known-gaps: the same root cause now bounds a second lane, and gap declarations get ONE home rather than two. Aggregate edge renamed with it. Local: transport marker still passes (the full journey is unchanged), unit lane 784 passed, ruff/ty/vulture/budgets/suppression-owners clean, actionlint clean. Handshake mode exercised end-to-end on Windows. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release-gate.yml | 48 +++++++++--- .../finding_F773_macos_detached_navigation.md | 26 ++++++- tests/release_gate_harness.py | 34 ++++++++- tools/install_smoke.py | 73 ++++++++++++++----- 4 files changed, 147 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 6b55c9c..a0b61e5 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -245,7 +245,7 @@ jobs: # start, tab/CDP health, reachability, Fetch interception, profile location, # the network-service process, and Chrome errors). The cell is EXCLUDED, not # xfail-quarantined: an xfail would let a green gate imply macOS coverage. - # `transport-known-gaps` below states the gap in the job list, so it is + # `known-gaps` below states the gap in the job list, so it is # visible without reading this file. Re-add the cell the moment F-773 closes — # `finding_F773_macos_detached_navigation.md` §7 is the experiment that does it. transport: @@ -307,15 +307,23 @@ jobs: # the check list where a reviewer reads it — so coverage gaps cost a visible # line rather than living only in a YAML comment. It passes: it reports, it # does not judge. Delete a line here only when the gap actually closes. - transport-known-gaps: - name: transport-known-gaps + # Renamed from `transport-known-gaps` in W3: the same root cause now bounds a + # second lane (install-smoke), and gap declarations get ONE home, not two. + known-gaps: + name: known-gaps runs-on: ubuntu-latest steps: - name: State the gaps run: | echo "::warning title=F-773::transport (macOS/ARM64) is NOT run. Chrome under the detached backend completes no network navigation on the hosted macOS runner. Cause unknown; 11 CI rounds, full elimination table in audit/stage2/finding_F773_macos_detached_navigation.md. Unknown whether real Macs are affected -- that needs a run on real hardware." - echo "This gate verifies the real-stdio journey on Linux/X64 and Windows/X64 ONLY." - echo "It makes NO claim about macOS navigation. Do not advertise one." + echo "::warning title=F-773::install-smoke (macOS/ARM64) runs as a PARTIAL cell for BOTH wheel and sdist: install + launcher resolve + initialize + tools/list + list_instances, and NO navigation. W3 confirmed F-773 reaches this lane from a FRESH INSTALL of the built artifacts, not just from the checkout." + echo "FULL-JOURNEY (navigating) coverage in this gate:" + echo " transport : Linux/X64, Windows/X64" + echo " install-smoke : Linux/X64, Windows/X64 -- wheel AND sdist (4 full cells)" + echo "PARTIAL, NON-NAVIGATING coverage:" + echo " install-smoke : macOS/ARM64 -- wheel AND sdist (2 install+handshake cells)" + echo "This gate makes NO claim about macOS navigation. Do not advertise one." + echo "It DOES claim the built artifacts install and serve on macOS/ARM64." # ── Offline stealth lane (W2 wires the edge; W4 lands the tests) ─────────── offline-stealth: @@ -492,8 +500,21 @@ jobs: # Install the EXACT downloaded artifact into a fresh environment and run W1's # canonical journey through the launcher that install produced. Both # publishable artifacts on every qualified cell. + # + # The macOS/ARM64 cells run `--stages handshake`: install + launcher resolve + + # initialize + tools/list + list_instances, and NO navigation. That is a + # DECLARED PARTIAL cell, not a quiet reduction — F-773 makes any navigation + # through the detached backend hang on hosted macOS runners, and this run + # proved it reaches this lane too (both macOS cells, from a FRESH INSTALL of + # the built wheel/sdist rather than the checkout: about:blank ok in 1.1s, a + # connection to a CLOSED port hung 35.3s, fixture served nothing). It is + # deliberately NOT an xfail and NOT continue-on-error: nothing failing is + # marked expected-to-fail; a strictly smaller claim is made and labelled in + # the cell NAME, in the tool's own output, and in the `known-gaps` job. + # The stage is the ONLY difference — same tool, same harness, same journey + # function. Flip these cells back to `full` the moment F-773 closes. install-smoke: - name: install-smoke (${{ matrix.kind }} ${{ matrix.cell.runner_os }}/${{ matrix.cell.runner_arch }}) + name: install-smoke (${{ matrix.kind }} ${{ matrix.cell.runner_os }}/${{ matrix.cell.runner_arch }}${{ matrix.cell.stages == 'handshake' && ' NO-NAVIGATION partial' || '' }}) needs: build-dist runs-on: ${{ matrix.cell.os }} strategy: @@ -501,9 +522,9 @@ jobs: matrix: kind: [wheel, sdist] cell: - - { os: ubuntu-latest, runner_os: Linux, runner_arch: X64 } - - { os: windows-latest, runner_os: Windows, runner_arch: X64 } - - { os: macos-latest, runner_os: macOS, runner_arch: ARM64 } + - { os: ubuntu-latest, runner_os: Linux, runner_arch: X64, stages: full } + - { os: windows-latest, runner_os: Windows, runner_arch: X64, stages: full } + - { os: macos-latest, runner_os: macOS, runner_arch: ARM64, stages: handshake } steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -540,6 +561,10 @@ jobs: uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: dist + - name: 'KNOWN GAP: this macOS cell verifies NO navigation (F-773)' + if: matrix.cell.stages == 'handshake' + run: | + echo "::warning title=F-773::install-smoke (${{ matrix.kind }} macOS/ARM64) is a PARTIAL cell: it proves the artifact installs, the launcher resolves out of the fresh environment, and the server completes initialize + tools/list + list_instances. It performs NO navigation, so it makes NO macOS navigation claim. See audit/stage2/finding_F773_macos_detached_navigation.md." - name: Install the exact artifact and run W1's journey # Step-level env for the same reason as the integration job: the # `runner` context is NOT available in job-level env (actionlint). @@ -548,6 +573,7 @@ jobs: uv run python tools/install_smoke.py --dist-dir dist --manifest release-manifest.json --kind "${{ matrix.kind }}" + --stages "${{ matrix.cell.stages }}" --work-dir "${{ runner.temp }}/install-smoke-${{ matrix.kind }}" --out "install-smoke-${{ matrix.kind }}.json" env: @@ -573,7 +599,7 @@ jobs: - coverage - integration - transport - - transport-known-gaps + - known-gaps - offline-stealth - build-dist - package-verify @@ -597,7 +623,7 @@ jobs: check coverage "${{ needs.coverage.result }}" check integration "${{ needs.integration.result }}" check transport "${{ needs.transport.result }}" - check transport-known-gaps "${{ needs.transport-known-gaps.result }}" + check known-gaps "${{ needs.known-gaps.result }}" check offline-stealth "${{ needs.offline-stealth.result }}" check build-dist "${{ needs.build-dist.result }}" check package-verify "${{ needs.package-verify.result }}" diff --git a/audit/stage2/finding_F773_macos_detached_navigation.md b/audit/stage2/finding_F773_macos_detached_navigation.md index 93ccda6..c7495ef 100644 --- a/audit/stage2/finding_F773_macos_detached_navigation.md +++ b/audit/stage2/finding_F773_macos_detached_navigation.md @@ -62,10 +62,20 @@ distinguishes the cause further. ## 4. Reproduction -- **Reliable** on `macos-latest` (ARM64) GitHub-hosted runners, every run, 11/11. +- **Reliable** on `macos-latest` (ARM64) GitHub-hosted runners, every run, 11/11 + (13/13 including the two W3 cells below). - **Never** on Windows/X64 (local dev machine and CI) or Ubuntu/X64 CI. - CI job: `release-gate / transport (macOS/ARM64)`; test `tests/test_e2e_transport.py::test_real_stdio_release_gate_journey`. +- **Also from a FRESH INSTALL of the built distribution** (plan_RELEASE W3, PR #48, + run 30161880495): `install-smoke (wheel macOS/ARM64)` and + `install-smoke (sdist macOS/ARM64)` both reproduced it after installing the + artifact into a brand-new venv — `about:blank` ok in 1.1s/3.1s, closed port + `127.0.0.1:1` hung 35.3s, fixture served nothing, network-service process alive, + workspace already under `RUNNER_TEMP`. This **rules out the editable/source-tree + install as a factor**: the same hang occurs through the exact wheel and sdist a + user would download. It narrows nothing about the mechanism, but it does mean a + packaging or install-layout explanation is no longer available. - Evidence lands in the failure text automatically: nav probe, fixture hit list, Chrome process snapshot, Chrome's log, and the backend's own logs. @@ -105,3 +115,17 @@ uv run python -m pytest tests/test_e2e_transport.py -m transport -v release-blocking for a macOS claim and needs its own FIX plan. Until one of those runs, F-773 stays open and macOS navigation stays unclaimed. + +## 8. What the gate does claim on macOS (W3) + +W3 did not shrink macOS coverage to nothing. The two `install-smoke` macOS cells +run as **declared partial cells** (`--stages handshake`): they install the exact +built wheel/sdist into a fresh environment, resolve that environment's console +launcher, and complete `initialize` → `tools/list` (94 tools) → `list_instances` +over real stdio. They perform **no navigation**. + +So the qualified claim on macOS/ARM64 is: *the published artifacts install and +serve*. It is **not**: *the published artifacts navigate*. These cells are not +xfails — nothing failing is marked expected-to-fail; a strictly smaller thing is +run and labelled, in the cell name, in the tool's stdout, in the result record's +`stages`/`navigation_verified` fields, and in the `known-gaps` job. diff --git a/tests/release_gate_harness.py b/tests/release_gate_harness.py index 1c4845e..755376c 100644 --- a/tests/release_gate_harness.py +++ b/tests/release_gate_harness.py @@ -72,6 +72,11 @@ _log = logging.getLogger("release_gate_harness") # ── Contract constants ────────────────────────────────────────────────────── +# How far `run_release_gate_journey` runs. ONE journey, two declared extents — +# never two journeys. See that function's docstring for what each one claims. +FULL_JOURNEY = "full" +HANDSHAKE_ONLY = "handshake" + SERVER_NAME = "stealth-chrome-devtools-mcp" REGISTRY_TOOL_COUNT = 94 # remediation baseline (CLAUDE.md: derived == 94) RESULT_SCHEMA_VERSION = 1 @@ -865,13 +870,35 @@ async def run_release_gate_journey( launcher: str | os.PathLike[str], work_dir: str | os.PathLike[str], singleton_port: int | None = None, + stages: str = FULL_JOURNEY, ) -> dict[str, Any]: """Drive the canonical real-stdio journey against ``launcher`` and return a versioned, JSON-serializable result record (consumed unchanged by W3). ``work_dir`` is a throwaway directory (e.g. pytest ``tmp_path``) used for the isolated HOME (singleton state), session root, clone output, and logs. + + ``stages`` selects how far the ONE journey runs — it never selects a + different journey: + + ``"full"`` (default) + everything: handshake, registry, parity, cold-start warmup, and the + navigating canonical journey. This is what W1's transport test and + every non-macOS W3 smoke cell run. + ``"handshake"`` + stops after the non-navigating prefix (initialize → ``tools/list`` → + ``list_instances`` → the representative parity call). W3 uses it for + the macOS/ARM64 install-smoke cells ONLY, because F-773 makes any + navigation through the detached backend hang on hosted macOS runners. + It is a genuinely reduced claim — "this artifact installs and serves" + — and the result record says so in ``stages`` so no consumer can read + it as the full journey. Not an xfail: nothing failing is being marked + as expected-to-fail; a smaller thing is being run and labelled. """ + if stages not in (FULL_JOURNEY, HANDSHAKE_ONLY): + raise ValueError( + f"stages must be {FULL_JOURNEY!r} or {HANDSHAKE_ONLY!r}, got {stages!r}" + ) launcher = Path(launcher) work_dir = Path(work_dir) home_dir = work_dir / "home" @@ -891,6 +918,8 @@ async def run_release_gate_journey( record: dict[str, Any] = { "schema_version": RESULT_SCHEMA_VERSION, + "stages": stages, + "navigation_verified": stages == FULL_JOURNEY, "transport": "stdio", "launcher": str(launcher.resolve()), "singleton_port": port, @@ -921,8 +950,9 @@ async def run_release_gate_journey( async with Client(transport, init_timeout=INIT_TIMEOUT) as client: await _foundation_proof(client, record) await _representative_parity(client, record) - await _cold_start_warmup(client, base_url, log_dir, record) - await _canonical_journey(client, base_url, record) + if stages == FULL_JOURNEY: + await _cold_start_warmup(client, base_url, log_dir, record) + await _canonical_journey(client, base_url, record) except BaseException as exc: # noqa: BLE001 PERMANENT(augment with child stderr + boot log, then re-raise) err = exc child_stderr = cap["text"] diff --git a/tools/install_smoke.py b/tools/install_smoke.py index 8cd8fbb..d67a789 100644 --- a/tools/install_smoke.py +++ b/tools/install_smoke.py @@ -29,6 +29,7 @@ import argparse import asyncio +import dataclasses import json import shutil import subprocess @@ -44,6 +45,8 @@ import package_verify # noqa: E402 PERMANENT(sys.path bootstrap above must run first) from release_gate_harness import ( # noqa: E402 PERMANENT(sys.path bootstrap above must run first) + FULL_JOURNEY, + HANDSHAKE_ONLY, gate_work_dir, resolve_launcher, run_release_gate_journey, @@ -218,16 +221,22 @@ def select_artifact(dist_dir: Path, kind: str) -> Path: return (wheel if kind == "wheel" else sdist).absolute() -def smoke( - *, - dist_dir: Path, - manifest_path: Path, - kind: str, - work_dir: Path, - python_version: str, -) -> dict[str, object]: - artifact = select_artifact(dist_dir, kind) - manifest = package_verify.load_manifest(manifest_path) +@dataclasses.dataclass(frozen=True) +class SmokeSpec: + """Everything that identifies ONE smoke cell: which artifact, how far.""" + + dist_dir: Path + manifest_path: Path + kind: str + work_dir: Path + python_version: str = "3.12" + stages: str = FULL_JOURNEY + + +def smoke(spec: SmokeSpec) -> dict[str, object]: + artifact = select_artifact(spec.dist_dir, spec.kind) + manifest = package_verify.load_manifest(spec.manifest_path) + work_dir = spec.work_dir # (1) The downloaded bytes are the built bytes — the same precondition the # publish job re-runs before it uploads anything. @@ -241,7 +250,7 @@ def smoke( probe_cwd.mkdir(exist_ok=True) # (2) fresh env + local install, caches off. - interpreter = create_fresh_env(venv_dir, python_version) + interpreter = create_fresh_env(venv_dir, spec.python_version) install_artifact(interpreter, artifact) # (3) what landed IS the artifact. @@ -261,19 +270,23 @@ def smoke( journey_dir = gate_work_dir(work_dir / "gate") journey_dir.mkdir(parents=True, exist_ok=True) record = asyncio.run( - run_release_gate_journey(launcher=launcher, work_dir=journey_dir) + run_release_gate_journey( + launcher=launcher, work_dir=journey_dir, stages=spec.stages + ) ) return { "schema_version": RESULT_SCHEMA_VERSION, "artifact": artifact.name, - "artifact_kind": kind, + "artifact_kind": spec.kind, + "stages": spec.stages, + "navigation_verified": spec.stages == FULL_JOURNEY, "artifact_sha256": package_verify.sha256_file(artifact), "version": manifest["version"], "installed_version": probe.get("version"), "package_root": probe.get("package_root"), "launcher": str(launcher), "venv": str(venv_dir), - "python_version": python_version, + "python_version": spec.python_version, "journey": record, } @@ -287,16 +300,29 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--kind", choices=("wheel", "sdist"), required=True) parser.add_argument("--work-dir", type=Path, required=True) parser.add_argument("--python", default="3.12") + parser.add_argument( + "--stages", + choices=(FULL_JOURNEY, HANDSHAKE_ONLY), + default=FULL_JOURNEY, + help=( + "how far W1's ONE journey runs. 'handshake' stops before any " + "navigation and therefore makes a strictly smaller claim " + "(installs and serves, NOT navigates)." + ), + ) parser.add_argument("--out", type=Path, default=None) args = parser.parse_args(argv) try: result = smoke( - dist_dir=args.dist_dir, - manifest_path=args.manifest, - kind=args.kind, - work_dir=args.work_dir, - python_version=args.python, + SmokeSpec( + dist_dir=args.dist_dir, + manifest_path=args.manifest, + kind=args.kind, + work_dir=args.work_dir, + python_version=args.python, + stages=args.stages, + ) ) except (SmokeError, package_verify.VerificationError) as exc: print(f"::error title=install-smoke::{exc}", file=sys.stderr) @@ -307,7 +333,14 @@ def main(argv: list[str] | None = None) -> int: args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(text + "\n", encoding="utf-8") print(text) - print(f"install-smoke {args.kind}: OK ({result['artifact']})") + if args.stages == FULL_JOURNEY: + print(f"install-smoke {args.kind}: OK ({result['artifact']}) — full journey") + else: + print( + f"install-smoke {args.kind}: OK ({result['artifact']}) — PARTIAL: " + f"install + launcher + handshake only. This cell verified NO " + f"navigation and must not be reported as full-journey coverage." + ) return 0 From 295c8de6e9bc3e74f16c2fbe63365b2ea7b26f7c Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Sat, 25 Jul 2026 10:59:46 -0400 Subject: [PATCH 4/4] RELEASE-3 W3: pin the release topology so publish cannot silently regress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W3 properties most likely to rot are the ones nothing executes on a normal PR. A tag build happens rarely and cannot be dry-run from here, so "publish never rebuilds" and "the aggregate never forgets an edge" were only review promises. tests/test_release_workflows.py turns them into 14 structural pins over the workflow YAML. Build once / publish the same files: - `uv build` (or any build command) appears in build-dist and NOWHERE else in the gate - publish.yml never builds and never re-installs the package from PyPI - publish.yml CALLS the reusable gate with both `ref` and `release_tag` instead of duplicating job semantics - the publish job needs that gate, downloads exactly the one `dist` artifact, and re-verifies it before uploading Aggregate integrity: - release-gate's `needs` equals every other job in the file — a lane that exists but is not an edge can fail while the required check stays green. This is what will tell W5 to wire `release-evidence` rather than letting it discover the omission later. - every listed edge's `.result` is actually asserted in the script - the aggregate keeps `if: always()` (a failed dependency must FAIL it, not skip it — a skipped required check does not block merge) - no job or step anywhere in the gate sets continue-on-error Declared gap stays declared: - install-smoke covers wheel AND sdist on all three cells - macOS/ARM64 is `handshake`, Linux/Windows are `full` - the partial cells carry "NO-NAVIGATION partial" in the check name - known-gaps exists, is a required edge, and names both bounded lanes - the PR caller has no path filter that could omit packaging changes Each pin was proven to BITE by temporarily mutating the workflow and watching the specific test go red (dropped aggregate edge -> failure; macOS flipped to full -> failure; `uv build` added to publish -> failure), then restoring from git. pyyaml moves from a transitive dependency to a declared TEST extra so the pin cannot vanish if uvicorn[standard] drops it. Test-only; the lockfile change is 2 lines and no version churn. Local: 798 passed (784 + 14), ruff/ty/vulture/budgets/suppression-owners and actionlint all clean. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 5 + tests/test_release_workflows.py | 215 ++++++++++++++++++++++++++++++++ uv.lock | 2 + 3 files changed, 222 insertions(+) create mode 100644 tests/test_release_workflows.py diff --git a/pyproject.toml b/pyproject.toml index 26549dc..1785ca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,11 @@ test = [ "pytest-asyncio>=0.23", "pytest-timeout>=2.3", "pytest-cov>=5.0", + # plan_RELEASE W3: tests/test_release_workflows.py parses the workflow YAML + # to pin the release topology (publish never rebuilds; the aggregate never + # forgets an edge). Test-only; already present transitively via + # uvicorn[standard], declared here so the pin cannot vanish silently. + "pyyaml>=6.0", ] dev = [ "ruff==0.15.20", diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py new file mode 100644 index 0000000..52385d7 --- /dev/null +++ b/tests/test_release_workflows.py @@ -0,0 +1,215 @@ +"""Structural pins for the release topology (plan_RELEASE W3, gap G-C). + +The W3 claims that are easiest to lose are the ones nothing executes on a normal +PR: *publish never rebuilds*, *the aggregate never forgets an edge*, and *the +macOS smoke cells are partial on purpose*. A tag build happens rarely and cannot +be dry-run here, so those properties are pinned by reading the workflow YAML +instead of by hoping a reviewer notices. + +These are deliberately structural, not stylistic: they assert the shape the plan +requires and say why, so a future workstream that adds a job (W5's +``release-evidence``) is told to wire its edge rather than discovering months +later that the required check was green while a lane never ran. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +WORKFLOWS = Path(__file__).resolve().parent.parent / ".github" / "workflows" +RELEASE_GATE = WORKFLOWS / "release-gate.yml" +PUBLISH = WORKFLOWS / "publish.yml" +TEST_CALLER = WORKFLOWS / "test.yml" + +AGGREGATE = "release-gate" +BUILD_COMMANDS = ("uv build", "python -m build", "hatchling build", "pip wheel") + + +def _load(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _jobs(path: Path) -> dict: + return _load(path)["jobs"] + + +def _all_run_steps(job: dict) -> list[str]: + return [step["run"] for step in job.get("steps", []) if "run" in step] + + +@pytest.fixture(scope="module") +def gate_jobs() -> dict: + return _jobs(RELEASE_GATE) + + +# --------------------------------------------------------------------------- +# Build once. +# --------------------------------------------------------------------------- +def test_exactly_one_job_builds_the_distribution(gate_jobs): + """`uv build` may appear in build-dist and nowhere else in the gate.""" + builders = { + name + for name, job in gate_jobs.items() + if any(cmd in run for run in _all_run_steps(job) for cmd in BUILD_COMMANDS) + } + assert builders == {"build-dist"}, ( + f"the distribution must be built exactly once, in build-dist; " + f"these jobs build: {sorted(builders)}" + ) + + +def test_publish_never_builds_and_never_downloads_from_pypi(): + """The publish job uploads the gated files; it does not make new ones.""" + for name, job in _jobs(PUBLISH).items(): + for run in _all_run_steps(job): + for cmd in BUILD_COMMANDS: + assert cmd not in run, ( + f"publish.yml job {name!r} runs {cmd!r} — publishing must " + f"upload the artifact the gate tested, never a rebuild" + ) + assert "pip install stealth-chrome-devtools-mcp" not in run, ( + f"publish.yml job {name!r} re-downloads the package from PyPI" + ) + + +def test_publish_calls_the_reusable_gate_rather_than_reimplementing_it(): + jobs = _jobs(PUBLISH) + assert "gate" in jobs, "publish.yml must qualify the tag through the gate" + assert jobs["gate"]["uses"].endswith("release-gate.yml"), ( + "publish.yml must CALL the one reusable gate, not duplicate job semantics" + ) + # The tag SHA and the tag itself are both handed to the gate, so the gate + # tests the commit it will publish and fails on a tag/version disagreement. + assert set(jobs["gate"]["with"]) == {"ref", "release_tag"} + + +def test_publish_requires_the_green_gate_and_downloads_that_runs_artifact(): + publish = _jobs(PUBLISH)["publish"] + needs = publish["needs"] + needs = [needs] if isinstance(needs, str) else needs + assert "gate" in needs, ( + "publish must need the gate: a failed, skipped, or cancelled cell has to " + "prevent PyPI and the GitHub release" + ) + downloads = [ + step + for step in publish["steps"] + if "download-artifact" in str(step.get("uses", "")) + ] + assert len(downloads) == 1, "publish must download the one gated dist artifact" + assert downloads[0]["with"]["name"] == "dist" + # …and re-check it before uploading anything. + assert any("package_verify.py verify" in run for run in _all_run_steps(publish)), ( + "publish must re-verify the downloaded artifact before uploading it" + ) + + +# --------------------------------------------------------------------------- +# The aggregate never forgets an edge. +# --------------------------------------------------------------------------- +def test_aggregate_directly_needs_every_other_job(gate_jobs): + """The one public required check must depend on every lane in the file. + + A lane that exists but is not in `needs` is invisible to the required check: + it can fail while `release-gate` reports green. Adding a job to this + workflow therefore MUST add its edge here (W5's `release-evidence` next). + """ + expected = set(gate_jobs) - {AGGREGATE} + actual = set(gate_jobs[AGGREGATE]["needs"]) + assert actual == expected, ( + f"release-gate is missing edges for {sorted(expected - actual)} and has " + f"stale edges for {sorted(actual - expected)}" + ) + + +def test_aggregate_checks_the_result_of_every_edge(gate_jobs): + """Listing a job in `needs` is not enough — its result must be asserted.""" + script = "\n".join(_all_run_steps(gate_jobs[AGGREGATE])) + for edge in gate_jobs[AGGREGATE]["needs"]: + assert f"needs.{edge}.result" in script, ( + f"the aggregate lists {edge!r} in needs but never checks its result" + ) + + +def test_aggregate_runs_even_when_a_dependency_fails(gate_jobs): + """Without always(), a failed dependency SKIPS the aggregate instead of + failing it — and a skipped required check does not block merge.""" + assert gate_jobs[AGGREGATE]["if"] == "always()" + + +def test_w3_edges_are_present(gate_jobs): + for job in ("build-dist", "package-verify", "install-smoke"): + assert job in gate_jobs, f"W3 edge {job!r} is missing from the gate" + + +# --------------------------------------------------------------------------- +# The declared macOS gap stays declared. +# --------------------------------------------------------------------------- +def test_install_smoke_covers_both_artifacts_on_all_three_cells(gate_jobs): + matrix = gate_jobs["install-smoke"]["strategy"]["matrix"] + assert set(matrix["kind"]) == {"wheel", "sdist"} + labels = {f"{c['runner_os']}/{c['runner_arch']}" for c in matrix["cell"]} + assert labels == {"Linux/X64", "Windows/X64", "macOS/ARM64"} + + +def test_macos_smoke_is_partial_and_the_others_are_full(gate_jobs): + """F-773: macOS cells run `handshake` (no navigation) and MUST say so. + + If F-773 closes, flip these cells to `full` and update this pin. If someone + quietly flips them to `full` while F-773 is open, the cells will simply hang + — this pin is what makes the intent explicit either way. + """ + stages = { + f"{c['runner_os']}/{c['runner_arch']}": c["stages"] + for c in gate_jobs["install-smoke"]["strategy"]["matrix"]["cell"] + } + assert stages == { + "Linux/X64": "full", + "Windows/X64": "full", + "macOS/ARM64": "handshake", + } + + +def test_partial_cells_are_labelled_in_the_check_name(gate_jobs): + """A reduced cell must be readable as reduced from the check list alone.""" + name = gate_jobs["install-smoke"]["name"] + assert "NO-NAVIGATION partial" in name + + +def test_the_gap_declaration_job_exists_and_is_required(gate_jobs): + assert "known-gaps" in gate_jobs, ( + "gap declarations have ONE home; it must be a job so a reviewer reads it " + "in the check list rather than in a YAML comment" + ) + assert "known-gaps" in gate_jobs[AGGREGATE]["needs"] + script = "\n".join(_all_run_steps(gate_jobs["known-gaps"])) + for lane in ("transport", "install-smoke"): + assert lane in script, f"known-gaps does not mention the {lane!r} gap" + + +def test_no_gate_job_hides_failure(gate_jobs): + """continue-on-error anywhere would let a red lane report green.""" + for name, job in gate_jobs.items(): + assert not job.get("continue-on-error"), ( + f"job {name!r} sets continue-on-error — a required gate may not " + f"absorb a failure" + ) + for step in job.get("steps", []): + assert not step.get("continue-on-error"), ( + f"job {name!r} has a continue-on-error step: {step.get('name')}" + ) + + +def test_pr_caller_runs_the_same_reusable_gate(): + """Every PR gets the full gate — no dispatch-only or path-filtered substitute.""" + caller = _load(TEST_CALLER) + assert caller["jobs"]["release-gate"]["uses"].endswith("release-gate.yml") + # `on:` parses as the boolean True in YAML 1.1; accept either spelling. + triggers = caller.get("on", caller.get(True)) + assert "pull_request" in triggers + assert "paths" not in triggers["pull_request"], ( + "path filtering may not omit packaging-relevant changes" + ) diff --git a/uv.lock b/uv.lock index 1524bb9..2b8eedf 100644 --- a/uv.lock +++ b/uv.lock @@ -1624,6 +1624,7 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, + { name = "pyyaml" }, ] transpiler = [ { name = "py2js" }, @@ -1644,6 +1645,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=5.0" }, { name = "pytest-timeout", marker = "extra == 'test'", specifier = ">=2.3" }, { name = "python-dotenv", specifier = "==1.1.1" }, + { name = "pyyaml", marker = "extra == 'test'", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.0,<4" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.20" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = "==2.64.0" },