Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions .github/workflows/release-provenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@<sha>
# with: { ref: <sha>, 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:
Expand All @@ -36,10 +71,24 @@
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:
Expand Down Expand Up @@ -71,11 +120,45 @@
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
4 changes: 4 additions & 0 deletions .release/release-provenance-draft-until-attached.md
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<sha>
with: { name: release-binaries, path: dist/* }
release:
needs: binaries
uses: bounded-systems/mint/.github/workflows/release-provenance.yml@<sha>
with: { ref: <sha>, 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** —
Expand Down
52 changes: 52 additions & 0 deletions mint.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
Loading