ktg-plugin-marketplace/scripts/release-plugin.test.mjs
Kjell Tore Guttormsen f9a99056fe
fix(release): release notes come from the CHANGELOG, not the tag message
Same-day correction to ee2259f. That commit followed the order literally --
"use the tag's own message" -- and the result was an llm-security v8.0.0
release page reading "llm-security v8.0.0" and nothing else.

The defect is structural, not a typo: --create-tag mints -m "<name>
v<version>", so the tag message is mechanical EXACTLY where this helper made
the tag. The tag message is a good source only for tags written by hand.

The right source was already proven on the instance: llm-security v7.8.3's
release body is byte-for-byte its CHANGELOG `## [7.8.3]` section. So the
CHANGELOG is the org's established source, not a new invention -- and all 10
backfilled repos ship one (measured, 10/10).

- extractChangelogSection / releaseBodyFrom: pure, tested. Priority is
  CHANGELOG section -> tag message -> empty, and the source is REPORTED so a
  run says where the text came from rather than implying it wrote it.
- Three heading dialects are live and all three are covered: `## [6.0.0] -
  date`, `## [0.2.0] -- date` (em-dash), `## v1.0 (date)`, and voyage's
  `## v5.10.1 -- date -- trailing prose`. The version token matches exactly,
  so 0.1.0-pre is not 0.1.0 and 1.1.0 is not 1.10.0. An empty section (the
  standing `## [Unreleased]`) returns null so the caller falls through
  instead of publishing a blank body.
- backfill gains --repair for the backlog the first cut created. It PATCHes
  a PUBLISHED page, so the bar is strictly more informative, never merely
  different: no CHANGELOG section means no update, and a hand-written body at
  least as long as the section is left alone. Measured: that rule is what
  protects portfolio-optimiser v1.1.0 (4750 hand-written chars vs 3704).

Dry-run over the org: 14 release objects would gain real notes, e.g.
llm-security v8.0.0 19 chars -> 10530, config-audit v6.0.0 19 -> 27309.

18 new tests, written red first. Suite 211/211; check-versions 12/12 OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:41:18 +02:00

1211 lines
62 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, preflightStatMismatches, reportPostWriteCheck,
parseForgejoRepo, planForgejoRelease, ensureForgejoRelease,
extractChangelogSection, releaseBodyFrom,
} 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' })), []);
});
// --- Q3f: the stat-line DEADLOCK between the two pre-flights ------------------
//
// Measured 2026-09-18 on two live orders (repo-mailbox 0.35.0, llm-security 8.0.0):
// a release that changes ANY badged stat number cannot complete, because the two
// pre-flights demand OPPOSITE values of the same catalog stat line:
// (a) preflightStatMismatches (runRelease, BEFORE the tag) reads the TARGET ref's
// badges -> demands the NEW number.
// (b) applyRelease's preflightErrors -> runGate -> inspectPlugin reads the stat
// source at the PINNED ref -> demands the OLD number.
// The pinned ref is moved by exactly the write that (b) blocks, so no value satisfies
// both, retry included. Worse: --create-tag is deliberately ungated on (b), so a real
// run mints and PUSHES the tag, then blocks on (b) — a published tag against a catalog
// that can never be bumped.
//
// The fix: for the ONE plugin being released, a stat finding measures the ref this
// release is about to replace, and (a) has already validated the stat line against the
// target. So (b) drops stat findings for that plugin only. Every other finding for it,
// and every finding for every other plugin, still blocks.
const statFinding = (msg = 'catalog says 22 scanner but the plugin\'s badge says 23 (catalog stat line is stale)') =>
({ level: 'ERROR', kind: 'stat', msg });
const oneResult = (name, status, findings) => ({
results: [{ name, status, findings }],
hasError: status === 'ERROR', hasWarn: status === 'WARN', failed: status === 'ERROR',
});
test('DEADLOCK: a stat-only ERROR on the plugin being RELEASED must not block its own write', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
// What the real classifier produces mid-release: catalog ref still v1.0.0 (WARN, normal),
// plus the stat line already carrying the NEW number that the OLD ref contradicts.
const io = fakeIo(oneResult('alpha', 'ERROR', [
{ level: 'WARN', msg: 'catalog ref v1.0.0 != plugin.json version 1.1.0 (unreleased bump)' },
statFinding(),
]));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'WROTE', 'the release must be able to complete');
assert.deepEqual(r.preflightErrors, []);
});
test('DEADLOCK fix is scoped: a stat ERROR on ANOTHER plugin still blocks', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
const io = fakeIo(oneResult('beta', 'ERROR', [statFinding()]));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'BLOCKED');
assert.deepEqual(r.preflightErrors, ['beta']);
assert.deepEqual(io.writes, []);
});
test('DEADLOCK fix is narrow: a NON-stat ERROR on the released plugin still blocks', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
const io = fakeIo(oneResult('alpha', 'ERROR', [
statFinding(),
{ level: 'ERROR', msg: 'plugin.json version 1.1.0 != README version-badge 1.0.9' },
]));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'BLOCKED', 'a dangling ref / bad badge must never be waived');
assert.deepEqual(r.preflightErrors, ['alpha']);
assert.deepEqual(io.writes, []);
});
test('DEADLOCK fix cannot reason without findings: ERROR with no ERROR finding still blocks', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
const io = fakeIo(oneResult('alpha', 'ERROR', []));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'BLOCKED', 'an unexplained ERROR is never waived');
assert.deepEqual(r.preflightErrors, ['alpha']);
});
test('preflightErrors without a releasing name waives nothing (catalog-wide default)', () => {
const gate = oneResult('alpha', 'ERROR', [statFinding()]);
assert.deepEqual(preflightErrors(gate), ['alpha']);
assert.deepEqual(preflightErrors(gate, 'alpha'), []);
});
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 });
}
});
// --- 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 });
}
});
// --- Forgejo release object (order 20260917T235642Z-730962924-from-from-ai-to-chitta) ---
//
// Measured 2026-09-18 against the instance's own API: 11 of the 21 tagged repos in org
// `open` had NO release object for their newest tag, because this helper only ever made a
// git TAG. Forgejo files a pushed tag under /tags; only an explicit release object appears
// under /releases. So a "released" plugin could show v7.8.3 on its releases page while
// v8.0.0 was the tag the catalog pinned — the same tag-vs-published drift the catalog-ref
// bump exists to prevent, one surface further out.
test('parseForgejoRepo pulls owner/repo out of a Forgejo repo URL', () => {
assert.deepEqual(
parseForgejoRepo('https://git.fromaitochitta.com/open/llm-security'),
{ owner: 'open', repo: 'llm-security' },
);
});
test('parseForgejoRepo tolerates a .git suffix and a trailing slash', () => {
assert.deepEqual(parseForgejoRepo('https://git.fromaitochitta.com/open/repo-mailbox.git'), { owner: 'open', repo: 'repo-mailbox' });
assert.deepEqual(parseForgejoRepo('https://git.fromaitochitta.com/open/voyage/'), { owner: 'open', repo: 'voyage' });
});
test('parseForgejoRepo returns null for a URL it cannot read — it never guesses an owner', () => {
assert.equal(parseForgejoRepo('x'), null);
assert.equal(parseForgejoRepo('https://git.fromaitochitta.com/open'), null);
assert.equal(parseForgejoRepo(null), null);
assert.equal(parseForgejoRepo(undefined), null);
});
test('planForgejoRelease: CREATE when no release object exists for the tag', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-security',
tag: 'v8.0.0',
releaseTags: ['v7.8.3'],
tagMessage: 'llm-security v8.0.0',
});
assert.equal(p.verdict, 'CREATE');
assert.equal(p.owner, 'open');
assert.equal(p.repo, 'llm-security');
assert.equal(p.tag, 'v8.0.0');
assert.equal(p.name, 'v8.0.0');
});
test('planForgejoRelease: NOOP when a release object for the tag already exists (idempotent re-run)', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-security',
tag: 'v8.0.0',
releaseTags: ['v8.0.0', 'v7.8.3'],
tagMessage: 'llm-security v8.0.0',
});
assert.equal(p.verdict, 'NOOP');
});
test('planForgejoRelease: BLOCKED when the source url cannot be parsed', () => {
const p = planForgejoRelease({ url: 'x', tag: 'v1.0.0', releaseTags: [], tagMessage: 'm' });
assert.equal(p.verdict, 'BLOCKED');
assert.match(p.reason, /url/);
});
test('planForgejoRelease: BLOCKED when there is no tag to release', () => {
const p = planForgejoRelease({ url: 'https://git.fromaitochitta.com/open/x', tag: null, releaseTags: [], tagMessage: '' });
assert.equal(p.verdict, 'BLOCKED');
});
test('planForgejoRelease: the body is the tag message VERBATIM — no invented release notes', () => {
const msg = '0.10.0 — a bundle carries the images its sources declare\n\nFive readers place them.';
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-ingestion-okf', tag: 'v0.10.0', releaseTags: [], tagMessage: msg,
});
assert.equal(p.body, msg, 'the tag message is the release text; the helper must not write prose of its own');
});
test('planForgejoRelease: an empty tag message yields an EMPTY body, never invented prose', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/x', tag: 'v1.0.0', releaseTags: [], tagMessage: '',
});
assert.equal(p.verdict, 'CREATE');
assert.equal(p.body, '');
});
test('ensureForgejoRelease: CREATE posts exactly once with tag_name/name/body', () => {
const calls = [];
const api = { createRelease: (owner, repo, payload) => { calls.push({ owner, repo, payload }); return { html_url: 'https://h/r' }; } };
const r = ensureForgejoRelease(
{ verdict: 'CREATE', owner: 'open', repo: 'llm-security', tag: 'v8.0.0', name: 'v8.0.0', body: 'llm-security v8.0.0' },
api,
);
assert.equal(r.created, true);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], {
owner: 'open', repo: 'llm-security',
payload: { tag_name: 'v8.0.0', name: 'v8.0.0', body: 'llm-security v8.0.0' },
});
assert.equal(r.url, 'https://h/r');
});
test('ensureForgejoRelease: NOOP and BLOCKED never call the API', () => {
const api = { createRelease: () => { throw new Error('BUG: must not post'); } };
assert.equal(ensureForgejoRelease({ verdict: 'NOOP', reason: 'exists' }, api).created, false);
assert.equal(ensureForgejoRelease({ verdict: 'BLOCKED', reason: 'bad url' }, api).created, false);
});
test('R-FJ1 (real git): a publishing run files the Forgejo release object for the tag it just pushed', () => {
const root = makeTempRoot('release-plugin-fj1-');
try {
const bare = join(root, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare]);
const repoDir = join(root, 'demo-plugin');
const catalogDir = join(root, 'catalog');
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
initPluginRepo(repoDir, { version: '1.1.0', remote: bare });
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'v1.0.0']);
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const url = 'https://git.fromaitochitta.com/open/demo-plugin';
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url, 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: () => true, unlink: () => {} });
const created = [];
const forgejo = {
listReleaseTags: () => ['v1.0.0'],
createRelease: (owner, repo, payload) => { created.push({ owner, repo, payload }); return { html_url: 'https://h/rel' }; },
};
const code = runRelease({
args: { name: 'demo-plugin', version: '1.1.0', createTag: true, write: true, commit: false, push: false },
catalogDir, mktPath, marketplace, pushGate, forgejo,
runCheckVersions: () => '1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
});
assert.equal(code, 0);
assert.equal(created.length, 1, 'the release object is part of the release, not an afterthought');
assert.equal(created[0].owner, 'open');
assert.equal(created[0].repo, 'demo-plugin');
assert.equal(created[0].payload.tag_name, 'v1.1.0');
assert.equal(created[0].payload.body, 'demo-plugin v1.1.0', 'body is the tag message this run wrote');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('R-FJ2 (real git): a failed release-object create reports precisely and returns non-zero — the release is NOT complete', () => {
const root = makeTempRoot('release-plugin-fj2-');
try {
const bare = join(root, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare]);
const repoDir = join(root, 'demo-plugin');
const catalogDir = join(root, 'catalog');
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
initPluginRepo(repoDir, { version: '1.1.0', remote: bare });
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'v1.0.0']);
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const url = 'https://git.fromaitochitta.com/open/demo-plugin';
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url, 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: () => true, unlink: () => {} });
const forgejo = {
listReleaseTags: () => ['v1.0.0'],
createRelease: () => { throw new Error('POST /releases -> HTTP 403: token lacks write:repository'); },
};
let code;
let threw = false;
try {
code = runRelease({
args: { name: 'demo-plugin', version: '1.1.0', createTag: true, write: true, commit: false, push: false },
catalogDir, mktPath, marketplace, pushGate, forgejo,
runCheckVersions: () => '1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
});
} catch { threw = true; }
assert.equal(threw, false, 'a release-object failure must become a message, never an unhandled exception over a half-done release');
assert.notEqual(code, 0, 'a release without its release object is not complete');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
// --- Release notes come from the CHANGELOG ----------------------------------
//
// The first cut used the tag's own message, which the order asked for. Measured against
// what it produced: llm-security v8.0.0's release page read "llm-security v8.0.0" and
// nothing else — because release-plugin.mjs mints tags with `-m "<name> <tag>"`, so the
// tag message is mechanical EXACTLY where this helper made the tag. The org's own answer
// was already on the instance: llm-security v7.8.3's release body is byte-for-byte its
// CHANGELOG `## [7.8.3]` section. So the CHANGELOG is the established source, not a new
// invention — and all 10 backfilled repos ship one (measured 2026-09-18, 10/10).
//
// Three heading dialects are in live use and all three are load-bearing here:
// ## [6.0.0] - 2026-08-18 bracketed, hyphen
// ## [0.2.0] — 2026-08-20 bracketed, em-dash
// ## v1.0 (2026-08-18) bare v-prefix, parenthesised date
// ## v5.10.1 — 2026-09-03 — ... bare v-prefix, trailing prose in the heading
const CL_BRACKET = `# Changelog
## [Unreleased]
## [8.0.0] - 2026-09-18
Major release. The breaking part is small.
### Added
- llms.txt at the repository root.
## [7.8.3] - 2026-07-18
Security and correctness patch.
`;
test('extractChangelogSection pulls the bracketed section and stops at the next release', () => {
const out = extractChangelogSection(CL_BRACKET, '8.0.0');
assert.match(out, /^Major release\./);
assert.match(out, /llms\.txt/);
assert.doesNotMatch(out, /Security and correctness patch/, 'the next release must not bleed in');
assert.doesNotMatch(out, /^## /m, 'the section heading itself is not part of the body');
});
test('extractChangelogSection never matches [Unreleased]', () => {
assert.equal(extractChangelogSection(CL_BRACKET, 'Unreleased'), null);
});
test('extractChangelogSection: a pre-release heading is not a match for the plain version', () => {
const cl = '## [0.1.0-pre] — 2026-05-15\n\npre stuff\n';
assert.equal(extractChangelogSection(cl, '0.1.0'), null, '0.1.0-pre is a different version');
});
test('extractChangelogSection reads the em-dash dialect', () => {
const cl = '# C\n\n## [0.2.0] — 2026-08-20\n\ntwo point oh\n\n## [0.1.0] — 2026-05-17\n\none\n';
assert.equal(extractChangelogSection(cl, '0.2.0'), 'two point oh');
});
test('extractChangelogSection reads the bare v-prefix dialect with a parenthesised date', () => {
const cl = '# C\n\n## v1.0 (2026-08-18)\n\nfemlagsstruktur\n\n## v0.13 (2026-08-18)\n\nolder\n';
assert.equal(extractChangelogSection(cl, '1.0'), 'femlagsstruktur');
});
test('extractChangelogSection reads a heading that carries trailing prose', () => {
const cl = '## v5.10.1 — 2026-09-03 — gemini-bridge dropped\n\nbody here\n\n## v5.10.0 — 2026-08-18 — STORM\n\nolder\n';
assert.equal(extractChangelogSection(cl, '5.10.1'), 'body here');
});
test('extractChangelogSection returns null when the version has no section — never a neighbour', () => {
assert.equal(extractChangelogSection(CL_BRACKET, '9.9.9'), null);
assert.equal(extractChangelogSection('', '1.0.0'), null);
assert.equal(extractChangelogSection(null, '1.0.0'), null);
});
test('extractChangelogSection does not confuse 1.1.0 with 1.10.0', () => {
const cl = '## [1.10.0] - 2026-01-01\n\nten\n\n## [1.1.0] - 2026-01-01\n\none\n';
assert.equal(extractChangelogSection(cl, '1.1.0'), 'one');
assert.equal(extractChangelogSection(cl, '1.10.0'), 'ten');
});
test('releaseBodyFrom prefers the CHANGELOG section over the tag message', () => {
const r = releaseBodyFrom({ changelogText: CL_BRACKET, tag: 'v8.0.0', tagMessage: 'llm-security v8.0.0' });
assert.equal(r.source, 'changelog');
assert.match(r.body, /^Major release\./);
});
test('releaseBodyFrom falls back to the tag message when the CHANGELOG has no such section', () => {
const r = releaseBodyFrom({ changelogText: CL_BRACKET, tag: 'v9.9.9', tagMessage: 'a real hand-written tag message' });
assert.equal(r.source, 'tag-message');
assert.equal(r.body, 'a real hand-written tag message');
});
test('releaseBodyFrom falls back with NO changelog at all', () => {
const r = releaseBodyFrom({ changelogText: null, tag: 'v1.0.0', tagMessage: 'msg' });
assert.equal(r.source, 'tag-message');
assert.equal(r.body, 'msg');
});
test('releaseBodyFrom reports an EMPTY body as its own source — it never invents prose', () => {
const r = releaseBodyFrom({ changelogText: null, tag: 'v1.0.0', tagMessage: '' });
assert.equal(r.source, 'none');
assert.equal(r.body, '');
});
test('releaseBodyFrom: a MECHANICAL tag message loses to the CHANGELOG — that was the whole defect', () => {
// `release-plugin.mjs --create-tag` mints `-m "<name> v<version>"`, so this exact shape
// is what the helper's own tags carry, and it is what made v8.0.0's page say nothing.
const r = releaseBodyFrom({ changelogText: CL_BRACKET, tag: 'v8.0.0', tagMessage: 'llm-security v8.0.0' });
assert.notEqual(r.body, 'llm-security v8.0.0');
});