Round 6 (v6.2.0..c3641ff, WARN: 11 MAJOR, 3 MINOR; 0 BLOCKER). The round-5
fixes hold (code-correctness-reviewer); three of the new MAJOR were side
effects of running checks with no shell. Each fixed behind a test that
failed first on c3641ff.
- git checks stay in the repo too: no path that climbs out with .., starts
at / or reads <rev>:../x, and no --no-index (both reviewers).
- A trailing `# comment` on a shell-fence line is dropped: with no shell,
its words made an honest check NOT RUN, among them the first check of the
shipped example plan.
- A glob reaches `node --test` only on Node 21 and later, which expands it
itself; on an older Node it is refused with that reason (engines >=18).
- With no shell, exit 126/127 is the test program's own: red proof reads it
as a red (the control run still tells an environment fault apart), the
done line and the test lock as a failure.
- A runner that could not start (not installed) is NOT RUN in the criteria
runner and not-run (exit 2) in regression-check, as red-proof and
done-line already said; never a red suite.
- Tests-only: a backticked `Makefile` alone in its entry is a path, and red
proof confirms the plan's tests-only reading against the step's own diff
(the altitude finding: the diff is a fact, the Files: prose is not).
- release-check stops a suite that never ends (15 minutes) and reads it as
not measured (exit 2).
- The two lines this release added to pre-bash-executor.mjs are ASCII.
- MINOR: git and the other runners share one plain-word pattern.
Not in this round: MINOR f00a2eb8 (shared norm/ticked-path and isMain
helpers) and MINOR on plan-review-dedup's repeated tokenizing.
Suite 2194 (2192/0/2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
19 KiB
JavaScript
360 lines
19 KiB
JavaScript
#!/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' };
|
||
}
|
||
|
||
// A suite that never ends is stopped, as every other check runner here stops
|
||
// its command (15 minutes), and read as not measured (release review of
|
||
// 6.3.0, round 6): the operator always gets an answer.
|
||
export const SUITE_TIMEOUT_MS = 900_000;
|
||
|
||
function checkSuite(repo, treeClean, timeoutMs = SUITE_TIMEOUT_MS) {
|
||
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(), timeout: timeoutMs });
|
||
if (r.signal) return { id: 'suite', status: 'error', detail: `npm test was killed (${r.signal}) after the ${Math.round(timeoutMs / 1000)} s timeout — not measured` };
|
||
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, suiteTimeoutMs }) {
|
||
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', suiteTimeoutMs);
|
||
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);
|
||
}
|