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..a0b61e5 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 @@ -225,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: @@ -287,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: @@ -343,6 +371,224 @@ 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. + # + # 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 }}${{ matrix.cell.stages == 'handshake' && ' NO-NAVIGATION partial' || '' }}) + 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, 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: + 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: '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). + shell: bash + run: >- + 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: + 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 @@ -353,8 +599,11 @@ jobs: - coverage - integration - transport - - transport-known-gaps + - known-gaps - offline-stealth + - build-dist + - package-verify + - install-smoke runs-on: ubuntu-latest steps: - name: Require every edge to have succeeded @@ -374,8 +623,11 @@ 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 }}" + 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 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/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/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/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/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/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/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..d67a789 --- /dev/null +++ b/tools/install_smoke.py @@ -0,0 +1,348 @@ +#!/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 dataclasses +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) + FULL_JOURNEY, + HANDSHAKE_ONLY, + 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() + + +@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. + 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, spec.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, stages=spec.stages + ) + ) + return { + "schema_version": RESULT_SCHEMA_VERSION, + "artifact": artifact.name, + "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": spec.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( + "--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( + 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) + 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) + 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 + + +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()) 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" },