feat(catalog): release-plugin.mjs — atomic catalog-ref release helper
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>
This commit is contained in:
parent
6cbe4800f5
commit
9b1838f1d7
4 changed files with 304 additions and 1 deletions
|
|
@ -18,6 +18,15 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
|
|||
- Adding/updating a plugin entry: edit `.claude-plugin/marketplace.json` (external `source: "url"` with
|
||||
a pinned `ref`) and re-state the plugin in README.md with its verified version.
|
||||
- Plugin source, issues, and releases live in each plugin's own repository — not here.
|
||||
- **Releasing a plugin (canonical path — `scripts/release-plugin.mjs`):** since the polyrepo split,
|
||||
a release is a TWO-repo act — tag the plugin repo AND bump the catalog `ref`. Forgetting the second
|
||||
step strands users on the old version (the exact drift this helper exists to prevent). Run
|
||||
`node scripts/release-plugin.mjs <plugin> [--version X.Y.Z]` — dry-run by default; it REFUSES unless
|
||||
`plugin.json` == README badge == the target version AND the `vX.Y.Z` tag exists, then prints the
|
||||
planned bump. Apply with `--write [--commit] [--push]`; `--create-tag` mints+pushes a missing plugin
|
||||
tag first. Because it only moves the `ref` to a verified, tagged, consistent version,
|
||||
`check-versions.mjs` is green by construction. Never hand-edit a `ref` for a release — use this.
|
||||
Pure planner covered by `scripts/release-plugin.test.mjs`.
|
||||
- **Version-consistency gate:** run `node scripts/check-versions.mjs` before committing any `ref`
|
||||
change. For each plugin it checks (against the sibling repo) that the catalog `ref` resolves to a
|
||||
real git tag (ERROR if dangling — breaks install), that `plugin.json` version == README
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ Key commands: `/architect`, `/architect:ros`, `/architect:security`, `/architect
|
|||
|
||||
---
|
||||
|
||||
### [LinkedIn Studio](https://git.fromaitochitta.com/open/linkedin-studio) `v0.4.0`
|
||||
### [LinkedIn Studio](https://git.fromaitochitta.com/open/linkedin-studio) `v0.5.0`
|
||||
|
||||
Build authentic LinkedIn authority through algorithmic understanding, strategic consistency, and AI-assisted content creation.
|
||||
|
||||
|
|
|
|||
190
scripts/release-plugin.mjs
Normal file
190
scripts/release-plugin.mjs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/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();
|
||||
}
|
||||
104
scripts/release-plugin.test.mjs
Normal file
104
scripts/release-plugin.test.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Tests for the atomic plugin-release helper.
|
||||
// Pure planner is the unit under test — the I/O shell (read files, git tag/commit/push)
|
||||
// is exercised by the CLI against the live tree, not here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planRelease } from './release-plugin.mjs';
|
||||
import { classifyPlugin } from './check-versions.mjs';
|
||||
|
||||
const marketplace = () => ({
|
||||
name: 'ktg-plugin-marketplace',
|
||||
plugins: [
|
||||
{ name: 'alpha', source: { source: 'url', url: 'https://x/alpha.git', ref: 'v1.0.0' }, description: 'a' },
|
||||
{ name: 'beta', source: { source: 'url', url: 'https://x/beta.git', ref: 'v2.3.0' }, description: 'b' },
|
||||
],
|
||||
});
|
||||
|
||||
const observed = (o = {}) => ({
|
||||
pluginVersion: '1.1.0',
|
||||
readmeBadge: '1.1.0',
|
||||
tags: ['v1.1.0', 'v1.0.0'],
|
||||
...o,
|
||||
});
|
||||
|
||||
test('READY: consistent plugin, tag exists, catalog ref behind → bump planned', () => {
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
|
||||
assert.equal(p.verdict, 'READY');
|
||||
assert.equal(p.targetVersion, '1.1.0');
|
||||
assert.equal(p.currentRef, 'v1.0.0');
|
||||
assert.equal(p.newRef, 'v1.1.0');
|
||||
assert.deepEqual(p.blockers, []);
|
||||
assert.equal(p.commitSubject, 'chore(catalog): bump alpha v1.0.0 -> v1.1.0');
|
||||
});
|
||||
|
||||
test('READY plan bumps ONLY the target plugin and does not mutate the input', () => {
|
||||
const mkt = marketplace();
|
||||
const p = planRelease({ marketplace: mkt, name: 'alpha', observed: observed() });
|
||||
// input untouched
|
||||
assert.equal(mkt.plugins[0].source.ref, 'v1.0.0');
|
||||
// output bumped on alpha only
|
||||
const out = p.newMarketplace.plugins;
|
||||
assert.equal(out.find(x => x.name === 'alpha').source.ref, 'v1.1.0');
|
||||
assert.equal(out.find(x => x.name === 'beta').source.ref, 'v2.3.0');
|
||||
});
|
||||
|
||||
test('READY plan is green by construction (post-bump classifyPlugin is OK)', () => {
|
||||
const o = observed();
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
|
||||
const post = classifyPlugin({
|
||||
name: 'alpha', catalogRef: p.newRef,
|
||||
pluginVersion: o.pluginVersion, readmeBadge: o.readmeBadge, tags: o.tags,
|
||||
});
|
||||
assert.equal(post.status, 'OK');
|
||||
});
|
||||
|
||||
test('explicit --version targets that version when consistent', () => {
|
||||
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.1.0', 'v1.0.0'] });
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.1.0' });
|
||||
assert.equal(p.verdict, 'READY');
|
||||
assert.equal(p.newRef, 'v1.1.0');
|
||||
});
|
||||
|
||||
test('NOOP: catalog ref already pins the target version', () => {
|
||||
const o = observed({ pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'] });
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
|
||||
assert.equal(p.verdict, 'NOOP');
|
||||
assert.equal(p.newRef, 'v1.0.0');
|
||||
assert.equal(p.newMarketplace, null);
|
||||
});
|
||||
|
||||
test('BLOCKED: target version has no git tag in the plugin repo', () => {
|
||||
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.0.0'] }); // no v1.1.0
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
|
||||
assert.equal(p.verdict, 'BLOCKED');
|
||||
assert.ok(p.blockers.some(b => /tag v1\.1\.0/.test(b) && /not found|tag the plugin/.test(b)));
|
||||
assert.equal(p.newMarketplace, null);
|
||||
});
|
||||
|
||||
test('BLOCKED: plugin.json version != target (asked to release an undeclared version)', () => {
|
||||
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.2.0', 'v1.1.0'] });
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.2.0' });
|
||||
assert.equal(p.verdict, 'BLOCKED');
|
||||
assert.ok(p.blockers.some(b => /plugin\.json/.test(b) && /1\.1\.0/.test(b)));
|
||||
});
|
||||
|
||||
test('BLOCKED: README badge disagrees with plugin.json (internal corruption)', () => {
|
||||
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.0.0', tags: ['v1.1.0'] });
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
|
||||
assert.equal(p.verdict, 'BLOCKED');
|
||||
assert.ok(p.blockers.some(b => /badge/i.test(b)));
|
||||
});
|
||||
|
||||
test('BLOCKED: plugin not present in the catalog', () => {
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'ghost', observed: observed() });
|
||||
assert.equal(p.verdict, 'BLOCKED');
|
||||
assert.ok(p.blockers.some(b => /not in (the )?catalog/i.test(b)));
|
||||
assert.equal(p.currentRef, null);
|
||||
});
|
||||
|
||||
test('BLOCKED: target version cannot be resolved (no --version, no plugin.json)', () => {
|
||||
const o = observed({ pluginVersion: null });
|
||||
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
|
||||
assert.equal(p.verdict, 'BLOCKED');
|
||||
assert.ok(p.blockers.some(b => /resolve.*version|version.*not/i.test(b)));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue