ktg-plugin-marketplace/scripts/release-plugin.test.mjs
Kjell Tore Guttormsen 5bc6c4ecbd fix(catalog): release-plugin.mjs shares one push token across a whole run
Q3b (order 20260912T213049Z-5772222747) corrects two measured defects in
Q3's ea9bf7a: pushWithToken checked-and-consumed per call, so a single
`--create-tag --write --commit --push` run spent the operator's one-shot
token on the tag push and always saw blocked:true on the catalog push
right after (D1). And `git tag -a` ran before any token check at all, so
a blocked run left a local annotated tag behind, breaking the retry with
"tag already exists" (D2).

createPushGate replaces the per-push check-and-consume with a run-scoped
gate: ensure() checks the token once and every later call in the same run
reuses that result, consume() fires once after the run's last successful
push. main() calls ensure() before the tag write (not just before the
push) and consume() once at the end. pushWithToken is now a single-push
convenience wrapper over the same gate — its existing tests stay green
unmodified.

Red-first: `createPushGate` did not exist on ea9bf7a (import error),
proving both new tests were red before the fix. After:
node --test scripts/release-plugin.test.mjs -> 37/37 (35 + 2 new)
node --test scripts/*.test.mjs -> 154/154
node scripts/check-versions.mjs -> 0 ERROR (2 known WARN: claude-design, repo-mailbox)
Live D2 check: `release-plugin.mjs repo-mailbox --create-tag --write`
without a token -> BLOCKED, exit 1, no local tag created.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 23:57:07 +02:00

414 lines
20 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 {
planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag,
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate,
} 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
});