fix(catalog): release-plugin.mjs consumes the push token on ANY exit after a push

Q3c (RETTELSE av Q3b `5bc6c4e`, ordre 20260912T220453Z-5021715764-from-.claude,
etter PM-re-måling på frossen klone 13.09 00:04). TDD: to nye røde tester skrevet
først (kjørt mot 5bc6c4e — SyntaxError: `runRelease` fantes ikke, se
release-plugin.test.mjs sin `Q3c fix` header for detaljer), deretter fiksen.

D3 (regresjon i Q3b) — `pushGate.consume()` sto nederst i main(), etter fire
tidligere process.exit()-kall (BLOCKED, NOOP, dry-run, rød pre-flight) som en
kjøring kan treffe ETTER at en tag-push allerede har lyktes. Live-verifisert
FØR fiksen (node -e med try/finally rundt process.exit(1)): finally kjører IKKE
når process.exit() kalles inne i try — Node terminerer før stack-avvikling.
Derfor holder ikke et enkelt try/finally rundt den gamle main()-kroppen.

Fiksen: `main()`s gren-logikk er flyttet til en eksportert `runRelease()` som
returnerer en exit-kode i stedet for å kalle process.exit() noe sted etter at en
push kan ha skjedd. `main()` kaller process.exit() nøyaktig ÉN gang, etter en
`finally` som kjører `if (pushGate.pushed) pushGate.consume()`. `pushGate` fikk
et nytt `pushed`-felt, satt til true rett etter hver vellykkede `git push`
(tag-push og katalog-push). Dette er det eneste stedet igjen å resonnere om
konsum — færrest utgangsstier, som ordren ba om.

S1 (svakhet i D2-testen fra Q3b) — den gamle testen satte `tagCreated` fra
`auth.authorised` i TESTEN SELV og asserterte på egen variabel; den beviste
ingenting om den faktiske main()-stien. Nye tester kjører `runRelease` mot
ekte midlertidige git-repoer (ingen mocket git):
- S1: uten token → `git tag -l` uendret, exit ≠ 0, `pushGate.pushed === false`.
  Mutasjonsbevis: flyttet `git tag -a` over token-sjekken → testen ble RØD →
  gjenopprettet original rekkefølge → GRØNN igjen.
- D3: med token, tag mangler men katalogen pinner allerede versjonen (NOOP-
  scenario) → ekte tag mintes+pushes til et lokalt bare-remote, run returnerer
  0, `pushGate.pushed === true`, tokenet er FYSISK BORTE etter — reproduserer
  PM-agentens live-probe (tag pushet, NOOP, token fortsatt der FØR fiksen).

Ordrens D1/D2-krav fra Q3b står uendret (delt token, sjekk før `git tag -a`) —
ikke rørt av denne fiksen, kun konsum-tidspunktet.

Verifisering (kommandoer kjørt, tall gjengitt her):
- `node --test scripts/release-plugin.test.mjs` → 39/39, 0 fail (opp fra 37).
- `node --test scripts/*.test.mjs` → 156/156, 0 fail (opp fra 154).
- `node scripts/check-versions.mjs` → 0 ERROR, 2 kjente WARN (claude-design,
  repo-mailbox — urørt, utenfor scope).
- Re-kjørt etter `git add` — uendret.

Kun `scripts/release-plugin.mjs` og `scripts/release-plugin.test.mjs` rørt.
Ingen versjonsbump, ingen tag, ingen push (forbudt i ordren). CLAUDE.md-
ordlyden («consumed after the push actually succeeds») er nå sann på alle
utgangsstier og krevde ingen endring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-13 00:12:17 +02:00
commit dd278ca70e
2 changed files with 154 additions and 24 deletions

View file

@ -3,9 +3,14 @@
// is exercised by the CLI against the live tree, not here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync as fsWriteFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag,
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate,
runRelease,
} from './release-plugin.mjs';
import { classifyPlugin } from './check-versions.mjs';
@ -412,3 +417,102 @@ test('createPushGate: an unauthorised run must not create the tag or touch the c
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).
function makeTempRoot(prefix) {
return 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 });
}
});
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 });
}
});