ktg-plugin-marketplace/scripts/release-plugin.test.mjs
Kjell Tore Guttormsen 19908a88b7 fix(catalog): test-vouch main()'s finally, orphan-tag cleanup, precise CLAUDE.md wording
Order 20260912T222008Z-8793921535-from-.claude (Q3d), follow-up to the PM
re-measurement of Q3c (dd278ca).

R1 — the run-scoped push-token consumption was only test-vouched at the
runRelease() level; D3's own test called consume() a second time in the TEST
BODY ("mirrors main()'s own finally") and asserted on that call, never on
anything main() itself did. A PM agent deleted the `finally` line in main()
in a copy and every existing test stayed green (39/39) while the real CLI,
run end-to-end, pushed the tag, hit NOOP, and left the token behind.

Added a test that runs the actual CLI entry point as a real subprocess
(main() calls process.exit(), so it cannot run in-process without killing
the test runner) against an isolated plugin repo, a local bare "remote",
and its own HOME — the same scenario the PM agent used (tag pushed, then a
NOOP branch that is not the run's final line). Mutation proof: removed the
`finally` line -> new test went RED (40 pass, 1 fail) while D3 stayed GREEN,
confirming D3 does not cover this path -> restored -> GREEN.

Sub-fix required to make the new test possible on this machine: macOS's
os.tmpdir() resolves through /var/folders, a symlink to /private/var/folders.
release-plugin.mjs's self-invocation guard compares the literal argv[1] path
against import.meta.url (which Node resolves through symlinks), so a script
run from the unresolved path never satisfies the guard and main() silently
never executes (exit 0, zero output). makeTempRoot() now returns the
realpath of the created temp dir.

S (side finding) — a tag push that fails after the local `git tag -a`
succeeded left an orphan local tag behind; a retry then failed on git's own
"tag already exists" (exit 128) instead of going through the idempotent
tag-absent path --create-tag already relies on. Chose: delete the local tag
when its push fails (option a) rather than detect-and-explain the orphan
state (option b) — it reuses the existing idempotency property instead of
adding a second one. Red-first: new test failed (orphan tag survived) ->
wrapped the push in try/catch, `git tag -d` on failure, rethrow -> GREEN.

R2 — CLAUDE.md said "a failed push leaves the token intact for the retry",
which is imprecise: with --create-tag --write --commit --push, if the tag
push succeeds and the catalog push then fails, the token IS consumed
(main()'s finally fires because pushGate.pushed was already set true by the
earlier successful push) even though the run overall "failed". Corrected to
state the actual rule: the token survives only when the run makes zero
successful pushes. The usage-block comment at the top of release-plugin.mjs
does not carry the same imprecise claim, so it needed no change.

Verification:
- node --test scripts/release-plugin.test.mjs: 39 -> 41/41 (R1, S added)
- node --test scripts/*.test.mjs: 156 -> 158/158
- node scripts/check-versions.mjs: 0 ERROR (1 known WARN: claude-design;
  repo-mailbox now OK — externally re-tagged since Q3c, untouched here)
- git tag -l: unchanged (12 tags, no new ones — no tag/push/bump this session)
- mutation proof for R1: finally line removed -> RED (D3 stayed green) -> restored -> GREEN
- mutation proof for S: recorded in scripts/release-plugin.test.mjs history above (red-first)

Not done (out of scope, deliberately): no version bump, no tag, no push;
no files touched outside scripts/release-plugin.mjs, scripts/release-plugin.test.mjs, CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 07:19:36 +02:00

621 lines
33 KiB
JavaScript

// 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,
} 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 (`<name> --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 <tag>` 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 });
}
});