fix(catalog): release-plugin.mjs stat-line pre-flight + no raw stacktrace on post-write fail

Q3e (order 20260913T051659Z-717911204-from-.claude), after Q3d. Live incident 13.09
(operator's own run): `release-plugin.mjs repo-mailbox --version 0.34.0 --create-tag
--write --commit --push` tagged+pushed v0.34.0, wrote the catalog ref + README label,
then crashed with a raw Node stacktrace because check-versions found the catalog's own
stat line stale against the plugin's NEW badge (868 vs 927). Neither --commit nor
--push of the catalog ran; the push token was correctly consumed (Q3c). Operator fixed
the line and committed/pushed manually.

D1 — the ordinary pre-flight (applyRelease -> check-versions) only ever inspects the
OLD ref, so a stat-line drift the release itself is about to expose slipped straight
through it into a pushed tag + a written, uncommitted catalog. New `preflightStatMismatches`
compares the catalog's stat line against what the release is about to make current (the
target ref's badge if that tag already exists, else the plugin's worktree README — exactly
what --create-tag is about to tag), BEFORE any tag or write. Extracted the shared
mismatch logic into check-versions.mjs as `statMismatchFindings` (pure refactor,
classifyPlugin's own behavior unchanged) so both the post-hoc gate and this pre-release
check use one rule.

D2 — the post-write confirmation was a bare execFileSync, which throws on a non-zero
exit. `reportPostWriteCheck` catches any failure (a real ERROR, or the subprocess
dying) and reports exactly what is done (tag pushed y/n, files written) and what
remains (commit/push), returning an exit code instead of an unhandled exception.

No version bump, no tag, no push, no CLAUDE.md wording this session.

Verification:
- node --test scripts/release-plugin.test.mjs: 41/41 (Q3d) -> 50/50 (9 new: 3
  preflightStatMismatches unit + 2 real-git D1 integration + 3 reportPostWriteCheck
  unit + 1 real-git D2 integration)
- node --test scripts/*.test.mjs: 158/158 (Q3d) -> 167/167
- node scripts/check-versions.mjs: 0 ERROR (1 known WARN: claude-design, unrelated)
- Mutation evidence (D1): commented out the new pre-flight call in runRelease ->
  exactly the new "D1 (Q3e, real git)" test went red (49 pass, 1 fail), all others
  stayed green; restored -> 50/50 again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-14 06:48:48 +02:00
commit b5b0e2b88e
3 changed files with 359 additions and 19 deletions

View file

@ -36,7 +36,9 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { normalizeVersion, runGate } from './check-versions.mjs';
import {
normalizeVersion, runGate, extractCatalogStats, extractStatBadges, pickStatSource, statMismatchFindings,
} from './check-versions.mjs';
// --- Pure planner (unit under test) -----------------------------------------
@ -148,6 +150,73 @@ export function shouldCreateTag(args, observed, target) {
return args.write ? 'create' : 'dry-run';
}
// Q3e/D1 (order 20260913T051659Z-717911204-from-.claude) — the ordinary pre-flight
// (applyRelease -> check-versions) only ever inspects the OLD ref, so a stat-line drift
// the release itself is about to expose (the catalog's stat line vs. the badge the NEW
// ref will carry) slips straight through it, into a pushed tag + a written, uncommitted
// catalog. Measured live 13.09: `repo-mailbox --version 0.34.0 --create-tag --write
// --commit --push` tagged+pushed v0.34.0, wrote the ref + README label, and only THEN
// (post-write) found the catalog said "868 selftest check" against the plugin's new
// badge "927".
//
// Pure: given the catalog's own README text and the "stat source" text — the target
// ref's README if that tag already exists, else the plugin's worktree README, which is
// exactly what --create-tag is about to tag (see pickStatSource in check-versions.mjs,
// reused here with the roles it already has: atRef wins when present) — return the
// mismatch messages before anything is touched. Either side missing means "nothing to
// check" (same posture as check-versions.mjs when a README is absent), not a false block.
export function preflightStatMismatches({ catalogReadmeText, statSourceReadmeText, name }) {
if (catalogReadmeText === null || statSourceReadmeText === null) return [];
const catalogStats = extractCatalogStats(catalogReadmeText, name);
const statBadges = extractStatBadges(statSourceReadmeText);
return statMismatchFindings(catalogStats, statBadges).map(f => f.msg);
}
// Q3e/D2 — the post-write confirmation used to be a bare execFileSync call, which THROWS
// on a non-zero exit: an unhandled exception, over a release that had already tagged,
// pushed, and written files but never committed ("half done", operator had to finish it
// by hand). Turns any failure — a real ERROR that slipped past pre-flight, or the
// subprocess dying for its own reasons — into one precise, actionable message: what is
// done, what remains, and (when found) the specific check-versions finding. `runCheckVersions`
// is injected so this is testable without a real subprocess.
// check-versions.mjs prints one status line per plugin ("✗ ERROR demo-plugin") followed
// by its finding lines indented underneath, until the next status line. Grab the whole
// block for the named plugin, not just the status line — the finding is the actionable
// part (which axis, which numbers).
function extractPluginBlock(stdout, name) {
const lines = String(stdout || '').split('\n');
const idx = lines.findIndex(l => l.includes(name));
if (idx === -1) return '';
const out = [lines[idx]];
for (let i = idx + 1; i < lines.length; i++) {
const l = lines[i];
if (l.trim() === '' || /^[✓⚠✗–]/.test(l.trim())) break;
out.push(l);
}
return out.join('\n').trim();
}
export function reportPostWriteCheck({ name, applied, tagged, willPush }, runCheckVersions) {
let out;
try {
out = runCheckVersions();
} catch (err) {
const stdout = String(err.stdout || err.message || '');
const block = extractPluginBlock(stdout, name);
const lines = [
' ✗ post-write check-versions FAILED — the release is HALF DONE, nothing further ran automatically:',
` tag pushed: ${tagged ? 'yes' : 'no'}`,
` catalog files written: yes (${applied.writes.join(', ')}${applied.readme === 'written' ? ' + README label' : ''})`,
` NOT done: commit${willPush ? ', push' : ''}`,
block ? ` check-versions:\n ${block.split('\n').join('\n ')}` : (stdout.trim() ? stdout.trim().split('\n').map(l => ` ${l}`).join('\n') : ' (no output captured)'),
' Fix the reported issue, then finish manually: git add .claude-plugin/marketplace.json README.md && git commit ... (add --push if needed).',
];
return { ok: false, message: lines.join('\n'), exitCode: (typeof err.status === 'number' && err.status !== 0) ? err.status : 1 };
}
const line = out.split('\n').find(l => l.includes(name)) ?? '';
return { ok: true, message: ` check-versions: ${line.trim() || '(no line)'}` };
}
// --- push-token gate ---------------------------------------------------------
//
// pre-push-gate.sh is a PreToolUse hook that matches `git push` in COMMAND TEXT — it
@ -285,6 +354,18 @@ function gitTags(repoDir) {
} catch { return null; }
}
// Q3e/D1 I/O helpers — read-only, both null-safe (missing file/tag reads as "not checked").
function readFileSafe(path) {
try { return readFileSync(path, 'utf8'); } catch { return null; }
}
function readGitShow(repoDir, ref, path) {
if (!ref) return null;
try {
return execFileSync('git', ['-C', repoDir, 'show', `${ref}:${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
} catch { return null; }
}
function extractBadge(readmeText) {
const m = /badge\/version-(\d+\.\d+\.\d+)/.exec(readmeText || '');
return m ? m[1] : null;
@ -318,10 +399,33 @@ function parseArgs(argv) {
// build the pushGate) and minus the final process.exit call — pulled out so it is
// testable against a real temp git repo (Q3c/S1) and so it can return an exit code
// instead of calling process.exit at each branch (Q3c/D3, see main() below for why).
export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate }) {
export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, runCheckVersions }) {
const checkVersionsRunner = runCheckVersions
|| (() => execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' }));
let obs = observePlugin(catalogDir, args.name);
const target = normalizeVersion(args.version ?? obs.pluginVersion ?? '');
// Q3e/D1: check the catalog's stat line against what this release is ABOUT TO MAKE
// current — BEFORE any tag or write, uncoupled from whether the ref is already
// consistent (the point is to catch it before --create-tag ever touches a remote).
if (target) {
const mismatches = preflightStatMismatches({
catalogReadmeText: readFileSafe(join(catalogDir, 'README.md')),
statSourceReadmeText: pickStatSource({
atRef: readGitShow(obs.repoDir, 'v' + target, 'README.md'),
atWorktree: readFileSafe(join(obs.repoDir, 'README.md')),
}),
name: args.name,
});
if (mismatches.length > 0) {
console.log(`\nrelease-plugin: ${args.name} — BLOCKED before any tag or write (stale catalog stat line):`);
for (const m of mismatches) console.log(`${m}`);
console.log(' Fix the catalog stat line (or the plugin badge) first, then re-run.');
return 1;
}
}
// --create-tag: if the only thing missing is the tag, mint + push it first — but only
// under --write. Without it this is a dry-run and must publish nothing.
// pushGate is shared across BOTH push sites in this run (tag push, catalog push) — one
@ -390,9 +494,12 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate })
// Confirm the gate is green for this plugin AFTER the write — the pre-flight validated the
// old state, this validates the new one. Different jobs; the redundancy is only apparent.
const gate = execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' });
const line = gate.split('\n').find(l => l.includes(args.name)) ?? '';
console.log(` check-versions: ${line.trim() || '(no line)'}`);
// Q3e/D2: any failure here (a real ERROR, or the subprocess itself dying) becomes one
// precise message instead of an unhandled execFileSync exception over an already
// half-applied release (tag pushed + files written, nothing committed).
const confirm = reportPostWriteCheck({ name: args.name, applied, tagged: tagStep === 'create', willPush: args.push }, checkVersionsRunner);
console.log(confirm.message);
if (!confirm.ok) return confirm.exitCode;
if (args.commit) {
const body = `${plan.name} ${plan.newRef} — release. Catalog ref now pins the ${plan.newRef} tag so \`claude plugin update\` resolves the release.`;