#!/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 [--repo ] [--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 (the CHANGELOG, the version in the version files, // 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 { commandEnv } from '../lib/util/command-env.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, and how much of // each (release review of 6.3.0, 2026-10-10: a changed test script or hook // wiring passed as bookkeeping): the CHANGELOG freely; the version files only // in their version (the JSON with it taken out must be equal); README.md only // on the 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', ]); // Voyage's own pipeline artifacts, committed by a repo that tracks them // (`artifact_store: git`, `.claude/voyage/approvals.jsonl`): the review itself // and the approval log land after the reviewed commit by design (release // review of 6.3.0, round 3). The plan-file sweep treats them the same way. const PROCESS_PREFIXES = Object.freeze(['.claude/projects/', '.claude/voyage/']); const BADGE_RE = /badge\/version-/; const VERSION_FILES = Object.freeze(['package.json', 'package-lock.json', '.claude-plugin/plugin.json']); // The JSON of a version file with its version taken out: the top-level // `version`, and in a lockfile the root package's `packages[""].version`. export function withoutVersion(text) { const o = JSON.parse(text); if (o && typeof o === 'object') { delete o.version; if (o.packages && o.packages[''] && typeof o.packages[''] === 'object') delete o.packages[''].version; } return JSON.stringify(o); } 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 }; } // The +/- lines of a unified diff, read inside its hunks only: a content line // that starts with "++ " or "-- " looks like a file header but is not one. export function changedLines(diff) { const out = []; let inHunk = false; for (const l of diff.split('\n')) { if (l.startsWith('diff --git ')) inHunk = false; else if (l.startsWith('@@')) inHunk = true; else if (inHunk && /^[+-]/.test(l)) out.push(l); } return out; } 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' }; // commandEnv: under a parent `node --test` the suite would report to it and exit 0 whatever it found. const r = spawnSync('npm', ['test'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env: commandEnv() }); 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 }; } /** * The review's `scope_sha_end` as written. Read from the frontmatter text, not * through the YAML reader, which turns an all-digit value into a number and * drops a leading zero (`0123456` → 123456; release review of 6.3.0, round 3). */ export function reviewedSha(reviewText) { const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? '')); const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]); return m ? m[2] : ''; } function checkCoverage(repo, reviewText) { if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' }; const sha = reviewedSha(reviewText); 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` }; // --relative: the paths, and the files that count, are those under the plugin's // own directory, also when it sits in a subdirectory of a larger repo. const names = git(repo, ['diff', '--relative', '--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) && !PROCESS_PREFIXES.some((p) => f.startsWith(p))); for (const file of changed.filter((f) => VERSION_FILES.includes(f))) { const before = git(repo, ['show', `${full}:./${file}`]); const after = git(repo, ['show', `HEAD:./${file}`]); if (before.status !== 0 || after.status !== 0) { outside.push(`${file} (added or removed after the review)`); continue; } let same; try { same = withoutVersion(before.stdout) === withoutVersion(after.stdout); } catch (e) { return { id: 'coverage', status: 'error', detail: `${file} is not JSON (${e.message}) — not measured` }; } if (!same) outside.push(`${file} (lines other than the version)`); } if (changed.includes('README.md')) { const d = git(repo, ['diff', '--relative', '-U0', full, 'HEAD', '--', 'README.md']); if (d.status !== 0) return { id: 'coverage', status: 'error', detail: `git diff of README.md failed: ${d.stderr.trim()} — not measured` }; if (changedLines(d.stdout).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` }; // The plugin's own directory, not the git top level: a plugin may sit in a // subdirectory of a larger repo (release review of 6.3.0, round 2). const root = resolve(repo); 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' }; // The cheap checks first: when one of them cannot be measured the answer is // already exit 2, and the suite (minutes) is not run for nothing. const tree = checkTree(root); const { check: rev, text } = checkReview(review ? resolve(review) : null); const coverage = checkCoverage(root, text); const door = checkDoor(root, version); const undecided = [tree, rev, coverage, door].filter((c) => c.status === 'error'); const suite = undecided.length > 0 ? { id: 'suite', status: 'skip', detail: `not run: ${undecided.map((c) => c.id).join(', ')} could not be measured, so the answer is exit 2 already` } : checkSuite(root, tree.status === 'pass'); 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' || a === '--review' || a === '--version') { const v = argv[++i]; if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`); out[a.slice(2)] = v; } 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()) { // Anything that throws is "could not run" (2), never "not ready" (1): exit 1 // promises a list of failed checks, and none ran. let result; let json = process.argv.includes('--json'); try { const args = parseArgs(process.argv.slice(2)); json = args.json; result = releaseCheck({ repo: resolve(args.repo), review: args.review, version: args.version }); } catch (e) { result = { exit: EXIT.UNDECIDED, version: null, repo: null, checks: [], reason: e.message }; } process.stdout.write((json ? JSON.stringify(result, null, 2) : render(result)) + '\n'); process.exit(result.exit); }