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:
Kjell Tore Guttormsen 2026-10-08 21:32:36 +02:00
commit 4807f6c458
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 985 additions and 0 deletions

View file

@ -0,0 +1,27 @@
# Commands run in the release + rollback drill (wave-2 order 8, P13), phases
# ROLLBACK A and ROLLBACK B, copied from the drill log of 2026-10-08T19:27:15Z.
# The drill ran in a sandbox: local bare repos as the remotes, a HOME of its own,
# no FORGEJO_TOKEN. Versions are written as the runbook's placeholders:
# 6.2.0 = <previous-version>, 6.2.1 = <withdrawn-version>, 6.2.2 = <next-patch>
# Commits appear as <release-commit>, <merge-commit>, <revert-commit>; the review
# file as <review.md>; drill-only helpers as <drill-helper>. [label] = the repo it ran in.
# tests/lib/release-prose-pins.test.mjs: every command in docs/release-runbook.md § 3
# must be one of these lines.
[voyage] git revert --no-edit <release-commit>
[voyage] git revert --no-edit -m 1 <merge-commit>
[voyage] node -p require('./.claude-plugin/plugin.json').version
[voyage] git push -q origin main
[catalog] node scripts/check-versions.mjs
[catalog] node scripts/release-plugin.mjs voyage --version <previous-version>
[catalog] mkdir -p "$HOME/.claude/runtime/push-approvals" && touch "$HOME/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"
[catalog] node scripts/release-plugin.mjs voyage --version <previous-version> --write --commit --push
[catalog] node scripts/check-versions.mjs
[sandbox] git -C $SANDBOX/remotes/catalog.git show main:.claude-plugin/marketplace.json
[voyage] node <drill-helper>/bump.mjs . <previous-version> <next-patch> $SANDBOX/section-<next-patch>.md
[voyage] git commit -q -am "chore(release): <next-patch> - withdraws <withdrawn-version>"
[voyage] node scripts/release-check.mjs --review <review.md>
[voyage] node lib/util/approval-log.mjs append --kind release --subject CHANGELOG.md --sha256-of CHANGELOG.md
[voyage] git push -q origin main
[catalog] mkdir -p "$HOME/.claude/runtime/push-approvals" && touch "$HOME/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"
[catalog] node scripts/release-plugin.mjs voyage --create-tag --write --commit --push
[catalog] node scripts/check-versions.mjs

View file

@ -0,0 +1,146 @@
// tests/lib/release-prose-pins.test.mjs
// Prose pins owned by the release process (wave-2 order 8, P13). Counted as
// doc pins by lib/util/test-census.mjs (PIN_FILE_RE matches `prose-pins.test.mjs`).
//
// The load-bearing pin: the rollback part of docs/release-runbook.md may only
// hold commands that were run in the release + rollback drill. The drill's
// rollback commands are recorded in tests/fixtures/release-drill/rollback-commands.txt
// (copied from the drill log, versions and commits written as the runbook's
// placeholders). A command written into § 3 that the drill never ran fails here.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDoors, releaseSection } from '../../scripts/release-check.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
const RUNBOOK = 'docs/release-runbook.md';
const FIXTURE = 'tests/fixtures/release-drill/rollback-commands.txt';
const APPROVAL = 'node lib/util/approval-log.mjs append --kind release --subject CHANGELOG.md --sha256-of CHANGELOG.md';
/** The lines from `## <heading>` to the next `## ` heading outside a fenced block. */
function part(md, heading) {
const lines = md.split('\n');
const start = lines.findIndex((l) => l === `## ${heading}`);
assert.ok(start !== -1, `${RUNBOOK} must have "## ${heading}"`);
let fenced = false;
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (lines[i].startsWith('```')) fenced = !fenced;
if (!fenced && lines[i].startsWith('## ')) { end = i; break; }
}
return lines.slice(start, end).join('\n');
}
/** Every command line in the ```sh blocks of a markdown part. */
function shCommands(md) {
const out = [];
const re = /```sh\n([\s\S]*?)```/g;
let m;
while ((m = re.exec(md)) !== null) {
for (const line of m[1].split('\n')) {
const t = line.trim();
if (t && !t.startsWith('#')) out.push(t);
}
}
return out;
}
function drillCommands() {
return read(FIXTURE).split('\n')
.filter((l) => l.trim() && !l.startsWith('#'))
.map((l) => l.replace(/^\[(voyage|catalog)\] /, '').trim());
}
test('runbook: the three parts, in order — before, release, rollback', () => {
const md = read(RUNBOOK);
const at = ['## 1. Before the release', '## 2. The release', '## 3. Rollback'].map((h) => md.indexOf(`\n${h}`));
for (const i of at) assert.ok(i !== -1, `${RUNBOOK} must have each of the three parts`);
assert.ok(at[0] < at[1] && at[1] < at[2], 'the parts must come in the order before → release → rollback');
});
test('runbook: every command in the rollback part was run in the drill', () => {
const ran = new Set(drillCommands());
const written = shCommands(part(read(RUNBOOK), '3. Rollback'));
assert.ok(written.length >= 10, `the rollback part must carry the drilled commands (found ${written.length})`);
const unrun = written.filter((c) => !ran.has(c));
assert.deepEqual(unrun, [], `commands in § 3 that the drill never ran:\n${unrun.join('\n')}`);
});
test('drill fixture: both rollback paths were run — revert + ref back, and the forward patch', () => {
const ran = drillCommands();
for (const c of [
'git revert --no-edit <release-commit>',
'git revert --no-edit -m 1 <merge-commit>',
'node scripts/release-plugin.mjs voyage --version <previous-version> --write --commit --push',
'node scripts/release-plugin.mjs voyage --create-tag --write --commit --push',
'node scripts/check-versions.mjs',
]) assert.ok(ran.includes(c), `the drill fixture must record: ${c}`);
});
test('runbook § 1: the verdict gate (K7), the release check and the logged yes (K2)', () => {
const before = part(read(RUNBOOK), '1. Before the release');
assert.ok(before.includes('node lib/review/verdict-gate.mjs <review.md>'), '§ 1 must run the K7 verdict gate');
assert.ok(before.includes('node scripts/release-check.mjs --review <review.md>'), '§ 1 must run the release check');
assert.ok(before.includes(APPROVAL), '§ 1 must log the release approval with the K2 command the check prints');
assert.match(before, /npm test/, '§ 1 must require the suite on the merged tree');
});
test('runbook § 2: push last, then one release-plugin run, then the catalog check, then cleanup', () => {
const rel = shCommands(part(read(RUNBOOK), '2. The release'));
const order = [
'git push origin main',
'node scripts/release-plugin.mjs voyage --create-tag --write --commit --push',
'node scripts/check-versions.mjs',
].map((c) => rel.indexOf(c));
for (const i of order) assert.ok(i !== -1, '§ 2 must carry the push, the release run and the catalog check');
assert.ok(order[0] < order[1] && order[1] < order[2], '§ 2 order: git push, release-plugin, check-versions');
assert.match(part(read(RUNBOOK), '2. The release'), /Cleanup comes last/);
});
test('runbook: the door-type example parses as a release section with one-way and two-way lines', () => {
const md = part(read(RUNBOOK), 'Door types');
const block = md.match(/```markdown\n([\s\S]*?)```/);
assert.ok(block, 'the door types part must show the CHANGELOG form');
const section = releaseSection(block[1], '6.3.0');
assert.ok(section !== null, 'the example must be a release section');
const doors = parseDoors(section);
assert.deepEqual(doors.entries.map((e) => e.door).sort(), ['one-way', 'two-way']);
assert.deepEqual(doors.bad, []);
});
test('runbook: the release check exits as the script does — 0 ready, 1 not ready, 2 never green', () => {
const before = part(read(RUNBOOK), '1. Before the release').replace(/\s+/g, ' ');
assert.match(before, /0 = ready, 1 = not ready[^.]*, 2 = could not run — never green/);
});
test('runbook: the eval gate (P10) is named as not built, with where it goes', () => {
const p10 = part(read(RUNBOOK), 'Not built yet: the eval gate (P10)');
assert.ok(p10.includes('scripts/release-check.mjs'), 'P10 must say which file the gate goes into');
});
test('runbook: public — no local paths', () => {
const md = read(RUNBOOK);
for (const bad of ['/Users/', '/private/', '/tmp/', '~/repos', 'scratchpad']) {
assert.ok(!md.includes(bad), `${RUNBOOK} must not carry a local path (${bad})`);
}
});
test('README: a section on releasing and rolling back that points to the runbook', () => {
const md = read('README.md');
const start = md.indexOf('\n## Releasing and rolling back');
assert.ok(start !== -1, 'README must have "## Releasing and rolling back"');
const sec = md.slice(start, md.indexOf('\n## ', start + 1));
assert.ok(sec.includes('docs/release-runbook.md'), 'the README section must link the runbook');
assert.ok(sec.includes('scripts/release-check.mjs'), 'the README section must name the release check');
});
test('CLAUDE.md: the pointer to the runbook and the release check', () => {
const md = read('CLAUDE.md');
assert.ok(md.includes('docs/release-runbook.md'), 'CLAUDE.md must point to the runbook');
assert.ok(md.includes('scripts/release-check.mjs'), 'CLAUDE.md must name the release check');
});

View file

@ -0,0 +1,303 @@
// tests/scripts/release-check.test.mjs
// P13 — the check before a release (wave-2 order 8). One command answers "may
// this HEAD be released?": the suite is green at HEAD, the review verdict for
// what is released is not BLOCK (K7) and covers it, and every delivery in the
// release says whether it is a one-way or a two-way door.
// 0 = ready, 1 = not ready (listed), 2 = could not run (never green).
//
// Every test builds a throwaway git repo shaped like a release: one commit with
// the work, then a release commit that only bumps versions and the CHANGELOG.
// The repo's own `npm test` is a one-liner that exits 0 or 1, so the suite
// check runs for real without running Voyage's suite inside its own suite.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CHECK = join(ROOT, 'scripts', 'release-check.mjs');
const mod = existsSync(CHECK) ? await import('../../scripts/release-check.mjs') : {};
const git = (cwd, ...args) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
function reviewText({ verdict = 'ALLOW', sha }) {
const id = 'a'.repeat(40);
const severity = verdict === 'BLOCK' ? 'BLOCKER' : 'MINOR';
const fmVerdict = verdict === null ? '' : `verdict: ${verdict}\n`;
const json = JSON.stringify({
verdict: verdict ?? undefined,
counts: { BLOCKER: severity === 'BLOCKER' ? 1 : 0, MAJOR: 0, MINOR: severity === 'MINOR' ? 1 : 0, SUGGESTION: 0 },
allow_blocked_by: [],
findings: [{ id, severity, rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }],
}, null, 2);
return `---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-08-s/
brief_path: .claude/projects/2026-10-08-s/brief.md
scope_sha_end: ${sha}
reviewed_files_count: 1
${fmVerdict}findings:
- ${id}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`;
}
const DOORS = `### Doors
- two-way: the fixture change — revert it and release a new patch.
`;
/**
* A repo with the work in one commit and a release commit for 1.0.1 on top.
* Options switch one thing off at a time; the default is a ready release.
*/
function fixture({
suiteExit = 0, doors = DOORS, verdict = 'ALLOW', writeReview = true,
reviewSha = 'work', afterRelease = null, dirty = false, version = '1.0.1',
} = {}) {
const base = mkdtempSync(join(tmpdir(), 'release-check-'));
const repo = join(base, 'repo');
mkdirSync(join(repo, '.claude-plugin'), { recursive: true });
git(repo, 'init', '-q', '-b', 'main');
git(repo, 'config', 'user.email', 't@example.invalid');
git(repo, 'config', 'user.name', 't');
git(repo, 'config', 'commit.gpgsign', 'false');
const pkg = (v) => JSON.stringify({ name: 'fx', version: v, scripts: { test: `node -e "process.exit(${suiteExit})"` } }, null, 2) + '\n';
writeFileSync(join(repo, 'package.json'), pkg('1.0.0'));
writeFileSync(join(repo, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx', version: '1.0.0' }) + '\n');
writeFileSync(join(repo, 'README.md'), '# fx\n\n![Version](https://img.shields.io/badge/version-1.0.0-blue)\n\nText.\n');
writeFileSync(join(repo, 'CHANGELOG.md'), '# Changelog\n\n## v1.0.0 — 2026-10-01 — first\n\n- first\n');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'feat: first');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 2;\n');
git(repo, 'add', 'a.mjs');
git(repo, 'commit', '-q', '-m', 'fix: the work');
const workSha = git(repo, 'rev-parse', 'HEAD');
if (version !== null) {
writeFileSync(join(repo, 'package.json'), pkg(version));
writeFileSync(join(repo, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx', version }) + '\n');
} else {
writeFileSync(join(repo, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'fx' }) + '\n');
writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'fx', scripts: { test: 'node -e "process.exit(0)"' } }) + '\n');
}
writeFileSync(join(repo, 'README.md'), `# fx\n\n![Version](https://img.shields.io/badge/version-${version ?? '1.0.0'}-blue)\n\nText.\n`);
writeFileSync(join(repo, 'CHANGELOG.md'),
`# Changelog\n\n## v${version ?? '9.9.9'} — 2026-10-08 — fixture\n\n${doors ?? ''}\n### Fixed\n\n- the work\n\n## v1.0.0 — 2026-10-01 — first\n\n- first\n`);
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'chore(release): fixture');
if (afterRelease) {
for (const [rel, text] of Object.entries(afterRelease)) writeFileSync(join(repo, rel), text);
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'after the review');
}
if (dirty) writeFileSync(join(repo, 'a.mjs'), 'export const a = 3;\n');
const review = join(base, 'review.md');
const sha = reviewSha === 'work' ? workSha : reviewSha;
if (writeReview) writeFileSync(review, reviewText({ verdict, sha }));
return { base, repo, review, cleanup: () => rmSync(base, { recursive: true, force: true }) };
}
function run(fx, extra = []) {
return spawnSync(process.execPath, [CHECK, '--repo', fx.repo, '--review', fx.review, ...extra], { encoding: 'utf8' });
}
test('release-check — the CLI exists', () => {
assert.ok(existsSync(CHECK), 'scripts/release-check.mjs must exist (P13)');
});
test('release-check — everything in place → 0 (ready)', () => {
const fx = fixture();
try {
const r = run(fx);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.match(r.stdout, /READY/);
} finally { fx.cleanup(); }
});
test('release-check — a ready release prints the release approval command (K2)', () => {
const fx = fixture();
try {
const r = run(fx);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.match(r.stdout, /approval-log\.mjs append --kind release --subject CHANGELOG\.md --sha256-of CHANGELOG\.md/);
} finally { fx.cleanup(); }
});
test('release-check — review verdict BLOCK → 1', () => {
const fx = fixture({ verdict: 'BLOCK' });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /BLOCK/);
} finally { fx.cleanup(); }
});
test('release-check — no review.md → 2 (never green)', () => {
const fx = fixture({ writeReview: false });
try {
const r = run(fx);
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('release-check — a review the verdict gate cannot decide (no verdict) → 2', () => {
const fx = fixture({ verdict: null });
try {
const r = run(fx);
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('release-check — no door type in the release section → 1', () => {
const fx = fixture({ doors: null });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /door/i);
} finally { fx.cleanup(); }
});
test('release-check — a door line that is neither one-way nor two-way → 1', () => {
const fx = fixture({ doors: '### Doors\n\n- two-way: fine\n- maybe: not a door type\n' });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /maybe/);
} finally { fx.cleanup(); }
});
test('release-check — an empty Doors section → 1', () => {
const fx = fixture({ doors: '### Doors\n\n' });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /no door entries/i);
} finally { fx.cleanup(); }
});
test('release-check — the suite red at HEAD → 1', () => {
const fx = fixture({ suiteExit: 1 });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /suite/i);
} finally { fx.cleanup(); }
});
test('release-check — uncommitted changes → 1 (the suite would not measure HEAD)', () => {
const fx = fixture({ dirty: true });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /uncommitted/i);
} finally { fx.cleanup(); }
});
test('release-check — a code change after the reviewed commit → 1 (the review does not cover it)', () => {
const fx = fixture({ afterRelease: { 'a.mjs': 'export const a = 99;\n' } });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /a\.mjs/);
} finally { fx.cleanup(); }
});
test('release-check — a README prose change after the reviewed commit → 1 (only the badge may move)', () => {
const fx = fixture({ afterRelease: { 'README.md': '# fx\n\n![Version](https://img.shields.io/badge/version-1.0.1-blue)\n\nNew text.\n' } });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /README\.md/);
} finally { fx.cleanup(); }
});
test('release-check — a review of a commit that is not in this history → 1', () => {
const fx = fixture({ reviewSha: 'f'.repeat(40) });
try {
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /not in the history of HEAD/);
} finally { fx.cleanup(); }
});
test('release-check — no version to release (no --version, no plugin.json version) → 2', () => {
const fx = fixture({ version: null });
try {
assert.equal(run(fx).status, 2);
} finally { fx.cleanup(); }
});
test('release-check — not a git repo → 2', () => {
const dir = mkdtempSync(join(tmpdir(), 'release-check-nogit-'));
try {
const r = spawnSync(process.execPath, [CHECK, '--repo', dir, '--review', join(dir, 'review.md'), '--version', '1.0.0'], { encoding: 'utf8' });
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('release-check — --json reports every check and the exit', () => {
const fx = fixture({ verdict: 'BLOCK' });
try {
const r = run(fx, ['--json']);
assert.equal(r.status, 1);
const out = JSON.parse(r.stdout);
assert.equal(out.exit, 1);
assert.equal(out.version, '1.0.1');
assert.deepEqual(out.checks.map((c) => c.id), ['tree', 'suite', 'review', 'coverage', 'door']);
assert.equal(out.checks.find((c) => c.id === 'review').status, 'fail');
} finally { fx.cleanup(); }
});
test('release-check — parseDoors reads one-way and two-way entries and names the bad ones', () => {
assert.equal(typeof mod.parseDoors, 'function');
const section = '### Doors\n\n- one-way: plan 1.9 rejects plan 1.7 files\n (users regenerate their plans)\n- two-way: a new check\n- maybe: x\n\n### Added\n\n- one-way: not in Doors\n';
const d = mod.parseDoors(section);
assert.deepEqual(d.entries.map((e) => e.door), ['one-way', 'two-way']);
assert.deepEqual(d.bad, ['- maybe: x']);
assert.equal(d.found, true);
});
test('release-check — releaseSection matches the CHANGELOG heading dialects exactly', () => {
assert.equal(typeof mod.releaseSection, 'function');
const text = '# C\n\n## v1.10.0 — x\n\nten\n\n## [1.1.0] - y\n\none\n\n## v1.1.0-pre\n\npre\n';
assert.match(mod.releaseSection(text, '1.1.0'), /one/);
assert.doesNotMatch(mod.releaseSection(text, '1.1.0'), /ten|pre/);
assert.match(mod.releaseSection(text, '1.10.0'), /ten/);
assert.equal(mod.releaseSection(text, '2.0.0'), null);
});