voyage/scripts/release-check.mjs
Kjell Tore Guttormsen a10018f9d3
fix: round 8 of the release review of 6.3.0, for 6.3.1
Round 8 (0f400d3..29b4ac9, BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR). Each fix
began with a test that failed on 29b4ac9 (13 tests, all red there); suite
2221 (2219/0/2).

- BLOCKER: a backticked check whose trailing comment holds a quote, $,
  backtick or backslash is refused, not dropped. The comment was never
  screened, while the executor hands the Verify span as written to red
  proof, the test lock and the done line inside --cmd "...", where a $(...)
  runs and a quote ends the argument. trekplan's text and the reviewer's
  NOT RUN table say so.
- A span that is only a comment is not-a-command, not a placeholder. The
  dropComment text says the allowlist still refuses a word with a # in it.
- The criteria runner, comparison-gate and end-state-gate use isMain: they
  did nothing and exited 0 under a symlinked path. The test now refuses any
  process.argv[1] comparison outside lib/util/is-main.mjs.
- Files: in a field with backticked paths, a plain word counts only before
  the entry's description (a dash, or a colon after the path); a name with
  no slash only first in its entry or after and/or/+/&. "mocks
  fs.readFileSync", "runs on Node.js 22.x" or "no change to package.json"
  no longer make a tests-only step look like it builds something.
  Lowercase makefile, rakefile and justfile are build files. Measured on the
  520 Files: fields in 48 plans of the marketplace and the test repo:
  3 fields read differently (two notes no longer read as files, one
  duplicate name dropped), tests-only changed in 0.
- The test lock compares --cmd without its trailing comment, and the test
  lock and the done line read a stop with stoppedBy, the one reading.
  red-proof drops its second reading, spawnError.
- release-check reads a relative --review in --repo or, when only that one
  exists, from the current directory as 6.3.0 did; two different files are
  exit 2, never guessed. The runbook says so.
- One trailing-json reader: a closing fence may be longer than three
  backticks, in the block reader and the verdict gate alike, and the review
  validator and the synthesis digest use it, so three backticks inside a
  JSON string no longer hide a BLOCKER from the remediation-step check.

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

380 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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]
// (a relative --review is read in --repo, as every other path, or from the
// current directory when only that one exists; two different files: exit 2)
// 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 } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, isAbsolute, 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';
import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
import { isMain } from '../lib/util/is-main.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;
}
// The README lines changed after the review differ only in the badge's
// version: every line holds the badge, and with the version taken out the
// removed lines and the added lines are the same (round 4: other text on the
// badge line got through).
export function onlyBadgeVersion(lines) {
if (!lines.every((l) => BADGE_RE.test(l))) return false;
const norm = (l) => l.slice(1).replace(/badge\/version-[^\s)]+-(?=[^-\s)]+\))/g, 'badge/version-V-');
const removed = lines.filter((l) => l.startsWith('-')).map(norm).sort();
const added = lines.filter((l) => l.startsWith('+')).map(norm).sort();
return removed.length === added.length && removed.every((l, i) => l === added[i]);
}
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' };
}
// A suite that never ends is stopped, as every other check runner here stops
// its command (the one 15-minute timeout of criteria-runner.mjs), and read as
// not measured (release review of 6.3.0, round 6): the operator always gets
// an answer. What stopped it is said as it was: the timeout, output past the
// buffer, another signal, or a runner that never started (round 7).
const SUITE_MAX_BUFFER = 256 * 1024 * 1024;
function checkSuite(repo, treeClean, timeoutMs = DEFAULT_TIMEOUT_MS, maxBuffer = SUITE_MAX_BUFFER) {
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, env: commandEnv(), timeout: timeoutMs });
const stopped = stoppedBy(r, { timeoutMs, maxBuffer });
if (stopped) return { id: 'suite', status: 'error', detail: `npm test ${stopped.why} — 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}` };
}
/**
* Where a relative --review is: in --repo, like every other path here (round 7),
* or from the caller's directory, as 6.3.0 read it (round 8: the round-7 change
* broke a command that worked). The one that exists is taken; two different
* files are never guessed between.
* @returns {{path: string|null, error?: string}}
*/
function reviewPath(root, review) {
if (!review) return { path: null };
if (isAbsolute(review)) return { path: review };
const inRepo = resolve(root, review);
const fromHere = resolve(review);
if (inRepo === fromHere || !existsSync(fromHere)) return { path: inRepo };
if (!existsSync(inRepo)) return { path: fromHere };
return { path: null, error: `--review ${review} names two files: ${inRepo} (in --repo) and ${fromHere} (from here); pass an absolute path` };
}
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]);
// A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it
// points at now, and coverage would pass whatever was reviewed (round 4).
return m && /^[0-9a-f]{7,40}$/i.test(m[2]) ? 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 that is a commit sha (7-40 hex characters) — 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.
// --no-renames: a file moved into an allowed place after the review shows
// both its old path and its new one, so its removal is seen (round 5).
const names = git(repo, ['diff', '--relative', '--no-renames', '--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', '--no-renames', '-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 (!onlyBadgeVersion(changedLines(d.stdout))) 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, suiteTimeoutMs, suiteMaxBuffer }) {
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 where = reviewPath(root, review);
const { check: rev, text } = where.error
? { check: { id: 'review', status: 'error', detail: where.error }, text: null }
: checkReview(where.path);
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', suiteTimeoutMs, suiteMaxBuffer);
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;
}
if (isMain(import.meta.url)) {
// 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);
}