diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 99b2e51..42fbf7f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,23 +1,7 @@ -# Publishes the tagged distributions to PyPI over Trusted Publishing. No API token exists -# anywhere in this repository — the only credential is a 15-minute macaroon PyPI mints in -# exchange for this workflow's OIDC identity (probed 2026-08-28 in warehouse's -# `warehouse/oidc/views.py`: `Expiration(expires_at=not_before + 900)`). -# -# Triggered by `release: [published]`, never by a push. The chain is deliberate and each link -# is a decision by a different mechanism: -# -# release.yml (dispatched by a human) -> tag + DRAFT release -# a human presses "Publish release" -> this workflow starts -# the `pypi` environment's reviewers -> bytes reach PyPI -# -# The draft step is not ceremony. GitHub starts no workflow run from an event raised by the -# default `GITHUB_TOKEN`, so a release that release.yml published itself would never reach -# this workflow. Requiring a human to press Publish turns that constraint into the gate. -# -# `workflow_dispatch` is the retry path. A publish fails for reasons that have nothing to do -# with the bytes — an unconfigured trusted publisher, a PyPI outage, a runner without a tool — -# and without a retry the only way forward would be to spend another version number. -name: publish +# Preparation builds, checks, attests, and attaches every asset to a draft. +# Publication downloads those exact immutable assets, then waits for the pypi environment. +# Dispatch preparation at the tag so the signing identity names the tagged source commit. +name: prepare or publish on: release: @@ -25,9 +9,13 @@ on: workflow_dispatch: inputs: tag: - description: "Existing release tag to publish, e.g. v0.2.0" + description: "Existing release tag to prepare or publish, e.g. v0.2.0" required: true type: string + prepare: + description: "Build and attach signed assets to the draft without publishing to PyPI" + type: boolean + default: false # Least privilege at the top, granted per job below. `id-token: write` in particular reaches # only the publish job: the build job must not be able to mint a publishing credential. @@ -45,7 +33,30 @@ jobs: # separate GitHub Actions CI/CD job running before the one where they call this action and # having restricted privileges". The restricted privilege here is the absence of # `id-token: write`. + # Draft visibility requires push access. Keep it in a job that executes no product code. + draft: + if: inputs.prepare + name: validate the empty draft + runs-on: ubuntu-latest + permissions: + contents: write # read the draft, which is hidden from read-only tokens + steps: + - name: Require the draft and its tag signing context + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + REF: ${{ github.ref }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + [ "$REF" = "refs/tags/$TAG" ] + gh api "repos/$REPO/releases/tags/$TAG" \ + --jq '.draft == true and (.assets | length) == 0' | grep -qx true + build: + if: inputs.prepare + needs: draft name: build and verify the distributions runs-on: ubuntu-latest permissions: @@ -57,9 +68,20 @@ jobs: # draft, and the bytes that reach PyPI have to be the bytes that were released. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.tag || github.event.release.tag_name }} + ref: ${{ github.sha }} persist-credentials: false + - name: Require the tag to still name the signing source + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + REPO: ${{ github.repository }} + SOURCE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + [ "$(git rev-parse HEAD)" = "$SOURCE_SHA" ] + [ "$(gh api "repos/$REPO/commits/$TAG" --jq '.sha')" = "$SOURCE_SHA" ] + # No cache in this job, and that is the point. `mise run check` and coverage read a uv # wheel cache in check.yml because a poisoned entry there costs a wrong test result; here # it would cost a wrong ARTIFACT. GitHub scopes a cache to a branch and lets a @@ -316,10 +338,8 @@ jobs: # and its inputs are the two artifacts the jobs above produced. The verifier script is read at # the TAG through the API, so a change on main cannot loosen the gate an old tag passes. # - # Assets are added to the release after it is published, so this needs immutable releases - # left off in the repository settings. `gh release upload` without `--clobber` refuses to - # replace an asset: a re-dispatch after a partial upload fails loudly rather than swapping - # bytes a consumer may already have verified. + # Assets are attached while the release is a draft. Publication locks every file and its + # tag. Existing assets are never replaced; rerun a failed upload using its saved artifacts. release-assets: name: upload the signed release assets needs: [build, attest] @@ -341,25 +361,35 @@ jobs: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.build.outputs.version }} REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ github.sha }} run: | set -euo pipefail bundle="release/atif_sql-$VERSION.intoto.jsonl" for asset in release/*; do [ "$asset" = "$bundle" ] && continue gh attestation verify "$asset" --bundle "$bundle" --repo "$REPO" \ - --signer-workflow "$REPO/.github/workflows/publish.yml" + --signer-workflow "$REPO/.github/workflows/publish.yml" \ + --source-ref "refs/tags/$TAG" --source-digest "$SOURCE_SHA" \ + --deny-self-hosted-runners + done + for asset in release/*.whl release/*.tar.gz; do + gh attestation verify "$asset" --bundle "$bundle" --repo "$REPO" \ + --signer-workflow "$REPO/.github/workflows/publish.yml" \ + --source-ref "refs/tags/$TAG" --source-digest "$SOURCE_SHA" \ + --predicate-type https://cyclonedx.org/bom --deny-self-hosted-runners done - name: Check the attestations cover the whole release env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.build.outputs.version }} - TAG: ${{ inputs.tag || github.event.release.tag_name }} + SOURCE_SHA: ${{ github.sha }} REPO: ${{ github.repository }} run: | set -euo pipefail gh api -H "Accept: application/vnd.github.raw+json" \ - "repos/$REPO/contents/scripts/verify_release_assets.py?ref=$TAG" > verify_release_assets.py + "repos/$REPO/contents/scripts/verify_release_assets.py?ref=$SOURCE_SHA" > verify_release_assets.py python3 verify_release_assets.py release "$VERSION" --attestations - name: Upload the assets to the release @@ -367,7 +397,81 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag || github.event.release.tag_name }} REPO: ${{ github.repository }} - run: gh release upload "$TAG" release/* --repo "$REPO" + SOURCE_SHA: ${{ github.sha }} + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + gh api "repos/$REPO/releases/tags/$TAG" --jq '.draft' | grep -qx true + [ "$(gh api "repos/$REPO/commits/$TAG" --jq '.sha')" = "$SOURCE_SHA" ] + mkdir existing + count="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.assets | length')" + if [ "$count" -gt 0 ]; then + gh release download "$TAG" --repo "$REPO" --dir existing + fi + for asset in release/*; do + name="$(basename "$asset")" + if [ -f "existing/$name" ]; then + cmp "$asset" "existing/$name" + else + gh release upload "$TAG" "$asset" --repo "$REPO" + fi + done + gh release download "$TAG" --repo "$REPO" --dir final-release + python3 verify_release_assets.py final-release "$VERSION" --attestations + for asset in release/*; do + cmp "$asset" "final-release/$(basename "$asset")" + done + echo "All six verified assets are attached to draft $TAG. Review and publish the draft, then approve the pypi deployment." >> "$GITHUB_STEP_SUMMARY" + + # No build in publication mode: PyPI receives the files already signed and locked on GitHub. + verify: + if: ${{ !inputs.prepare }} + name: verify the immutable release distributions + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Download and verify the immutable release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + gh api "repos/$REPO/releases/tags/$TAG" \ + --jq '.draft == false and .immutable == true' | grep -qx true + gh release verify "$TAG" --repo "$REPO" + gh release download "$TAG" --repo "$REPO" --dir release + version="${TAG#v}" + bundle="release/atif_sql-$version.intoto.jsonl" + # Resolve the peeled commit, because an annotated tag's object SHA is not its source. + source_sha="$(gh api "repos/$REPO/commits/$TAG" --jq '.sha')" + for asset in release/*; do + [ "$asset" = "$bundle" ] && continue + gh attestation verify "$asset" --bundle "$bundle" --repo "$REPO" \ + --signer-workflow "$REPO/.github/workflows/publish.yml" \ + --source-ref "refs/tags/$TAG" --source-digest "$source_sha" \ + --deny-self-hosted-runners + done + for asset in release/*.whl release/*.tar.gz; do + gh attestation verify "$asset" --bundle "$bundle" --repo "$REPO" \ + --signer-workflow "$REPO/.github/workflows/publish.yml" \ + --source-ref "refs/tags/$TAG" --source-digest "$source_sha" \ + --predicate-type https://cyclonedx.org/bom --deny-self-hosted-runners + done + gh api -H "Accept: application/vnd.github.raw+json" \ + "repos/$REPO/contents/scripts/verify_release_assets.py?ref=$source_sha" > verify_release_assets.py + python3 verify_release_assets.py release "$version" --attestations + mkdir dist + cp "release/atif_sql-$version-py3-none-any.whl" "release/atif_sql-$version.tar.gz" dist/ + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + retention-days: 7 + if-no-files-found: error # WHAT A MAINTAINER CONFIGURES ON PYPI, BEFORE THE FIRST RUN # --------------------------------------------------------- @@ -415,13 +519,11 @@ jobs: # single-distribution shape rather than of this file. publish: name: publish atif-sql - # `attest` as well as `build`: no version reaches PyPI without its provenance in GitHub's - # attestation API. - needs: [build, attest] + # Verification supplies only the immutable files whose signatures and manifests passed. + needs: verify runs-on: ubuntu-latest - # The human decision, and the reason it sits HERE rather than earlier: publishing the draft - # release says "these are the release notes", which is reversible. This says "these bytes - # are on PyPI", which is not — a version can be yanked but never replaced or reused. The + # Publishing the draft locks its assets and tag. This approval permits uploading the same + # bytes to PyPI, where a version can be yanked but never replaced or reused. The # environment must be created with REQUIRED REVIEWERS (Settings -> Environments -> pypi -> # Required reviewers) or the gate is decorative, and its name must match the `Environment # name` on the trusted publisher. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06df103..28cd65a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ # Cuts a release: bump the version, write the changelog, tag, and open a DRAFT GitHub -# release. It publishes nothing. `publish.yml` does that, and it starts when a human presses -# "Publish release" on the draft this workflow leaves behind. +# release, then dispatches publish.yml at the tag to prepare its signed assets. +# PyPI publication starts when a maintainer publishes the complete draft. # # Why the two are separate, and why the draft is load-bearing: # @@ -54,6 +54,7 @@ jobs: # is deliberately absent: nothing here talks to PyPI, so this job cannot mint a # publishing credential even if a step tried. contents: write # push the bump commit, the tag, and the draft release + actions: write # workflow_dispatch preparation at the newly created tag steps: # A release is cut from `main` or not at all. Dispatching this workflow from a feature # branch would tag a tree that never passed review, and the tag is what `publish.yml` @@ -261,4 +262,14 @@ jobs: --verify-tag \ --title "v$VERSION" \ --notes-file release-notes.md - echo "Draft release v$VERSION is open. Review the notes, then press Publish release: that event starts publish.yml, which waits on the 'pypi' environment's reviewers before anything reaches PyPI." >> "$GITHUB_STEP_SUMMARY" + echo "Draft release v$VERSION is open. Asset preparation runs next; wait for its six verified assets before publishing the draft." >> "$GITHUB_STEP_SUMMARY" + + # workflow_dispatch is an explicit exception to GITHUB_TOKEN's event suppression. + # Run at the tag so OIDC provenance identifies the same source that the builder checks out. + - name: Prepare the draft assets + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.next.outputs.version }} + REPO: ${{ github.repository }} + run: gh workflow run publish.yml --repo "$REPO" --ref "v$VERSION" -f "tag=v$VERSION" -f prepare=true diff --git a/RELEASING.md b/RELEASING.md index 0ae71be..6728e7f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -89,9 +89,9 @@ contracts, and every doc citation valid. Verified 2026-08-28: the wheel carries files, every top-level module, the `atif-sql` console script, and `Requires-Dist` entries with no `atif-*` among them. -## What the publish preflight asserts +## What the preparation preflight asserts -`publish.yml` checks these properties before it uploads anything, and fails with a pointer to +`publish.yml` preparation checks these properties before it attaches draft assets, and fails with a pointer to this file when one is missing. Each is a defect `twine check` passes over — the metadata stays structurally valid and the damage is semantic — and each becomes permanent the moment a first release goes out under the wrong name or an unpinned requirement. @@ -248,17 +248,18 @@ litellm`) prints nothing, and `test_dev_only_imports_guard.py` fails if `src/` i ```bash gh workflow run release.yml -f dry_run=false ``` -4. **Review the draft and press "Publish release".** That press is what starts `publish.yml` — - see below for why it has to be a human and not the workflow. -5. **Approve the `pypi` environment.** `publish.yml` builds the sdist and the wheel, verifies - them, installs them, stages the release assets, attests them, and then waits. A reviewer - approves once and all the upload legs proceed. The signed assets reach the GitHub release - without waiting for that approval, so they are on the release whether or not PyPI is. +4. **Wait for preparation, then publish the draft.** `release.yml` dispatches `publish.yml` + at the tag to build, test, sign, and attach all six assets. Wait for that run to pass and + review the notes and assets before pressing "Publish release". Publication locks the tag + and assets and starts the PyPI workflow. +5. **Approve the `pypi` environment.** Publication mode downloads and verifies the immutable + wheel and sdist, then waits for review before sending those exact files to PyPI. It does + not rebuild them. ## What a GitHub release carries, and how to verify it Every release carries the same six assets, staged by `mise run release:assets` on the tag and -uploaded by `publish.yml`: +attached to the draft by `publish.yml` preparation: | Asset | What it is | | --- | --- | @@ -286,12 +287,17 @@ Drop `--bundle` to verify against the attestation API instead. A wheel installed verifies the same way, and PyPI shows its own PEP 740 attestation for each file on the project page. -`publish.yml` runs the same `gh attestation verify` on every asset, then -`scripts/verify_release_assets.py --attestations`, before `gh release upload`. A stray file, a -name that disagrees with the tag, a digest `SHA256SUMS` does not hold, an SBOM for another -version, or an asset the provenance does not cover fails the upload; its tests plant each -defect. `--clobber` is never passed, so a re-dispatch never replaces an asset someone may have -verified: it fails on the first name that exists. +`publish.yml` preparation runs `gh attestation verify` on every asset, then +`scripts/verify_release_assets.py --attestations`, before attaching files to the draft. It +runs at the tag, and verification binds the signatures to that tag's commit and ref. A stray +file, a mismatched version or digest, an SBOM for another version, or incomplete provenance +fails preparation. An upload retry compares existing files against the saved artifacts and +uploads only missing files. It never replaces an asset. + +Publishing the draft locks its tag and all six assets. Publication mode downloads those +immutable assets, checks GitHub's release attestation, verifies provenance and the SBOM +signatures, and checks the asset manifest again. Only the verified wheel and sdist reach the +reviewer-gated PyPI job. It builds no replacement files and uploads no GitHub assets. ### Why the release is a draft @@ -377,7 +383,7 @@ the fat-wheel restructure above. Neither is on the table. | Ruleset `main checks` on the default branch | Settings → Rules → Rulesets | The check jobs required on an up-to-date branch. The deploy key is the only bypass actor. See below. | | Ruleset `main review` on the default branch | Settings → Rules → Rulesets | Pull requests with two approvals, Code Owner review, the latest push approved, review threads resolved, stale approvals dismissed. Bypass actors: the deploy key, and the repository admin role for pull requests only. See below. | | Environment `release` with secret `RELEASE_DEPLOY_KEY`, deployments from `main` only | Settings → Environments | The private half of the write deploy key `release.yml` pushes the bump commit and tag with. See below. | -| **Immutable releases** off | Settings → General → Releases | `publish.yml` adds the signed assets after the release is published, which an immutable release refuses. | +| **Immutable releases** on | Settings → General → Releases | Preparation attaches all signed assets to the draft. Publishing locks those assets and the tag before the PyPI job downloads them. | | Pages **Source = GitHub Actions** | Settings → Pages | `docs.yml` deploys through `actions/deploy-pages`, which needs the Actions source rather than a branch. `base: "/atif-sql/"` in `site/astro.config.ts` matches the resulting project-site path. | | The AI-crawler policy, at the ORIGIN root | The `laithalsaadoon.github.io` user-pages repository | `robots.txt` is per-origin (RFC 9309 §2.3), so a project site served from a path segment cannot own one: Astro emits this site's copy to `/atif-sql/robots.txt`, which no crawler fetches. `site/public/robots.txt` is the decided policy and the exact text to install at `https://laithalsaadoon.github.io/robots.txt`. Until it is installed there, these pages inherit whatever that origin already serves — and absent a file, that is fully permissive. Outside this repository's reach, so no gate here can assert it. | @@ -470,7 +476,14 @@ gh release create "v$VERSION" --draft --verify-tag \ Create the tag after the merge, not before: a squash or rebase merge gives the change a new hash, and the tag must name the commit that is on `main`. -From there the flow is identical: review the draft, press Publish, approve `pypi`. +Prepare the assets at the existing tag: + +```bash +gh workflow run publish.yml --ref "v$VERSION" -f "tag=v$VERSION" -f prepare=true +``` + +Wait for preparation to succeed and all six assets to appear. Review the draft, publish it, +then approve the `pypi` deployment. ## Cutting a specific version @@ -511,6 +524,7 @@ an existing filename either way, so this cannot replace bytes. | `CurrentVersionNotFoundError: Current version ... is not found in ` | A `version_files` entry points at a file whose version was hand-edited. `--check-consistency` is doing its job. | | `GH006: Protected branch update failed` | The push did not use the deploy key, or the key is not a bypass actor of the `main checks` and `main review` rulesets. A maintainer's direct push gets this too: their bypass covers merging a pull request only. See "The release push and branch protection". | | `The release environment has no RELEASE_DEPLOY_KEY secret` | The deploy key setup has not happened yet. Same section. | -| `gh release upload` fails with `already exists` | A re-dispatch of `publish.yml` for a release that already carries its assets. Nothing to do: the assets on the release are the ones first verified. | +| Preparation refuses a draft with existing assets | Rerun the failed upload job to reuse its saved artifacts. A fresh build cannot replace existing assets. | +| Immutable release verification fails | The release is still a draft, immutability is disabled, or its attestation does not verify. Publication stops before PyPI. | | 403 from PyPI naming a publisher it cannot find | On a first release, the pending publisher does not exist yet. Afterwards, the publisher does not match the claims. | | `expected one sdist and one wheel for , staged 0` | `uv build --all-packages` did not produce that project — usually a member removed from `[tool.uv.workspace] members`. | diff --git a/mise.toml b/mise.toml index 2490f26..33417f4 100644 --- a/mise.toml +++ b/mise.toml @@ -692,7 +692,7 @@ depends = [ # --------------------------------------------------------------------------- # Release assets: what a GitHub release carries beside the PyPI upload. # -# publish.yml runs this on the tag after `uv build`, attests what it staged, and uploads it to +# publish.yml preparation runs this on the tag after `uv build`, attests it, and attaches it to # the release. It runs the same on a laptop, so what a release will carry is never something # only CI can show. RELEASING.md has the verify commands a consumer runs. # --------------------------------------------------------------------------- diff --git a/packages/atif-cli/tests/test_release_assets.py b/packages/atif-cli/tests/test_release_assets.py index b90c77a..3d2801e 100644 --- a/packages/atif-cli/tests/test_release_assets.py +++ b/packages/atif-cli/tests/test_release_assets.py @@ -2,7 +2,7 @@ """`scripts/verify_release_assets.py` is the gate a GitHub release passes before upload. -publish.yml stages the release with `mise run release:assets`, attests it, and uploads it only +publish.yml preparation stages the release with `mise run release:assets`, attests it, and uploads it only when this script accepts the directory. A release asset nobody can verify must fail the release, so each planted defect below turns the gate red, and a well-formed release is green: the anti-vacuity proof every gate here carries. @@ -13,8 +13,11 @@ import base64 import hashlib import json +import os +import shutil import subprocess import sys +import textwrap from pathlib import Path #: Repository root: `packages/atif-cli/tests/` -> three parents up. @@ -205,7 +208,188 @@ def test_publish_workflow_gates_the_upload_on_this_script() -> None: stage = text.index('run: RELEASE_VERSION="${RELEASE_TAG#v}" mise run release:assets') verify_signatures = text.index('gh attestation verify "$asset" --bundle') verify_assets = text.index('verify_release_assets.py release "$VERSION" --attestations') - upload = text.index("run: gh release upload") + upload = text.index('gh release upload "$TAG" "$asset"') assert stage < verify_assets < upload assert verify_signatures < upload assert "--signer-workflow" in text + + +def test_pypi_consumes_verified_immutable_assets_without_rebuilding() -> None: + text = PUBLISH.read_text(encoding="utf-8") + verify = text.split(" verify:\n", 1)[1].split(" # WHAT A MAINTAINER", 1)[0] + publish = text.split(" publish:\n", 1)[1] + assert ".draft == false and .immutable == true" in verify + assert "gh release verify" in verify + assert verify.index("gh release download") < verify.index("gh attestation verify") + assert verify.index("--attestations") < verify.index("cp ") + assert "--source-ref" in verify and "--source-digest" in verify + assert "--predicate-type https://cyclonedx.org/bom" in verify + assert "uv build" not in verify + publish + assert "gh release upload" not in verify + publish + assert "needs: verify" in publish + + +def test_preparation_requires_draft_and_tag_context_before_build() -> None: + text = PUBLISH.read_text(encoding="utf-8") + draft = text.split(" draft:\n", 1)[1].split(" build:\n", 1)[0] + build = text.split(" build:\n", 1)[1].split(" attest:\n", 1)[0] + assert "if: inputs.prepare" in draft + assert '[ "$REF" = "refs/tags/$TAG" ]' in draft + assert ".draft == true" in draft + assert "contents: write" in draft and "actions/checkout@" not in draft + assert "if: inputs.prepare" in build + assert "needs: draft" in build and "contents: read" in build + assert "ref: ${{ github.sha }}" in build + release = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + assert "actions: write" in release + assert '--ref "v$VERSION"' in release and "-f prepare=true" in release + + +def _upload_run_block() -> str: + """Execute the workflow's shell rather than a second implementation of its retry policy.""" + text = PUBLISH.read_text(encoding="utf-8") + step = text.split(" - name: Upload the assets to the release\n", 1)[1] + body = step.split(" run: |\n", 1)[1] + lines: list[str] = [] + for line in body.splitlines(): + if line.strip() and not line.startswith(" "): + break + lines.append(line) + script = textwrap.dedent("\n".join(lines)) + assert "gh release upload" in script and "final-release" in script + return script + + +# This fake implements only the API and release commands the upload step uses. Every other +# invocation fails, and upload refuses replacement, so a policy regression cannot call GitHub +# or turn a mismatched asset into a successful retry. +FAKE_GH = """\ +import json +import pathlib +import shutil +import sys + +args = sys.argv[1:] +root = pathlib.Path.cwd() +remote = root / 'remote' +state = json.loads((root / 'state.json').read_text()) +with (root / 'gh-calls.jsonl').open('a') as log: + log.write(json.dumps(args) + '\\n') +if args[0] == 'api': + query = args[args.index('--jq') + 1] + if query == '.draft': + print('true' if state['draft'] else 'false') + elif query == '.sha': + print(state['source_sha']) + elif query == '.assets | length': + print(len(list(remote.iterdir()))) + else: + raise SystemExit('unexpected API query: ' + query) +elif args[:2] == ['release', 'download']: + destination = root / args[args.index('--dir') + 1] + destination.mkdir(exist_ok=True) + if destination.name == 'final-release' and state['stray']: + (remote / 'helper').write_bytes(b'unlisted asset') + for asset in remote.iterdir(): + shutil.copyfile(asset, destination / asset.name) +elif args[:2] == ['release', 'upload']: + asset = root / args[3] + destination = remote / asset.name + if destination.exists() or '--clobber' in args: + raise SystemExit('asset replacement refused') + shutil.copyfile(asset, destination) +else: + raise SystemExit('unexpected gh command: ' + repr(args)) +""" + + +def _run_upload( + tmp_path: Path, + *, + existing: tuple[str, ...] = (), + mismatch: bool = False, + draft: bool = True, + stray: bool = False, +) -> subprocess.CompletedProcess[str]: + directory = _stage(tmp_path) + remote = tmp_path / "remote" + remote.mkdir() + for name in existing: + shutil.copyfile(directory / name, remote / name) + if mismatch: + (remote / WHEEL).write_bytes(b"different wheel bytes") + source_sha = "a" * 40 + state = {"draft": draft, "source_sha": source_sha, "stray": stray} + (tmp_path / "state.json").write_text(json.dumps(state), encoding="utf-8") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_text(f"#!{sys.executable}\n{FAKE_GH}", encoding="utf-8") + gh.chmod(0o755) + (bin_dir / "python3").symlink_to(sys.executable) + shutil.copyfile(SCRIPT, tmp_path / "verify_release_assets.py") + environment = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GH_TOKEN": "test", + "TAG": f"v{VERSION}", + "VERSION": VERSION, + "REPO": "example/atif-sql", + "SOURCE_SHA": source_sha, + "GITHUB_STEP_SUMMARY": str(tmp_path / "summary.md"), + } + return subprocess.run( # noqa: S603 - fixed bash executes the local workflow with a fake gh + ["bash", "-c", _upload_run_block()], # noqa: S607 - bash is the repository's shell + capture_output=True, + text=True, + check=False, + cwd=tmp_path, + env=environment, + ) + + +def _uploaded_names(tmp_path: Path) -> list[str]: + calls = [json.loads(line) for line in (tmp_path / "gh-calls.jsonl").read_text().splitlines()] + return [Path(args[3]).name for args in calls if args[:2] == ["release", "upload"]] + + +def test_draft_upload_retry_reuses_identical_existing_assets(tmp_path: Path) -> None: + names = (WHEEL, SDIST, CDX, SPDX, INTOTO, "SHA256SUMS") + result = _run_upload(tmp_path, existing=names) + assert result.returncode == 0, result.stderr + assert _uploaded_names(tmp_path) == [] + assert "6 assets, attestations covered, 0 findings" in result.stdout + assert (tmp_path / "summary.md").is_file() + + +def test_draft_upload_retry_uploads_only_missing_assets(tmp_path: Path) -> None: + result = _run_upload(tmp_path, existing=(WHEEL,)) + assert result.returncode == 0, result.stderr + assert set(_uploaded_names(tmp_path)) == {SDIST, CDX, SPDX, INTOTO, "SHA256SUMS"} + for asset in (tmp_path / "release").iterdir(): + assert (tmp_path / "remote" / asset.name).read_bytes() == asset.read_bytes() + + +def test_draft_upload_retry_refuses_mismatched_asset_without_replacement(tmp_path: Path) -> None: + names = (WHEEL, SDIST, CDX, SPDX, INTOTO, "SHA256SUMS") + result = _run_upload(tmp_path, existing=names, mismatch=True) + assert result.returncode != 0 + assert "differ" in result.stdout + assert _uploaded_names(tmp_path) == [] + assert (tmp_path / "remote" / WHEEL).read_bytes() == b"different wheel bytes" + assert not (tmp_path / "summary.md").exists() + + +def test_draft_upload_rejects_stray_asset_in_final_download(tmp_path: Path) -> None: + result = _run_upload(tmp_path, stray=True) + assert result.returncode != 0 + assert "UNLISTED helper" in result.stderr + assert not (tmp_path / "summary.md").exists() + + +def test_draft_upload_refuses_release_published_during_build(tmp_path: Path) -> None: + result = _run_upload(tmp_path, draft=False) + assert result.returncode != 0 + assert _uploaded_names(tmp_path) == [] + assert list((tmp_path / "remote").iterdir()) == [] + assert not (tmp_path / "summary.md").exists() diff --git a/scripts/verify_release_assets.py b/scripts/verify_release_assets.py index bb0f5f9..23ebd80 100755 --- a/scripts/verify_release_assets.py +++ b/scripts/verify_release_assets.py @@ -7,7 +7,7 @@ `mise run release:assets` stages the release in one directory: the wheel and the sdist that go to PyPI, a CycloneDX and an SPDX SBOM of the locked runtime closure, and a `SHA256SUMS` -manifest. `publish.yml` then signs Sigstore attestations over those files and adds them as +manifest. `publish.yml` preparation signs Sigstore attestations over those files and adds them as `atif_sql-.intoto.jsonl`, one bundle per line. This is the release gate both steps run before anything is uploaded, and its rule is that an asset nobody can verify fails the release rather than shipping beside the ones that can be: