From 9b1838f1d78009328268bca0db0ec3ed5062c277 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 21 Jun 2026 19:40:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog):=20release-plugin.mjs=20=E2=80=94?= =?UTF-8?q?=20atomic=20catalog-ref=20release=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 9 ++ README.md | 2 +- scripts/release-plugin.mjs | 190 ++++++++++++++++++++++++++++++++ scripts/release-plugin.test.mjs | 104 +++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 scripts/release-plugin.mjs create mode 100644 scripts/release-plugin.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 3489efe..c37bca2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 [--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 diff --git a/README.md b/README.md index 87d183d..9ff47d5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/scripts/release-plugin.mjs b/scripts/release-plugin.mjs new file mode 100644 index 0000000..b2ac4a0 --- /dev/null +++ b/scripts/release-plugin.mjs @@ -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 [--version X.Y.Z] # dry-run: print the plan +// node scripts/release-plugin.mjs --create-tag # create+push the missing vX.Y.Z plugin tag first +// node scripts/release-plugin.mjs --write # write the bumped catalog ref +// node scripts/release-plugin.mjs --write --commit # + git commit the catalog +// node scripts/release-plugin.mjs --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 [--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) \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(); +} diff --git a/scripts/release-plugin.test.mjs b/scripts/release-plugin.test.mjs new file mode 100644 index 0000000..5aa035f --- /dev/null +++ b/scripts/release-plugin.test.mjs @@ -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))); +});