From 03a47569d0a0b82ce9491e364f27cfcfbb759be0 Mon Sep 17 00:00:00 2001 From: owieschon Date: Mon, 20 Jul 2026 12:20:23 -0400 Subject: [PATCH] Verify published Sourcebound releases end to end --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 27 +- .sourcebound.yml | 1 + .sourcebound/context/contributor.md | 18 +- README.md | 14 +- docs/INSTALL.md | 80 ++---- docs/SURFACE.md | 8 +- docs/VERIFY_RELEASE.md | 70 ++++++ docs/learn/tutorial-catch-a-lying-doc.md | 2 +- llms.txt | 9 +- pyproject.toml | 1 + scripts/test_readme_quickstart.py | 29 +-- scripts/verify_published_release.py | 306 +++++++++++++++++++++++ tests/test_doctor_integrations.py | 5 + tests/test_release_artifact.py | 38 ++- tests/test_support.py | 4 +- tests/test_v04_acceptance.py | 7 +- tests/test_verify_published_release.py | 201 +++++++++++++++ 18 files changed, 706 insertions(+), 118 deletions(-) create mode 100644 docs/VERIFY_RELEASE.md create mode 100644 scripts/verify_published_release.py create mode 100644 tests/test_verify_published_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a740d87..ebf0319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,11 +168,13 @@ jobs: cache: pip - run: >- python -m pip install - "build==1.2.2.post1" "PyYAML==6.0.3" "setuptools==75.8.0" "wheel==0.45.1" + "build==1.2.2.post1" "pipx==1.8.0" "PyYAML==6.0.3" + "setuptools==75.8.0" "wheel==0.45.1" - run: >- python -m pip download --only-binary=:all: --dest wheelhouse + "pip==25.3" "PyYAML==6.0.3" - run: python scripts/build_release.py --out dist - run: >- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2d0082..6834ff0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,8 @@ jobs: cache: pip - run: >- python -m pip install - "build==1.2.2.post1" "PyYAML==6.0.3" "setuptools==75.8.0" "wheel==0.45.1" + "build==1.2.2.post1" "pipx==1.8.0" "PyYAML==6.0.3" + "setuptools==75.8.0" "wheel==0.45.1" - run: python scripts/build_release.py --out dist - run: python -m venv /tmp/sourcebound-release - run: /tmp/sourcebound-release/bin/pip install dist/*.whl @@ -33,6 +34,7 @@ jobs: python -m pip download --only-binary=:all: --dest /tmp/sourcebound-wheelhouse + "pip==25.3" "PyYAML==6.0.3" - run: >- python scripts/test_readme_quickstart.py @@ -80,7 +82,6 @@ jobs: fi python scripts/publish_release.py "${args[@]}" - name: Stage the PyPI distribution - if: vars.PYPI_PUBLISH_ENABLED == 'true' shell: bash run: | mapfile -t wheels < <(find dist -maxdepth 1 -type f -name '*.whl' -print) @@ -91,15 +92,35 @@ jobs: mkdir -p pypi-dist cp -f -- "${wheels[0]}" pypi-dist/ - name: Publish the attested wheel to PyPI - if: vars.PYPI_PUBLISH_ENABLED == 'true' + id: publish-pypi uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 with: packages-dir: pypi-dist/ + skip-existing: true + - name: Install publication verifiers + if: ${{ always() && steps.publish-pypi.outcome != 'skipped' }} + run: python -m pip install "pipx==1.8.0" "uv==0.8.22" + - name: Verify published bytes and installers + if: ${{ always() && steps.publish-pypi.outcome != 'skipped' }} + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + python scripts/verify_published_release.py \ + --repo "$GITHUB_REPOSITORY" \ + --tag "$GITHUB_REF_NAME" \ + --version "$version" \ + --dist dist \ + --root . \ + --out sourcebound-publication-verification.json - name: Upload publication receipt + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sourcebound-release-publication-${{ github.sha }} path: | release-publication.json + sourcebound-publication-verification.json sourcebound-quickstart.json if-no-files-found: error diff --git a/.sourcebound.yml b/.sourcebound.yml index 84e78bd..e2e94bf 100644 --- a/.sourcebound.yml +++ b/.sourcebound.yml @@ -230,6 +230,7 @@ projections: - docs/EXTENSIONS.md - docs/ARCHITECTURE.md - docs/INSTALL.md + - docs/VERIFY_RELEASE.md - docs/IMPROVEMENTS.md - docs/LIFECYCLE_EVIDENCE.md - docs/REVIEW_LEDGER.md diff --git a/.sourcebound/context/contributor.md b/.sourcebound/context/contributor.md index 8821900..01e0c52 100644 --- a/.sourcebound/context/contributor.md +++ b/.sourcebound/context/contributor.md @@ -1,13 +1,13 @@ # Context bundle: contributor - Source ref: `WORKTREE` -- Corpus sha256: `2bbea252669405281abe5b3e84a61eeec205257d99b18c78bf52996322cb38e0` +- Corpus sha256: `90daf326a4950cf2e97a04e8f3a0490ee702637513adc392d335c8db71422288` - Content: exact canonical document bytes ## Canonical document: README.md - Source: [README.md](../../README.md) -- Content sha256: `d943d7a09a22e2a67c94b370b9a92bc000de02d0e460cb6bcf6cb31022a3534c` +- Content sha256: `7bae61d93eef29e6ac61bf489e61f3ca2a7857295ee0cf0176ba513d2770dce4` # Sourcebound @@ -49,18 +49,18 @@ Human review can improve a sentence. It cannot make the sentence fail when its d ## Install in the repository you want to protect -From that repository, download the latest stable wheel, install it in an isolated environment, and run the manifest-free audit: +Install the stable CLI in an isolated environment, then run the manifest-free audit from the +repository you want to protect: ```bash -release_dir="$(mktemp -d)" -gh release download --repo owieschon/sourcebound \ - --pattern 'sourcebound-*-py3-none-any.whl' --dir "$release_dir" -python3 -m venv .venv -source .venv/bin/activate -python -m pip install "$release_dir"/sourcebound-*.whl +pipx install sourcebound sourcebound audit ``` +Use `uv tool install sourcebound` instead when `uv` owns your command-line tools. The +[installation guide](docs/INSTALL.md) covers offline wheelhouses, upgrades, and rollback. The +[release verification guide](docs/VERIFY_RELEASE.md) checks published bytes and provenance. + After reviewing the assessment, inspect the files that `init` proposes before accepting its gate: ```bash diff --git a/README.md b/README.md index 529ac57..bb71861 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,18 @@ Human review can improve a sentence. It cannot make the sentence fail when its d ## Install in the repository you want to protect -From that repository, download the latest stable wheel, install it in an isolated environment, and run the manifest-free audit: +Install the stable CLI in an isolated environment, then run the manifest-free audit from the +repository you want to protect: ```bash -release_dir="$(mktemp -d)" -gh release download --repo owieschon/sourcebound \ - --pattern 'sourcebound-*-py3-none-any.whl' --dir "$release_dir" -python3 -m venv .venv -source .venv/bin/activate -python -m pip install "$release_dir"/sourcebound-*.whl +pipx install sourcebound sourcebound audit ``` +Use `uv tool install sourcebound` instead when `uv` owns your command-line tools. The +[installation guide](docs/INSTALL.md) covers offline wheelhouses, upgrades, and rollback. The +[release verification guide](docs/VERIFY_RELEASE.md) checks published bytes and provenance. + After reviewing the assessment, inspect the files that `init` proposes before accepting its gate: ```bash diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 263ce1a..8797ce9 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -7,21 +7,21 @@ or move between versions without changing repository documentation. Each path en version or artifact check, so the executable is known before it becomes a gate. -**[Install the latest stable release](#install-the-latest-stable-release)**. +**[Install Sourcebound with pipx](#install-with-a-python-tool-installer)**. ## Install with a Python tool installer -After a stable Sourcebound release reaches PyPI, install the CLI in an isolated environment: +Install the stable Sourcebound CLI from PyPI in an isolated environment: ```bash -uv tool install sourcebound +pipx install sourcebound sourcebound --version ``` -Use `pipx install sourcebound` for the same persistent command, or `uvx sourcebound --help` once. -PyPI receives the same attested wheel published as a GitHub Release asset. +Use `uv tool install sourcebound` for the same persistent command, or `uvx sourcebound --help` for +one invocation. The release workflow publishes the same attested wheel to PyPI and GitHub. -## Install the latest stable release +## Install from a GitHub release From the repository you want to protect, download the latest stable wheel and let `pip` resolve PyYAML from your configured package index: @@ -76,75 +76,41 @@ making Node.js a requirement for every Sourcebound installation. ## Upgrade, roll back, or remove the executable -Install the newer wheel, then preview any requested manifest change before writing: +Use the same tool that installed Sourcebound. Upgrade the executable, confirm its version, then +preview any requested manifest change before writing: ```bash -python -m pip install --upgrade ./sourcebound-*.whl +pipx upgrade sourcebound sourcebound migrate sourcebound migrate --write ``` -`migrate --write` stores the prior manifest bytes in `.sourcebound.yml.v0.bak`. Restore them with -`sourcebound migrate --rollback`. Reinstall the prior wheel to roll back the executable. +With `uv`, replace the first command with `uv tool upgrade sourcebound`. -Remove the package with: +`migrate --write` stores the prior manifest bytes in `.sourcebound.yml.v0.bak`. Restore them with +`sourcebound migrate --rollback`. Reinstall an exact prior version to roll back the executable: ```bash -python -m pip uninstall sourcebound +pipx install --force "sourcebound==" ``` -Uninstalling leaves repository manifests and documentation in place. - -## Verify release artifacts - -Download the wheel and its checksum file into one directory: +With `uv`, run `uv tool install --force "sourcebound=="` instead. If you installed a local +wheel in a virtual environment, replace it with: ```bash -artifact_dir="$(mktemp -d)" -gh release download --repo owieschon/sourcebound \ - --pattern 'sourcebound-*-py3-none-any.whl' \ - --pattern SHA256SUMS \ - --dir "$artifact_dir" -cd "$artifact_dir" +python -m pip install --upgrade ./sourcebound-*.whl ``` -### Check the wheel bytes - -Verify the one wheel without requiring every release asset to be present: +Remove the tool with the installer that owns it: ```bash -python3 - <<'PY' -from hashlib import sha256 -from pathlib import Path - -wheels = list(Path(".").glob("sourcebound-*.whl")) -if len(wheels) != 1: - raise SystemExit(f"expected one wheel, found {len(wheels)}") -expected = { - filename: digest - for digest, filename in ( - line.split(maxsplit=1) for line in Path("SHA256SUMS").read_text().splitlines() - ) -} -actual = sha256(wheels[0].read_bytes()).hexdigest() -if expected.get(wheels[0].name) != actual: - raise SystemExit("wheel checksum mismatch") -print(f"{wheels[0].name}: {actual}") -PY +pipx uninstall sourcebound ``` -### Verify the attestation +With `uv`, run `uv tool uninstall sourcebound`. -Ask GitHub to match the wheel to its build provenance: - -```bash -gh attestation verify ./sourcebound-*.whl \ - --repo owieschon/sourcebound -``` - -The checksum step is local. The attestation command needs GitHub access, so run it outside a -network-blocked environment. The release gate also exercises upgrade, executable rollback, a second -upgrade, and uninstall. +Uninstalling leaves repository manifests and documentation in place. -Return to the [support guide](SUPPORT.md) to adopt an existing corpus, pin the reusable CI gate, or -build a diagnostic bundle. +Use the [release verification guide](VERIFY_RELEASE.md) when you need to check published wheel bytes +or provenance. Return to the [support guide](SUPPORT.md) to adopt an existing corpus, pin the +reusable CI gate, or build a diagnostic bundle. diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 35f9665..b03bb10 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -17,13 +17,13 @@ The catalog binding catches additions, removals, and replacements across the det | surface | discovered | examples | | --- | ---: | --- | -| api-symbol | 305 | `AcceptanceCase`, `Assertion`, `AuditFinding`, and 302 more | +| api-symbol | 307 | `AcceptanceCase`, `Assertion`, `AuditFinding`, and 304 more | | cli-command | 43 | `audit`, `benchmark`, `binding`, and 40 more | -| cli-option | 89 | `--accept-hygiene-baseline`, `--base`, `--binding`, and 86 more | +| cli-option | 97 | `--accept-hygiene-baseline`, `--attempts`, `--base`, and 94 more | | package | 2 | `sourcebound`, `sourcebound-mdx-parser-build` | | package-script | 1 | `build` | | runtime-constraint | 3 | `ES modules`, `Python >=3.10`, `node >=20` | -| test-suite | 69 | `scripts/test_readme_quickstart.py`, `scripts/test_release_lifecycle.py`, `tests/test_accessibility.py`, and 66 more | +| test-suite | 70 | `scripts/test_readme_quickstart.py`, `scripts/test_release_lifecycle.py`, `tests/test_accessibility.py`, and 67 more | - + diff --git a/docs/VERIFY_RELEASE.md b/docs/VERIFY_RELEASE.md new file mode 100644 index 0000000..59f5fc2 --- /dev/null +++ b/docs/VERIFY_RELEASE.md @@ -0,0 +1,70 @@ +# Verify a Sourcebound release + + + +Operators use this guide to confirm that a published Sourcebound wheel matches its checksum and +GitHub attestation before they trust it as a local or CI gate. + + +**[Download the published artifacts](#download-the-published-artifacts)**. + +## Download the published artifacts + +Download the wheel and its checksum file into one directory: + +```bash +artifact_dir="$(mktemp -d)" +gh release download --repo owieschon/sourcebound \ + --pattern 'sourcebound-*-py3-none-any.whl' \ + --pattern SHA256SUMS \ + --dir "$artifact_dir" +cd "$artifact_dir" +``` + +## Check the wheel bytes + +Verify the one wheel without requiring every release asset to be present: + +```bash +python3 - <<'PY' +from hashlib import sha256 +from pathlib import Path + +wheels = list(Path(".").glob("sourcebound-*.whl")) +if len(wheels) != 1: + raise SystemExit(f"expected one wheel, found {len(wheels)}") +expected = { + filename: digest + for digest, filename in ( + line.split(maxsplit=1) for line in Path("SHA256SUMS").read_text().splitlines() + ) +} +actual = sha256(wheels[0].read_bytes()).hexdigest() +if expected.get(wheels[0].name) != actual: + raise SystemExit("wheel checksum mismatch") +print(f"{wheels[0].name}: {actual}") +PY +``` + +The printed digest must match the wheel entry in `SHA256SUMS`. + +## Verify the attestation + +Ask GitHub to match the wheel to its build provenance: + +```bash +gh attestation verify ./sourcebound-*.whl \ + --repo owieschon/sourcebound +``` + +The checksum step is local. The attestation command needs GitHub access, so run it outside a +network-blocked environment. Each release workflow also compares the GitHub and PyPI wheel bytes, +then installs that exact version with both `pipx` and `uv`. + +When publication reaches PyPI, the workflow uploads a +`sourcebound.publication-verification.v1` receipt with `ok: true` or the observed failure. The +receipt records the local checksum, wheel digests observed from GitHub and PyPI, attestation status, +and both installed versions on success. + +Return to the [installation guide](INSTALL.md) to install, upgrade, roll back, or remove the +executable. diff --git a/docs/learn/tutorial-catch-a-lying-doc.md b/docs/learn/tutorial-catch-a-lying-doc.md index 75b4925..41fdbc4 100644 --- a/docs/learn/tutorial-catch-a-lying-doc.md +++ b/docs/learn/tutorial-catch-a-lying-doc.md @@ -5,7 +5,7 @@ This tutorial is for maintainers who want to see one source-bound fact fail and recover in a disposable repository. It turns an easy-to-miss command rename into a named check failure, a region-only repair, and a verified projection using an installed sourcebound release. -**[Install the stable release](../INSTALL.md#install-the-latest-stable-release)**, then +**[Install the stable release](../INSTALL.md#install-from-a-github-release)**, then build the disposable repository below. Success appears in the [outcome receipt](../SUPPORT.md#record-local-outcomes) after the last diff --git a/llms.txt b/llms.txt index c8e24e9..ca7b4e3 100644 --- a/llms.txt +++ b/llms.txt @@ -4,7 +4,7 @@ ## Canonical documentation -- [README.md](README.md): bindings: product-overview; sha256: d943d7a09a22e2a67c94b370b9a92bc000de02d0e460cb6bcf6cb31022a3534c +- [README.md](README.md): bindings: product-overview; sha256: 7bae61d93eef29e6ac61bf489e61f3ca2a7857295ee0cf0176ba513d2770dce4 - [SECURITY.md](SECURITY.md): declared canonical context; sha256: bbf72331f43440b96cda99f0fbc09c9eb6a95ecd0941fee7ee4089cc5d592b40 - [SOURCEBOUND_SPEC.md](SOURCEBOUND_SPEC.md): bindings: assurance-boundaries; sha256: 40899a1ddaf5a1ba471207a06aa5722700df8c3017a075c3e95399784951a482 - [STANDARD.md](STANDARD.md): declared canonical context; sha256: 86eb173495677069224628217adc075374ad4ab8526bf05e3e392072f4891185 @@ -15,17 +15,18 @@ - [docs/EXTENSIONS.md](docs/EXTENSIONS.md): bindings: extension-guide, migration-guide; sha256: cf335f74506cf2708307f6dd714dbbd89eed3880393e18f0f0d12943158521e6 - [docs/FEEDBACK.md](docs/FEEDBACK.md): declared canonical context; sha256: 3e41fa226cb201048255405a4e056c5de855d56f371a942d944fd289b5847163 - [docs/IMPROVEMENTS.md](docs/IMPROVEMENTS.md): bindings: improvement-candidate-guide; sha256: 4c81bb9c3f80010f0a13c0145201f536376b907e9a200da417752d6cdaefa125 -- [docs/INSTALL.md](docs/INSTALL.md): declared canonical context; sha256: f716ed919c4b39d7c8783ed06cce70ee230993d1453b846867da869681017c5f +- [docs/INSTALL.md](docs/INSTALL.md): declared canonical context; sha256: e5aab0dbbf3616d0084928a1feda3f48ddb4eebd3d0e5bfca65408e424e499c6 - [docs/LIFECYCLE_EVIDENCE.md](docs/LIFECYCLE_EVIDENCE.md): declared canonical context; sha256: c43eab4d65a2388ab464df82b5b8ceaf09b72dadc870575791a4ec9f31e90ae8 - [docs/REFERENCE.md](docs/REFERENCE.md): bindings: binding-sensitivity-reference, manifest-reference, supported-bindings; sha256: 3927425d848eb92eb6e755ae59720a8bc6071670bb141e0814f37d08d1949274 - [docs/RELEASES.md](docs/RELEASES.md): bindings: release-guide; sha256: e8a62cf85ea473ebdac2185c0600319371b95539e11350b0e7036a6d40de21be - [docs/REVIEW_LEDGER.md](docs/REVIEW_LEDGER.md): declared canonical context; sha256: d84d6b5928b066ee7fe4b260012a9cf937c83714380b3d247462c8134cde140d - [docs/SECURITY_MODEL.md](docs/SECURITY_MODEL.md): bindings: security-model; sha256: 3865422d3fe05b1f97cf68e89eaf4bd2cb662c1b7514d0dbcc64e04fc5755403 - [docs/SUPPORT.md](docs/SUPPORT.md): bindings: support-guide; sha256: cac6334e2c7645898b783ccdd72cc62ea1f140141dac0eec2dcf3954e430959d -- [docs/SURFACE.md](docs/SURFACE.md): bindings: repository-surface; sha256: 12fd3104512404262dcec6507eb5288b7c0e94c13e2a29ceb2bb954446f4a109 +- [docs/SURFACE.md](docs/SURFACE.md): bindings: repository-surface; sha256: abb69fc14f2790a91a87638c5c1637eb57f6402bc66288b0e41ae87659dce1ba +- [docs/VERIFY_RELEASE.md](docs/VERIFY_RELEASE.md): declared canonical context; sha256: 5a6bce17d664594d768e742193cc1edbb1598e588aaf200d485d6ecf2c4cac04 - [docs/learn/deep-dive-the-deterministic-seam.md](docs/learn/deep-dive-the-deterministic-seam.md): bindings: deterministic-seam-evidence, deterministic-seam-gate, deterministic-seam-phrasing; sha256: 37447f841031a48e4745083d1489de48d441f15ef13bc31c89d6af19b597e028 - [docs/learn/index.md](docs/learn/index.md): declared canonical context; sha256: abd813774363fc58aa0b86a65ed7aa29b44d99b6bb3c0d263197a4f71e93096f - [docs/learn/postmortem-the-readme-that-lied.md](docs/learn/postmortem-the-readme-that-lied.md): bindings: postmortem-examples, postmortem-measurements; sha256: 360caf0dcd92bec01e008bc8c3b1ae631bbcb1dfb98684d1a5a1b63aca272341 -- [docs/learn/tutorial-catch-a-lying-doc.md](docs/learn/tutorial-catch-a-lying-doc.md): bindings: tutorial-outcomes; sha256: 63e186394f16bcba07bb84bbd3b8c391e324f12f4a0b12d3952bff6457f917b8 +- [docs/learn/tutorial-catch-a-lying-doc.md](docs/learn/tutorial-catch-a-lying-doc.md): bindings: tutorial-outcomes; sha256: ad35c0291a8f850d9468499b37bff7fc4ccd0aa4a4cf88cd3bc59a426c962dbc - [skill/SKILL.md](skill/SKILL.md): declared canonical context; sha256: 2bdbad462bd43a3741ccf3445368dd2ac50dabfc71b78ec4ef2fa11cca0beb11 - [skills/sourcebound-verify/SKILL.md](skills/sourcebound-verify/SKILL.md): declared canonical context; sha256: 1cbf8f8d417297475be37a6c49795343724386e52bfc13ff2694d02be426b77b diff --git a/pyproject.toml b/pyproject.toml index 1d39906..fbbe36a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = ["PyYAML>=6.0", "tomli>=2.0; python_version < '3.11'"] dev = [ "build==1.2.2.post1", "mypy>=1.10", + "pipx==1.8.0", "pytest>=8.0", "ruff>=0.6", "setuptools==75.8.0", diff --git a/scripts/test_readme_quickstart.py b/scripts/test_readme_quickstart.py index fc5082e..6357376 100644 --- a/scripts/test_readme_quickstart.py +++ b/scripts/test_readme_quickstart.py @@ -8,7 +8,6 @@ import json import os import re -import stat import subprocess import sys import tempfile @@ -62,6 +61,8 @@ def _run_quickstart(candidate: Path, wheelhouse: Path) -> dict[str, object]: path.name.lower().startswith("pyyaml-") for path in wheelhouse.glob("*.whl") ): raise RuntimeError("wheelhouse must contain one PyYAML wheel") + if not any(path.name.lower().startswith("pip-") for path in wheelhouse.glob("*.whl")): + raise RuntimeError("wheelhouse must contain one pip wheel for the isolated pipx runtime") version = _wheel_version(candidate) candidate_sha256 = hashlib.sha256(candidate.read_bytes()).hexdigest() with tempfile.TemporaryDirectory(prefix="sourcebound-readme-quickstart-") as raw: @@ -83,19 +84,7 @@ def _run_quickstart(candidate: Path, wheelhouse: Path) -> dict[str, object]: bin_dir = workspace / "bin" bin_dir.mkdir() - gh = bin_dir / "gh" - gh.write_text( - "#!/usr/bin/env python3\n" - "import os, pathlib, shutil, sys\n" - "args = sys.argv[1:]\n" - "if args[:2] != ['release', 'download'] or '--dir' not in args:\n" - " raise SystemExit('quickstart gh shim accepts only release download --dir')\n" - "destination = pathlib.Path(args[args.index('--dir') + 1])\n" - "destination.mkdir(parents=True, exist_ok=True)\n" - "shutil.copy2(os.environ['CLEAN_DOCS_CANDIDATE_WHEEL'], destination)\n", - encoding="utf-8", - ) - gh.chmod(gh.stat().st_mode | stat.S_IXUSR) + pipx_home = workspace / "pipx-home" environment = { key: value for key, value in os.environ.items() @@ -103,12 +92,15 @@ def _run_quickstart(candidate: Path, wheelhouse: Path) -> dict[str, object]: } environment.update( { - "CLEAN_DOCS_CANDIDATE_WHEEL": candidate.as_posix(), "HOME": (workspace / "home").as_posix(), "PATH": bin_dir.as_posix() + os.pathsep + environment["PATH"], "PIP_DISABLE_PIP_VERSION_CHECK": "1", - "PIP_FIND_LINKS": wheelhouse.as_posix(), + "PIP_FIND_LINKS": f"{candidate.parent} {wheelhouse}", "PIP_NO_INDEX": "1", + "PIPX_BIN_DIR": bin_dir.as_posix(), + "PIPX_DEFAULT_PYTHON": sys.executable, + "PIPX_HOME": pipx_home.as_posix(), + "PIPX_SHARED_LIBS": (workspace / "pipx-shared").as_posix(), } ) (workspace / "home").mkdir() @@ -124,8 +116,8 @@ def _run_quickstart(candidate: Path, wheelhouse: Path) -> dict[str, object]: if process.returncode != 0: detail = process.stderr.strip() or process.stdout.strip() raise RuntimeError(f"README quickstart failed: {detail}") - executable = repository / ".venv/bin/sourcebound" - python = repository / ".venv/bin/python" + executable = bin_dir / "sourcebound" + python = pipx_home / "venvs/sourcebound/bin/python" reported = subprocess.run( [str(executable), "--version"], cwd=repository, @@ -172,6 +164,7 @@ def _run_quickstart(candidate: Path, wheelhouse: Path) -> dict[str, object]: "package_module": module_path.as_posix(), }, "observed": { + "installer": "pipx", "readme_bash_blocks": 2, "verification_receipt": True, "network_package_index_used": False, diff --git a/scripts/verify_published_release.py b/scripts/verify_published_release.py new file mode 100644 index 0000000..b4a57c2 --- /dev/null +++ b/scripts/verify_published_release.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Verify one published Sourcebound release across GitHub, PyPI, pipx, and uv.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +SCHEMA = "sourcebound.publication-verification.v1" +PYPI_PROJECT = "sourcebound" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _run( + args: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, +) -> str: + process = subprocess.run( + args, + cwd=cwd, + env=env, + text=True, + capture_output=True, + timeout=180, + check=False, + ) + if process.returncode != 0: + detail = process.stderr.strip() or process.stdout.strip() + raise RuntimeError(f"{' '.join(args)} failed: {detail}") + return process.stdout.strip() + + +def _request_json(url: str) -> dict[str, Any]: + request = urllib.request.Request(url, headers={"User-Agent": "sourcebound-release-verifier/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + if not isinstance(payload, dict): + raise RuntimeError(f"{url} returned a non-object response") + return payload + + +def _request_text(url: str) -> str: + request = urllib.request.Request(url, headers={"User-Agent": "sourcebound-release-verifier/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read().decode("utf-8") + + +def _wait_for_pypi(version: str, wheel_name: str, *, attempts: int, delay: float) -> dict[str, Any]: + version_url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json" + simple_url = f"https://pypi.org/simple/{PYPI_PROJECT}/" + last_error: Exception | None = None + for attempt in range(attempts): + try: + payload = _request_json(version_url) + if wheel_name in _request_text(simple_url): + return payload + except (OSError, UnicodeError, ValueError, urllib.error.HTTPError) as exc: + last_error = exc + if attempt + 1 < attempts: + time.sleep(delay) + detail = f": {last_error}" if last_error is not None else "" + raise RuntimeError(f"PyPI did not expose {wheel_name} after {attempts} attempts{detail}") + + +def _pypi_wheel(payload: dict[str, Any], wheel_name: str) -> tuple[str, str]: + urls = payload.get("urls") + if not isinstance(urls, list): + raise RuntimeError("PyPI release response omitted urls") + matches = [ + item + for item in urls + if isinstance(item, dict) + and item.get("filename") == wheel_name + and item.get("packagetype") == "bdist_wheel" + ] + if len(matches) != 1: + raise RuntimeError(f"PyPI exposed {len(matches)} matching wheels; expected 1") + item = matches[0] + url = item.get("url") + digests = item.get("digests") + digest = digests.get("sha256") if isinstance(digests, dict) else None + if not isinstance(url, str) or not url.startswith("https://files.pythonhosted.org/"): + raise RuntimeError("PyPI wheel URL is missing or outside files.pythonhosted.org") + if not isinstance(digest, str) or len(digest) != 64: + raise RuntimeError("PyPI wheel response omitted a SHA-256 digest") + return url, digest + + +def _download(url: str, destination: Path) -> None: + request = urllib.request.Request(url, headers={"User-Agent": "sourcebound-release-verifier/1"}) + with urllib.request.urlopen(request, timeout=60) as response: + destination.write_bytes(response.read()) + + +def _checksum_digest(path: Path, wheel_name: str) -> str: + matches = [] + for line in path.read_text().splitlines(): + parts = line.split(maxsplit=1) + if len(parts) == 2 and parts[1] == wheel_name: + matches.append(parts[0]) + if len(matches) != 1 or len(matches[0]) != 64: + raise RuntimeError(f"SHA256SUMS must contain exactly one digest for {wheel_name}") + return matches[0] + + +def _installed_binary(directory: Path) -> Path: + suffix = ".exe" if os.name == "nt" else "" + binary = directory / f"sourcebound{suffix}" + if not binary.is_file(): + raise RuntimeError(f"installer did not create {binary}") + return binary + + +def _smoke_installers(version: str, root: Path, temporary: Path) -> dict[str, str]: + specification = f"{PYPI_PROJECT}=={version}" + if importlib.util.find_spec("pipx") is None: + raise RuntimeError("pipx module not found; install the workflow's pinned pipx version") + + pipx_home = temporary / "pipx-home" + pipx_bin = temporary / "pipx-bin" + pipx_env = dict(os.environ) + pipx_env.update( + { + "PIPX_BIN_DIR": str(pipx_bin), + "PIPX_DEFAULT_PYTHON": sys.executable, + "PIPX_HOME": str(pipx_home), + "PIPX_SHARED_LIBS": str(temporary / "pipx-shared"), + } + ) + _run( + [ + sys.executable, + "-m", + "pipx", + "install", + specification, + "--pip-args=--no-cache-dir", + ], + env=pipx_env, + ) + pipx_sourcebound = _installed_binary(pipx_bin) + pipx_version = _run([str(pipx_sourcebound), "--version"], env=pipx_env) + _run([str(pipx_sourcebound), "--root", str(root), "doctor"], env=pipx_env) + + uv = shutil.which("uv") + if uv is None: + raise RuntimeError("uv executable not found") + uv_bin = temporary / "uv-bin" + uv_env = dict(os.environ) + uv_env.update( + { + "UV_CACHE_DIR": str(temporary / "uv-cache"), + "UV_TOOL_BIN_DIR": str(uv_bin), + "UV_TOOL_DIR": str(temporary / "uv-tools"), + } + ) + _run([uv, "tool", "install", specification], env=uv_env) + uv_sourcebound = _installed_binary(uv_bin) + uv_version = _run([str(uv_sourcebound), "--version"], env=uv_env) + _run([str(uv_sourcebound), "--root", str(root), "doctor"], env=uv_env) + + if pipx_version != version or uv_version != version: + raise RuntimeError( + f"installer versions do not match {version}: pipx={pipx_version}, uv={uv_version}" + ) + return {"pipx": pipx_version, "uv": uv_version} + + +def verify_published_release( + *, + repo: str, + tag: str, + version: str, + dist: Path, + root: Path, + attempts: int = 24, + delay: float = 5.0, +) -> dict[str, Any]: + wheels = sorted(dist.glob("*.whl")) + if len(wheels) != 1: + raise RuntimeError(f"expected one local wheel, found {len(wheels)}") + local_wheel = wheels[0] + local_digest = _sha256(local_wheel) + checksum_digest = _checksum_digest(dist / "SHA256SUMS", local_wheel.name) + if local_digest != checksum_digest: + raise RuntimeError("local wheel does not match SHA256SUMS") + + with tempfile.TemporaryDirectory(prefix="sourcebound-publication-") as temporary_name: + temporary = Path(temporary_name) + github = temporary / "github" + github.mkdir() + _run( + [ + "gh", + "release", + "download", + tag, + "--repo", + repo, + "--pattern", + local_wheel.name, + "--pattern", + "SHA256SUMS", + "--dir", + str(github), + ] + ) + github_wheel = github / local_wheel.name + github_digest = _sha256(github_wheel) + github_checksum = _checksum_digest(github / "SHA256SUMS", local_wheel.name) + _run(["gh", "attestation", "verify", str(github_wheel), "--repo", repo]) + + pypi_payload = _wait_for_pypi( + version, + local_wheel.name, + attempts=attempts, + delay=delay, + ) + pypi_url, pypi_declared_digest = _pypi_wheel(pypi_payload, local_wheel.name) + pypi_wheel = temporary / local_wheel.name + _download(pypi_url, pypi_wheel) + pypi_digest = _sha256(pypi_wheel) + + observed = { + "local": local_digest, + "checksum": checksum_digest, + "github": github_digest, + "github_checksum": github_checksum, + "pypi": pypi_digest, + "pypi_declared": pypi_declared_digest, + } + if set(observed.values()) != {local_digest}: + raise RuntimeError(f"published wheel digests differ: {observed}") + installers = _smoke_installers(version, root, temporary) + + return { + "schema": SCHEMA, + "ok": True, + "repo": repo, + "tag": tag, + "version": version, + "wheel": {"file": local_wheel.name, "sha256": local_digest}, + "digests": observed, + "attestation": "verified", + "installers": installers, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--dist", type=Path, required=True) + parser.add_argument("--root", type=Path, default=Path(".")) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--attempts", type=int, default=24) + parser.add_argument("--delay", type=float, default=5.0) + args = parser.parse_args() + try: + receipt = verify_published_release( + repo=args.repo, + tag=args.tag, + version=args.version, + dist=args.dist.resolve(), + root=args.root.resolve(), + attempts=args.attempts, + delay=args.delay, + ) + exit_code = 0 + except (OSError, RuntimeError, subprocess.SubprocessError, ValueError) as exc: + receipt = { + "schema": SCHEMA, + "ok": False, + "repo": args.repo, + "tag": args.tag, + "version": args.version, + "error": {"type": type(exc).__name__, "message": str(exc)}, + } + exit_code = 1 + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n") + print(json.dumps(receipt, indent=2, sort_keys=True)) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_doctor_integrations.py b/tests/test_doctor_integrations.py index 3d33bc7..b15d266 100644 --- a/tests/test_doctor_integrations.py +++ b/tests/test_doctor_integrations.py @@ -182,6 +182,11 @@ def test_mdx_adapter_boundary_is_explicit_and_tested() -> None: install = (ROOT / "docs/INSTALL.md").read_text() assert "uv tool install sourcebound" in install assert "pipx install sourcebound" in install + assert "pipx upgrade sourcebound" in install + assert 'pipx install --force "sourcebound=="' in install + assert "pipx uninstall sourcebound" in install + assert "uv tool upgrade sourcebound" in install + assert "uv tool uninstall sourcebound" in install workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text()) mdx_runtime = workflow["jobs"]["mdx-runtime"] diff --git a/tests/test_release_artifact.py b/tests/test_release_artifact.py index f9c8277..8bc1ca0 100644 --- a/tests/test_release_artifact.py +++ b/tests/test_release_artifact.py @@ -30,8 +30,7 @@ def test_published_wheel_checksum_command_accepts_matching_artifact(tmp_path: Pa digest = hashlib.sha256(wheel.read_bytes()).hexdigest() (tmp_path / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n") - install = (ROOT / "docs/INSTALL.md").read_text() - section = install.split("## Verify release artifacts", maxsplit=1)[1] + section = (ROOT / "docs/VERIFY_RELEASE.md").read_text() match = re.search(r"```bash\npython3 - <<'PY'\n(.*?)\nPY\n", section, re.DOTALL) assert match is not None @@ -69,9 +68,12 @@ def test_release_toolchain_and_ci_install_are_pinned() -> None: "setuptools==75.8.0", "wheel==0.45.1", ] - assert {"build==1.2.2.post1", "setuptools==75.8.0", "wheel==0.45.1"} <= set( - project["project"]["optional-dependencies"]["dev"] - ) + assert { + "build==1.2.2.post1", + "pipx==1.8.0", + "setuptools==75.8.0", + "wheel==0.45.1", + } <= set(project["project"]["optional-dependencies"]["dev"]) package = (ROOT / "src/clean_docs/__init__.py").read_text() assert '__version__ = "' not in package assert 'version("sourcebound")' in package @@ -88,7 +90,9 @@ def test_release_toolchain_and_ci_install_are_pinned() -> None: quickstart_commands = [ step["run"] for step in quickstart["steps"] if "run" in step ] + assert any('"pipx==1.8.0"' in command for command in quickstart_commands) assert any("pip download --only-binary=:all:" in command for command in quickstart_commands) + assert any('"pip==25.3"' in command for command in quickstart_commands) assert any("scripts/test_readme_quickstart.py" in command for command in quickstart_commands) quickstart_upload = next( step for step in quickstart["steps"] if step.get("uses") == UPLOAD_ARTIFACT @@ -112,6 +116,7 @@ def test_release_workflow_attests_wheel_and_sbom() -> None: assert "python scripts/test_release_lifecycle.py --wheel dist/*.whl" in commands assert any("scripts/test_readme_quickstart.py" in command for command in commands) assert any("pip download --only-binary=:all:" in command for command in commands) + assert any('"pip==25.3"' in command for command in commands) attestations = [step for step in steps if str(step.get("uses", "")).startswith("actions/attest@")] assert len(attestations) == 2 assert attestations[0]["with"] == {"subject-path": "dist/*.whl"} @@ -128,7 +133,7 @@ def test_release_workflow_attests_wheel_and_sbom() -> None: assert "--source-digest \"$GITHUB_SHA\"" in publisher["run"] assert "gh release create" not in publisher["run"] pypi_stage = next(step for step in steps if step.get("name") == "Stage the PyPI distribution") - assert pypi_stage["if"] == "vars.PYPI_PUBLISH_ENABLED == 'true'" + assert "if" not in pypi_stage assert "find dist -maxdepth 1 -type f -name '*.whl'" in pypi_stage["run"] assert 'if [[ "${#wheels[@]}" -ne 1 ]]' in pypi_stage["run"] assert 'cp -f -- "${wheels[0]}" pypi-dist/' in pypi_stage["run"] @@ -136,13 +141,30 @@ def test_release_workflow_attests_wheel_and_sbom() -> None: assert "SHA256SUMS" not in pypi_stage["run"] assert "release.json" not in pypi_stage["run"] pypi = next(step for step in steps if step.get("name") == "Publish the attested wheel to PyPI") - assert pypi["if"] == "vars.PYPI_PUBLISH_ENABLED == 'true'" + assert "if" not in pypi assert pypi["uses"] == "pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247" - assert pypi["with"] == {"packages-dir": "pypi-dist/"} + assert pypi["with"] == { + "packages-dir": "pypi-dist/", + "skip-existing": True, + } + verifier_install = next( + step for step in steps if step.get("name") == "Install publication verifiers" + ) + assert verifier_install["if"] == "${{ always() && steps.publish-pypi.outcome != 'skipped' }}" + assert verifier_install["run"] == 'python -m pip install "pipx==1.8.0" "uv==0.8.22"' + verifier = next( + step for step in steps if step.get("name") == "Verify published bytes and installers" + ) + assert verifier["if"] == "${{ always() && steps.publish-pypi.outcome != 'skipped' }}" + assert verifier["env"] == {"GH_TOKEN": "${{ github.token }}"} + assert "python scripts/verify_published_release.py" in verifier["run"] + assert "--out sourcebound-publication-verification.json" in verifier["run"] publication_upload = next( step for step in steps if step.get("name") == "Upload publication receipt" ) + assert publication_upload["if"] == "always()" assert "release-publication.json" in publication_upload["with"]["path"] + assert "sourcebound-publication-verification.json" in publication_upload["with"]["path"] assert "sourcebound-quickstart.json" in publication_upload["with"]["path"] assert publication_upload["with"]["if-no-files-found"] == "error" diff --git a/tests/test_support.py b/tests/test_support.py index 6f6de82..61c7ffd 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -26,10 +26,10 @@ def test_reader_install_and_repair_guidance_matches_candidate_artifacts() -> Non "./wheelhouse/sourcebound-*.whl" ) in install assert "The version must match the wheel filename" in install - checksum_section = install.split("## Verify release artifacts", 1)[1] + checksum_section = (PROJECT / "docs/VERIFY_RELEASE.md").read_text() assert "python3 - <<'PY'" in checksum_section assert "\npython - <<'PY'" not in checksum_section - assert "expected one wheel" in install + assert "expected one wheel" in checksum_section assert "non-ignored untracked Markdown and MDX files enter the" in support normalized_readme = " ".join(readme.split()) assert "run `check`, then use `drive` for a declared repair" in normalized_readme diff --git a/tests/test_v04_acceptance.py b/tests/test_v04_acceptance.py index b6efb27..1c05f66 100644 --- a/tests/test_v04_acceptance.py +++ b/tests/test_v04_acceptance.py @@ -13,9 +13,7 @@ def test_human_quickstart_installs_and_runs_from_declared_docs(tmp_path: Path) - del tmp_path quickstart = _quickstart_script() for command in ( - "gh release download --repo owieschon/sourcebound", - "python3 -m venv .venv", - 'python -m pip install "$release_dir"/sourcebound-*.whl', + "pipx install sourcebound", "sourcebound audit", "sourcebound init --no-model", "sourcebound check", @@ -27,7 +25,8 @@ def test_human_quickstart_installs_and_runs_from_declared_docs(tmp_path: Path) - lifecycle = (ROOT / "scripts/test_readme_quickstart.py").read_text() assert '"PYTHONPATH"' in lifecycle assert "source_checkout_shadowed" in lifecycle - assert "CLEAN_DOCS_CANDIDATE_WHEEL" in lifecycle + assert '"PIPX_HOME"' in lifecycle + assert '"PIP_NO_INDEX"' in lifecycle def test_agent_configuration_round_trip_uses_only_contributor_bundle() -> None: diff --git a/tests/test_verify_published_release.py b/tests/test_verify_published_release.py new file mode 100644 index 0000000..77d6e3a --- /dev/null +++ b/tests/test_verify_published_release.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +import scripts.verify_published_release as publication + + +def _pypi_payload(wheel_name: str, digest: str) -> dict[str, Any]: + return { + "urls": [ + { + "filename": wheel_name, + "packagetype": "bdist_wheel", + "url": f"https://files.pythonhosted.org/packages/{wheel_name}", + "digests": {"sha256": digest}, + } + ] + } + + +def test_pypi_wheel_requires_one_file_on_the_expected_host() -> None: + wheel_name = "sourcebound-1.2.2-py3-none-any.whl" + digest = "a" * 64 + + assert publication._pypi_wheel(_pypi_payload(wheel_name, digest), wheel_name) == ( + f"https://files.pythonhosted.org/packages/{wheel_name}", + digest, + ) + + payload = _pypi_payload(wheel_name, digest) + payload["urls"][0]["url"] = f"https://example.test/{wheel_name}" + with pytest.raises(RuntimeError, match="outside files.pythonhosted.org"): + publication._pypi_wheel(payload, wheel_name) + + +def test_checksum_digest_requires_one_exact_wheel_entry(tmp_path: Path) -> None: + wheel_name = "sourcebound-1.2.2-py3-none-any.whl" + digest = "b" * 64 + checksums = tmp_path / "SHA256SUMS" + checksums.write_text(f"{digest} {wheel_name}\n") + + assert publication._checksum_digest(checksums, wheel_name) == digest + + checksums.write_text(f"{digest} {wheel_name}\n{digest} {wheel_name}\n") + with pytest.raises(RuntimeError, match="exactly one digest"): + publication._checksum_digest(checksums, wheel_name) + + +def test_smoke_installers_names_the_missing_pipx_prerequisite( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(publication.importlib.util, "find_spec", lambda name: None) + + with pytest.raises(RuntimeError, match="pinned pipx version"): + publication._smoke_installers("1.2.2", tmp_path, tmp_path / "temporary") + + +def test_verify_published_release_matches_bytes_and_smokes_installers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + dist = tmp_path / "dist" + dist.mkdir() + wheel = dist / "sourcebound-1.2.2-py3-none-any.whl" + wheel.write_bytes(b"one canonical wheel") + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + (dist / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n") + + def fake_run(args: list[str], **_: object) -> str: + if args[:3] == ["gh", "release", "download"]: + github = Path(args[args.index("--dir") + 1]) + (github / wheel.name).write_bytes(wheel.read_bytes()) + (github / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n") + elif args[:3] != ["gh", "attestation", "verify"]: + raise AssertionError(f"unexpected command: {args}") + return "" + + monkeypatch.setattr(publication, "_run", fake_run) + monkeypatch.setattr( + publication, + "_wait_for_pypi", + lambda *args, **kwargs: _pypi_payload(wheel.name, digest), + ) + monkeypatch.setattr( + publication, + "_download", + lambda url, destination: destination.write_bytes(wheel.read_bytes()), + ) + monkeypatch.setattr( + publication, + "_smoke_installers", + lambda version, root, temporary: {"pipx": version, "uv": version}, + ) + + receipt = publication.verify_published_release( + repo="owieschon/sourcebound", + tag="v1.2.2", + version="1.2.2", + dist=dist, + root=tmp_path, + ) + + assert receipt["schema"] == "sourcebound.publication-verification.v1" + assert receipt["ok"] is True + assert receipt["wheel"] == {"file": wheel.name, "sha256": digest} + assert set(receipt["digests"].values()) == {digest} + assert receipt["attestation"] == "verified" + assert receipt["installers"] == {"pipx": "1.2.2", "uv": "1.2.2"} + + +def test_verify_published_release_rejects_a_pypi_digest_mismatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + dist = tmp_path / "dist" + dist.mkdir() + wheel = dist / "sourcebound-1.2.2-py3-none-any.whl" + wheel.write_bytes(b"local wheel") + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + (dist / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n") + + def fake_run(args: list[str], **_: object) -> str: + if args[:3] == ["gh", "release", "download"]: + github = Path(args[args.index("--dir") + 1]) + (github / wheel.name).write_bytes(wheel.read_bytes()) + (github / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n") + return "" + + monkeypatch.setattr(publication, "_run", fake_run) + monkeypatch.setattr( + publication, + "_wait_for_pypi", + lambda *args, **kwargs: _pypi_payload(wheel.name, "0" * 64), + ) + monkeypatch.setattr( + publication, + "_download", + lambda url, destination: destination.write_bytes(b"different wheel"), + ) + + with pytest.raises(RuntimeError, match="published wheel digests differ"): + publication.verify_published_release( + repo="owieschon/sourcebound", + tag="v1.2.2", + version="1.2.2", + dist=dist, + root=tmp_path, + ) + + +def test_main_writes_a_failure_receipt_before_returning_nonzero( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "receipts/publication.json" + + def fail_verification(**_: object) -> dict[str, Any]: + raise RuntimeError("published bytes differ") + + monkeypatch.setattr( + publication, + "verify_published_release", + fail_verification, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "verify_published_release.py", + "--repo", + "owieschon/sourcebound", + "--tag", + "v1.2.2", + "--version", + "1.2.2", + "--dist", + str(tmp_path), + "--out", + str(output), + ], + ) + + assert publication.main() == 1 + assert json.loads(output.read_text()) == { + "schema": "sourcebound.publication-verification.v1", + "ok": False, + "repo": "owieschon/sourcebound", + "tag": "v1.2.2", + "version": "1.2.2", + "error": { + "type": "RuntimeError", + "message": "published bytes differ", + }, + }