// Tests for the atomic plugin-release helper. // Pure planner is the unit under test โ€” the I/O shell (read files, git tag/commit/push) // is exercised by the CLI against the live tree, not here. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync as fsWriteFileSync, readFileSync as fsReadFileSync, existsSync, realpathSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag, pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate, runRelease, preflightStatMismatches, reportPostWriteCheck, } from './release-plugin.mjs'; import { classifyPlugin } from './check-versions.mjs'; const marketplace = () => ({ name: 'ktg-plugin-marketplace', plugins: [ { name: 'alpha', source: { source: 'url', url: 'https://x/alpha.git', ref: 'v1.0.0' }, description: 'a' }, { name: 'beta', source: { source: 'url', url: 'https://x/beta.git', ref: 'v2.3.0' }, description: 'b' }, ], }); const observed = (o = {}) => ({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.1.0', 'v1.0.0'], ...o, }); test('READY: consistent plugin, tag exists, catalog ref behind โ†’ bump planned', () => { const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); assert.equal(p.verdict, 'READY'); assert.equal(p.targetVersion, '1.1.0'); assert.equal(p.currentRef, 'v1.0.0'); assert.equal(p.newRef, 'v1.1.0'); assert.deepEqual(p.blockers, []); assert.equal(p.commitSubject, 'chore(catalog): bump alpha v1.0.0 -> v1.1.0'); }); test('READY plan bumps ONLY the target plugin and does not mutate the input', () => { const mkt = marketplace(); const p = planRelease({ marketplace: mkt, name: 'alpha', observed: observed() }); // input untouched assert.equal(mkt.plugins[0].source.ref, 'v1.0.0'); // output bumped on alpha only const out = p.newMarketplace.plugins; assert.equal(out.find(x => x.name === 'alpha').source.ref, 'v1.1.0'); assert.equal(out.find(x => x.name === 'beta').source.ref, 'v2.3.0'); }); test('READY plan is green by construction (post-bump classifyPlugin is OK)', () => { const o = observed(); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); const post = classifyPlugin({ name: 'alpha', catalogRef: p.newRef, pluginVersion: o.pluginVersion, readmeBadge: o.readmeBadge, tags: o.tags, }); assert.equal(post.status, 'OK'); }); test('explicit --version targets that version when consistent', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.1.0', 'v1.0.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.1.0' }); assert.equal(p.verdict, 'READY'); assert.equal(p.newRef, 'v1.1.0'); }); test('NOOP: catalog ref already pins the target version', () => { const o = observed({ pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'NOOP'); assert.equal(p.newRef, 'v1.0.0'); assert.equal(p.newMarketplace, null); }); test('BLOCKED: target version has no git tag in the plugin repo', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.0.0'] }); // no v1.1.0 const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /tag v1\.1\.0/.test(b) && /not found|tag the plugin/.test(b))); assert.equal(p.newMarketplace, null); }); test('BLOCKED: plugin.json version != target (asked to release an undeclared version)', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.2.0', 'v1.1.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.2.0' }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /plugin\.json/.test(b) && /1\.1\.0/.test(b))); }); test('BLOCKED: README badge disagrees with plugin.json (internal corruption)', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.0.0', tags: ['v1.1.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /badge/i.test(b))); }); test('BLOCKED: plugin not present in the catalog', () => { const p = planRelease({ marketplace: marketplace(), name: 'ghost', observed: observed() }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /not in (the )?catalog/i.test(b))); assert.equal(p.currentRef, null); }); test('BLOCKED: target version cannot be resolved (no --version, no plugin.json)', () => { const o = observed({ pluginVersion: null }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /resolve.*version|version.*not/i.test(b))); }); // --- reconcileReadmeLabel: keeps the catalog README label in lock-step with the ref --- test('reconcileReadmeLabel bumps only the target plugin heading label', () => { const readme = [ '### [Config-Audit](https://git.fromaitochitta.com/open/config-audit) `v5.5.0`', 'body text', '### [Voyage](https://git.fromaitochitta.com/open/voyage) `v5.1.1`', ].join('\n'); const out = reconcileReadmeLabel(readme, 'config-audit', 'v5.7.0'); assert.ok(out.includes('/open/config-audit) `v5.7.0`')); assert.ok(out.includes('/open/voyage) `v5.1.1`')); // untouched }); test('reconcileReadmeLabel leaves a trailing lang/flag badge intact', () => { const readme = '### [MS AI Architect](https://x/open/ms-ai-architect) `v1.15.0` `๐Ÿ‡ณ๐Ÿ‡ด Norwegian`'; const out = reconcileReadmeLabel(readme, 'ms-ai-architect', 'v1.16.0'); assert.equal(out, '### [MS AI Architect](https://x/open/ms-ai-architect) `v1.16.0` `๐Ÿ‡ณ๐Ÿ‡ด Norwegian`'); }); test('reconcileReadmeLabel returns null when the label already matches (no-op)', () => { const readme = '### [Config-Audit](https://x/open/config-audit) `v5.7.0`'; assert.equal(reconcileReadmeLabel(readme, 'config-audit', 'v5.7.0'), null); }); test('reconcileReadmeLabel returns null when the plugin has no heading', () => { const readme = '### [Other](https://x/open/other) `v1.0.0`'; assert.equal(reconcileReadmeLabel(readme, 'ghost', 'v2.0.0'), null); }); // --- pre-flight gate: check-versions must run BEFORE the writes, not after ----- // // The old order wrote marketplace.json + the README label first and only THEN ran the // gate (which throws on exit 1) โ€” leaving a half-applied release in the working tree // that a parallel session could carry to the public remote. These tests pin the // ORDER, so they must assert on writes-not-taken, not just on a verdict string. const gateResult = (statuses) => { const results = Object.entries(statuses).map(([name, status]) => ({ name, status, findings: [] })); return { results, hasError: results.some(r => r.status === 'ERROR'), hasWarn: results.some(r => r.status === 'WARN'), failed: results.some(r => r.status === 'ERROR'), }; }; const fakeIo = (gate, readmeText = '### [Alpha](https://x/open/alpha) `v1.0.0`') => { const writes = []; return { writes, runGate: () => gate, readFileSync: () => readmeText, writeFileSync: (p) => { writes.push(p); }, }; }; const paths = { catalogDir: '/cat', mktPath: '/cat/.claude-plugin/marketplace.json', readmePath: '/cat/README.md' }; test('preflightErrors names every ERROR plugin, catalog-wide (not just the target)', () => { assert.deepEqual(preflightErrors(gateResult({ alpha: 'WARN', beta: 'ERROR', gamma: 'ERROR' })), ['beta', 'gamma']); assert.deepEqual(preflightErrors(gateResult({ alpha: 'OK', beta: 'WARN', gamma: 'SKIP' })), []); }); test('pre-flight ERROR aborts BEFORE any file is written (ordering, not just verdict)', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); const io = fakeIo(gateResult({ alpha: 'WARN', beta: 'ERROR' })); // ERROR on a DIFFERENT plugin const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'BLOCKED'); assert.deepEqual(r.preflightErrors, ['beta']); assert.deepEqual(io.writes, [], 'no file may be written when the gate is red'); }); test('pre-bump WARN is the NORMAL state and must NOT block the release', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); // Real classifier, pre-bump: catalog ref still v1.0.0 while plugin.json is 1.1.0. // This is exactly what a release looks like before it is applied โ€” it MUST be WARN, // or gating on `failed`/`--strict` would brick every release. const pre = classifyPlugin({ name: 'alpha', catalogRef: plan.currentRef, pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.0.0', 'v1.1.0'], }); assert.equal(pre.status, 'WARN'); const io = fakeIo({ results: [pre], hasError: false, hasWarn: true, failed: false }); const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'WROTE'); assert.deepEqual(io.writes, [paths.mktPath, paths.readmePath]); }); test('green pre-flight writes the ref and reports an already-correct README label as unchanged', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); const io = fakeIo(gateResult({ alpha: 'OK' }), '### [Alpha](https://x/open/alpha) `v1.1.0`'); const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'WROTE'); assert.equal(r.readme, 'unchanged'); assert.deepEqual(io.writes, [paths.mktPath]); }); test('a missing catalog README does not abort the ref bump', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); const io = fakeIo(gateResult({ alpha: 'OK' })); io.readFileSync = () => { throw new Error('ENOENT'); }; const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'WROTE'); assert.equal(r.readme, 'missing'); assert.deepEqual(io.writes, [paths.mktPath]); }); test('a FAILING README write surfaces โ€” it must NOT be misreported as a missing README', () => { // The `try` used to span the README read AND the README write, so a real EACCES/ENOSPC // on the write came back as readme:'missing' ("no catalog README to update") with verdict // WROTE and exit 0 โ€” a bumped ref with a stale label, reported as success. // Path-selective on purpose: a fake that throws for EVERY path dies on the marketplace // write at the top of applyRelease (outside the try, before and after the fix), which // would make this test green against the unfixed file. const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); const io = fakeIo(gateResult({ alpha: 'OK' })); const record = io.writeFileSync; io.writeFileSync = (p, ...rest) => { if (p === paths.readmePath) throw new Error('EACCES: permission denied'); return record(p, ...rest); }; assert.throws(() => applyRelease({ plan, ...paths }, io), /EACCES/); assert.deepEqual(io.writes, [paths.mktPath], 'the ref write happened; the README write is what failed'); }); // --- shouldCreateTag: --create-tag is a WRITE, so it must obey --write -------- // // `--create-tag` mints AND PUSHES a tag to a public remote โ€” the one genuinely // irreversible side effect in this helper. It used to fire on the documented // dry-run entry point (` --create-tag`, no --write), which contradicts // "dry-run by default": the tag was already public before the plan was printed. // The decision (2026-08-10) was to gate it on --write and leave the catalog-wide // pre-flight where it is โ€” that gate can only prevent an EARLY tag, never a WRONG // one, since the preconditions below already make the tag correct by construction. const tagArgs = (o = {}) => ({ createTag: true, write: true, ...o }); test('shouldCreateTag: --create-tag --write on a consistent plugin with no tag โ†’ create', () => { assert.equal(shouldCreateTag(tagArgs(), observed({ tags: ['v1.0.0'] }), '1.1.0'), 'create'); }); test('shouldCreateTag: --create-tag WITHOUT --write never pushes (dry-run stays dry)', () => { assert.equal(shouldCreateTag(tagArgs({ write: false }), observed({ tags: ['v1.0.0'] }), '1.1.0'), 'dry-run'); }); test('shouldCreateTag: no --create-tag โ†’ skip, even with --write', () => { assert.equal(shouldCreateTag(tagArgs({ createTag: false }), observed({ tags: ['v1.0.0'] }), '1.1.0'), 'skip'); }); test('shouldCreateTag: tag already exists โ†’ skip (retry after a red gate is idempotent)', () => { assert.equal(shouldCreateTag(tagArgs(), observed({ tags: ['v1.0.0', 'v1.1.0'] }), '1.1.0'), 'skip'); }); test('shouldCreateTag: skips when the plugin is not internally consistent', () => { // plugin.json behind the target โ€” planRelease would BLOCK anyway; never mint for it. assert.equal(shouldCreateTag(tagArgs(), observed({ pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'] }), '1.1.0'), 'skip'); // README badge disagrees with plugin.json assert.equal(shouldCreateTag(tagArgs(), observed({ readmeBadge: '1.0.0', tags: ['v1.0.0'] }), '1.1.0'), 'skip'); // no target version resolved assert.equal(shouldCreateTag(tagArgs(), observed({ tags: ['v1.0.0'] }), null), 'skip'); // plugin repo absent (gitTags returned null) โ€” nothing to tag assert.equal(shouldCreateTag(tagArgs(), observed({ tags: null }), '1.1.0'), 'skip'); }); test('shouldCreateTag: a null README badge is tolerated (badge-less plugin)', () => { assert.equal(shouldCreateTag(tagArgs(), observed({ readmeBadge: null, tags: ['v1.0.0'] }), '1.1.0'), 'create'); }); // --- push token gate (Q3, order 20260912T202210Z-7585415566-from-.claude) ------ // // pre-push-gate.sh is a text-matching PreToolUse hook: it cannot see a `git push` // issued via execFileSync inside this script (measured 2026-08-26, pinned as GAP // in the gate's own header). release-plugin.mjs mints+pushes a plugin tag // (--create-tag) and pushes the catalog itself (--push) โ€” both invisible to the // gate. So the ONE script that pushes must require the SAME one-shot approval // token the gate checks, and consume it itself after a push actually succeeds // (post-push-consume.sh, a PostToolUse hook, never fires for a call the gate // never saw). Tag-push and catalog-push share ONE token: one publish from the // operator's point of view. test('pushAuthorisation computes the token path exactly like token_path() โ€” only / becomes _', () => { // Deliberately includes '-' and '.' in the path to prove ONLY '/' is rewritten, // mirroring token_path()'s `sed 's|/|_|g'` (hooks/lib/cmd-parse.sh:86-88). const home = '/Users/ktg'; const cwd = '/Users/ktg/repos/my-repo.local/sub-dir'; const r = pushAuthorisation({ cwd, home, exists: () => false }); assert.equal(r.tokenPath, '/Users/ktg/.claude/runtime/push-approvals/_Users_ktg_repos_my-repo.local_sub-dir'); assert.equal(r.authorised, false); }); test('pushAuthorisation reports authorised when the token file exists at the computed path', () => { const home = '/Users/ktg'; const cwd = '/Users/ktg/repos/ktg-plugin-marketplace/catalog'; const r = pushAuthorisation({ cwd, home, exists: (p) => p === '/Users/ktg/.claude/runtime/push-approvals/_Users_ktg_repos_ktg-plugin-marketplace_catalog' }); assert.equal(r.authorised, true); }); test('requirePushAuthorisation refuses and prints the exact operator command to create the token', () => { const r = requirePushAuthorisation({ cwd: '/Users/ktg/repos/x', home: '/Users/ktg', exists: () => false }); assert.equal(r.authorised, false); assert.ok(r.message.includes( 'mkdir -p /Users/ktg/.claude/runtime/push-approvals && touch "/Users/ktg/.claude/runtime/push-approvals/_Users_ktg_repos_x"' )); }); test('requirePushAuthorisation authorises silently when the token exists', () => { const r = requirePushAuthorisation({ cwd: '/Users/ktg/repos/x', home: '/Users/ktg', exists: () => true }); assert.equal(r.authorised, true); assert.equal(r.message, undefined); }); test('pushWithToken refuses and never calls push() when the token is missing', () => { let called = false; const r = pushWithToken({ cwd: '/Users/ktg/repos/x', home: '/Users/ktg', exists: () => false, unlink: () => { throw new Error('must not unlink without a push'); }, push: () => { called = true; }, }); assert.equal(r.blocked, true); assert.equal(called, false, 'push() must not run without the token'); }); test('pushWithToken pushes and consumes the token after a successful push', () => { let pushed = false; const unlinked = []; const r = pushWithToken({ cwd: '/Users/ktg/repos/x', home: '/Users/ktg', exists: () => true, unlink: (p) => unlinked.push(p), push: () => { pushed = true; }, }); assert.equal(r.pushed, true); assert.equal(pushed, true); assert.deepEqual(unlinked, [r.tokenPath]); }); test('pushWithToken does NOT consume the token when push() throws (injected exec failure)', () => { const unlinked = []; assert.throws(() => pushWithToken({ cwd: '/Users/ktg/repos/x', home: '/Users/ktg', exists: () => true, unlink: (p) => unlinked.push(p), push: () => { throw new Error('git push failed: non-fast-forward'); }, }), /non-fast-forward/); assert.deepEqual(unlinked, [], 'a failed push must leave the one-shot token intact for the retry'); }); test('consumeToken is a no-op when the token file is already gone', () => { let unlinkCalls = 0; consumeToken({ tokenPath: '/x', exists: () => false, unlink: () => { unlinkCalls++; } }); assert.equal(unlinkCalls, 0); }); test('consumeToken deletes the token when it is present', () => { const unlinked = []; consumeToken({ tokenPath: '/x', exists: () => true, unlink: (p) => unlinked.push(p) }); assert.deepEqual(unlinked, ['/x']); }); // --- Q3b fix: D1 (shared token across BOTH pushes in one run) + D2 (checked before // the tag write, not just before the push) โ€” order 20260912T213049Z-5772222747 ----- // // Q3's pushWithToken checked-and-consumed per call: with one token, the tag push consumed // it before the catalog push ran, so inside a single `--create-tag --write --commit --push` // run the catalog push always saw blocked:true. And `git tag -a` (pre-fix main():295) ran // before ANY token check, so a blocked run left a local annotated tag behind โ€” the retry // after the operator drops the token then fails with "tag already exists" (exit 128). // createPushGate fixes both: ONE ensure() shared across every push this run makes, checked // before the FIRST write (including a local tag meant to precede a later push), consumed // ONCE after the run's last push succeeds. test('createPushGate: one token covers two pushes in the same run, consumed once at the end', () => { let existsCalls = 0; const exists = () => { existsCalls++; return true; }; const unlinked = []; const gate = createPushGate({ cwd: '/c', home: '/h', exists, unlink: (p) => unlinked.push(p) }); const authForTag = gate.ensure(); assert.equal(authForTag.authorised, true, 'first push (tag) must be authorised by the one token'); // ... tag push happens here in main() ... const authForCatalog = gate.ensure(); assert.equal(authForCatalog.authorised, true, "second push (catalog) must reuse the SAME token, not find it already consumed"); assert.equal(existsCalls, 1, 'the token is checked ONCE for the whole run, not once per push'); gate.consume(); assert.deepEqual(unlinked, [authForTag.tokenPath], "the token is consumed exactly once, after the run's last push"); gate.consume(); assert.deepEqual(unlinked, [authForTag.tokenPath], 'a second consume() must not double-unlink'); }); test('createPushGate: an unauthorised run must not create the tag or touch the catalog', () => { let tagCreated = false; let catalogWritten = false; const gate = createPushGate({ cwd: '/c', home: '/h', exists: () => false, unlink: () => { throw new Error('BUG: must not unlink without a token'); }, }); const auth = gate.ensure(); assert.equal(auth.authorised, false); if (auth.authorised) tagCreated = true; // mirrors main(): `git tag -a` only runs past this check if (auth.authorised) catalogWritten = true; // mirrors main(): applyRelease() only runs past this check assert.equal(tagCreated, false, 'D2: the tag must not be created before the token check passes'); assert.equal(catalogWritten, false, 'no catalog change either โ€” the run stops at the first blocked check'); gate.consume(); // must be safe even though ensure() never authorised anything }); // --- Q3c fix: D3 (token consumed on ANY exit once a push has succeeded) + S1 // (a real main()-path test, not a mirrored-variable one) โ€” order // 20260912T220453Z-5021715764-from-.claude ------------------------------------ // // D3: Q3b's pushGate.consume() sat at the very bottom of main(), after four earlier // process.exit() calls (BLOCKED plan, NOOP, dry-run, red pre-flight) that a run can hit // AFTER a tag push already succeeded. process.exit() called from inside a try does NOT // run its finally โ€” verified live (node -e with a try/finally around process.exit(1) // prints nothing from the finally) โ€” so main() cannot just wrap the old body in // try/finally as-is. The fix extracts the branching logic into `runRelease`, which // returns an exit code instead of calling process.exit anywhere past the point a push // might occur; main() alone calls process.exit, exactly once, after a finally that runs // `if (pushGate.pushed) pushGate.consume()`. That is the ONLY exit point once a push may // have happened โ€” the fewest paths the order asked for. // // These two tests exercise the REAL git plumbing (temp repos, no mocked git calls) so // they run against runRelease itself, not a copy of its logic โ€” the exact weakness S1 // found in the superseded D2 test (it asserted on a variable the test set itself). // realpathSync matters here: macOS's tmpdir() is under /var/folders, itself a symlink // to /private/var/folders. release-plugin.mjs's own self-invocation guard // (`pathToFileURL(process.argv[1]).href === import.meta.url`, main() below) compares the // literal argv[1] path against import.meta.url, which Node resolves through symlinks โ€” // so a script run from the unresolved /var/folders path never satisfies the guard and // main() silently never runs (exit 0, zero output). Only matters for tests that spawn // the real CLI as a subprocess (R1); the other temp-repo tests call runRelease directly // and never hit this path-identity check at all. function makeTempRoot(prefix) { return realpathSync(mkdtempSync(join(tmpdir(), prefix))); } function initPluginRepo(repoDir, { version, remote } = {}) { mkdirSync(join(repoDir, '.claude-plugin'), { recursive: true }); execFileSync('git', ['init', '-q', repoDir]); execFileSync('git', ['-C', repoDir, 'config', 'user.email', 'x@x.com']); execFileSync('git', ['-C', repoDir, 'config', 'user.name', 'x']); if (remote) execFileSync('git', ['-C', repoDir, 'remote', 'add', 'origin', remote]); fsWriteFileSync(join(repoDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ version })); fsWriteFileSync(join(repoDir, 'README.md'), ''); execFileSync('git', ['-C', repoDir, 'add', '.']); execFileSync('git', ['-C', repoDir, 'commit', '-q', '-m', 'init']); } test('S1 (real git, no mocked ensure): runRelease does not create the tag when the push token is missing', () => { const root = makeTempRoot('release-plugin-s1-'); try { const catalogDir = join(root, 'catalog'); const repoDir = join(root, 'demo-plugin'); mkdirSync(catalogDir, { recursive: true }); initPluginRepo(repoDir, { version: '1.1.0' }); execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']); const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url: 'x', ref: 'v1.0.0' }, description: 'd' }] }; const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => false, unlink: () => { throw new Error('BUG: must not consume without a push'); }, }); const code = runRelease({ args: { name: 'demo-plugin', createTag: true, write: true, commit: false, push: false, version: undefined }, catalogDir, mktPath: join(catalogDir, '.claude-plugin', 'marketplace.json'), marketplace, pushGate, }); assert.notEqual(code, 0, 'an unauthorised tag push must not report success'); const tags = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean); assert.deepEqual(tags, ['v1.0.0'], 'no new tag may be created when the push token is missing'); assert.equal(pushGate.pushed, false, 'kjent-negativ: no push succeeded, so nothing must be marked pushed'); } finally { rmSync(root, { recursive: true, force: true }); } }); // --- Q3d/S: a failed TAG PUSH used to leave a local orphan tag behind (the local // `git tag -a` succeeds, then `git push origin ` fails), so a retry after fixing // the network/permission issue hit "tag already exists" (exit 128) instead of a clean // idempotent re-run. Order 20260912T222008Z-8793921535-from-.claude, decided: delete the // local tag when its push fails (option a) โ€” this reuses shouldCreateTag's existing // tag-absent check to make the retry idempotent, the same property --create-tag already // relies on, rather than inventing a second "orphan tag" state to detect and explain. test('S: a failed tag push deletes the local orphan tag so a retry is not blocked by "tag already exists"', () => { const root = makeTempRoot('release-plugin-s-'); try { const repoDir = join(root, 'demo-plugin'); const catalogDir = join(root, 'catalog'); mkdirSync(catalogDir, { recursive: true }); initPluginRepo(repoDir, { version: '1.1.0' }); // no origin remote configured -> the push fails execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']); // pre-existing unrelated tag const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url: 'x', ref: 'v1.0.0' }, description: 'd' }] }; const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => true, unlink: () => { throw new Error('BUG: must not consume โ€” no push succeeded'); }, }); assert.throws(() => runRelease({ args: { name: 'demo-plugin', createTag: true, write: true, commit: false, push: false, version: undefined }, catalogDir, mktPath: join(catalogDir, '.claude-plugin', 'marketplace.json'), marketplace, pushGate, }), /origin/); const tags = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean); assert.deepEqual(tags, ['v1.0.0'], 'the orphan v1.1.0 tag must be gone once its push has failed'); assert.equal(pushGate.pushed, false, 'a failed push must not be recorded as pushed'); } finally { rmSync(root, { recursive: true, force: true }); } }); test('D3 (Q3c): the shared token is consumed once the tag push succeeds, even though the run then hits NOOP', () => { const root = makeTempRoot('release-plugin-d3-'); try { const originDir = join(root, 'origin.git'); const repoDir = join(root, 'demo-plugin'); const catalogDir = join(root, 'catalog'); mkdirSync(catalogDir, { recursive: true }); execFileSync('git', ['init', '-q', '--bare', originDir]); initPluginRepo(repoDir, { version: '1.0.0', remote: originDir }); execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'HEAD:refs/heads/main']); // No v1.0.0 tag exists yet โ€” the catalog already pins v1.0.0 (as if it was bumped by // hand before the tag was ever cut), so --create-tag has real work to do even though // planRelease will resolve to NOOP once the tag exists. const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url: originDir, ref: 'v1.0.0' }, description: 'd' }] }; const unlinked = []; const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => true, unlink: (p) => unlinked.push(p) }); const code = runRelease({ args: { name: 'demo-plugin', createTag: true, write: true, commit: false, push: false, version: undefined }, catalogDir, mktPath: join(catalogDir, '.claude-plugin', 'marketplace.json'), marketplace, pushGate, }); // Mirrors main()'s own finally โ€” the only place consume() is called from. if (pushGate.pushed) pushGate.consume(); assert.equal(code, 0, 'catalog already pins v1.0.0 once the tag exists -> NOOP, exit 0'); const tags = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean); assert.deepEqual(tags, ['v1.0.0'], '--create-tag minted + pushed the tag before the NOOP verdict was even computed'); assert.equal(pushGate.pushed, true, 'runRelease must record the push even on a branch that is not the final line'); assert.deepEqual(unlinked, [pushGate.ensure().tokenPath], 'the token must be gone โ€” a NOOP exit must not leave a used token behind (D3 regression)'); } finally { rmSync(root, { recursive: true, force: true }); } }); // --- Q3d/R1: the consume-on-exit line is only wired into main() itself, not into // runRelease โ€” D3's own test calls consume() a second time in the TEST BODY ("Mirrors // main()'s own finally") and asserts on that call, not on anything main() actually did. // A PM re-measurement proved this gap live: deleting the `finally` line from main() // left every runRelease-level test green (39/39) while the real CLI, run end-to-end, // pushed the tag, hit NOOP, and left the token behind. This test exercises main() the // only way that is possible without killing the test-runner process โ€” main() calls // process.exit(), so it must run as a real subprocess, against an isolated plugin repo, // a local bare "remote", and its own HOME (so the token path resolves inside the temp // tree, never the operator's real ~/.claude). Order 20260912T222008Z-8793921535-from-.claude. test('R1 (main(), real subprocess): the token is gone after the CLI returns, once its tag push has succeeded', () => { const root = makeTempRoot('release-plugin-r1-'); try { const originDir = join(root, 'origin.git'); const repoDir = join(root, 'demo-plugin'); const catalogDir = join(root, 'catalog'); mkdirSync(join(catalogDir, 'scripts'), { recursive: true }); mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true }); execFileSync('git', ['init', '-q', '--bare', originDir]); initPluginRepo(repoDir, { version: '1.0.0', remote: originDir }); execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'HEAD:refs/heads/main']); // No v1.0.0 tag yet; the catalog already pins v1.0.0, so once --create-tag mints + // pushes it, planRelease resolves to NOOP โ€” the exact PM-measured scenario (tag // pushed, then a later branch that isn't the run's final line, token still used). // main() locates the catalog from import.meta.url, not from an injected path โ€” so the // real script (and its check-versions.mjs import) must physically live inside the temp // tree for "catalogDir" to resolve there instead of to this repo's own working tree. const scriptSrc = fileURLToPath(new URL('./release-plugin.mjs', import.meta.url)); const cvSrc = fileURLToPath(new URL('./check-versions.mjs', import.meta.url)); const scriptDest = join(catalogDir, 'scripts', 'release-plugin.mjs'); fsWriteFileSync(scriptDest, fsReadFileSync(scriptSrc, 'utf8')); fsWriteFileSync(join(catalogDir, 'scripts', 'check-versions.mjs'), fsReadFileSync(cvSrc, 'utf8')); fsWriteFileSync(join(catalogDir, '.claude-plugin', 'marketplace.json'), JSON.stringify({ plugins: [{ name: 'demo-plugin', source: { source: 'url', url: originDir, ref: 'v1.0.0' }, description: 'd' }], }, null, 2)); fsWriteFileSync(join(catalogDir, 'README.md'), ''); const tokenDir = join(root, '.claude', 'runtime', 'push-approvals'); mkdirSync(tokenDir, { recursive: true }); const tokenPath = join(tokenDir, catalogDir.split('/').join('_')); fsWriteFileSync(tokenPath, ''); const result = spawnSync(process.execPath, [scriptDest, 'demo-plugin', '--create-tag', '--write'], { env: { ...process.env, HOME: root }, encoding: 'utf8', }); assert.equal(result.status, 0, `expected NOOP exit 0; got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); const tags = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean); assert.deepEqual(tags, ['v1.0.0'], '--create-tag minted + pushed the tag before the NOOP verdict was even computed'); assert.ok(!existsSync(tokenPath), "main()'s finally must consume the token after a real, end-to-end run โ€” not just after runRelease() returns inside a test's own mirrored consume() call"); } finally { 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 = '![selftest_checks](https://img.shields.io/badge/selftest__checks-927-blue)'; 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 = '![selftest_checks](https://img.shields.io/badge/selftest__checks-868-blue)'; 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'), '![selftest_checks](https://img.shields.io/badge/selftest__checks-927-blue)'); 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'), '![selftest_checks](https://img.shields.io/badge/selftest__checks-868-blue)'); 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 }); } });