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
|
|
@ -3,7 +3,10 @@
|
|||
// is exercised by the CLI against the live tree, not here.
|
||||
import { test } from 'node:test';
|
||||
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';
|
||||
|
||||
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)', () => {
|
||||
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