runward

RW™ · V0.43.0

Docs · Decisions · ADR-0085

ADR-0085.

ADR-0085-the-release-chain-publishes-a-draft-first-then-makes-it-immutable.md

ADR-0085 — The release chain publishes a draft first, then makes it immutable

Date: 2026-10-01 Status: accepted (2026-10-01) — the maintainer chose the recommended chain, with the prepare phase started by a tag push, and the ratchet summary committed under docs/compliance/ratchet-summaries/; the setting is enabled only after one release has run on the new chain Deciders: the maintainer Method: .github/workflows/release.yml, build-and-attest.yml, verify-release.yml and mutation-ratchet.yml, docs/verifying-a-release.md, runward/runbook.md §3, ADR-0048, ADR-0049, ADR-0079 and ROADMAP.md ("Immutable releases") read on the branch of this ADR (base main after 0.42.3); GitHub's and npm's documentation and the gh manual read at the source on 2026-10-01 and quoted below; the repository setting read through the API on 2026-09-30 and again on 2026-10-01 Relates to: ADR-0048 (the release carries its proof as assets), ADR-0049 (the isolated builder and the determinism cross-check), ADR-0079 (the signed ratchet summary), ADR-0065 (what an agent may prepare, only the operator's hand completes)

Context

The chain as it stands. The maintainer creates a published release (gh release create vX.Y.Z --target main, runbook §3 step 4). release.yml runs on release: types: [published]: the isolated builder packs and attests the tarball, an unprivileged job writes the SBOM, and the publish job cross-checks the builder's tarball against its own pack, attests the SBOM, runs npm publish "$TARBALL" --provenance --access public, and only then attaches the tarball, the two .intoto.jsonl bundles and the SBOM with gh release upload ... --clobber. The same published event starts mutation-ratchet.yml, whose summary job signs ratchet-summary-X.Y.Z.json hours later and keeps it as a workflow artifact for 90 days; ADR-0079 and runbook step 4 say the maintainer may then attach it to the release by hand. verify-release.yml runs on workflow_run of the Release workflow and replays the reader's verification against the published assets.

The setting. gh api repos/stranxik/runward/immutable-releases returned {"enabled":false} on 2026-09-30 and {"enabled":false,"enforced_by_owner":false} on 2026-10-01.

What GitHub documents, read at the source on 2026-10-01.

  1. What immutability locks. Immutable releases (docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases): "Immutable releases are releases where the assets and associated Git tag cannot be changed after publication." "Once an immutable release is published, its associated Git tag is locked to a specific commit, cannot be changed, and cannot be deleted while the release exists. If you delete the immutable release, you can delete the tag, but you cannot reuse the same tag name." "All files attached to the release (such as binaries and archives) are protected from modification or deletion." "Only the assets and tag are locked. You can still edit the title and release notes of a published immutable release, and change whether it is marked as a pre-release or as the latest release." "creating an immutable release automatically generates a release attestation, which is a cryptographically verifiable record of a release containing the release tag, commit SHA, and release assets."
  2. From when. The same page recommends: "1. Create the release as a draft. 2. Attach all associated assets to the draft release. 3. Publish the draft release. This ensures that all assets are in place before the release becomes immutable". The gh release create manual (cli.github.com/manual/gh_release_create): "Immutability is enforced only after a release is published. Draft releases can be modified or deleted, and the associated git tags can be modified or deleted as well."
  3. Existing releases. Preventing changes to your releases: "select Enable release immutability. Be aware that immutability will only apply to future releases." Every release up to the one after enablement stays mutable.
  4. Uploading to a draft. The immutable-releases page (point 2) and Managing releases in a repository ("If you have enabled immutable releases for the repository, creating a draft first allows you to attach all assets before the release becomes immutable") both describe attaching assets to a draft. Whether a NEW file can be added to a published immutable release is not stated in those words; the docs state that attached files cannot be modified or deleted, which already rules out the --clobber replace the current step relies on. The chain designed here does not depend on the answer either way.
  5. Which events fire for a draft. Events that trigger workflows, release: "Workflows are not triggered for the created, edited, or deleted activity types for draft releases." And: "The prereleased type will not trigger for pre-releases published from draft releases, but the published type will trigger." A draft starts no workflow; publishing it fires published.
  6. Who may publish without losing the event. GITHUB_TOKEN (docs.github.com/en/actions/concepts/security/github_token): "events triggered by the GITHUB_TOKEN will not create a new workflow run, with the following exceptions: workflow_dispatch and repository_dispatch events always create workflow runs." A job that publishes the draft with its own token fires no published run: not the ratchet, not anything else listening.
  7. Where a draft's tag comes from. REST Create a release, target_commitish: "Specifies the commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. Unused if the Git tag already exists." The gh manual: "Use --verify-tag to abort the release if the tag doesn't already exist."
  8. npm trusted publishing. docs.npmjs.com/trusted-publishers, for GitHub Actions: "Workflow filename (required): The filename of your workflow (e.g., publish.yml)". The trust names release.yml, not an event; and "Some GitHub Actions workflows use workflow_call to invoke other workflows that run npm publish, or use workflow_dispatch for manual publishing. When this happens, validation checks the calling workflow's name instead of the workflow that actually contains the publish command". Keeping npm publish in release.yml keeps the configuration on npmjs.com unchanged.

What breaks if the setting is turned on today. In release.yml, npm publish runs before gh release upload. On an immutable release the version would reach npm and the release would keep whatever it holds at publication (nothing), with no way to replace an asset afterwards (point 1). The ratchet summary could not be attached later the way ADR-0079 allows. And verify-release.yml would fail on missing assets for a release that cannot be repaired.

Decision

Recommended chain: the workflow prepares a draft, the maintainer publishes it, npm receives the immutable asset. Two phases in release.yml, one irreversible gesture, and it is human.

  1. Pin the commit first. The maintainer creates an annotated tag vX.Y.Z on the release merge commit and pushes it. The tag is what every later step reads; nothing derives the commit from main at a later time.
  2. Prepare (trigger: push of a v* tag, plus workflow_dispatch on a tag ref for a re-run). The isolated builder, the SBOM job and the cross-check run exactly as today (ADR-0049 unchanged). The SBOM attestation stays in a job holding id-token: write and attestations: write and no contents: write. A separate draft job holding only contents: write runs gh release create "$TAG" --draft --verify-tag and uploads the tarball, both .intoto.jsonl bundles and the SBOM to the draft. No npm publish in this phase. A failure here leaves a draft that can be deleted or re-run with --clobber, because a draft is mutable (Context, 2).
  3. Look, then publish (the maintainer). The maintainer opens the draft, checks the four assets (the commands of docs/verifying-a-release.md Steps 2 and 3 run on downloaded draft assets, which a user with push access can list), writes the notes, and publishes from their own session (gh release edit vX.Y.Z --draft=false or the web page). Because the gesture is not the GITHUB_TOKEN, it fires release: published (Context, 5 and 6). From that moment the tag and the assets are locked.
  4. Publish to npm (trigger: release: published). A publish job holding contents: read and id-token: write downloads the tarball from the release, verifies it before anything else (gh attestation verify --signer-workflow .../build-and-attest.yml; the tag's commit equals the commit the provenance names; once the setting is on, gh release verify "$TAG"), then runs npm publish "$TARBALL" --provenance --access public. What npm serves is then the byte-identical file the immutable release holds, checked against its signature, not a second pack.
  5. The mutation ratchet is unchanged. It still runs on release: published, which the maintainer's gesture fires. Its summary job keeps its condition (github.event_name == 'release').
  6. verify-release.yml verifies the publish run only. It gains the condition github.event.workflow_run.event == 'release' (a prepare run must not be verified: npm does not have the version yet), and, once the setting is on, two steps: gh release verify "$TAG" and gh release verify-asset "$TAG" runward-X.Y.Z.tgz, documented on GitHub's Verifying the integrity of a release page.
  7. Where the ratchet summary lives. It is signed hours after publication and can no longer be attached to an immutable release. Its proof never was the attachment: it is the attestation in GitHub's attestation store, which gh attestation verify ... --signer-workflow .../mutation-ratchet.yml reads by the file's digest. The file itself (a few kilobytes) is committed by the maintainer, in the next release pull request, under docs/compliance/ratchet-summaries/, and the release notes (still editable, Context, 1) carry its SHA-256 and path. ADR-0079 refused a committed ledger because a line in the repository is self-declared; that objection does not reach a committed file that must still verify against a signature only the ratchet workflow can produce. This replaces one sentence of ADR-0079's decision ("may be attached to the release by the maintainer's hand") and the matching sentence of runbook step 4 and of docs/verifying-a-release.md Step 6; the rest of ADR-0079 stands.

Migration order.

  1. Decide this ADR.
  2. One pull request changes release.yml (two phases), verify-release.yml (condition and steps), runbook §3 step 4, docs/verifying-a-release.md (Step 6 sentence; a step for gh release verify marked "from the first immutable release"), and adds the ratchet-summaries/ directory.
  3. Cut one release on the new chain with the setting still off. It exercises the draft, the maintainer's gesture, the published event, npm, the ratchet and verify-release; if any link fails, the release can still be repaired the old way, because nothing is locked.
  4. Enable the setting (an administrator's act, the maintainer's) and read it back with gh api repos/stranxik/runward/immutable-releases, expecting "enabled":true. That read-back joins the three settings of runbook §3 step 2.
  5. The next release is the first immutable one: gh release verify vX.Y.Z must report it, and docs/verifying-a-release.md and ROADMAP.md say so from that version on, never before.

What immutability shows, and what it does not. It shows that, after publication, the tag still points at the commit it pointed at and every attached file is the file that was attached: no collaborator, stolen token or workflow can swap them silently, and a deleted release burns its tag name. It shows nothing about what was attached: whether the tarball is sound is still the attestations' and the cross-check's question (ADR-0048, ADR-0049). It does not cover the draft window, the release notes or the title, the npm registry (a separate store with its own rules), or any release published before the setting was enabled. A compromised maintainer account can still publish a bad release, which then stays bad, visibly. Immutability preserves; it does not judge.

Alternatives discarded

  • One dispatched run does everything, publication included. workflow_dispatch, the job builds, drafts, uploads, publishes npm and publishes the release. One gesture, but the publication comes from the GITHUB_TOKEN, so release: published starts nothing (Context, 6): the ratchet would have to move to workflow_run or be dispatched by the release job (actions: write), and the summary's condition would change with it. npm would be published before the release becomes immutable, so a failure between the two leaves npm ahead of GitHub. And the one irreversible act would run with no person looking at the draft. Cost: the largest change of the options. Risk: a workflow holding contents: write publishes something nobody can take back.
  • The maintainer creates the draft by hand, then dispatches the build. Same end state as the recommendation with three gestures instead of two (draft, dispatch, publish), because creating a draft fires no workflow (Context, 5). Kept as the fallback if the tag-push trigger is judged too easy to fire by accident; an accidental tag push only produces a draft, which is why it is not preferred.
  • Enable the setting now and keep the chain. Breaks the next release in the worst order: npm published, the GitHub release locked without its assets (Context, "What breaks").
  • Keep releases mutable. Costs nothing, and the 2026-08-11 retroactive attachment to v0.32.0 through v0.33.2 (docs/verifying-a-release.md) shows mutability was once used for a legitimate repair. It leaves every asset and tag of every release replaceable by anyone holding write access or a token with contents: write, which today includes the publish job itself. Default until this ADR is decided.
  • Publish npm in the prepare phase, before the release is public. The tarball would reach npm while the GitHub release is still a draft that may never be published, so the two stores could disagree for good; and the npm publish would read the builder's artifact rather than the locked asset.

Consequences

  • Positive. The only irreversible gesture of a release is the maintainer's, made after they have seen the assets. npm serves the file the immutable release holds, verified against its signature before publication. The job that writes to the release no longer holds id-token: write, and the job that holds id-token: write no longer writes to the release, a narrower split than today's publish job, which holds both. A failed preparation is repairable; a failed npm publish is re-run from the locked asset without touching the release.
  • Negative, accepted. One more gesture per release (publish the draft). The ratchet and the npm publish depend on that gesture coming from a person's token: a future automation that publishes with the GITHUB_TOKEN would silently start neither, and no verify-release run would follow to say so. The runbook names the gesture; the reevaluation trigger watches for it. Releases up to the first immutable one stay mutable forever. The ratchet summary file moves into the repository, a few kilobytes per release.
  • On other boundaries. The CLI, the gate, the npm package's content and the mission are untouched; this concerns how runward's own releases are produced. ADR-0079 keeps its decision except the one sentence on where the summary file lives.

What would settle it

  • Ratify the chain when one release cut on it with the setting off shows, in its runs: a draft created by the prepare run with four assets; a release: published run started by the maintainer's gesture; npm publish from the release's own tarball, verify-release green on that run only; the ratchet started by the same event with its summary signed.
  • Ratify the setting when the following release reports immutable under gh release verify, and an attempt to delete one of its assets (gh release delete-asset) is refused; the refusal's text is recorded here.
  • Reverse to the fallback (maintainer-created draft) if a tag push ever starts a prepare run nobody meant; reverse the whole decision if GitHub's documentation stops describing assets attached to a draft as kept at publication, or if published stops firing for a draft published by a person.
  • Settle point 4 of the Context by measurement, not by reading: whether adding a new file to a published immutable release is refused. The chain does not need the answer; the documentation of what immutability covers does.

Reevaluation trigger (mandatory, dated)

Reopen when GitHub changes what immutability locks, which release events fire for drafts, or the GITHUB_TOKEN exception list; when npm changes how a trusted publisher is matched (the workflow filename today); when a release is published by anything other than a person's session; or when a prepare run starts without a tag the maintainer pushed.

Trigger set on: 2026-10-01 · Watched via: the GitHub changelog and the four pages quoted above, read at each release's runbook step 2; the actor of each release: published run in the Actions history.

References

  • .github/workflows/release.yml, build-and-attest.yml, verify-release.yml, mutation-ratchet.yml; docs/verifying-a-release.md; runward/runbook.md §3; ROADMAP.md ("Immutable releases").
  • GitHub Docs, read 2026-10-01: Immutable releases; Preventing changes to your releases; Managing releases in a repository; Verifying the integrity of a release; Events that trigger workflows (release); GITHUB_TOKEN; REST API endpoints for releases (target_commitish).
  • gh release create manual, cli.github.com/manual/gh_release_create, read 2026-10-01 (gh 2.100.0).
  • npm Docs, Trusted publishing for npm packages, docs.npmjs.com/trusted-publishers, read 2026-10-01.
← All ADRs