ktg-plugin-marketplace/scripts/release-plugin.mjs
Kjell Tore Guttormsen 06ede0ebe9 fix(scripts): a failing README write must surface, not read as "missing"
applyRelease()'s `try` spanned both the catalog README read AND its write, so a
real EACCES/ENOSPC on the write was swallowed and reported as readme:'missing'
("no catalog README to update") with verdict WROTE and exit 0 — a bumped ref
with a stale label, announced as success. Pre-existing form, not a regression.

The `try` now covers the READ only: a catalog without a README stays a tolerated
state, a README that cannot be written throws.

Known narrow guarantee: the throw propagates out of main() AFTER marketplace.json
is written, so the working tree is left half-applied (bumped ref, stale label).
That is the same hazard ac7ad42 closed for gate ordering — but strictly better
than today's silent exit 0, and widening the fix is not in scope here.

Test is path-selective on purpose: a fake that throws for every path dies on the
marketplace write above the try (in both the old and the new code) and would go
green against the unfixed file. Verified red before the fix.

Tests 25 -> 26 (suite total 131 -> 132).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A839v1MGa3Bm8icX8dTHYi
2026-08-10 21:00:40 +02:00

300 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 (you own the push window)
import { readFileSync, writeFileSync, existsSync } 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';
}
// 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' });
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) {
console.log(' → pushing (you own the push window: weekday 2023, weekend/holiday anytime)');
execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' });
console.log(' ✓ pushed');
}
}
process.exit(0);
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main();
}