feat(release): a drilled release and rollback runbook, and a release check
P13 (wave 2, order 8). docs/release-runbook.md has three parts: before the release, the release, and the rollback. The rollback was drilled first, in a sandbox with local bare repos as the remotes: a release 6.2.0 -> 6.2.1, then path A (git revert, catalog ref back to v6.2.0) and path B (a forward patch 6.2.2 that carries the revert), with the catalog's check-versions green after each. Nothing reached a real remote; the real catalog was byte-identical before and after. Measured: release-plugin.mjs refuses a ref backwards while plugin.json still says the newer version, and accepts it once the release commit is reverted. scripts/release-check.mjs decides before a release: clean tree, npm test green at HEAD, review verdict not BLOCK (K7) and covering HEAD, and a door type per delivery (### Doors in the CHANGELOG section). 0 ready, 1 not ready, 2 could not run (never green). A ready run prints the K2 command that logs the operator's yes. tests/lib/release-prose-pins.test.mjs holds every command in the rollback part to the commands the drill ran (tests/fixtures/release-drill/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4ee711a6ac
commit
4807f6c458
7 changed files with 985 additions and 0 deletions
237
docs/release-runbook.md
Normal file
237
docs/release-runbook.md
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
# Release and rollback runbook
|
||||
|
||||
How a Voyage release goes out, and how it is rolled back. The rollback part is
|
||||
not theory: every command in § 3 was run in a drill — a release 6.2.0 → 6.2.1
|
||||
and both rollback paths, in a sandbox with local bare repositories as the
|
||||
remotes — and `tests/lib/release-prose-pins.test.mjs` holds § 3 to the commands
|
||||
recorded there (`tests/fixtures/release-drill/rollback-commands.txt`).
|
||||
|
||||
A release is a two-repo act. The plugin repo (this one) gets a tag `vX.Y.Z`;
|
||||
the catalog repo pins each plugin by that tag (`source.ref` in
|
||||
`.claude-plugin/marketplace.json`), so users get the release only when the
|
||||
catalog ref moves. The catalog's `scripts/release-plugin.mjs` finds the plugin
|
||||
as a sibling directory (`../voyage`), and both repos are run from their root.
|
||||
|
||||
Placeholders: `<previous-version>` is the release before (6.2.0 in the drill),
|
||||
`<withdrawn-version>` the one rolled back (6.2.1), `<next-patch>` the next
|
||||
unused patch number (6.2.2). `<release-commit>` is the release commit,
|
||||
`<merge-commit>` a delivery merged since the previous tag, `<revert-commit>`
|
||||
the last commit of the revert, and `<review.md>` the review of what is
|
||||
released.
|
||||
|
||||
## Door types
|
||||
|
||||
Every delivery in a release says whether it is a **one-way door** or a
|
||||
**two-way door**:
|
||||
|
||||
- **one-way** — cannot be walked back without a cost to users. A break in a
|
||||
public contract (the brief format of Handover 1, a `plan_version` or
|
||||
`review_version` that rejects files that used to validate), a removed command
|
||||
or flag, anything users must migrate. A published tag is one-way too: it is
|
||||
never deleted or moved.
|
||||
- **two-way** — a revert restores the old behaviour and nobody has to do
|
||||
anything: a new optional field, a new check, a fix, documentation.
|
||||
|
||||
At the release, the CHANGELOG section for the version carries one line per
|
||||
delivery under `### Doors`:
|
||||
|
||||
```markdown
|
||||
## v6.3.0 — 2026-10-15 — <title>
|
||||
|
||||
### Doors
|
||||
|
||||
- two-way: release check and runbook — a revert removes them, nothing reads them.
|
||||
- one-way: <delivery> — <what a user must do, and from which version>.
|
||||
```
|
||||
|
||||
The release is one-way if any line is. A one-way line says what users must
|
||||
do. `scripts/release-check.mjs` refuses a release whose section has no
|
||||
`### Doors` list, an empty one, or a line that is neither `- one-way:` nor
|
||||
`- two-way:`.
|
||||
|
||||
## 1. Before the release
|
||||
|
||||
1. **Every delivery is merged into `main`** (`git merge --no-ff <branch>`), in
|
||||
the agreed order. Each delivery's report states its door type.
|
||||
2. **The suite is green on the merged tree.** `npm test` on `main` with every
|
||||
delivery in it: 0 failed. A run that goes red only under heavy machine load
|
||||
is still red — run it again on a quiet machine, never release over it.
|
||||
Worktrees and branches stay until the release is out (§ 2.6): if the merged
|
||||
tree is red, the branches are where the fix starts.
|
||||
3. **A review of what is released, and its verdict is not BLOCK.** `/trekreview`
|
||||
over the commits since the previous tag writes `<review.md>`; the verdict
|
||||
gate (K7) decides on it:
|
||||
|
||||
```sh
|
||||
node lib/review/verdict-gate.mjs <review.md>
|
||||
```
|
||||
|
||||
0 = ALLOW or WARN, 1 = BLOCK, 2 = could not decide. Only 0 goes on.
|
||||
4. **The release commit.** It changes only the version and the release notes:
|
||||
`.claude-plugin/plugin.json`, `package.json`, `package-lock.json` (both
|
||||
`version` fields), the README version badge, and a new CHANGELOG section
|
||||
`## vX.Y.Z — <date> — <title>` with its `### Doors` list. Commit it as
|
||||
`chore(release): X.Y.Z — <title>`.
|
||||
5. **The release check.**
|
||||
|
||||
```sh
|
||||
node scripts/release-check.mjs --review <review.md>
|
||||
```
|
||||
|
||||
It checks, in order: the working tree is clean (otherwise the suite would
|
||||
not measure HEAD), `npm test` exits 0 at HEAD, the review verdict is not
|
||||
BLOCK, the review covers HEAD (its `scope_sha_end` is HEAD or an ancestor,
|
||||
and the commits after it touch only the release bookkeeping above), and the
|
||||
door type is set. 0 = ready, 1 = not ready (each failure listed), 2 = could
|
||||
not run — never green.
|
||||
6. **The operator's yes, logged.** The operator reads the release section and
|
||||
says yes (an answer like "sounds good" is not a yes). The check prints the
|
||||
command that logs it (K2):
|
||||
|
||||
```sh
|
||||
node lib/util/approval-log.mjs append --kind release --subject CHANGELOG.md --sha256-of CHANGELOG.md
|
||||
```
|
||||
|
||||
The record is bound to the CHANGELOG as approved: if the release notes
|
||||
change afterwards, `verify` reports `mismatch`. In this repo `.claude/` is
|
||||
ignored by git (the remote is a public mirror), so the log stays local and
|
||||
`verify` reports the record as not committed. That is the trace this repo
|
||||
keeps; a target repo that tracks `.claude/voyage/` commits it.
|
||||
|
||||
## 2. The release
|
||||
|
||||
1. **Push the plugin last of all its steps:**
|
||||
|
||||
```sh
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Voyage's push guard asks first; a yes to a push to `main` is logged as a
|
||||
`merge` approval.
|
||||
2. **In the catalog, the operator's one-shot push token.** `release-plugin.mjs`
|
||||
pushes from inside node, where the command-text push guard cannot see it, so
|
||||
it requires the same token the guard checks:
|
||||
|
||||
```sh
|
||||
mkdir -p "$HOME/.claude/runtime/push-approvals" && touch "$HOME/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"
|
||||
```
|
||||
|
||||
One token covers every push of ONE run. A second run needs a new token.
|
||||
3. **Dry run.** It must stop on the missing tag and nothing else:
|
||||
|
||||
```sh
|
||||
node scripts/release-plugin.mjs voyage
|
||||
```
|
||||
|
||||
4. **The release, in one run.** `FORGEJO_TOKEN` must be exported (the script
|
||||
says how when it is missing):
|
||||
|
||||
```sh
|
||||
node scripts/release-plugin.mjs voyage --create-tag --write --commit --push
|
||||
```
|
||||
|
||||
It refuses unless `plugin.json`, the README badge and the version agree.
|
||||
It mints and pushes the tag, runs the catalog's version gate before
|
||||
writing, bumps the ref and the catalog README label, runs the gate again,
|
||||
commits and pushes the catalog, and files the Forgejo release object with
|
||||
the CHANGELOG section as its text.
|
||||
5. **The catalog's own check is green:**
|
||||
|
||||
```sh
|
||||
node scripts/check-versions.mjs
|
||||
```
|
||||
|
||||
6. **Cleanup comes last.** Merged worktrees and branches are removed only now,
|
||||
after the suite was green on the merged tree (§ 1.2) and the release is out.
|
||||
|
||||
## 3. Rollback
|
||||
|
||||
The operator decides whether a release is rolled back. There are two paths,
|
||||
and both were drilled. **A** takes the catalog back to the previous tag at
|
||||
once. **B** ships the revert as a new patch version, so the version moves
|
||||
forward again. The recommended order is A, then B.
|
||||
|
||||
### Path A — revert, and the catalog ref back
|
||||
|
||||
`release-plugin.mjs` accepts a ref backwards only when the plugin agrees with
|
||||
it. Measured in the drill: with `plugin.json` at `<withdrawn-version>`,
|
||||
`--version <previous-version>` is refused ("plugin.json version is 6.2.1,
|
||||
asked to release 6.2.0 — bump plugin.json first"). So the plugin is reverted
|
||||
first, and the release commit's revert puts `plugin.json` and the badge back.
|
||||
|
||||
In the plugin repo, revert the release commit, then each delivery merged since
|
||||
the previous tag, newest first (`-m 1` keeps `main` as the mainline), and push:
|
||||
|
||||
```sh
|
||||
git revert --no-edit <release-commit>
|
||||
git revert --no-edit -m 1 <merge-commit>
|
||||
git push -q origin main
|
||||
```
|
||||
|
||||
In the catalog — between these steps the version gate reports a WARN (the ref
|
||||
is ahead of `plugin.json`), not an ERROR:
|
||||
|
||||
```sh
|
||||
node scripts/check-versions.mjs
|
||||
node scripts/release-plugin.mjs voyage --version <previous-version>
|
||||
mkdir -p "$HOME/.claude/runtime/push-approvals" && touch "$HOME/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"
|
||||
node scripts/release-plugin.mjs voyage --version <previous-version> --write --commit --push
|
||||
node scripts/check-versions.mjs
|
||||
```
|
||||
|
||||
The dry run must say `v<withdrawn-version> -> v<previous-version> [READY]`.
|
||||
|
||||
What path A does not do:
|
||||
- The tag `v<withdrawn-version>` stays, and so does its Forgejo release object;
|
||||
the version number is used up. Edit the release object's text to say the
|
||||
version is withdrawn (not drilled: it is the one step that needs the real
|
||||
Forgejo instance).
|
||||
- Whether a user who already updated to `<withdrawn-version>` is moved back to
|
||||
`<previous-version>` by a plugin update is **not verified**. Path B does not
|
||||
depend on it.
|
||||
|
||||
### Path B — a forward patch that carries the revert
|
||||
|
||||
On top of the revert, make a release commit for `<next-patch>` as in § 1.4,
|
||||
with a CHANGELOG section that says `<withdrawn-version>` is withdrawn and a
|
||||
`### Doors` line (`- two-way: withdraws <withdrawn-version> — …`). Its review
|
||||
covers the revert, so `scope_sha_end` is `<revert-commit>`. Then the release as
|
||||
usual:
|
||||
|
||||
```sh
|
||||
git commit -q -am "chore(release): <next-patch> - withdraws <withdrawn-version>"
|
||||
node scripts/release-check.mjs --review <review.md>
|
||||
node lib/util/approval-log.mjs append --kind release --subject CHANGELOG.md --sha256-of CHANGELOG.md
|
||||
git push -q origin main
|
||||
mkdir -p "$HOME/.claude/runtime/push-approvals" && touch "$HOME/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"
|
||||
node scripts/release-plugin.mjs voyage --create-tag --write --commit --push
|
||||
node scripts/check-versions.mjs
|
||||
```
|
||||
|
||||
The first four lines run in the plugin repo, the last three in the catalog.
|
||||
|
||||
### Bringing a reverted delivery back
|
||||
|
||||
Do not merge its branch again. git's own documentation for `revert -m`:
|
||||
"Reverting a merge commit declares that you will never want the tree changes
|
||||
brought in by the merge. As a result, later merges will only bring in tree
|
||||
changes introduced by commits that are not ancestors of the previously
|
||||
reverted merge." Revert the revert commit instead, and release it as a new
|
||||
version.
|
||||
|
||||
## Not built yet: the eval gate (P10)
|
||||
|
||||
The local gate before a release that reads the date and score of the last run
|
||||
of the fixed test set and compares it with the last change in the
|
||||
configuration paths (commands, agents, skills, hooks, and a changed model alias
|
||||
counts as a change). It waits for the test set to run again; a gate that can
|
||||
never fire is not built. When it comes, it is one more check in
|
||||
`scripts/release-check.mjs`, before the door check, and one more step in § 1.
|
||||
|
||||
## Credits
|
||||
|
||||
- The runbook form: [bashebr/ai-native-sdlc](https://github.com/bashebr/ai-native-sdlc) (MIT).
|
||||
- Green on the merged tree before cleanup: [obra/superpowers](https://github.com/obra/superpowers) (MIT), at `8ca22db`.
|
||||
- One-way and two-way doors: [mattpocock/skills](https://github.com/mattpocock/skills), at `c55ee46`.
|
||||
|
||||
Ideas only; no text is copied.
|
||||
Loading…
Add table
Add a link
Reference in a new issue