Skip to content

feat: make releases atomic - #19

Merged
craigmbooth merged 1 commit into
mainfrom
chore/atomic-releases
Aug 4, 2026
Merged

feat: make releases atomic#19
craigmbooth merged 1 commit into
mainfrom
chore/atomic-releases

Conversation

@craigmbooth

Copy link
Copy Markdown
Collaborator

Why

Releasing was a six-step checklist in LIFECYCLE.md, and the steps came apart.

release/verify.sh --audit-all on the current history — 9 of 16 published tags are internally inconsistent:

tag gates.yml Makefile plugin.json
v2.1.0 – v2.4.0 v2.0.0 v2.0.0 correct fetch gate scripts from v2.0.0
v3.0.0 v3.0.0 v2.0.0 v3.0.0 preflight 3 releases behind CI
v0.5.0, v0.6.0, v1.1.0, v2.0.0 correct correct stale plugin.json missed

And the live one: main currently advertises a tag that does not exist. v3.1.0 was stamped into gates.yml and Makefile.colormath and written up in the changelog, but never tagged — so raw.githubusercontent.com/ColorMath/ci/v3.1.0/scripts/diff-coverage.sh 404s. Anyone vendoring Makefile.colormath from main today gets a make preflight that cannot fetch a single gate script.

The old refs-lockstep job could not catch any of this: it compared the two stamps to each other and never asked whether the ref resolved. It also did not know plugin.json existed — a site CHANGELOG.md records having been missed once before.

The cause, and the fix

The cause is stamping forward: a human writes the next version into the tree days before the tag exists, and the window closes only if they remember step 4.

So stamps now move only in the release commit, which is tagged with the version it stamps in a single git push --atomic — git updates both refs or neither. The window is gone rather than shortened, and the invariant becomes true continuously:

On main, at every commit: every version this repo writes down agrees, equals the newest tag reachable from HEAD, and that tag exists.

What's here

  • release/cut.sh — the one gesture. Refuses to start unless the tree is clean, synced with origin/main, and green in CI; stamps; re-verifies; commits; creates an annotated tag; pushes atomically; publishes the GitHub Release from the changelog. Rolls back its own commit and tag if the push is rejected; resumes at the publish step if the tag landed but gh failed.
  • release/verify.sh — replaces refs-lockstep, strict superset. Covers plugin.json, asks whether the ref resolves, and requires example/'s vendored copies to stay byte-identical to root. --audit-all produced the table above and stays advisory — published tags are never rewritten.
  • release/stamp.sh — pure filesystem, idempotent, anchored patterns only. Historical prose (Default-on since v2.0.0, the adoption notes) is never touched.
  • release/backfill-releases.sh — all 16 tags are bare; this fills them in from the changelog. Dry-run by default.
  • .github/workflows/release.yml — thin workflow_dispatch wrapper so releases can be rehearsed locally rather than only inside Actions.

This PR also clears the 404 by resetting both stamps to v3.0.0, the tag that actually exists, and renaming the changelog's v3.1.0 heading back to ## Unreleased. v3.1.0 then gets cut properly with the new machinery.

Two deliberate choices

Docs are de-versioned, not stamped. Nine copy-paste pins had rotted (README said @v2.0.0; the workflow usage-comments said @v1.1.0 and @v1.0.0). They now read @vX.Y.Z and point at /releases/latest. Deleting the data beat automating it, and verify.sh fails the PR if a concrete version reappears.

Tooling is in release/, not scripts/. gates.yml sparse-checks-out scripts onto consumer runners at 16 call sites, and cone-mode sparse checkout is recursive — scripts/release/ would ship to every consumer CI run and become part of the published contract surface.

Is the new gate safe to block?

LIFECYCLE.md warns that a hotfix blocked by a drift gate gets the gate disabled, "and that's the beginning of the end." release-consistency is safe to block because it can only fail on a PR that edited a version string — a PR touching none of them inherits main's consistent state and passes unconditionally. It can never fire because the world moved.

Verification

Exercised end-to-end against a throwaway bare remote:

  • stamp → exactly 4 files, 1 line each; historical prose untouched; idempotent on rerun
  • full cut.sh → annotated tag, both refs landed in one atomic push, invariant holds on the release commit itself, --audit-all reports the new tag consistent
  • push rejection (forced via a pre-receive hook) → both refs rejected together, local commit and tag rolled back, working tree clean, origin unchanged
  • resume after the tag landed → skips to publish
  • half-bumping one stamp → fails; re-pinning a doc example → fails; an unrelated change → passes

🤖 Generated with Claude Code

https://claude.ai/code/session_011614N8pZWRHsCE6HGpJNXm

Releasing was a six-step checklist in LIFECYCLE.md and the steps came apart.
An audit of all sixteen published tags finds nine internally inconsistent:
v2.1.0-v2.4.0 each ship a gates.yml fetching gate scripts from v2.0.0, and
v3.0.0's Makefile.colormath points three releases back. v3.1.0 was stamped
into main and written up in the changelog but never tagged, so main has been
advertising a ref that 404s for every consumer running `make preflight`.

The cause is stamping forward: writing the next version into the tree days
before the tag exists, leaving a window that closes only if someone remembers.
Stamps now move only in the release commit, which is tagged with the version it
stamps in a single `git push --atomic` -- both refs land or neither does.

release/cut.sh is the one gesture. release/verify.sh replaces refs-lockstep and
is a strict superset: it covers plugin.json (a stamp site nothing checked, and
which had been missed twice) and asks whether the stamped ref resolves, which
is the question that would have caught both bugs.

Documentation examples are de-versioned to @vX.Y.Z rather than stamped -- nine
had rotted one to three majors stale.

Tooling lives in release/, not scripts/: gates.yml sparse-checks-out `scripts`
onto consumer runners at 16 call sites, and cone-mode sparse checkout is
recursive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011614N8pZWRHsCE6HGpJNXm
@craigmbooth
craigmbooth merged commit 7af277d into main Aug 4, 2026
17 checks passed
@craigmbooth
craigmbooth deleted the chore/atomic-releases branch August 4, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant