fix(catalog): release-plugin.mjs shares one push token across a whole run
Q3b (order 20260912T213049Z-5772222747) corrects two measured defects in Q3'sea9bf7a: pushWithToken checked-and-consumed per call, so a single `--create-tag --write --commit --push` run spent the operator's one-shot token on the tag push and always saw blocked:true on the catalog push right after (D1). And `git tag -a` ran before any token check at all, so a blocked run left a local annotated tag behind, breaking the retry with "tag already exists" (D2). createPushGate replaces the per-push check-and-consume with a run-scoped gate: ensure() checks the token once and every later call in the same run reuses that result, consume() fires once after the run's last successful push. main() calls ensure() before the tag write (not just before the push) and consume() once at the end. pushWithToken is now a single-push convenience wrapper over the same gate — its existing tests stay green unmodified. Red-first: `createPushGate` did not exist onea9bf7a(import error), proving both new tests were red before the fix. After: node --test scripts/release-plugin.test.mjs -> 37/37 (35 + 2 new) node --test scripts/*.test.mjs -> 154/154 node scripts/check-versions.mjs -> 0 ERROR (2 known WARN: claude-design, repo-mailbox) Live D2 check: `release-plugin.mjs repo-mailbox --create-tag --write` without a token -> BLOCKED, exit 1, no local tag created. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ea9bf7ab02
commit
5bc6c4ecbd
2 changed files with 105 additions and 16 deletions
|
|
@ -187,14 +187,49 @@ 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.
|
||||
// A run-scoped push-token gate (Q3b, order 20260912T213049Z-5772222747 — fixes two
|
||||
// defects in Q3's per-call pushWithToken):
|
||||
//
|
||||
// D1: --create-tag --write --commit --push does TWO pushes (tag, then catalog) in ONE
|
||||
// run. pushWithToken checked-and-consumed per call, so the tag push spent the operator's
|
||||
// one-shot token and the catalog push right after always saw blocked:true. ensure()
|
||||
// checks the token ONCE per run and every later call reuses that same result — one
|
||||
// token covers every push the run makes.
|
||||
//
|
||||
// D2: `git tag -a` used to run before any token check at all, so a blocked run left a
|
||||
// local annotated tag behind (a retry after the operator drops the token then fails
|
||||
// with "tag already exists", exit 128). Callers must call ensure() BEFORE the first
|
||||
// write this run intends to push toward — including a local tag meant to precede a
|
||||
// later push — not only immediately before the `git push` itself.
|
||||
//
|
||||
// consume() deletes the token once, after the run's LAST push has succeeded; it is a
|
||||
// no-op if ensure() was never authorised (nothing pushed) or already consumed.
|
||||
export function createPushGate({ cwd, home, exists, unlink }) {
|
||||
let auth = null;
|
||||
let consumed = false;
|
||||
return {
|
||||
ensure() {
|
||||
if (auth === null) auth = requirePushAuthorisation({ cwd, home, exists });
|
||||
return auth;
|
||||
},
|
||||
consume() {
|
||||
if (consumed) return;
|
||||
if (auth && auth.authorised) consumeToken({ tokenPath: auth.tokenPath, exists, unlink });
|
||||
consumed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Single-push convenience wrapper over createPushGate: checks, pushes, consumes for
|
||||
// exactly one 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 });
|
||||
const gate = createPushGate({ cwd, home, exists, unlink });
|
||||
const auth = gate.ensure();
|
||||
if (!auth.authorised) return { pushed: false, blocked: true, message: auth.message, tokenPath: auth.tokenPath };
|
||||
push();
|
||||
consumeToken({ tokenPath: auth.tokenPath, exists, unlink });
|
||||
gate.consume();
|
||||
return { pushed: true, blocked: false, tokenPath: auth.tokenPath };
|
||||
}
|
||||
|
||||
|
|
@ -288,16 +323,20 @@ function main() {
|
|||
|
||||
// --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
|
||||
// `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); }
|
||||
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' });
|
||||
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); }
|
||||
execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' });
|
||||
obs = observePlugin(catalogDir, args.name);
|
||||
}
|
||||
|
||||
|
|
@ -349,15 +388,16 @@ function main() {
|
|||
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); process.exit(1); }
|
||||
console.log(' → pushing');
|
||||
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); }
|
||||
execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' });
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue