Order 20260912T222008Z-8793921535-from-.claude (Q3d), follow-up to the PM
re-measurement of Q3c (dd278ca).
R1 — the run-scoped push-token consumption was only test-vouched at the
runRelease() level; D3's own test called consume() a second time in the TEST
BODY ("mirrors main()'s own finally") and asserted on that call, never on
anything main() itself did. A PM agent deleted the `finally` line in main()
in a copy and every existing test stayed green (39/39) while the real CLI,
run end-to-end, pushed the tag, hit NOOP, and left the token behind.
Added a test that runs the actual CLI entry point as a real subprocess
(main() calls process.exit(), so it cannot run in-process without killing
the test runner) against an isolated plugin repo, a local bare "remote",
and its own HOME — the same scenario the PM agent used (tag pushed, then a
NOOP branch that is not the run's final line). Mutation proof: removed the
`finally` line -> new test went RED (40 pass, 1 fail) while D3 stayed GREEN,
confirming D3 does not cover this path -> restored -> GREEN.
Sub-fix required to make the new test possible on this machine: macOS's
os.tmpdir() resolves through /var/folders, a symlink to /private/var/folders.
release-plugin.mjs's self-invocation guard compares the literal argv[1] path
against import.meta.url (which Node resolves through symlinks), so a script
run from the unresolved path never satisfies the guard and main() silently
never executes (exit 0, zero output). makeTempRoot() now returns the
realpath of the created temp dir.
S (side finding) — a tag push that fails after the local `git tag -a`
succeeded left an orphan local tag behind; a retry then failed on git's own
"tag already exists" (exit 128) instead of going through the idempotent
tag-absent path --create-tag already relies on. Chose: delete the local tag
when its push fails (option a) rather than detect-and-explain the orphan
state (option b) — it reuses the existing idempotency property instead of
adding a second one. Red-first: new test failed (orphan tag survived) ->
wrapped the push in try/catch, `git tag -d` on failure, rethrow -> GREEN.
R2 — CLAUDE.md said "a failed push leaves the token intact for the retry",
which is imprecise: with --create-tag --write --commit --push, if the tag
push succeeds and the catalog push then fails, the token IS consumed
(main()'s finally fires because pushGate.pushed was already set true by the
earlier successful push) even though the run overall "failed". Corrected to
state the actual rule: the token survives only when the run makes zero
successful pushes. The usage-block comment at the top of release-plugin.mjs
does not carry the same imprecise claim, so it needed no change.
Verification:
- node --test scripts/release-plugin.test.mjs: 39 -> 41/41 (R1, S added)
- node --test scripts/*.test.mjs: 156 -> 158/158
- node scripts/check-versions.mjs: 0 ERROR (1 known WARN: claude-design;
repo-mailbox now OK — externally re-tagged since Q3c, untouched here)
- git tag -l: unchanged (12 tags, no new ones — no tag/push/bump this session)
- mutation proof for R1: finally line removed -> RED (D3 stayed green) -> restored -> GREEN
- mutation proof for S: recorded in scripts/release-plugin.test.mjs history above (red-first)
Not done (out of scope, deliberately): no version bump, no tag, no push;
no files touched outside scripts/release-plugin.mjs, scripts/release-plugin.test.mjs, CLAUDE.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
441 lines
22 KiB
JavaScript
441 lines
22 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 {
|
|
// Set by a caller (runRelease) right after a `git push` it issued actually succeeds.
|
|
// Q3c/D3: consume() must fire whenever this is true, on EVERY exit from the run, not
|
|
// only the run's final line — a run can push a tag and then resolve to BLOCKED/NOOP/
|
|
// dry-run/red-pre-flight afterwards, and each of those used to skip consume() entirely.
|
|
pushed: false,
|
|
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;
|
|
}
|
|
|
|
// The body of main(), minus the top-level plumbing (read marketplace.json off disk,
|
|
// build the pushGate) and minus the final process.exit call — pulled out so it is
|
|
// testable against a real temp git repo (Q3c/S1) and so it can return an exit code
|
|
// instead of calling process.exit at each branch (Q3c/D3, see main() below for why).
|
|
export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate }) {
|
|
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.
|
|
// pushGate is 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 tagStep = shouldCreateTag(args, obs, target);
|
|
if (tagStep === 'create') {
|
|
const auth = pushGate.ensure();
|
|
if (!auth.authorised) { console.error(auth.message); return 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' });
|
|
// Q3d/S: if the push itself fails (network, permissions, remote gone), delete the
|
|
// local tag we just made rather than leave an orphan behind — a retry after fixing
|
|
// the underlying problem must go through shouldCreateTag's tag-absent check again,
|
|
// not hit git's own "tag already exists" (exit 128).
|
|
try {
|
|
execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' });
|
|
} catch (err) {
|
|
execFileSync('git', ['-C', obs.repoDir, 'tag', '-d', tag], { stdio: 'inherit' });
|
|
throw err;
|
|
}
|
|
pushGate.pushed = true;
|
|
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.`);
|
|
}
|
|
|
|
// Q3c/D3: every branch below returns instead of calling process.exit. A tag push above
|
|
// may already have set pushGate.pushed = true, and process.exit() called from inside a
|
|
// try does not run its finally (verified live) — so main() must be the only place that
|
|
// calls process.exit, after its finally has had a chance to consume the token.
|
|
if (plan.verdict === 'BLOCKED') return 1;
|
|
if (plan.verdict === 'NOOP') { console.log(' ✓ catalog already pins this version — nothing to do.'); return 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.');
|
|
return 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.');
|
|
return 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); return 1; }
|
|
console.log(' → pushing');
|
|
execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' });
|
|
pushGate.pushed = true;
|
|
console.log(' ✓ pushed');
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
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'));
|
|
const pushGate = createPushGate({ cwd: catalogDir, home: process.env.HOME, exists: existsSync, unlink: unlinkSync });
|
|
|
|
let exitCode;
|
|
try {
|
|
exitCode = runRelease({ args, catalogDir, mktPath, marketplace, pushGate });
|
|
} finally {
|
|
// Q3c/D3 (order 20260912T220453Z-5021715764): consume the shared token on ANY exit
|
|
// from this run once at least one push has succeeded — not just the final line, which
|
|
// four earlier exits (BLOCKED plan, NOOP, dry-run, red pre-flight) used to bypass
|
|
// entirely. This is the only place consume() is called from.
|
|
if (pushGate.pushed) pushGate.consume();
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
main();
|
|
}
|