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

265
scripts/release-check.mjs Normal file
View file

@ -0,0 +1,265 @@
#!/usr/bin/env node
// scripts/release-check.mjs
// P13 — the check before a release (docs/release-runbook.md § Before the release).
// One command answers "may this HEAD be released?", so the answer is an exit
// code, never a summary:
//
// node scripts/release-check.mjs --review <review.md> [--repo <dir>] [--version X.Y.Z] [--json]
// 0 = ready
// 1 = not ready — every failed check is listed
// 2 = could not run: not a git repo, no version to release, the suite could
// not be started, or the review is missing or one the verdict gate (K7)
// cannot decide. Exit 2 is NEVER green.
//
// The five checks, in the order they are reported:
// tree no uncommitted changes — otherwise the suite measures something
// other than HEAD
// suite `npm test` exits 0 at HEAD (run for real, never read from a log)
// review lib/review/verdict-gate.mjs on the release's review.md: BLOCK
// stops (1), undecided stops (2)
// coverage the review is of THIS history: its `scope_sha_end` is HEAD or an
// ancestor of HEAD, and the commits after it touch only the release
// bookkeeping (version files, CHANGELOG, the README version badge)
// door the CHANGELOG section for the version has a `### Doors` list with
// one `- one-way: …` or `- two-way: …` line per delivery
//
// The version is --version, else `.claude-plugin/plugin.json`. The repo is
// --repo, else the working directory. Nothing here writes, tags or pushes.
// A ready run prints the K2 command that logs the operator's yes; it does not
// run it — the approval is the operator's.
//
// Door types (a one-way door cannot be walked back without a cost to users; a
// two-way door can) are credited to mattpocock/skills@c55ee46; the green suite
// on the merged tree to obra/superpowers@8ca22db. Ideas only, no text copied.
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { decideVerdict } from '../lib/review/verdict-gate.mjs';
import { parseDocument } from '../lib/util/frontmatter.mjs';
const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const EXIT = Object.freeze({ READY: 0, NOT_READY: 1, UNDECIDED: 2 });
export const CHECK_IDS = Object.freeze(['tree', 'suite', 'review', 'coverage', 'door']);
// Files a release commit may change after the reviewed commit. README.md is
// allowed only for lines that carry the version badge.
export const RELEASE_BOOKKEEPING = Object.freeze([
'CHANGELOG.md', 'package.json', 'package-lock.json', '.claude-plugin/plugin.json', 'README.md',
]);
const BADGE_RE = /badge\/version-/;
const DOOR_LINE_RE = /^- (one-way|two-way): \S/;
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
/**
* The CHANGELOG section for `version`, heading line excluded, or null. Matches
* `## v1.2.3 …`, `## [1.2.3] …` and `## 1.2.3 …` exactly: 1.1.0 is not 1.10.0
* and not 1.1.0-pre.
*/
export function releaseSection(text, version) {
if (typeof text !== 'string' || !version) return null;
const lines = text.split('\n');
const headingVersion = (line) => {
const m = line.match(/^##\s+\[?v?([^\]\s(]+)\]?/);
return m ? m[1] : null;
};
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^##\s/.test(lines[i]) && headingVersion(lines[i]) === version) { start = i + 1; break; }
}
if (start === -1) return null;
let end = lines.length;
for (let i = start; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) { end = i; break; }
}
return lines.slice(start, end).join('\n');
}
/**
* The `### Doors` list of a release section. Top-level `- ` lines must read
* `- one-way: …` or `- two-way: …`; indented lines continue the entry above.
* @returns {{ found: boolean, entries: Array<{door: string, text: string}>, bad: string[] }}
*/
export function parseDoors(section) {
const lines = String(section ?? '').split('\n');
const start = lines.findIndex((l) => /^###\s+Doors\s*$/.test(l));
if (start === -1) return { found: false, entries: [], bad: [] };
const entries = [];
const bad = [];
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (/^#{2,3}\s/.test(line)) break;
if (!line.startsWith('- ')) continue;
const m = line.match(DOOR_LINE_RE);
if (m) entries.push({ door: m[1], text: line.slice(m[0].length - 1).trim() });
else bad.push(line);
}
return { found: true, entries, bad };
}
function checkTree(repo) {
const r = git(repo, ['status', '--porcelain']);
if (r.status !== 0) return { id: 'tree', status: 'error', detail: `git status failed: ${r.stderr.trim()}` };
const changed = r.stdout.split('\n').filter(Boolean);
if (changed.length > 0) {
return { id: 'tree', status: 'fail', detail: `${changed.length} uncommitted change(s) — commit or remove them; the suite must measure HEAD`, items: changed };
}
return { id: 'tree', status: 'pass', detail: 'working tree clean' };
}
function checkSuite(repo, treeClean) {
if (!treeClean) return { id: 'suite', status: 'fail', detail: 'suite not run: the tree has uncommitted changes, so it would not measure HEAD' };
const r = spawnSync('npm', ['test'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (r.error) return { id: 'suite', status: 'error', detail: `npm test could not start: ${r.error.message} — not measured` };
const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
const summary = out.split('\n').filter((l) => /^ℹ (tests|pass|fail|skipped) /.test(l)).map((l) => l.slice(2));
const tail = summary.length ? ` (${summary.join(', ')})` : '';
if (r.status !== 0) return { id: 'suite', status: 'fail', detail: `npm test exited ${r.status} at HEAD${tail}` };
return { id: 'suite', status: 'pass', detail: `npm test exited 0 at HEAD${tail}` };
}
function checkReview(reviewPath) {
if (!reviewPath) return { check: { id: 'review', status: 'error', detail: 'no --review given — the release has no review verdict to read (K7)' }, text: null };
if (!existsSync(reviewPath)) return { check: { id: 'review', status: 'error', detail: `${reviewPath} does not exist — no review, no release` }, text: null };
let text;
try { text = readFileSync(reviewPath, 'utf8'); } catch (e) {
return { check: { id: 'review', status: 'error', detail: `${reviewPath} cannot be read: ${e.message}` }, text: null };
}
const v = decideVerdict(text);
if (v.exit === 2) return { check: { id: 'review', status: 'error', detail: `verdict gate could not decide: ${v.reason}` }, text: null };
if (v.exit === 1) return { check: { id: 'review', status: 'fail', detail: `review verdict BLOCK — ${v.reason}` }, text };
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
}
function checkCoverage(repo, reviewText) {
if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' };
let sha;
try { sha = parseDocument(reviewText).parsed?.frontmatter?.scope_sha_end; } catch { sha = undefined; }
sha = sha === undefined || sha === null ? '' : String(sha).trim();
if (!sha) return { id: 'coverage', status: 'fail', detail: 'review.md has no scope_sha_end — cannot tell what it reviewed' };
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
if (resolved.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${sha} is not in the history of HEAD (unknown commit)` };
const full = resolved.stdout.trim();
const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']);
if (anc.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${full.slice(0, 12)} is not in the history of HEAD` };
const names = git(repo, ['diff', '--name-only', full, 'HEAD']);
if (names.status !== 0) return { id: 'coverage', status: 'error', detail: `git diff failed: ${names.stderr.trim()}` };
const changed = names.stdout.split('\n').filter(Boolean);
const outside = changed.filter((f) => !RELEASE_BOOKKEEPING.includes(f));
if (changed.includes('README.md')) {
const d = git(repo, ['diff', '-U0', full, 'HEAD', '--', 'README.md']);
const moved = d.stdout.split('\n').filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---)( |$)/.test(l));
if (moved.some((l) => !BADGE_RE.test(l))) outside.push('README.md (lines other than the version badge)');
}
if (outside.length > 0) {
return { id: 'coverage', status: 'fail', detail: `changed after the reviewed commit ${full.slice(0, 12)} and not covered by the review: ${outside.join(', ')}`, items: outside };
}
const n = changed.length;
return { id: 'coverage', status: 'pass', detail: n === 0 ? `review is of HEAD (${full.slice(0, 12)})` : `review covers HEAD except release bookkeeping (${changed.join(', ')})` };
}
function checkDoor(repo, version) {
const p = join(repo, 'CHANGELOG.md');
if (!existsSync(p)) return { id: 'door', status: 'fail', detail: 'no CHANGELOG.md — the door type is not set' };
const section = releaseSection(readFileSync(p, 'utf8'), version);
if (section === null) return { id: 'door', status: 'fail', detail: `CHANGELOG.md has no section for ${version} — the door type is not set` };
const d = parseDoors(section);
if (!d.found) return { id: 'door', status: 'fail', detail: `the ${version} section has no "### Doors" list — the door type is not set` };
if (d.bad.length > 0) return { id: 'door', status: 'fail', detail: `door lines that are neither one-way nor two-way: ${d.bad.join(' | ')}`, items: d.bad };
if (d.entries.length === 0) return { id: 'door', status: 'fail', detail: `the ${version} "### Doors" list has no door entries` };
const oneWay = d.entries.filter((e) => e.door === 'one-way');
const n = `${d.entries.length} ${d.entries.length === 1 ? 'delivery' : 'deliveries'}`;
const detail = oneWay.length
? `${n}, ${oneWay.length} one-way: ${oneWay.map((e) => e.text).join(' | ')}`
: `${n}, all two-way`;
return { id: 'door', status: 'pass', detail, oneWay: oneWay.length };
}
function resolveVersion(repo, explicit) {
if (explicit) return String(explicit).replace(/^v/, '');
try {
const v = JSON.parse(readFileSync(join(repo, '.claude-plugin', 'plugin.json'), 'utf8')).version;
return typeof v === 'string' && v ? v : null;
} catch { return null; }
}
/**
* Run every check. Pure of process exit; the CLI below prints and exits.
* @returns {{ exit: 0|1|2, version: string|null, repo: string, checks: object[], reason?: string }}
*/
export function releaseCheck({ repo, review, version: explicitVersion }) {
const top = git(repo, ['rev-parse', '--show-toplevel']);
if (top.error || top.status !== 0) return { exit: EXIT.UNDECIDED, version: null, repo, checks: [], reason: `${repo} is not a git repository` };
const root = top.stdout.trim();
const version = resolveVersion(root, explicitVersion);
if (!version) return { exit: EXIT.UNDECIDED, version: null, repo: root, checks: [], reason: 'no version to release: pass --version or set .claude-plugin/plugin.json version' };
const tree = checkTree(root);
const suite = checkSuite(root, tree.status === 'pass');
const { check: rev, text } = checkReview(review ? resolve(review) : null);
const coverage = checkCoverage(root, text);
const door = checkDoor(root, version);
const checks = [tree, suite, rev, coverage, door];
let exit = EXIT.READY;
if (checks.some((c) => c.status === 'error')) exit = EXIT.UNDECIDED;
else if (checks.some((c) => c.status === 'fail')) exit = EXIT.NOT_READY;
return { exit, version, repo: root, checks };
}
function approvalCommand(root) {
const cli = join(PLUGIN_ROOT, 'lib', 'util', 'approval-log.mjs');
const rel = relative(root, cli);
const shown = rel && !rel.startsWith('..') ? rel : cli;
return `node ${shown} append --kind release --subject CHANGELOG.md --sha256-of CHANGELOG.md`;
}
function render(result) {
const mark = { pass: '✓', fail: '✗', error: '?', skip: '·' };
const out = [];
if (result.reason) {
out.push(`release-check: could not run — ${result.reason}`);
out.push('NOT MEASURED (exit 2) — never green.');
return out.join('\n');
}
out.push(`release-check: v${result.version} in ${result.repo}`);
for (const c of result.checks) out.push(` ${mark[c.status]} ${c.id.padEnd(8)} ${c.detail}`);
if (result.exit === EXIT.READY) {
out.push('READY (exit 0). Before anything is pushed, the operator reads the release section and, if yes, logs it from the repo root:');
out.push(` ${approvalCommand(result.repo)}`);
} else if (result.exit === EXIT.NOT_READY) {
out.push('NOT READY (exit 1) — fix every ✗ above, commit, and run the check again.');
} else {
out.push('NOT MEASURED (exit 2) — at least one check could not run (?). Never green.');
}
return out.join('\n');
}
function parseArgs(argv) {
const out = { repo: process.cwd(), review: null, version: null, json: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo') out.repo = argv[++i];
else if (a === '--review') out.review = argv[++i];
else if (a === '--version') out.version = argv[++i];
else if (a === '--json') out.json = true;
}
return out;
}
function isMain() {
try {
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch { return false; }
}
if (isMain()) {
const args = parseArgs(process.argv.slice(2));
const result = releaseCheck({ repo: resolve(args.repo), review: args.review, version: args.version });
process.stdout.write((args.json ? JSON.stringify(result, null, 2) : render(result)) + '\n');
process.exit(result.exit);
}