From a7654d41520ca9bc6eb0cd05c720b746481a5ffc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:10:20 +0000 Subject: [PATCH] fix(release-provenance): draft the release until its assets are attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A published GitHub release is immutable once the repo has immutable releases enabled, so whichever workflow publishes first locks every other one out. release-provenance published the release as soon as it created it, which is why drift-gate v0.2.0 shipped with no binaries: its separate binaries.yml, racing on the same tag, got HTTP 422: Cannot upload assets to an immutable release. Create the release as a draft (drafts stay mutable) and publish it in a final step, so it becomes immutable only once it is complete. Add two inputs for callers that ship extra assets: - assets-artifact: name of an artifact from an earlier job in the same run, attached alongside the provenance. This is the one-writer path — the caller builds binaries in a needs: job instead of in a second tag-triggered workflow, so nothing races for the release. Needs actions: read, declared here and required on the caller too, since a called workflow only gets the intersection. - finalize: pass false to leave the draft for another workflow to publish. Documented as the weaker option — it moves the finish line without ordering the two workflows. Tests parse the workflow and assert creation is a draft and publishing comes after the attach; both fail if the ordering is undone. Refs bounded-systems/drift-gate#5 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C5vCUb3KDboKFJnFRpTwWg --- .github/workflows/release-provenance.yml | 91 ++++++++++++++++++- ...release-provenance-draft-until-attached.md | 4 + README.md | 31 +++++++ mint.test.mjs | 52 +++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 .release/release-provenance-draft-until-attached.md diff --git a/.github/workflows/release-provenance.yml b/.github/workflows/release-provenance.yml index 2c510dc..6be3209 100644 --- a/.github/workflows/release-provenance.yml +++ b/.github/workflows/release-provenance.yml @@ -25,6 +25,41 @@ # bundle @bounded-systems/verify consumes). name: release-provenance (reusable) +# ── Why the release is created as a DRAFT (#19) ────────────────────────────── +# A *published* GitHub release is immutable once the repo has immutable releases +# enabled, so anything that attaches assets after the release is published gets +# +# HTTP 422: Cannot upload assets to an immutable release. +# +# That is exactly what happened to drift-gate v0.2.0: this workflow published the +# release, and the repo's separate `binaries.yml` — racing it on the same tag — +# could no longer attach the compiled binaries. Publishing is therefore the LAST +# thing this workflow does: it creates the release as a draft (drafts stay +# mutable), attaches everything, and only then finalizes. +# +# Two ways for a caller that ships extra assets to stay correct: +# +# 1. PREFERRED — one writer. Build the assets in a job of the caller's own +# release workflow, upload them as an artifact, and hand the artifact name +# to `assets-artifact`. This job attaches them alongside the provenance and +# publishes ONCE, so there is no second workflow racing for the release. +# The caller must add `actions: read` to its `permissions:` block (a called +# workflow only ever gets the intersection with the caller's) — without it +# the artifact download cannot see the run: +# +# permissions: { contents: write, id-token: write, actions: read } +# jobs: +# binaries: { ... } # uploads artifact "release-binaries" +# release: +# needs: binaries +# uses: bounded-systems/mint/.github/workflows/release-provenance.yml@ +# with: { ref: , assets-artifact: release-binaries } +# +# 2. Keep a separate attach workflow, and pass `finalize: false` so this job +# leaves the release a draft for that workflow to publish when it is done. +# Note this only moves the finish line: two workflows triggered by the same +# tag are still unordered, so the one that publishes can still beat the +# other's upload. Prefer (1). on: workflow_call: inputs: @@ -36,10 +71,24 @@ on: description: "Node version for the mint runtime." type: string default: "22" + assets-artifact: + description: >- + Optional name of a workflow artifact from an earlier job in the SAME + run; its files are attached to the release alongside the provenance. + Requires `actions: read` on the caller. Empty = provenance only. + type: string + default: "" + finalize: + description: >- + Publish the release (leave draft) once assets are attached. Pass false + only if a separate workflow attaches more assets and publishes itself. + type: boolean + default: true permissions: contents: write # create / upload to the caller's GitHub release id-token: write # OIDC — cosign keyless signing + actions: read # `gh run download` — only needed with assets-artifact jobs: release-provenance: @@ -71,11 +120,45 @@ jobs: env: COSIGN_EXPERIMENTAL: "1" run: cosign sign-blob --yes --bundle mint-release.intoto.sigstore.json mint-release.intoto.json + # Same run as the caller (a reusable workflow shares its run id), so the + # artifact an earlier `needs:` job uploaded is fetchable with the gh CLI + # that is already in use here — no extra action to pin. + - name: Collect caller-supplied release assets + if: inputs.assets-artifact != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p .mint-assets + # Fail loudly: a silent miss here ships a release MISSING the binaries + # it was asked to attach, which is the failure #19 is about. + gh run download "$GITHUB_RUN_ID" --name "${{ inputs.assets-artifact }}" --dir .mint-assets + if [ -z "$(ls -A .mint-assets)" ]; then + echo "release-provenance: artifact '${{ inputs.assets-artifact }}' is empty" >&2 + exit 1 + fi + ls -lh .mint-assets + - name: Attach provenance to the GitHub release env: GH_TOKEN: ${{ github.token }} run: | - assets="mint-release.intoto.json mint-release.intoto.sigstore.json" - # Create the release from the tag annotation, or upload to it if it exists. - gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-from-tag $assets \ - || gh release upload "$GITHUB_REF_NAME" $assets --clobber + set -euo pipefail + assets=(mint-release.intoto.json mint-release.intoto.sigstore.json) + # nullglob: an absent/empty dir must expand to nothing, not to a + # literal `.mint-assets/*` that gh would try to open as a file. + shopt -s nullglob + assets+=(.mint-assets/*) + shopt -u nullglob + # DRAFT — see the header. A published release is immutable, so creating + # it published here is what locks out every later attach. + gh release create "$GITHUB_REF_NAME" --draft --title "$GITHUB_REF_NAME" --notes-from-tag "${assets[@]}" \ + || gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber + + # Publishing LAST is the fix: everything is attached by now, so the release + # is immutable only once it is complete. + - name: Finalize (publish) the release + if: inputs.finalize + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false diff --git a/.release/release-provenance-draft-until-attached.md b/.release/release-provenance-draft-until-attached.md new file mode 100644 index 0000000..0ff01eb --- /dev/null +++ b/.release/release-provenance-draft-until-attached.md @@ -0,0 +1,4 @@ +--- +bump: minor +--- +release-provenance: create the GitHub release as a draft and publish it only once assets are attached, so a separate binaries job can no longer hit `422: Cannot upload assets to an immutable release`; adds `assets-artifact` (attach a caller artifact from the same run) and `finalize` inputs diff --git a/README.md b/README.md index a094990..33d4a2b 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,37 @@ jobs: `adoption.mjs --write` drops both this caller and the `version.yml` caller into every publishable repo — the path off hand-tagging. +#### Shipping extra assets (binaries) on the same release + +The release is created as a **draft** and published only once its assets are +attached. A published release is immutable, so anything that attaches *after* +the release goes public fails with `422: Cannot upload assets to an immutable +release` — which is how drift-gate v0.2.0 shipped with no binaries. + +So build the assets in a job of the *same* workflow and hand the artifact to +`assets-artifact`; the release job attaches them with the provenance and +publishes once. A separate tag-triggered workflow racing for the same release is +the thing to avoid — it is unordered by construction. + +```yaml +permissions: { contents: write, id-token: write, actions: read } # actions: read — artifact download +jobs: + binaries: + runs-on: ubuntu-latest + steps: + # ... compile into dist/ ... + - uses: actions/upload-artifact@ + with: { name: release-binaries, path: dist/* } + release: + needs: binaries + uses: bounded-systems/mint/.github/workflows/release-provenance.yml@ + with: { ref: , assets-artifact: release-binaries } +``` + +If a repo must keep a separate attach workflow, pass `finalize: false` so this +job leaves the draft for that workflow to publish — but that only moves the +finish line, it does not order the two workflows. + ## Publish (npm + JSR) mint ships from `release.yml` on each `v*` tag via **OIDC trusted publishing** — diff --git a/mint.test.mjs b/mint.test.mjs index c809d0a..e461dc4 100644 --- a/mint.test.mjs +++ b/mint.test.mjs @@ -243,3 +243,55 @@ test("release-cut.yml: guard case and cut if: allow the same events", () => { assert.ok(!fromCase.has(denied), `${denied} must never be trusted: a fork PR would control the version being tagged`); } }); + +// --- release-provenance.yml: never publish a release before it is complete --- +// +// #19: a published GitHub release is immutable, so whoever publishes first locks +// everyone else out — drift-gate v0.2.0 shipped with NO binaries because this +// workflow published the release before the repo's binaries job could attach +// them (`HTTP 422: Cannot upload assets to an immutable release`). +// +// The fix is an ordering, and an ordering is exactly what a later edit can undo +// without noticing. So the file is the fixture: creation must be a draft, and +// publishing must come after the attach. +test("release-provenance.yml: creates a draft and publishes only after attaching", () => { + const src = readFileSync(new URL("./.github/workflows/release-provenance.yml", import.meta.url), "utf8"); + + const create = src.match(/^\s*gh release create .*$/m); + assert.ok(create, "no `gh release create` — did the attach step get renamed or removed?"); + assert.match( + create[0], + /--draft\b/, + "`gh release create` must pass --draft: a published release is immutable, so creating it published " + + "is what makes every later asset upload fail with 422 (#19)", + ); + + const attachAt = src.indexOf("gh release create"); + const publishAt = src.indexOf("--draft=false"); + assert.ok(publishAt !== -1, "nothing ever publishes the draft — the release would stay invisible"); + assert.ok( + publishAt > attachAt, + "the release is published before its assets are attached — that is the #19 ordering bug, reintroduced", + ); +}); + +// The artifact hand-off is the one-writer path out of #19: assets are attached by +// this job rather than by a second workflow racing it for the same release. It +// needs `actions: read` to see the run, and a called workflow only ever gets the +// intersection with its caller — so the permission has to be declared here too. +test("release-provenance.yml: assets-artifact input is wired and permitted", () => { + const src = readFileSync(new URL("./.github/workflows/release-provenance.yml", import.meta.url), "utf8"); + + assert.match(src, /^\s{6}assets-artifact:$/m, "assets-artifact input is missing"); + assert.match(src, /^\s{6}finalize:$/m, "finalize input is missing"); + assert.match( + src, + /^\s*actions:\s*read\b/m, + "`actions: read` is missing — `gh run download` cannot fetch the caller's artifact without it", + ); + assert.match( + src, + /gh run download "\$GITHUB_RUN_ID"/, + "the artifact must come from the CALLER's run — a reusable workflow shares its run id", + ); +});