fix(catalog): release-plugin.mjs requires the push-approval token before it pushes

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-12 23:20:54 +02:00
commit ea9bf7ab02
3 changed files with 174 additions and 5 deletions

View file

@ -23,8 +23,16 @@
// 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
//
// 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):
// 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.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
@ -140,6 +148,56 @@ export function shouldCreateTag(args, observed, target) {
return args.write ? 'create' : 'dry-run';
}
// --- push-token gate ---------------------------------------------------------
//
// pre-push-gate.sh is a PreToolUse hook that matches `git push` in COMMAND TEXT — it
// cannot see a push issued via execFileSync inside this script's own process (pinned
// as GAP in the gate's header, and this script is the concretely-named example there).
// This script mints+pushes a plugin tag (--create-tag) and pushes the catalog itself
// (--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.
// Computes the token path EXACTLY like token_path(): `sed 's|/|_|g'` on $PWD — only
// '/' is rewritten, every other character (including '-' and '.') is left alone.
export function pushAuthorisation({ cwd, home, exists }) {
const tokenPath = join(home, '.claude', 'runtime', 'push-approvals', cwd.split('/').join('_'));
return { tokenPath, authorised: exists(tokenPath) };
}
export function requirePushAuthorisation({ cwd, home, exists }) {
const { tokenPath, authorised } = pushAuthorisation({ cwd, home, exists });
if (authorised) return { authorised: true, tokenPath };
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.",
'',
'To approve exactly one publish from this run, the OPERATOR runs:',
` mkdir -p ${dirname(tokenPath)} && touch "${tokenPath}"`,
].join('\n');
return { authorised: false, tokenPath, message };
}
// Deletes the token if present. A no-op if it is already gone (idempotent across the
// two push sites that share it).
export function consumeToken({ tokenPath, exists, unlink }) {
if (exists(tokenPath)) unlink(tokenPath);
}
// Checks authorisation, then runs `push()`. Consumes the token only after `push()`
// returns without throwing — if it throws (a real push failure), the exception
// propagates and the token is left intact for the retry.
export function pushWithToken({ cwd, home, exists, unlink, push }) {
const auth = requirePushAuthorisation({ cwd, home, exists });
if (!auth.authorised) return { pushed: false, blocked: true, message: auth.message, tokenPath: auth.tokenPath };
push();
consumeToken({ tokenPath: auth.tokenPath, exists, unlink });
return { pushed: true, blocked: false, tokenPath: auth.tokenPath };
}
// 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.
@ -235,7 +293,11 @@ function main() {
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' });
const tagPush = pushWithToken({
cwd: catalogDir, home: process.env.HOME, exists: existsSync, unlink: unlinkSync,
push: () => execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' }),
});
if (tagPush.blocked) { console.error(tagPush.message); process.exit(1); }
obs = observePlugin(catalogDir, args.name);
}
@ -288,7 +350,11 @@ function main() {
console.log(' ✓ committed the catalog');
if (args.push) {
console.log(' → pushing');
execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' });
const catalogPush = pushWithToken({
cwd: catalogDir, home: process.env.HOME, exists: existsSync, unlink: unlinkSync,
push: () => execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' }),
});
if (catalogPush.blocked) { console.error(catalogPush.message); process.exit(1); }
console.log(' ✓ pushed');
}
}