ktg-plugin-marketplace/scripts/release-plugin.mjs
Kjell Tore Guttormsen 5bc6c4ecbd fix(catalog): release-plugin.mjs shares one push token across a whole run
Q3b (order 20260912T213049Z-5772222747) corrects two measured defects in
Q3's ea9bf7a: 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 on ea9bf7a (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>
2026-09-12 23:57:07 +02:00

406 lines
20 KiB
JavaScript

#!/usr/bin/env node
// Atomic plugin-release helper for the polyrepo marketplace.
//
// In the monorepo, marketplace.json used a relative `source` path, so a plugin's
// version was read straight from its plugin.json and could never drift. In the
// polyrepo, each plugin's `source` pins a release tag (`ref`), so a release is a
// TWO-repo act: tag the plugin repo AND bump the catalog ref. The second step is
// manual and easily forgotten — that drift is exactly what stranded a plugin on
// an old version while its plugin.json moved ahead.
//
// This helper makes the catalog side impossible to do wrong: it REFUSES unless
// plugin.json == README badge == the target version AND the vX.Y.Z tag exists,
// then bumps the ref AND the catalog README's per-plugin label together so
// `check-versions.mjs` is green by construction (it now also gates label == ref).
// The pure planner (planRelease) and label reconciler (reconcileReadmeLabel) are
// 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
//
// 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, unlinkSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { normalizeVersion, runGate } from './check-versions.mjs';
// --- Pure planner (unit under test) -----------------------------------------
export function planRelease({ marketplace, name, observed, targetVersion }) {
const plugins = marketplace?.plugins ?? [];
const entry = plugins.find(p => p.name === name);
if (!entry) {
return {
name, verdict: 'BLOCKED', targetVersion: targetVersion ? normalizeVersion(targetVersion) : null,
currentRef: null, newRef: null,
blockers: [`plugin "${name}" is not in the catalog`],
newMarketplace: null, commitSubject: null,
};
}
const currentRef = entry.source?.ref ?? null;
const raw = targetVersion ?? observed.pluginVersion ?? null;
const target = raw === null ? null : normalizeVersion(raw);
if (target === null) {
return {
name, verdict: 'BLOCKED', targetVersion: null, currentRef, newRef: null,
blockers: ['cannot resolve a target version (no --version and no plugin.json version)'],
newMarketplace: null, commitSubject: null,
};
}
const newRef = 'v' + target;
const blockers = [];
// Release preconditions — each must hold, else the catalog must not move.
if (observed.pluginVersion !== null && observed.pluginVersion !== target) {
blockers.push(`plugin.json version is ${observed.pluginVersion}, asked to release ${target} — bump plugin.json first`);
}
if (observed.readmeBadge !== null && observed.pluginVersion !== null && observed.readmeBadge !== observed.pluginVersion) {
blockers.push(`README version-badge ${observed.readmeBadge} != plugin.json ${observed.pluginVersion} — fix internal consistency first`);
}
if (observed.tags !== null && !observed.tags.includes(newRef)) {
blockers.push(`tag ${newRef} not found in the plugin repo — tag the plugin (and push the tag) first, or pass --create-tag`);
}
if (blockers.length > 0) {
return { name, verdict: 'BLOCKED', targetVersion: target, currentRef, newRef, blockers, newMarketplace: null, commitSubject: null };
}
if (currentRef === newRef) {
return { name, verdict: 'NOOP', targetVersion: target, currentRef, newRef, blockers: [], newMarketplace: null, commitSubject: null };
}
// READY — deep-clone the marketplace, bump only this plugin's ref.
const newMarketplace = JSON.parse(JSON.stringify(marketplace));
newMarketplace.plugins.find(p => p.name === name).source.ref = newRef;
return {
name, verdict: 'READY', targetVersion: target, currentRef, newRef, blockers: [],
newMarketplace,
commitSubject: `chore(catalog): bump ${name} ${currentRef} -> ${newRef}`,
};
}
// Bump the catalog README's per-plugin label so the human-facing doc matches the new ref.
// Replaces the FIRST `vX.Y.Z` token on the plugin's `/open/<name>)` heading line, leaving any
// trailing lang/flag badge untouched. Returns the new text, or null if nothing changed
// (label already correct, or the plugin has no heading).
export function reconcileReadmeLabel(readmeText, name, newRef) {
let changed = false;
const out = String(readmeText || '').split('\n').map(line => {
if (!changed && line.includes(`/open/${name})`)) {
const replaced = line.replace(/`v\d+\.\d+\.\d+`/, '`' + newRef + '`');
if (replaced !== line) changed = true;
return replaced;
}
return line;
});
return changed ? out.join('\n') : null;
}
// --- Pre-flight gate + write step (unit under test via injected io) ----------
// Which plugins does check-versions call ERROR right now? Catalog-wide on purpose: one red
// plugin blocks every bump, because check-versions' exit code is global — a bump committed
// on top of someone else's ERROR ships a catalog that cannot pass its own gate.
//
// Reads the ERROR set explicitly and NEVER `failed`/`hasWarn`: pre-bump, the plugin being
// released is SUPPOSED to be WARN (catalog ref behind plugin.json). Gating on WARN would
// brick every release.
export function preflightErrors(gateResult) {
return (gateResult?.results ?? []).filter(r => r.status === 'ERROR').map(r => r.name);
}
// --create-tag mints AND PUSHES a tag to a public remote — the one genuinely irreversible
// side effect here — so it is a WRITE and must obey --write. It used to fire on the
// documented dry-run entry point, publishing the tag before the plan was even printed.
//
// Deliberately NOT gated on the catalog-wide pre-flight: every precondition below is
// local to this plugin (plugin.json == target, badge agrees, tag absent), so the minted
// tag is correct by construction. A red OTHER plugin can only make the tag EARLY, never
// WRONG — and `!tags.includes(newRef)` makes the retry idempotent once that plugin is
// fixed. Gating on it would let plugin Y block the tagging of plugin X: the same
// over-coupling that `preflightErrors` reading ERROR-only (never `failed`) exists to avoid.
//
// Returns 'create' (mint + push), 'dry-run' (would, but no --write), or 'skip'.
export function shouldCreateTag(args, observed, target) {
if (!args?.createTag || !target) return 'skip';
if (observed?.tags === null || observed?.tags === undefined) return 'skip';
if (observed.tags.includes('v' + target)) return 'skip';
if (observed.pluginVersion !== target) return 'skip';
if (observed.readmeBadge !== null && observed.readmeBadge !== observed.pluginVersion) return 'skip';
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);
}
// 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 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();
gate.consume();
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.
export function applyRelease({ plan, catalogDir, mktPath, readmePath }, io) {
const errors = preflightErrors(io.runGate(catalogDir));
if (errors.length > 0) return { verdict: 'BLOCKED', preflightErrors: errors, writes: [], readme: null };
const writes = [];
io.writeFileSync(mktPath, JSON.stringify(plan.newMarketplace, null, 2) + '\n', 'utf8');
writes.push(mktPath);
// Keep the human-facing catalog README label in lock-step with the ref (gated by check-versions).
// The `try` covers the READ only: a catalog without a README is a tolerated state, but a README
// that cannot be WRITTEN is a real failure and must surface. The wider try reported EACCES/ENOSPC
// on the write as readme:'missing' ("no catalog README to update") with verdict WROTE and exit 0 —
// a bumped ref with a stale label, announced as success.
let readme;
let readmeText;
try {
readmeText = io.readFileSync(readmePath, 'utf8');
} catch { readmeText = null; }
if (readmeText === null) {
readme = 'missing';
} else {
const newReadme = reconcileReadmeLabel(readmeText, plan.name, plan.newRef);
if (newReadme !== null) {
io.writeFileSync(readmePath, newReadme, 'utf8');
writes.push(readmePath);
readme = 'written';
} else {
readme = 'unchanged';
}
}
return { verdict: 'WROTE', preflightErrors: [], writes, readme };
}
// --- I/O shell --------------------------------------------------------------
function gitTags(repoDir) {
try {
return execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' })
.split('\n').map(s => s.trim()).filter(Boolean);
} catch { return null; }
}
function extractBadge(readmeText) {
const m = /badge\/version-(\d+\.\d+\.\d+)/.exec(readmeText || '');
return m ? m[1] : null;
}
function observePlugin(catalogDir, name) {
const repoDir = join(catalogDir, '..', name);
if (!existsSync(repoDir)) return { repoDir, pluginVersion: null, readmeBadge: null, tags: null };
let pluginVersion = null;
try { pluginVersion = JSON.parse(readFileSync(join(repoDir, '.claude-plugin', 'plugin.json'), 'utf8')).version ?? null; } catch { /* null */ }
let readmeBadge = null;
try { readmeBadge = extractBadge(readFileSync(join(repoDir, 'README.md'), 'utf8')); } catch { /* null */ }
return { repoDir, pluginVersion, readmeBadge, tags: gitTags(repoDir) };
}
function parseArgs(argv) {
const out = { name: null, version: null, write: false, commit: false, push: false, createTag: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--version') out.version = argv[++i];
else if (a === '--write') out.write = true;
else if (a === '--commit') out.commit = true;
else if (a === '--push') out.push = true;
else if (a === '--create-tag') out.createTag = true;
else if (!a.startsWith('--') && out.name === null) out.name = a;
}
return out;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.name) {
console.error('usage: release-plugin.mjs <name> [--version X.Y.Z] [--create-tag] [--write] [--commit] [--push]');
process.exit(2);
}
const catalogDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const marketplace = JSON.parse(readFileSync(mktPath, 'utf8'));
let obs = observePlugin(catalogDir, args.name);
const target = normalizeVersion(args.version ?? obs.pluginVersion ?? '');
// --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' });
execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' });
obs = observePlugin(catalogDir, args.name);
}
const plan = planRelease({ marketplace, name: args.name, observed: obs, targetVersion: args.version ?? undefined });
console.log(`\nrelease-plugin: ${plan.name} ${plan.currentRef ?? '?'} -> ${plan.newRef ?? '?'} [${plan.verdict}]`);
if (plan.blockers.length) { for (const b of plan.blockers) console.log(`${b}`); }
// Printed AFTER the blockers: the missing-tag blocker points at --create-tag, and this is
// the answer to "I did pass it" — the flag is a write, so it waited for --write.
if (tagStep === 'dry-run') {
console.log(` (dry-run) --create-tag would mint + push v${target} in ${obs.repoDir} — re-run with --write.`);
}
if (plan.verdict === 'BLOCKED') process.exit(1);
if (plan.verdict === 'NOOP') { console.log(' ✓ catalog already pins this version — nothing to do.'); process.exit(0); }
// READY
if (!args.write) {
console.log(` ✓ ready — would bump catalog ref + README label and commit:\n ${plan.commitSubject}`);
console.log(' (dry-run) re-run with --write [--commit] [--push] to apply.');
process.exit(0);
}
const readmePath = join(catalogDir, 'README.md');
const applied = applyRelease({ plan, catalogDir, mktPath, readmePath }, { readFileSync, writeFileSync, runGate });
if (applied.verdict === 'BLOCKED') {
console.log(' ✗ pre-flight check-versions is RED — nothing written.');
for (const n of applied.preflightErrors) console.log(` ERROR: ${n}`);
console.log(' Fix every ERROR (any plugin — the gate exit code is catalog-wide), then re-run.');
process.exit(1);
}
console.log(` ✓ wrote ${mktPath} (ref ${plan.currentRef} -> ${plan.newRef})`);
if (applied.readme === 'written') console.log(` ✓ updated README label (${plan.name} -> ${plan.newRef})`);
else if (applied.readme === 'unchanged') console.log(` · README label already ${plan.newRef} (or no heading found)`);
else console.log(' · no catalog README to update');
// Confirm the gate is green for this plugin AFTER the write — the pre-flight validated the
// old state, this validates the new one. Different jobs; the redundancy is only apparent.
const gate = execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' });
const line = gate.split('\n').find(l => l.includes(args.name)) ?? '';
console.log(` check-versions: ${line.trim() || '(no line)'}`);
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); process.exit(1); }
console.log(' → pushing');
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);
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main();
}