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:
parent
19908a88b7
commit
b5b0e2b88e
3 changed files with 359 additions and 19 deletions
|
|
@ -141,6 +141,30 @@ export function extractCatalogStats(readmeText, name) {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Compare a catalog's stated stat-line counts for one plugin against that plugin's own
|
||||
// stat badges — per-axis, badge-gated (see the comment block above extractStatBadges).
|
||||
// Pure, and shared by two callers with different timing: classifyPlugin (post-hoc, gates
|
||||
// a release that has already happened) and release-plugin.mjs's pre-release check
|
||||
// (Q3e/D1 — BEFORE anything is tagged or written, order 20260913T051659Z-717911204).
|
||||
export function statMismatchFindings(catalogStats, statBadges) {
|
||||
const findings = [];
|
||||
if (!catalogStats || !statBadges) return findings;
|
||||
for (const [axis, stated] of catalogStats) {
|
||||
if (!statBadges.has(axis)) continue;
|
||||
const badge = statBadges.get(axis);
|
||||
const approx = stated !== null && typeof stated === 'object';
|
||||
const bad = approx ? badge < stated.atLeast : badge !== stated;
|
||||
if (bad) {
|
||||
const shown = approx ? `${stated.atLeast}+` : String(stated);
|
||||
findings.push({
|
||||
level: 'ERROR',
|
||||
msg: `catalog says ${shown} ${axis} but the plugin's badge says ${badge} (catalog stat line is stale)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Pure classifier — all I/O is resolved into the input shape before this is called.
|
||||
// tags === null means "repo not inspected" (missing locally); [] means "no tags".
|
||||
export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags, catalogLabel = null, statBadges = null, catalogStats = null, homepage = null, homepageReachable = null }) {
|
||||
|
|
@ -184,19 +208,7 @@ export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, t
|
|||
// badge carries but the catalog does not restate is likewise nothing: the catalog
|
||||
// chooses what to show. Only a stated number contradicting a badged one is a defect.
|
||||
if (statBadges !== null && catalogStats !== null) {
|
||||
for (const [axis, stated] of catalogStats) {
|
||||
if (!statBadges.has(axis)) continue;
|
||||
const badge = statBadges.get(axis);
|
||||
const approx = stated !== null && typeof stated === 'object';
|
||||
const bad = approx ? badge < stated.atLeast : badge !== stated;
|
||||
if (bad) {
|
||||
const shown = approx ? `${stated.atLeast}+` : String(stated);
|
||||
findings.push({
|
||||
level: 'ERROR',
|
||||
msg: `catalog says ${shown} ${axis} but the plugin's badge says ${badge} (catalog stat line is stale)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
findings.push(...statMismatchFindings(catalogStats, statBadges));
|
||||
}
|
||||
|
||||
// 6. plugin.json homepage must resolve when present — the first link an agent follows
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
|
|||
import {
|
||||
planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag,
|
||||
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate,
|
||||
runRelease,
|
||||
runRelease, preflightStatMismatches, reportPostWriteCheck,
|
||||
} from './release-plugin.mjs';
|
||||
import { classifyPlugin } from './check-versions.mjs';
|
||||
|
||||
|
|
@ -619,3 +619,224 @@ test('R1 (main(), real subprocess): the token is gone after the CLI returns, onc
|
|||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Q3e: release-plugin.mjs left a HALF release (measured live 13.09, operator's own
|
||||
// run) — `--create-tag --write --commit --push` tagged + pushed v0.34.0, wrote the
|
||||
// catalog ref + README label, then crashed with a raw Node stacktrace from the post-write
|
||||
// execFileSync in runRelease because check-versions found the catalog's stat line stale
|
||||
// against the plugin's NEW badge. Neither --commit nor --push of the catalog ran; the
|
||||
// push token was (correctly, per Q3c) already consumed. Two defects, order
|
||||
// 20260913T051659Z-717911204-from-.claude:
|
||||
//
|
||||
// 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 slips straight
|
||||
// through it. Fix: compare 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.
|
||||
//
|
||||
// D2 — the post-write confirmation call used a bare execFileSync, which THROWS on a
|
||||
// non-zero exit — an unhandled exception over a release that had already tagged, pushed,
|
||||
// and written files but never committed. Fix: catch it, report exactly what is done and
|
||||
// what remains, return an exit code instead of letting the exception propagate.
|
||||
|
||||
test('preflightStatMismatches: catalog stat line stale vs. the badge the release is about to make current', () => {
|
||||
const catalogReadmeText = [
|
||||
'### [Demo Plugin](https://x/open/demo-plugin) `v0.33.1`',
|
||||
'',
|
||||
'3 hooks · 868 selftest checks · [Full documentation →](x)',
|
||||
].join('\n');
|
||||
const statSourceReadmeText = '';
|
||||
const msgs = preflightStatMismatches({ catalogReadmeText, statSourceReadmeText, name: 'demo-plugin' });
|
||||
assert.equal(msgs.length, 1);
|
||||
assert.match(msgs[0], /868 selftest check/);
|
||||
assert.match(msgs[0], /927/);
|
||||
});
|
||||
|
||||
test('preflightStatMismatches: agreeing badge -> no mismatch', () => {
|
||||
const catalogReadmeText = '### [Demo Plugin](https://x/open/demo-plugin) `v0.33.1`\n\n868 selftest checks · [Full documentation →](x)';
|
||||
const statSourceReadmeText = '';
|
||||
assert.deepEqual(preflightStatMismatches({ catalogReadmeText, statSourceReadmeText, name: 'demo-plugin' }), []);
|
||||
});
|
||||
|
||||
test('preflightStatMismatches: a missing README on either side is "nothing to check", not a block', () => {
|
||||
assert.deepEqual(preflightStatMismatches({ catalogReadmeText: null, statSourceReadmeText: 'x', name: 'demo' }), []);
|
||||
assert.deepEqual(preflightStatMismatches({ catalogReadmeText: 'x', statSourceReadmeText: null, name: 'demo' }), []);
|
||||
});
|
||||
|
||||
test('D1 (Q3e, real git): a stale catalog stat line for the RELEASED plugin blocks BEFORE any tag is created or any file is written', () => {
|
||||
const root = makeTempRoot('release-plugin-d1-');
|
||||
try {
|
||||
const repoDir = join(root, 'demo-plugin');
|
||||
const catalogDir = join(root, 'catalog');
|
||||
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
|
||||
initPluginRepo(repoDir, { version: '1.1.0' }); // no v1.1.0 tag yet -> worktree README is the stat source
|
||||
|
||||
fsWriteFileSync(join(repoDir, 'README.md'), '');
|
||||
execFileSync('git', ['-C', repoDir, 'add', 'README.md']);
|
||||
execFileSync('git', ['-C', repoDir, 'commit', '-q', '-m', 'bump badge']);
|
||||
|
||||
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
|
||||
const marketplaceBefore = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url: 'x', ref: 'v1.0.0' }, description: 'd' }] };
|
||||
const mktTextBefore = JSON.stringify(marketplaceBefore, null, 2);
|
||||
fsWriteFileSync(mktPath, mktTextBefore);
|
||||
const readmeBefore = [
|
||||
'### [Demo Plugin](https://x/open/demo-plugin) `v1.0.0`',
|
||||
'',
|
||||
'868 selftest checks · [Full documentation →](x)',
|
||||
'',
|
||||
].join('\n');
|
||||
fsWriteFileSync(join(catalogDir, 'README.md'), readmeBefore);
|
||||
|
||||
const pushGate = createPushGate({
|
||||
cwd: catalogDir, home: root,
|
||||
exists: () => { throw new Error('BUG: must not check the push token before the stat pre-flight'); },
|
||||
unlink: () => { throw new Error('BUG: must not consume — nothing was pushed'); },
|
||||
});
|
||||
|
||||
const logs = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...a) => logs.push(a.join(' '));
|
||||
let code;
|
||||
try {
|
||||
code = runRelease({
|
||||
args: { name: 'demo-plugin', version: '1.1.0', createTag: true, write: true, commit: false, push: false },
|
||||
catalogDir, mktPath, marketplace: marketplaceBefore, pushGate,
|
||||
});
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
assert.notEqual(code, 0, 'a stale catalog stat line must not report success');
|
||||
const output = logs.join('\n');
|
||||
assert.match(output, /868 selftest check/);
|
||||
assert.match(output, /927/);
|
||||
|
||||
const tags = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
|
||||
assert.deepEqual(tags, [], 'no tag may be created before the stat pre-flight passes');
|
||||
assert.equal(fsReadFileSync(mktPath, 'utf8'), mktTextBefore, 'the catalog ref must not be written either');
|
||||
assert.equal(fsReadFileSync(join(catalogDir, 'README.md'), 'utf8'), readmeBefore, 'the catalog README must be untouched');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('D1 (Q3e, real git, known-negative): an agreeing stat line does not block the ordinary flow', () => {
|
||||
const root = makeTempRoot('release-plugin-d1-neg-');
|
||||
try {
|
||||
const repoDir = join(root, 'demo-plugin');
|
||||
const catalogDir = join(root, 'catalog');
|
||||
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
|
||||
initPluginRepo(repoDir, { version: '1.1.0' });
|
||||
fsWriteFileSync(join(repoDir, 'README.md'), '');
|
||||
execFileSync('git', ['-C', repoDir, 'add', 'README.md']);
|
||||
execFileSync('git', ['-C', repoDir, 'commit', '-q', '-m', 'add badge']);
|
||||
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
|
||||
|
||||
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
|
||||
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url: 'x', ref: 'v1.0.0' }, description: 'd' }] };
|
||||
fsWriteFileSync(mktPath, JSON.stringify(marketplace, null, 2));
|
||||
fsWriteFileSync(join(catalogDir, 'README.md'), [
|
||||
'### [Demo Plugin](https://x/open/demo-plugin) `v1.0.0`',
|
||||
'',
|
||||
'868 selftest checks · [Full documentation →](x)',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => false, unlink: () => {} });
|
||||
|
||||
const code = runRelease({
|
||||
args: { name: 'demo-plugin', version: '1.1.0', createTag: false, write: false, commit: false, push: false },
|
||||
catalogDir, mktPath, marketplace, pushGate,
|
||||
});
|
||||
|
||||
// v1.1.0 has no tag -> planRelease BLOCKs on the missing-tag precondition, same as
|
||||
// ever; the point of this test is only that the stat pre-flight itself did NOT fire.
|
||||
assert.equal(code, 1);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Q3e/D2: the post-write confirmation must never surface as an unhandled exception ---
|
||||
|
||||
test('reportPostWriteCheck: green check-versions -> unchanged, informational, ok', () => {
|
||||
const applied = { writes: ['/cat/.claude-plugin/marketplace.json'], readme: 'written' };
|
||||
const r = reportPostWriteCheck(
|
||||
{ name: 'demo-plugin', applied, tagged: true, willPush: true },
|
||||
() => '✓ OK demo-plugin\n\n1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
|
||||
);
|
||||
assert.equal(r.ok, true);
|
||||
assert.match(r.message, /demo-plugin/);
|
||||
});
|
||||
|
||||
test('reportPostWriteCheck: a failing check-versions becomes ONE precise message, not a thrown exception', () => {
|
||||
const applied = { writes: ['/cat/.claude-plugin/marketplace.json', '/cat/README.md'], readme: 'written' };
|
||||
const failing = () => {
|
||||
const err = new Error('Command failed');
|
||||
err.status = 1;
|
||||
err.stdout = '✗ ERROR demo-plugin\n catalog says 868 selftest check but the plugin\'s badge says 927 (catalog stat line is stale)\n\n1 plugins — 0 OK, 0 WARN, 1 ERROR, 0 SKIP — verified 1/1\n';
|
||||
throw err;
|
||||
};
|
||||
let r;
|
||||
assert.doesNotThrow(() => { r = reportPostWriteCheck({ name: 'demo-plugin', applied, tagged: true, willPush: true }, failing); });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.exitCode, 1);
|
||||
assert.match(r.message, /HALF DONE/);
|
||||
assert.match(r.message, /tag pushed: yes/);
|
||||
assert.match(r.message, /catalog files written: yes/);
|
||||
assert.match(r.message, /NOT done: commit, push/);
|
||||
assert.match(r.message, /868 selftest check/);
|
||||
});
|
||||
|
||||
test('reportPostWriteCheck: NOT done omits push when --push was not requested', () => {
|
||||
const applied = { writes: ['/cat/.claude-plugin/marketplace.json'], readme: 'unchanged' };
|
||||
const failing = () => { const err = new Error('fail'); err.status = 1; err.stdout = ''; throw err; };
|
||||
const r = reportPostWriteCheck({ name: 'demo-plugin', applied, tagged: false, willPush: false }, failing);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.message, /tag pushed: no/);
|
||||
assert.match(r.message, /NOT done: commit$/m);
|
||||
});
|
||||
|
||||
test('D2 (Q3e, real git): a post-write check-versions failure reports precisely and returns non-zero — no thrown exception reaches the caller', () => {
|
||||
const root = makeTempRoot('release-plugin-d2-');
|
||||
try {
|
||||
const repoDir = join(root, 'demo-plugin');
|
||||
const catalogDir = join(root, 'catalog');
|
||||
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
|
||||
initPluginRepo(repoDir, { version: '1.1.0' });
|
||||
// Both the OLD ref and the NEW target need a real tag, or the ordinary pre-flight
|
||||
// (dangling-ref check on the OLD ref) blocks the release for an unrelated reason —
|
||||
// that is not what this test is about; D2 is about the POST-write check failing.
|
||||
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
|
||||
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.1.0', '-m', 'v1.1.0']);
|
||||
|
||||
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
|
||||
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url: 'x', ref: 'v1.0.0' }, description: 'd' }] };
|
||||
fsWriteFileSync(mktPath, JSON.stringify(marketplace, null, 2));
|
||||
fsWriteFileSync(join(catalogDir, 'README.md'), '### [Demo Plugin](https://x/open/demo-plugin) `v1.0.0`\n');
|
||||
|
||||
const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => false, unlink: () => {} });
|
||||
|
||||
let code;
|
||||
let threw = false;
|
||||
try {
|
||||
code = runRelease({
|
||||
args: { name: 'demo-plugin', version: '1.1.0', createTag: false, write: true, commit: true, push: false },
|
||||
catalogDir, mktPath, marketplace, pushGate,
|
||||
// Injected: simulates check-versions.mjs dying — the real subprocess call is
|
||||
// exercised by R1 elsewhere; this proves runRelease never lets it throw upward.
|
||||
runCheckVersions: () => { const err = new Error('Command failed: node check-versions.mjs'); err.status = 1; err.stdout = '✗ ERROR demo-plugin\n'; throw err; },
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
assert.equal(threw, false, 'runRelease must never let the post-write check throw upward');
|
||||
assert.notEqual(code, 0);
|
||||
// The ref+label write already happened (pre-flight was green on the OLD state); the
|
||||
// point of D2 is that the run stops cleanly there instead of crashing mid-commit.
|
||||
assert.ok(fsReadFileSync(mktPath, 'utf8').includes('v1.1.0'), 'the ref write is not rolled back — D2 only stops what has not happened yet (commit)');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue