voyage/docs/release-runbook.md
Kjell Tore Guttormsen 0e612c88ac
fix: round 3 of the release review of 6.3.0
Round 3 (v6.2.0..96cb977, BLOCK: 1 BLOCKER, 8 MAJOR, 2 MINOR), each fixed
behind a test that failed first.

- Every allowlisted runner, not only git, takes plain words and a short list
  of options that only read: an option that writes a file or loads code
  (--test-reporter-destination, --outputFile, --junitxml, --basetemp,
  --import, --config, -p, -o, -exec, a reporter path) is on no list; after
  `npm test --` only paths follow; `make test` takes no VAR=value. A built-in
  node test reporter (tap, spec, dot, junit, lcov) stays allowed.
- /trekexecute: a restore the guard refuses (destructive git, in the mode
  that shows no prompts) or the operator declines is never recorded as
  reverted: simplify = "revert-refused" and the run stops before review; a
  refused step revert is reported with its paths.
- stepTestsOnly: a fixture document under tests/ is a test file, "e.g." is
  not a path, ./ is dropped; the plan-file sweep reads Files: with the same
  reader (fieldPaths); the validator hands its parsed fields over.
- plan-validator --steps-from asks for Verify: on a 1.8 plan too.
- skill frontmatter: a value starting with # is a hazard; a quote after an
  escaped backslash closes the value. Routing reads kind tags with hasOwn.
- release-check reads scope_sha_end as written (an all-digit sha keeps its
  leading zero), and Voyage's own artifacts under .claude/projects/ and
  .claude/voyage/ committed after the review do not count as unreviewed.

Suite 2170 (2168/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:25:24 +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>
    

    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.