Release runbook — releasing rungs itself
Written 2026-08-17 while preparing v0.2.0, from the state the preparation actually found. Every trap in §5 is one that had already happened in this repository, not one imagined for the document.
What this is, and what it is not
modules/release/skills/cut-release/SKILL.md
is the portable procedure: it ships to every repo that installs the release module, and it is
written in parameters ({{changelog_dir}}, {{stable_branch}}) because it cannot know your branch
names. It is the authority on what order the steps go in and which ones are irreversible.
This document is the concrete procedure for one repository — this one. It names real commands, real paths, and the specific things that have gone wrong here. Where the two disagree about a command, this file is right about this repo and the skill is right about the general shape; where they disagree about order or reversibility, the skill wins and this file is the defect.
Keep them apart on purpose. Folding this repo’s specifics into the shipped skill would export
.ai/gates.toml trivia into every consumer repo, which is the failure the module format exists to
prevent.
1. Decide the version
First read changelog.d/CONSUMED_THROUGH. It records the
last version whose fragments were assembled, not what npm says is public. In a steady tree it must
equal the package version. UNINITIALIZED is a deliberate stop: choose none only when no release
has consumed fragments, or write the exact last consumed version. Never infer that history from a
tag or manifest.
From the fragments in changelog.d/, not from the last release and not from
what someone asked for. The rule, from the skill: a breaking change decides the major; a feature
decides the minor.
git log --no-merges --format='%s' "$(git describe --tags --abbrev=0)"..main | grep -oE '^[a-z]+:' | sort | uniq -c | sort -rn
That is a prompt, not an oracle — a feat: prefix on a docs change does not make a minor, and a
fix: that removes a documented flag is a major. Read the fragments; the commit census only tells
you where to look.
If the fragments do not support the version you were asked for, say so before continuing. When
v0.2.0 was prepared, the request was for a patch and the branch was already named
candidate/0.1.4; twelve feat: commits since v0.1.3 made it a minor. A branch name is a prefix,
not a promise, and renaming a branch is free. Republishing a version number is impossible.
2. Gate
npm audit &&
npm audit --prefix site &&
npm test && node --experimental-strip-types src/cli.ts check && node scripts/check-doc-claims.mjs
Both audits must report zero vulnerabilities from their committed lockfiles. The root and site are
separate package trees, so a clean result from either one says nothing about the other. This check
was made explicit after the site silently resolved vulnerable fast-uri@3.1.5 while the root audit
remained clean (F-053). Treat a new advisory like any other red release gate: record its exact
dependency path and split remediation into a bounded item rather than applying an unrelated forced
upgrade during the cut.
Plain check runs every registered gate, which is what a release wants; since
ADR-0008 a named tier is an ordered level rather
than a tag that could silently select nothing (T1). check-doc-claims also runs as
a gate — it is listed separately only because its output names the values it derived.
Expected at v0.2.0 preparation, 2026-08-17: 25 tests pass, 27 gates pass, 0 fail.
Do not proceed on a red gate, and do not weaken one to get through. If a gate is red for reasons that predate the release, say so explicitly and get a decision from a person.
3. Assemble the changelog
- The fragment for this release lives at
changelog.d/<version>.mdand should have been accumulating all cycle. If it does not exist, it is being reconstructed from commits — record that, because a reconstructed changelog is a weaker claim than one written alongside the work. - Fold its entries into the
releasesarray at the top ofsite/src/pages/versions.astro, newest first, withkindset tomajor/minor/patch. - Delete every consumed fragment. They are consumed, not archived — see T2.
- In the same reversible change, advance
changelog.d/CONSUMED_THROUGHto the version being prepared. It will be temporarily ahead of the package version until step 4; that red state is intentional and must not be committed or tagged on its own.
4. Bump the version, everywhere
npm version <version> --no-git-tag-version
cd site && npm version <version> --no-git-tag-version && cd ..
Now run the complete step 2 gate set again. The first pass proved the starting tree; this pass is the release authority. It must see the consumption boundary and package version equal, every consumed fragment absent, and CI green at the exact commit that will be tagged.
Both packages. The docs site is versioned in lockstep with the CLI rather than excluded from
the check: release-version-consistent compares them and fails if you bump only one. It sat at
0.0.1 beside a 0.2.0 package until 2026-08-17 — exactly the drift that gate exists to catch —
so the answer was to align it rather than teach the gate to look away (T7).
Then the surfaces:
| Surface | What to update | Held by |
|---|---|---|
site/src/pages/versions.astro | publishedVersion and publishedDate — the version already on npm, not the one being cut. Verify against the registry (T3) | nothing — check by hand |
site/src/pages/versions.astro | A releases entry for the new version, from the fragment | nothing — check by hand |
README.md § Status | The current-release sentence and the gate count | docs-version-claims |
docs/roadmap.md | Phase 7 row and the “What is left” Phase 7 paragraph | docs-version-claims |
The gated rows will fail rungs check if you forget them, which is the point. The first row will
not, which is why it is first.
Then regenerate the derived site claims and build (T6):
cd site && npm run claims && npm run build
5. Traps, each one measured
T1 — closed
A tier used to be able to select zero gates and exit as though the release had been gated. The
shipped skill’s step 2 prescribed rungs check --tier full; --tier is not a recognised flag
(src/cli.ts accepts a positional tier or --fast / --full), and full
matched no gate on a registry where all of them are fast. The result was a confident
no gates registered — is this a rungs repo? about a repo holding 25 of them.
Closed 2026-08-17 by ADR-0008: a tier is an
ordered level, so full is a superset of fast, an untiered gate runs in every tier, and a
tier the registry does not declare is refused with a non-zero exit. Plain rungs check is still
what step 2 says, because running every gate is what a release wants.
T2 — closed
A consumed fragment that is not deleted reappears in the next release.
changelog.d/0.1.1.md was written for v0.1.1, folded into the versions page, and never removed. It
was still sitting there at v0.2.0 preparation — through two releases — where it read as unreleased
work. The v0.1.3 fragment was deleted, so the discipline existed and was simply skipped once,
which is the argument for a gate rather than a louder sentence.
Partly closed 2026-08-17 by release-fragment-current: a fragment naming a version below the one
being prepared fails rungs check. Fully closed 2026-09-05 by its tracked consumption boundary:
an equal-version fragment left behind after assembly now fails too, reproducing and preventing
F-025 rather than relying on the deletion sentence again.
T3
publishedVersion in versions.astro is hand-typed and drifts. It read 0.1.2 while npm
latest had been 0.1.3 since 2026-08-15T18:06:25Z — so the page rendered
“0.1.3 · npm publication pending” above a version anyone could already npm install. Check the
registry, never memory:
npm view @rungs/cli dist-tags
T4 — mostly closed
The prose version claims were ungated. README.md and docs/roadmap.md each stated the public
latest as v0.1.2 two days after v0.1.3 shipped, and the README’s gate count sat at “20 pass” when
the answer was 25.
Closed 2026-08-17 for everything derivable: docs-version-claims holds the prepared version, the
CLI size and the command count against the manifest and the source. One claim is still on this
checklist and always will be — the published version is only knowable from the registry, and
the runner does no network. It is now stated in exactly one place, the versions page, rather than
copied into three. Verify it before publishing:
npm view @rungs/cli dist-tags
T5
The candidate branch drifts from main. At v0.2.0 preparation candidate/0.1.4 was 46
commits behind main and zero ahead: every work item had merged to main directly. The skill
assumes the candidate is what you tag. Reconcile before tagging, or tag main deliberately and
know that is what you did.
git rev-list --left-right --count candidate/<version>...main
T6
Site counts are generated, not typed. npm run claims writes
site/src/generated/claims.json, and the site-claims-current gate refuses drift. Run it before
the build or the build fails on stale counts. Note that claims.run records one rungs check
run on a stated date — it is a captured observation, not a derived property, so it can legitimately
disagree with a run you did five minutes later.
Two things about it that have already caused confusion:
- It captures whatever the tree does at that moment, including a failure you are mid-way through
fixing. During this preparation it recorded
24 pass, 1 failbecause the runbook was referenced from the README before it existed. Regenerate after the tree is green, and read the line it prints rather than assuming. atis UTC (generate-claims.mjs:38), while every hand-written date in this repo is local. Near midnight the site and the README will name different days for the same run. Neither is wrong; only the disagreement is.
T7 — closed
A sibling package drifts, or the gate that would catch it is turned off. site/package.json
sat at 0.0.1 while the CLI reached 0.2.0. release-version-consistent would have caught it,
but the gate globs */package.json under all-agree and was therefore held back at the 0.2.0 cut
rather than weakened to fit (F-023).
Closed 2026-08-17, both halves. The engine takes an exclude list so a repo can state what is
genuinely versioned on its own; this repo aligned instead of excluding, so the gate now checks
two manifests and the second bump in step 4 is not optional. Excluding one sibling does not blind
the gate to another — there is a fixture asserting it, because an exclude that silently widened
would be worse than no gate at all.
6. Tag, branch, publish — the irreversible part
Everything above is reversible. These steps are not. Do them deliberately, one at a time.
First: the CI run for the commit you are about to tag must be green
gh run list --limit 3
This is the one check that cannot be done on your machine, which is why it has to be named here.
Step 2’s rungs check is local, and a local run is green on exactly one platform. v0.3.0 was tagged
and published on 2026-08-17 while all four CI runs were red, including the run for the release
commit itself — it had already failed ten minutes before npm publish. The failures were real: a
gate in four of five profiles that had never worked off Windows (F-033),
a CRLF assumption, and an ordering bug in the workflow itself. None of them were visible to a local
rungs check, and none of them ever will be.
A red run is not automatically a stop — a known-red cell you have decided to ship past is a choice someone can make. Not looking is not.
Second: prove you can still publish, before anything becomes permanent
npm whoami
This is the other remote fact the irreversible block depends on, and it is the cheapest one to check. No gate can do it for you — the runner does no network, by design — so it sits here beside the CI check for the same reason.
Measured 2026-09-05 while cutting v0.4.0: an _authToken was configured, npm whoami would have
answered 401 Unauthorized, and nobody asked. The tag and release/0.4.0 were pushed first, and
npm publish then failed 404 Not Found - PUT — npm’s reply for an unauthenticated write to a
scope that exists, which reads as a missing package and sends you to look in the wrong place. The
release sat tagged and branched with nothing on the registry until a person re-authenticated.
Nothing was lost, because the tag names the commit the publish would have shipped and re-running
npm publish from it is the whole repair. That is the argument for the check rather than against
it: the recovery is easy this time, and the order — tag, branch, publish — guarantees the
half-shipped state every time the token has quietly expired.
git tag -a v<version> -m "rungs v<version> — <one line>"
git push origin main --follow-tags
git branch release/<version> v<version> && git push -u origin release/<version>
Then publish. The registry is immutable: a published version is never rewritten, only superseded.
npm publish --access public
Verify from outside the repo before announcing anything — a package that passes its own tests and fails a clean install is exactly the failure v0.1.1 shipped (release-readiness):
Run the install from inside the consumer directory, not with --prefix from this checkout.
--prefix places node_modules/ where you asked, and npm still resolves the manifest it saves
to from the current working directory — so the documented npm install --prefix "$(mktemp -d)"
added "@rungs/cli": "^0.4.0" to this repository’s own package.json and lockfile, and installed
a copy of the published package into the producer’s node_modules/. Measured 2026-09-05 verifying
v0.4.0: the package was made to depend on itself by the step whose entire purpose is proving it
works somewhere else. It was caught by reading the diff before pushing, which is not a control.
npm view @rungs/cli dist-tags
consumer="$(mktemp -d)" && ( cd "$consumer" && npm init -y >/dev/null &&
npm install @rungs/cli && npx --yes @rungs/cli@<version> --help )
The subshell is what keeps the cd from outliving the check. Confirm afterwards that
git status is clean here — a verification that edits the thing it verifies has told you nothing
about the registry and something you did not want about your tree.
Last: record the publish, in the one place that claims it
The moment that install succeeds, publishedVersion and publishedDate in
versions.astro are wrong — they still name the previous
release as npm latest, and the page renders the version you just shipped as “prepared, not yet
published”. Update both, rebuild, and commit.
This is T3 with a name for when it happens. Step 4’s table calls publishedVersion “the
version already on npm”, which is true when a cut starts and stops being true the second the
publish lands — so the update belongs here, not deferred to the next release. It was deferred once:
the constant read 0.1.2 for two days after 0.1.3 was installable, and v0.3.1 needed its own
follow-up commit (6bf59bc) to say it had shipped. Nothing checks this — docs-version-claims
deliberately does not, because published-ness is a registry fact and the runner does no network.
7. Open the next candidate
git branch candidate/<next> main && git push -u origin candidate/<next>
A period with no open candidate is a period where work lands somewhere improvised — which is how
candidate/0.1.4 ended up 46 commits behind the branch that actually held the work (T5).
Hotfix and rollback
Unchanged from the skill, and deliberately not restated here — one definition per concept. Branch a
hotfix from release/<version> and never from the candidate; forward-merge it into the active
candidate or the next release silently reverts it. Roll back by pointing the deploy at the previous
release/<version>, never by reverting commits, and record why as a finding at minimum.
See cut-release.