Field notes
Release candidate pipeline
Date: 2026-08-23 Status: shipped
Context#
Releases went straight from main to a stable vX.Y.Z tag. Anything that broke
the install path broke it for every user at once, and the only recovery was a
follow-up patch release. We wanted a soak stage.
What already worked#
release.yml had prerelease: ${{ contains(github.ref_name, '-') }} — a tag with
a hyphen already published as a GitHub pre-release. And install.sh resolves the
version from releases/latest, which GitHub excludes pre-releases from. So the
"don't hand an RC to normal users" half was free: no new workflow, no new trigger.
The gap was everything around it.
Three latent bugs the RC path exposed#
Cutting a fake 0.41.0-rc.1 locally surfaced bugs that had been dormant because
no version string had ever contained a non-numeric character.
tests/installer/smoke.shversion parse. Usedsed -E 's/.*"([0-9.]+)".*/\1/'. On"version": "0.41.0-rc.1"there is no"-delimited run of pure[0-9.], so the pattern did not match at all and sed passed the line through unchanged —VERSIONbecame the whole JSON line. A silent no-match, not an error. Fixed to the capturepackage-release.shalready used:s/.*"version": *"([^"]+)".*/\1/.src/cli/dist.tsSEMVER = /^\d+\.\d+\.\d+$/. This gatescurrentVersion(),installedVersions(),previousVersion(), andpruneVersions(). An installed RC failed the test, soslaude versionreported(none)and rollback/prune could not see the directory. An RC was installable but not operable — precisely the thing an RC is supposed to prove. Regex widened to accept a prerelease suffix, andcmpSemverextended to sort per spec: prerelease below its own stable, numeric identifiers compared numerically sorc.10 > rc.2.Tag /
package.jsondrift was unguarded.install.shbuilds the asset URL from the tag;package-release.shnames the tarball frompackage.json. Disagreement ships a release whose download 404s, and nothing checked. Added a fail-fast step at the top ofrelease.yml.
The lesson: the RC pipeline paid for itself before its first real RC. A format
that has only ever been exercised with one shape (\d+\.\d+\.\d+) accumulates
parsers that assume that shape, and a silent non-match is the common failure —
neither the sed nor the regex threw.
What shipped#
scripts/promote-rc.sh— strips-rc.N, bumpspackage.json, commits, tags, pushes. Refuses unless: tag is well-formed, RC tag exists, stable tag does not, cleanmain, RC is an ancestor of HEAD, stable notes file exists. Prints commits that landed after the RC (those would ship unsoaked).--dry-run.release.yml— tag/version guard; installer smoke test against the packaged tarball on every tag (ci.ymlonly smoked on main/PRs, so tag builds shipped the install path unverified).dist.tsprerelease-aware version handling + test coverage.- Flow documented in
CLAUDE.mdand.claude/skills/release-prep/SKILL.md.
Deliberately not done#
- No auto-promotion on a soak timer. Promotion is a judgment call; the script is a guard rail, not a scheduler.
slaude updatestill pulls an RC user back to stable. Falls out ofreleases/latestsemantics. Correct: an RC is a deliberate pin, and a user who runsupdateis asking for the supported version.- No
cut-rc.sh. Cutting is a version bump and a tag; the CI guard catches the one mistake that matters (drift). Add it if cutting turns out to be error-prone in practice.