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

@ -208,6 +208,11 @@ export function createPushGate({ cwd, home, exists, unlink }) {
let auth = null;
let consumed = false;
return {
// Set by a caller (runRelease) right after a `git push` it issued actually succeeds.
// Q3c/D3: consume() must fire whenever this is true, on EVERY exit from the run, not
// only the run's final line — a run can push a tag and then resolve to BLOCKED/NOOP/
// dry-run/red-pre-flight afterwards, and each of those used to skip consume() entirely.
pushed: false,
ensure() {
if (auth === null) auth = requirePushAuthorisation({ cwd, home, exists });
return auth;
@ -309,34 +314,29 @@ function parseArgs(argv) {
return out;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.name) {
console.error('usage: release-plugin.mjs <name> [--version X.Y.Z] [--create-tag] [--write] [--commit] [--push]');
process.exit(2);
}
const catalogDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const marketplace = JSON.parse(readFileSync(mktPath, 'utf8'));
// The body of main(), minus the top-level plumbing (read marketplace.json off disk,
// build the pushGate) and minus the final process.exit call — pulled out so it is
// testable against a real temp git repo (Q3c/S1) and so it can return an exit code
// instead of calling process.exit at each branch (Q3c/D3, see main() below for why).
export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate }) {
let obs = observePlugin(catalogDir, args.name);
const target = normalizeVersion(args.version ?? obs.pluginVersion ?? '');
// --create-tag: if the only thing missing is the tag, mint + push it first — but only
// under --write. Without it this is a dry-run and must publish nothing.
// Shared across BOTH push sites in this run (tag push, catalog push) — one operator
// token authorises the whole publish, not each push individually (D1). ensure() is
// called before the FIRST write this run intends to push toward, including the local
// pushGate is shared across BOTH push sites in this run (tag push, catalog push) — one
// operator token authorises the whole publish, not each push individually (D1). ensure()
// is called before the FIRST write this run intends to push toward, including the local
// `git tag -a` below, which must not run before the check passes (D2).
const pushGate = createPushGate({ cwd: catalogDir, home: process.env.HOME, exists: existsSync, unlink: unlinkSync });
const tagStep = shouldCreateTag(args, obs, target);
if (tagStep === 'create') {
const auth = pushGate.ensure();
if (!auth.authorised) { console.error(auth.message); process.exit(1); }
if (!auth.authorised) { console.error(auth.message); return 1; }
const tag = 'v' + target;
console.log(`→ creating annotated tag ${tag} in ${obs.repoDir}`);
execFileSync('git', ['-C', obs.repoDir, 'tag', '-a', tag, '-m', `${args.name} ${tag}`], { stdio: 'inherit' });
execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' });
pushGate.pushed = true;
obs = observePlugin(catalogDir, args.name);
}
@ -350,14 +350,18 @@ function main() {
console.log(` (dry-run) --create-tag would mint + push v${target} in ${obs.repoDir} — re-run with --write.`);
}
if (plan.verdict === 'BLOCKED') process.exit(1);
if (plan.verdict === 'NOOP') { console.log(' ✓ catalog already pins this version — nothing to do.'); process.exit(0); }
// Q3c/D3: every branch below returns instead of calling process.exit. A tag push above
// may already have set pushGate.pushed = true, and process.exit() called from inside a
// try does not run its finally (verified live) — so main() must be the only place that
// calls process.exit, after its finally has had a chance to consume the token.
if (plan.verdict === 'BLOCKED') return 1;
if (plan.verdict === 'NOOP') { console.log(' ✓ catalog already pins this version — nothing to do.'); return 0; }
// READY
if (!args.write) {
console.log(` ✓ ready — would bump catalog ref + README label and commit:\n ${plan.commitSubject}`);
console.log(' (dry-run) re-run with --write [--commit] [--push] to apply.');
process.exit(0);
return 0;
}
const readmePath = join(catalogDir, 'README.md');
@ -367,7 +371,7 @@ function main() {
console.log(' ✗ pre-flight check-versions is RED — nothing written.');
for (const n of applied.preflightErrors) console.log(` ERROR: ${n}`);
console.log(' Fix every ERROR (any plugin — the gate exit code is catalog-wide), then re-run.');
process.exit(1);
return 1;
}
console.log(` ✓ wrote ${mktPath} (ref ${plan.currentRef} -> ${plan.newRef})`);
@ -389,16 +393,38 @@ function main() {
console.log(' ✓ committed the catalog');
if (args.push) {
const auth = pushGate.ensure();
if (!auth.authorised) { console.error(auth.message); process.exit(1); }
if (!auth.authorised) { console.error(auth.message); return 1; }
console.log(' → pushing');
execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' });
pushGate.pushed = true;
console.log(' ✓ pushed');
}
}
// Consume the shared token once, after the run's LAST successful push (D1) — a no-op
// if nothing this run pushed.
pushGate.consume();
process.exit(0);
return 0;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.name) {
console.error('usage: release-plugin.mjs <name> [--version X.Y.Z] [--create-tag] [--write] [--commit] [--push]');
process.exit(2);
}
const catalogDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const marketplace = JSON.parse(readFileSync(mktPath, 'utf8'));
const pushGate = createPushGate({ cwd: catalogDir, home: process.env.HOME, exists: existsSync, unlink: unlinkSync });
let exitCode;
try {
exitCode = runRelease({ args, catalogDir, mktPath, marketplace, pushGate });
} finally {
// Q3c/D3 (order 20260912T220453Z-5021715764): consume the shared token on ANY exit
// from this run once at least one push has succeeded — not just the final line, which
// four earlier exits (BLOCKED plan, NOOP, dry-run, red pre-flight) used to bypass
// entirely. This is the only place consume() is called from.
if (pushGate.pushed) pushGate.consume();
}
process.exit(exitCode);
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {