voyage/docs/release-runbook.md
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:11:57 +02:00

10 KiB

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:

## 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:

    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.

    node scripts/release-check.mjs --review <review.md>
    

    From another directory, add --repo <dir>: a relative --review is then read in that directory, like every other path the check reads.

    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 Voyage's own artifacts under .claude/projects/ and .claude/voyage/, such as the review itself and the approval log), 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):

    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:

    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:

    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:

    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):

    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:

    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:

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:

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:

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

Ideas only; no text is copied.