voyage/scripts/release-check.mjs
Kjell Tore Guttormsen c3641ffa42
fix: round 5 of the release review of 6.3.0, the structural fix
Round 5 (v6.2.0..d37e538, WARN: 11 MAJOR, 2 MINOR). The patching did not
converge (the same two places every round), so the operator chose a
structural fix on 2026-10-11. Each change behind a test that failed first.

- A check runs with no shell. criteria-runner's runCommand starts the runner
  with the very words the screen checked (spawnSync(words[0], words.slice(1)),
  without NODE_TEST_CONTEXT); Phase 7 criteria, Regression: (its exec copy is
  gone, MINOR 0e98d5c7), proof of red, the done line and the test lock all
  run through it. No quote, glob, tilde or variable can rewrite a checked
  word any more. The screen stays and says why: a word that means something
  else in a shell (quote, backslash, $, brace, leading ~) is refused; a glob
  reaches only node --test, which expands its own patterns (sh read
  `tests/.?/` as `tests/../`); no path climbs out with .. or starts at /;
  a word starting with @ is refused (pytest reads @file as more arguments).
- The K6 plan-file sweep has v6.2.0's own Files: reader again (516dffa):
  backticked paths, and plain words only in a field without them. The
  shared reader from round 3 planned directories a description named and
  dropped paths v6.2.0 planned. fieldPaths stays for the tests-only check,
  with a path test on spans (`parse()`, `p.parse()` are no paths), plain
  descriptions ignored when the field has backticked paths, dotfiles,
  backslash paths and glued spans read.
- Skill frontmatter reads a value as YAML does: trailing space is nothing,
  and a comment ends a plain value, a flow list or a block indicator
  (`model: opus # pinned`). A comment that cuts a plain description, or is
  the whole value, is still a hazard.
- release-check reads the diff with --no-renames: a test moved into an
  allowed place after the review shows as removed.
- The example plan's step 2 heading no longer says "must FAIL initially";
  steps 1-2 keep the tests-only warning (operator 2026-10-09, 73262b6).

Open: MINOR c1fef1ad (one shared isMain helper) is not in this round.
Measured: 0 of 463 Files: fields with backticked paths (520 fields in 182
plans across the marketplace repos) carry another path-like word.

Suite 2185 (2183/0/2).

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

354 lines
19 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]
// 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;
}
// 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' };
}
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]);
// 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 }) {
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);
}