The polyrepo split made a plugin release a TWO-repo act: tag the plugin repo AND bump the catalog `ref`. The second step is manual and easily forgotten — that drift just stranded linkedin-studio on v0.4.0 while its plugin.json moved to 0.5.0. This adds the canonical release path that makes the catalog side impossible to do wrong. scripts/release-plugin.mjs: - Pure planner planRelease() — given the catalog + observed plugin state + target version, computes verdict (READY/NOOP/BLOCKED), the bumped marketplace object, and the commit subject. REFUSES (BLOCKED) unless plugin.json == README badge == target AND the vX.Y.Z tag exists, so a READY plan is check-versions- green by construction. Reuses normalizeVersion/classifyPlugin from check-versions.mjs (no duplicated rules). - I/O shell: dry-run by default; --write bumps the ref + re-runs the gate; --commit/--push apply; --create-tag mints+pushes a missing plugin tag first. - 10/10 unit tests (scripts/release-plugin.test.mjs); check-versions 9/9 still green. - Dogfooded: linkedin-studio → NOOP (already pinned v0.5.0); ms-ai-architect → BLOCKED (v1.16.0 in plugin.json was never tagged — refuses to publish it). Also: fix the stale LinkedIn Studio README label v0.4.0 -> v0.5.0 (loose end of the v0.5.0 release), and document the canonical release path in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
9.1 KiB
JavaScript
190 lines
9.1 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 so `check-versions.mjs` is green by construction. The pure
|
||
// planner (planRelease) is 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}`,
|
||
};
|
||
}
|
||
|
||
// --- 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 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})`);
|
||
|
||
// 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'], { 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 20–23, 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();
|
||
}
|