ktg-plugin-marketplace/scripts/release-plugin.mjs
Kjell Tore Guttormsen 2e1d206ab3 feat(catalog): gate catalog README labels against ref + close the drift
The catalog README's per-plugin `vX.Y.Z` labels were an UNGUARDED surface:
check-versions validated each plugin's OWN README badge, never the catalog
README's labels, so they drifted (config-audit shown v5.5.0 while pinned to
v5.7.0; voyage v5.1.1 while pinned to v5.6.0) — the doc misstated what
`claude plugin install` actually resolves.

Close the class, same pattern as the ref surface:
- check-versions.mjs: new ERROR rule "catalog README label == catalog ref"
  via pure extractCatalogLabel() (matches the /open/<name>) heading, takes the
  first `vX.Y.Z`, ignores a trailing lang/flag badge). No legitimate transient
  state lets label != ref, so ERROR (not WARN). +5 tests.
- release-plugin.mjs: on --write, bump the README label atomically with the ref
  via pure reconcileReadmeLabel() and git add README.md on --commit, so future
  releases keep label and ref in lock-step. +4 tests.
- README.md: reconcile the 2 stale labels (config-audit -> v5.7.0,
  voyage -> v5.6.0). Gate now 9 OK / 1 WARN / 0 ERROR.
- CLAUDE.md: doctrine updated to document the new gate rule + atomic label bump.

ms-ai-architect stays WARN by decision (1.16.0 is unreleased WIP, never tagged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cm2RxKbomdLqjiWGcwCCPi
2026-06-22 13:38:23 +02:00

220 lines
11 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 # create+push the missing vX.Y.Z plugin tag first
// 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 } 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;
}
// --- 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.
if (args.createTag && target && obs.tags !== null && !obs.tags.includes('v' + target)
&& obs.pluginVersion === target && (obs.readmeBadge === null || obs.readmeBadge === obs.pluginVersion)) {
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}`); }
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);
}
writeFileSync(mktPath, JSON.stringify(plan.newMarketplace, null, 2) + '\n', 'utf8');
console.log(` ✓ wrote ${mktPath} (ref ${plan.currentRef} -> ${plan.newRef})`);
// Keep the human-facing catalog README label in lock-step with the ref (gated by check-versions).
const readmePath = join(catalogDir, 'README.md');
try {
const newReadme = reconcileReadmeLabel(readFileSync(readmePath, 'utf8'), plan.name, plan.newRef);
if (newReadme !== null) {
writeFileSync(readmePath, newReadme, 'utf8');
console.log(` ✓ updated README label (${plan.name} -> ${plan.newRef})`);
} else {
console.log(` · README label already ${plan.newRef} (or no heading found)`);
}
} catch { console.log(' · no catalog README to update'); }
// Confirm the gate is green for this plugin after the write.
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();
}