fix(release): NOOP no longer drops a pending commit/push, token scope documented accurately

Three defects in release-plugin.mjs measured live by llm-security S4 during the
v8.1.0 release (order 20260922T185613Z-5930828122-from-.claude):

1. The header/token comments claimed the tag-push and catalog-push "share ONE
   token" without qualifying the scope, which read as spanning a whole release
   however many separate invocations it took. The push-approval token is
   actually scoped to ONE script invocation (createPushGate consumes it as
   soon as that run's first push succeeds) — chosen over making the token
   survive across separate processes, since a persisted cross-invocation
   authorisation is exactly the kind of standing grant the one-shot design
   exists to avoid. Comments and the operator-facing BLOCKED message now say
   this, and recommend the combined `--create-tag --write --commit --push`
   as the one-token-one-release path.

2. `--write --commit` run after an earlier `--write`-only invocation reported
   NOOP and committed nothing: a fresh process re-reads marketplace.json off
   disk, sees the target ref already written (but uncommitted) by the prior
   run, and planRelease — which has no git access — cannot tell that apart
   from an already-released catalog.

3. NOOP returned before ever checking --push, so a pending write could also
   never be pushed by a follow-up invocation.

Fixed by pendingCatalogChanges(), which checks the working tree for the
plugin's catalog files; a NOOP verdict with --commit requested against a
dirty tree now finishes the release (commit, push, Forgejo release object)
via a shared finishPublish() instead of silently reporting "nothing to do".
A genuinely clean NOOP is unchanged (still exits early, still never touches
the push gate).

Also isolates release-plugin.test.mjs's temp git fixtures from the
machine-global pre-push hook (installed today, order 20260918T004628Z, live
during this session) via a repo-local core.hooksPath override — those tests
exercise this script's own token/NOOP/push logic, not that unrelated global
CHANGELOG policy, and R-FJ1 specifically needs the tag-message fallback path
a real CHANGELOG.md would short-circuit.

TDD: BUG 2 and BUG 3 were written RED against the unmodified script (real
git temp repos, two-invocation traces reproducing the measured scenario)
before the fix; full suite 217/217, check-versions 0 ERROR (1 pre-existing
WARN on llm-security's in-flight, unrelated v8.1.1 bump).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-22 21:13:07 +02:00
commit bb43a20376
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 328 additions and 85 deletions

View file

@ -16,21 +16,34 @@
// fully tested; the I/O shell reads the tree and, under explicit flags,
// writes/commits/pushes. Dry-run by default — it changes nothing until you pass --write.
//
// Usage:
// node scripts/release-plugin.mjs <name> [--version X.Y.Z] # dry-run: print the plan
// node scripts/release-plugin.mjs <name> --create-tag --write # create+push the missing vX.Y.Z plugin tag first
// # (--create-tag is a WRITE: without --write it only reports)
// node scripts/release-plugin.mjs <name> --write # write the bumped catalog ref
// node scripts/release-plugin.mjs <name> --write --commit # + git commit the catalog
// node scripts/release-plugin.mjs <name> --write --commit --push # + push
// Usage — PREFER the single combined command (see the token note below):
// node scripts/release-plugin.mjs <name> [--version X.Y.Z] # dry-run: print the plan
// node scripts/release-plugin.mjs <name> --create-tag --write --commit --push
// # the whole release in one run
// node scripts/release-plugin.mjs <name> --create-tag --write # create+push the missing vX.Y.Z plugin tag ONLY
// # (--create-tag is a WRITE: without --write it only reports)
// node scripts/release-plugin.mjs <name> --write # write the bumped catalog ref (no commit)
// node scripts/release-plugin.mjs <name> --write --commit # + git commit the catalog
// node scripts/release-plugin.mjs <name> --write --commit --push # + push
//
// PUSHING NEEDS THE SAME ONE-SHOT TOKEN AS pre-push-gate.sh. That hook matches `git
// push` in command text and cannot see a push this script issues via execFileSync
// inside node — so `--create-tag --write` and `--push` each REFUSE unless the operator
// has left the approval token first (tag-push and catalog-push share ONE token — one
// publish from the operator's perspective):
// has left the approval token first:
// mkdir -p ~/.claude/runtime/push-approvals && touch "~/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"
// The token is consumed after the push actually succeeds, same as the gate's own.
// The token is scoped to ONE SCRIPT INVOCATION (createPushGate, below) and is consumed as
// soon as any push THAT INVOCATION makes succeeds — the run-scoped gate exists so the tag
// push and the catalog push inside a SINGLE combined `--create-tag --write --commit --push`
// run share it, giving one token = one release for that (recommended) usage. It does NOT
// span separate invocations: splitting a release across two calls (e.g. `--create-tag
// --write` now, `--write --commit --push` later, to review the diff first) needs the token
// touched again before the second one — that second push is a genuinely separate decision
// from the operator's perspective, made at a different time, and the token cannot outlive
// the process without becoming a standing authorisation for whatever push happens next,
// which is exactly the kind of open-ended grant the one-shot design exists to avoid.
// (Measured live 22.09.2026, order 20260922T185613Z-5930828122-from-.claude: a run split
// this way used to also silently drop the pending commit/push on its second invocation —
// see pendingCatalogChanges, below, for that separate fix.)
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
@ -255,8 +268,11 @@ export function reportPostWriteCheck({ name, applied, tagged, willPush }, runChe
// (--push), both invisible to that gate. So it must require the SAME one-shot approval
// token the gate checks (`hooks/lib/cmd-parse.sh` token_path()) before either push, and
// consume it itself after a push succeeds — post-push-consume.sh (PostToolUse) never
// fires for a call the gate never saw. Tag-push and catalog-push share ONE token: one
// publish from the operator's perspective.
// fires for a call the gate never saw. Scoped to ONE SCRIPT INVOCATION (createPushGate,
// below): every push that ONE run makes shares the token, so a single combined
// `--create-tag --write --commit --push` needs it touched only once. It is consumed as
// soon as that run's first push succeeds and does NOT carry over to a later, separate
// invocation — see the top-of-file usage note for why that is by design, not a gap.
// Computes the token path EXACTLY like token_path(): `sed 's|/|_|g'` on $PWD — only
// '/' is rewritten, every other character (including '-' and '.') is left alone.
@ -271,7 +287,8 @@ export function requirePushAuthorisation({ cwd, home, exists }) {
const message = [
'BLOCKED: this run would push — release-plugin.mjs pushes a tag and/or the catalog itself',
"via execFileSync inside node, invisible to pre-push-gate.sh's command-text match.",
"Tag-push and catalog-push share ONE token: one publish from the operator's perspective.",
"One token authorises every push THIS INVOCATION makes (tag + catalog, if both happen",
"in one run — prefer --create-tag --write --commit --push for exactly that reason).",
'',
'To approve exactly one publish from this run, the OPERATOR runs:',
` mkdir -p ${dirname(tokenPath)} && touch "${tokenPath}"`,
@ -555,6 +572,25 @@ export function sleepMs(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
// --- pending-write detection (order 20260922T185613Z, measured live 22.09.2026) --------
//
// planRelease has no git access, so a NOOP verdict (catalog ref already matches the
// target) cannot distinguish "already released" from "an earlier --write already wrote
// this ref, uncommitted" — e.g. `--create-tag --write` (writes the ref, no --commit)
// followed later by a separate `--write --commit` invocation: a fresh process re-reads
// marketplace.json off disk, sees the target ref already there, and used to report NOOP
// unconditionally, silently dropping the pending commit (and, if asked, the push).
// Checked explicitly so runRelease can still finish what an earlier run started, instead
// of a NOOP that quietly leaves the operator's --commit/--push unfulfilled.
export function pendingCatalogChanges({ catalogDir, paths }, exec = execFileSync) {
try {
const out = exec('git', ['-C', catalogDir, 'status', '--porcelain', '--', ...paths], { encoding: 'utf8' });
return out.trim().length > 0;
} catch {
return false;
}
}
// Run the gate FIRST, then write. The old order wrote both files and only then ran the gate
// (which throws on exit 1), leaving a half-applied release in the working tree for a parallel
// session to carry to the public remote. `io` is injected so the ORDER is testable.
@ -653,6 +689,7 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
let obs = observePlugin(catalogDir, args.name);
const target = normalizeVersion(args.version ?? obs.pluginVersion ?? '');
const readmePath = join(catalogDir, 'README.md');
// Q3e/D1: check the catalog's stat line against what this release is ABOUT TO MAKE
// current — BEFORE any tag or write, uncoupled from whether the ref is already
@ -711,12 +748,99 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
console.log(` (dry-run) --create-tag would mint + push v${target} in ${obs.repoDir} — re-run with --write.`);
}
// Finishes a release: commit + push the catalog (if requested), then file the Forgejo
// release object (if this run publishes anything). Shared by the WROTE path below and
// by a NOOP verdict that still has a pending write on disk (pendingCatalogChanges,
// above) — both are "is there something on disk this run must still commit/push/
// release", and the caller must not care which branch produced that answer.
function finishPublish() {
if (args.commit) {
const subject = plan.commitSubject ?? `chore(catalog): release ${plan.name} ${plan.newRef}`;
const body = `${plan.name} ${plan.newRef} — release. Catalog ref now pins the ${plan.newRef} tag so \`claude plugin update\` resolves the release.`;
const msg = `${subject}\n\n${body}\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>\n`;
execFileSync('git', ['-C', catalogDir, 'add', '.claude-plugin/marketplace.json', 'README.md'], { stdio: 'inherit' });
execFileSync('git', ['-C', catalogDir, 'commit', '-m', msg], { stdio: 'inherit' });
console.log(' ✓ committed the catalog');
if (args.push) {
const auth = pushGate.ensure();
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');
}
}
// The release object. A run that PUBLISHES (pushed the tag, or pushed the catalog) is
// not finished until Forgejo's /releases page shows the tag it just made current — that
// page is what a human reads to answer "what version is out?". A run that publishes
// nothing leaves it alone and says so: filing a release object is itself a publish, and
// it must not slip past the operator's one-shot push token by riding along on a local
// --write.
const publishes = tagStep === 'create' || args.push;
if (!publishes) {
console.log(` · Forgejo release object for ${plan.newRef}: not filed — this run publishes nothing.`);
console.log(' File it with --push (or --create-tag --write), or with scripts/backfill-forgejo-releases.mjs.');
return 0;
}
const sourceUrl = marketplace.plugins?.find(x => x.name === args.name)?.source?.url ?? null;
const loc = parseForgejoRepo(sourceUrl);
if (!loc) {
console.log(` ✗ Forgejo release object NOT filed: cannot derive owner/repo from the catalog source url: ${JSON.stringify(sourceUrl)}`);
console.log(' The tag and the catalog are published; only the /releases page is behind.');
return 1;
}
// Never let this step throw upward: by here the tag — and possibly the catalog commit —
// are already public, and an unhandled exception would report that as a crash instead of
// as the one precise thing still undone.
try {
const notes = releaseBodyFrom({
changelogText: readGitShow(obs.repoDir, plan.newRef, 'CHANGELOG.md'),
tag: plan.newRef,
tagMessage: readTagMessage(obs.repoDir, plan.newRef),
});
const fjPlan = planForgejoRelease({
url: sourceUrl,
tag: plan.newRef,
releaseTags: (forgejo ?? (forgejo = forgejoApi({ token: process.env.FORGEJO_TOKEN }))).listReleaseTags(loc.owner, loc.repo),
body: notes.body,
});
const res = ensureForgejoRelease(fjPlan, forgejo);
if (res.created) {
console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`);
// Say where the text came from. `none` is the one worth seeing: the page went up
// with an empty body, which is honest but useless, and the CHANGELOG is the fix.
console.log(` release notes source: ${notes.source}${notes.source === 'none' ? ' — add a CHANGELOG section for this version' : ''}`);
} else console.log(` · Forgejo release object for ${plan.newRef} already exists — nothing to file.`);
} catch (err) {
console.log(` ✗ Forgejo release object NOT filed for ${plan.newRef}: ${err.message}`);
console.log(' The tag and the catalog are published — the /releases page is the only thing behind.');
console.log(` Retry just this step: node scripts/backfill-forgejo-releases.mjs --repo ${loc.owner}/${loc.repo} --write`);
return 1;
}
return 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; }
if (plan.verdict === 'NOOP') {
// BUG (order 20260922T185613Z, measured live 22.09.2026 during the v8.1.0 release):
// planRelease cannot tell "already released" from "an earlier --write left this
// uncommitted" — finish it instead of silently reporting "nothing to do" whenever
// there is a pending write AND the operator actually asked to --commit it.
if (args.commit && pendingCatalogChanges({ catalogDir, paths: [mktPath, readmePath] })) {
console.log(` · catalog already pins ${plan.newRef}, but an earlier --write left it uncommitted — finishing the pending release.`);
return finishPublish();
}
console.log(' ✓ catalog already pins this version — nothing to do.');
return 0;
}
// READY
if (!args.write) {
@ -725,7 +849,6 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
return 0;
}
const readmePath = join(catalogDir, 'README.md');
const applied = applyRelease({ plan, catalogDir, mktPath, readmePath }, { readFileSync, writeFileSync, runGate });
if (applied.verdict === 'BLOCKED') {
@ -749,73 +872,7 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
console.log(confirm.message);
if (!confirm.ok) return confirm.exitCode;
if (args.commit) {
const body = `${plan.name} ${plan.newRef} — release. Catalog ref now pins the ${plan.newRef} tag so \`claude plugin update\` resolves the release.`;
const msg = `${plan.commitSubject}\n\n${body}\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>\n`;
execFileSync('git', ['-C', catalogDir, 'add', '.claude-plugin/marketplace.json', 'README.md'], { stdio: 'inherit' });
execFileSync('git', ['-C', catalogDir, 'commit', '-m', msg], { stdio: 'inherit' });
console.log(' ✓ committed the catalog');
if (args.push) {
const auth = pushGate.ensure();
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');
}
}
// The release object. A run that PUBLISHES (pushed the tag, or pushed the catalog) is
// not finished until Forgejo's /releases page shows the tag it just made current — that
// page is what a human reads to answer "what version is out?". A run that publishes
// nothing leaves it alone and says so: filing a release object is itself a publish, and
// it must not slip past the operator's one-shot push token by riding along on a local
// --write.
const publishes = tagStep === 'create' || args.push;
if (!publishes) {
console.log(` · Forgejo release object for ${plan.newRef}: not filed — this run publishes nothing.`);
console.log(' File it with --push (or --create-tag --write), or with scripts/backfill-forgejo-releases.mjs.');
return 0;
}
const sourceUrl = marketplace.plugins?.find(x => x.name === args.name)?.source?.url ?? null;
const loc = parseForgejoRepo(sourceUrl);
if (!loc) {
console.log(` ✗ Forgejo release object NOT filed: cannot derive owner/repo from the catalog source url: ${JSON.stringify(sourceUrl)}`);
console.log(' The tag and the catalog are published; only the /releases page is behind.');
return 1;
}
// Never let this step throw upward: by here the tag — and possibly the catalog commit —
// are already public, and an unhandled exception would report that as a crash instead of
// as the one precise thing still undone.
try {
const notes = releaseBodyFrom({
changelogText: readGitShow(obs.repoDir, plan.newRef, 'CHANGELOG.md'),
tag: plan.newRef,
tagMessage: readTagMessage(obs.repoDir, plan.newRef),
});
const fjPlan = planForgejoRelease({
url: sourceUrl,
tag: plan.newRef,
releaseTags: (forgejo ?? (forgejo = forgejoApi({ token: process.env.FORGEJO_TOKEN }))).listReleaseTags(loc.owner, loc.repo),
body: notes.body,
});
const res = ensureForgejoRelease(fjPlan, forgejo);
if (res.created) {
console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`);
// Say where the text came from. `none` is the one worth seeing: the page went up
// with an empty body, which is honest but useless, and the CHANGELOG is the fix.
console.log(` release notes source: ${notes.source}${notes.source === 'none' ? ' — add a CHANGELOG section for this version' : ''}`);
} else console.log(` · Forgejo release object for ${plan.newRef} already exists — nothing to file.`);
} catch (err) {
console.log(` ✗ Forgejo release object NOT filed for ${plan.newRef}: ${err.message}`);
console.log(' The tag and the catalog are published — the /releases page is the only thing behind.');
console.log(` Retry just this step: node scripts/backfill-forgejo-releases.mjs --repo ${loc.owner}/${loc.repo} --write`);
return 1;
}
return 0;
return finishPublish();
}
function main() {