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:
parent
80c25faaf5
commit
ea9bf7ab02
3 changed files with 174 additions and 5 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -73,6 +73,17 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
|
||||||
consistent version, `check-versions.mjs` is green by construction. Never hand-edit a `ref` or a
|
consistent version, `check-versions.mjs` is green by construction. Never hand-edit a `ref` or a
|
||||||
README label for a release — use this. Pure planner + label reconciler + pre-flight/write step
|
README label for a release — use this. Pure planner + label reconciler + pre-flight/write step
|
||||||
covered by `scripts/release-plugin.test.mjs`.
|
covered by `scripts/release-plugin.test.mjs`.
|
||||||
|
- **`--create-tag --write` and `--push` each require the operator's push-approval token FIRST**
|
||||||
|
(Q3, decided 2026-09-12): `~/.claude/hooks/pre-push-gate.sh` matches `git push` in command
|
||||||
|
text and cannot see a push this script issues via `execFileSync` inside node — the script
|
||||||
|
mints+pushes a plugin tag and pushes the catalog itself, both invisible to that gate. So
|
||||||
|
before either push, run:
|
||||||
|
`mkdir -p ~/.claude/runtime/push-approvals && touch "~/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"`
|
||||||
|
(`pwd` must be this catalog directory — tag-push and catalog-push share ONE token, one
|
||||||
|
publish from the operator's perspective). The script consumes the token itself right after
|
||||||
|
a push succeeds, the same way `post-push-consume.sh` does for a direct push; a failed push
|
||||||
|
leaves the token intact for the retry. Covered by `pushAuthorisation`/`requirePushAuthorisation`/
|
||||||
|
`pushWithToken`/`consumeToken` in `scripts/release-plugin.test.mjs`.
|
||||||
- **Pre-flight gate (`--write` runs `check-versions` BEFORE it writes):** the helper calls `runGate()`
|
- **Pre-flight gate (`--write` runs `check-versions` BEFORE it writes):** the helper calls `runGate()`
|
||||||
first and aborts with exit 1 — **nothing written** — if ANY plugin is ERROR, not just the one being
|
first and aborts with exit 1 — **nothing written** — if ANY plugin is ERROR, not just the one being
|
||||||
released (`check-versions`' exit code is catalog-wide). Previously the gate ran *after* both writes,
|
released (`check-versions`' exit code is catalog-wide). Previously the gate ran *after* both writes,
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,16 @@
|
||||||
// node scripts/release-plugin.mjs <name> --write # write the bumped catalog ref
|
// 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 # + git commit the catalog
|
||||||
// node scripts/release-plugin.mjs <name> --write --commit --push # + push
|
// 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 { execFileSync } from 'node:child_process';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
@ -140,6 +148,56 @@ export function shouldCreateTag(args, observed, target) {
|
||||||
return args.write ? 'create' : 'dry-run';
|
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
|
// 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
|
// (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.
|
// 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;
|
const tag = 'v' + target;
|
||||||
console.log(`→ creating annotated tag ${tag} in ${obs.repoDir}`);
|
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, '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);
|
obs = observePlugin(catalogDir, args.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,7 +350,11 @@ function main() {
|
||||||
console.log(' ✓ committed the catalog');
|
console.log(' ✓ committed the catalog');
|
||||||
if (args.push) {
|
if (args.push) {
|
||||||
console.log(' → pushing');
|
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');
|
console.log(' ✓ pushed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@
|
||||||
// is exercised by the CLI against the live tree, not here.
|
// is exercised by the CLI against the live tree, not here.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag } from './release-plugin.mjs';
|
import {
|
||||||
|
planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag,
|
||||||
|
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken,
|
||||||
|
} from './release-plugin.mjs';
|
||||||
import { classifyPlugin } from './check-versions.mjs';
|
import { classifyPlugin } from './check-versions.mjs';
|
||||||
|
|
||||||
const marketplace = () => ({
|
const marketplace = () => ({
|
||||||
|
|
@ -271,3 +274,92 @@ test('shouldCreateTag: skips when the plugin is not internally consistent', () =
|
||||||
test('shouldCreateTag: a null README badge is tolerated (badge-less plugin)', () => {
|
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');
|
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']);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue