The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each fixed behind a test that failed first, or, for the two missing tests, a test shown to fail on a stub of the branch it covers. - Review steps appended by /trekexecute Phase 7.2 are held to the step rules on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a template review step validated and was executed). - The guard refuses where no one can be asked also when its rules will not load; the wave merge path names that refusal instead of MERGE_REFUSED. Tests for the classifier-error refusal and post-approval-log's own check. - release-check: the version files may change only in their version after the review (JSON compared without it), README diff lines are read inside hunks only, an unreadable diff is exit 2, and npm test runs without NODE_TEST_CONTEXT. - What counts as a step of only tests: a note in parentheses is not a path, and a data file under specs/ is not a test. End-to-end: rides on the last step that builds behaviour (validator hint, trekplan, template). - A removed line is cited as <rev>:<path>:<line> (findings-schema hint, trekreview re-ask). - skill-lint counts a trailing newline as no line; regression-check takes commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read exemptions. - /security-review, older than v6.2.0: the criteria runner refuses git --output, which writes a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
16 KiB
JavaScript
306 lines
16 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';
|
||
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, 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',
|
||
]);
|
||
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 };
|
||
}
|
||
|
||
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));
|
||
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', '-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` };
|
||
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);
|
||
}
|