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>
366 lines
18 KiB
JavaScript
366 lines
18 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);
|
|
}
|
|
|
|
// 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.
|
|
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.
|
|
const tagStep = shouldCreateTag(args, obs, target);
|
|
if (tagStep === 'create') {
|
|
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); }
|
|
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) {
|
|
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); }
|
|
console.log(' ✓ pushed');
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
main();
|
|
}
|