pre-push-gate.sh is a text-matching PreToolUse hook and cannot see a `git push` issued via execFileSync inside this script's own process — pinned as GAP in the gate's header, with this script named as the concrete case (a plugin tag left the machine unseen, 2026-09-12). --create-tag --write and --push now each require the same one-shot push-approval token the gate checks, and consume it themselves after a push succeeds, since post-push-consume.sh never fires for a call the gate never saw. Tag-push and catalog-push share one token — one publish from the operator's perspective. Red-first: 9 new tests (26 -> 35 in release-plugin.test.mjs, 0 fail before implementation existed as an import error, 152/152 across the suite after). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
365 lines
18 KiB
JavaScript
365 lines
18 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,
|
|
} 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']);
|
|
});
|