Same-day correction to ee2259f. That commit followed the order literally --
"use the tag's own message" -- and the result was an llm-security v8.0.0
release page reading "llm-security v8.0.0" and nothing else.
The defect is structural, not a typo: --create-tag mints -m "<name>
v<version>", so the tag message is mechanical EXACTLY where this helper made
the tag. The tag message is a good source only for tags written by hand.
The right source was already proven on the instance: llm-security v7.8.3's
release body is byte-for-byte its CHANGELOG `## [7.8.3]` section. So the
CHANGELOG is the org's established source, not a new invention -- and all 10
backfilled repos ship one (measured, 10/10).
- extractChangelogSection / releaseBodyFrom: pure, tested. Priority is
CHANGELOG section -> tag message -> empty, and the source is REPORTED so a
run says where the text came from rather than implying it wrote it.
- Three heading dialects are live and all three are covered: `## [6.0.0] -
date`, `## [0.2.0] -- date` (em-dash), `## v1.0 (date)`, and voyage's
`## v5.10.1 -- date -- trailing prose`. The version token matches exactly,
so 0.1.0-pre is not 0.1.0 and 1.1.0 is not 1.10.0. An empty section (the
standing `## [Unreleased]`) returns null so the caller falls through
instead of publishing a blank body.
- backfill gains --repair for the backlog the first cut created. It PATCHes
a PUBLISHED page, so the bar is strictly more informative, never merely
different: no CHANGELOG section means no update, and a hand-written body at
least as long as the section is left alone. Measured: that rule is what
protects portfolio-optimiser v1.1.0 (4750 hand-written chars vs 3704).
Dry-run over the org: 14 release objects would gain real notes, e.g.
llm-security v8.0.0 19 chars -> 10530, config-audit v6.0.0 19 -> 27309.
18 new tests, written red first. Suite 211/211; check-versions 12/12 OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
847 lines
44 KiB
JavaScript
847 lines
44 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 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
|
|
//
|
|
// PUSHING NEEDS THE SAME ONE-SHOT TOKEN AS pre-push-gate.sh. That hook matches `git
|
|
// push` in command text and cannot see a push this script issues via execFileSync
|
|
// inside node — so `--create-tag --write` and `--push` each REFUSE unless the operator
|
|
// has left the approval token first (tag-push and catalog-push share ONE token — one
|
|
// publish from the operator's perspective):
|
|
// mkdir -p ~/.claude/runtime/push-approvals && touch "~/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')"
|
|
// The token is consumed after the push actually succeeds, same as the gate's own.
|
|
|
|
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { join, dirname } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import {
|
|
normalizeVersion, runGate, extractCatalogStats, extractStatBadges, pickStatSource, statMismatchFindings,
|
|
} 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.
|
|
//
|
|
// Q3f — ONE exemption, for the ONE plugin being released: its STAT findings. Measured
|
|
// 2026-09-18 on two live orders (repo-mailbox 0.35.0, llm-security 8.0.0), the two
|
|
// pre-flights demanded opposite values of the same stat line and no release that changes
|
|
// a badged number could complete at all:
|
|
// (a) preflightStatMismatches (runRelease, before the tag) reads the TARGET ref -> new number
|
|
// (b) this gate -> runGate -> inspectPlugin reads the stat source at the PINNED ref -> old number
|
|
// The pinned ref is moved by exactly the write (b) blocks, so retry does not help. And
|
|
// --create-tag is deliberately ungated on (b), so a real run PUBLISHES the tag and then
|
|
// blocks here: a pushed tag against a catalog that can never be bumped.
|
|
//
|
|
// Nothing goes unverified by waiving it. The released plugin's stat line is checked at
|
|
// BOTH edges of the window where the two refs disagree: (a) validates it against the
|
|
// target before anything is touched, and the post-write check-versions run validates it
|
|
// against the now-pinned new ref. Only the middle — where the pinned ref is knowably
|
|
// stale — is skipped.
|
|
//
|
|
// The waiver is deliberately narrow in three ways: it applies to `releasingName` only,
|
|
// to `kind === 'stat'` findings only (a discriminator, never a prose match), and it
|
|
// refuses to reason about an ERROR that carries no ERROR finding — an unexplained ERROR
|
|
// blocks. A dangling ref, a badge/plugin.json disagreement, a wrong README label or a
|
|
// dead homepage on the released plugin still block it, exactly as before.
|
|
export function preflightErrors(gateResult, releasingName = null) {
|
|
return (gateResult?.results ?? []).filter(r => {
|
|
if (r.status !== 'ERROR') return false;
|
|
if (releasingName === null || r.name !== releasingName) return true;
|
|
const errs = (r.findings ?? []).filter(f => f.level === 'ERROR');
|
|
if (errs.length === 0) return true;
|
|
return errs.some(f => f.kind !== 'stat');
|
|
}).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';
|
|
}
|
|
|
|
// Q3e/D1 (order 20260913T051659Z-717911204-from-.claude) — the ordinary pre-flight
|
|
// (applyRelease -> check-versions) only ever inspects the OLD ref, so a stat-line drift
|
|
// the release itself is about to expose (the catalog's stat line vs. the badge the NEW
|
|
// ref will carry) slips straight through it, into a pushed tag + a written, uncommitted
|
|
// catalog. Measured live 13.09: `repo-mailbox --version 0.34.0 --create-tag --write
|
|
// --commit --push` tagged+pushed v0.34.0, wrote the ref + README label, and only THEN
|
|
// (post-write) found the catalog said "868 selftest check" against the plugin's new
|
|
// badge "927".
|
|
//
|
|
// Pure: given the catalog's own README text and the "stat source" text — the target
|
|
// ref's README if that tag already exists, else the plugin's worktree README, which is
|
|
// exactly what --create-tag is about to tag (see pickStatSource in check-versions.mjs,
|
|
// reused here with the roles it already has: atRef wins when present) — return the
|
|
// mismatch messages before anything is touched. Either side missing means "nothing to
|
|
// check" (same posture as check-versions.mjs when a README is absent), not a false block.
|
|
export function preflightStatMismatches({ catalogReadmeText, statSourceReadmeText, name }) {
|
|
if (catalogReadmeText === null || statSourceReadmeText === null) return [];
|
|
const catalogStats = extractCatalogStats(catalogReadmeText, name);
|
|
const statBadges = extractStatBadges(statSourceReadmeText);
|
|
return statMismatchFindings(catalogStats, statBadges).map(f => f.msg);
|
|
}
|
|
|
|
// Q3e/D2 — the post-write confirmation used to be a bare execFileSync call, which THROWS
|
|
// on a non-zero exit: an unhandled exception, over a release that had already tagged,
|
|
// pushed, and written files but never committed ("half done", operator had to finish it
|
|
// by hand). Turns any failure — a real ERROR that slipped past pre-flight, or the
|
|
// subprocess dying for its own reasons — into one precise, actionable message: what is
|
|
// done, what remains, and (when found) the specific check-versions finding. `runCheckVersions`
|
|
// is injected so this is testable without a real subprocess.
|
|
// check-versions.mjs prints one status line per plugin ("✗ ERROR demo-plugin") followed
|
|
// by its finding lines indented underneath, until the next status line. Grab the whole
|
|
// block for the named plugin, not just the status line — the finding is the actionable
|
|
// part (which axis, which numbers).
|
|
function extractPluginBlock(stdout, name) {
|
|
const lines = String(stdout || '').split('\n');
|
|
const idx = lines.findIndex(l => l.includes(name));
|
|
if (idx === -1) return '';
|
|
const out = [lines[idx]];
|
|
for (let i = idx + 1; i < lines.length; i++) {
|
|
const l = lines[i];
|
|
if (l.trim() === '' || /^[✓⚠✗–]/.test(l.trim())) break;
|
|
out.push(l);
|
|
}
|
|
return out.join('\n').trim();
|
|
}
|
|
|
|
export function reportPostWriteCheck({ name, applied, tagged, willPush }, runCheckVersions) {
|
|
let out;
|
|
try {
|
|
out = runCheckVersions();
|
|
} catch (err) {
|
|
const stdout = String(err.stdout || err.message || '');
|
|
const block = extractPluginBlock(stdout, name);
|
|
const lines = [
|
|
' ✗ post-write check-versions FAILED — the release is HALF DONE, nothing further ran automatically:',
|
|
` tag pushed: ${tagged ? 'yes' : 'no'}`,
|
|
` catalog files written: yes (${applied.writes.join(', ')}${applied.readme === 'written' ? ' + README label' : ''})`,
|
|
` NOT done: commit${willPush ? ', push' : ''}`,
|
|
block ? ` check-versions:\n ${block.split('\n').join('\n ')}` : (stdout.trim() ? stdout.trim().split('\n').map(l => ` ${l}`).join('\n') : ' (no output captured)'),
|
|
' Fix the reported issue, then finish manually: git add .claude-plugin/marketplace.json README.md && git commit ... (add --push if needed).',
|
|
];
|
|
return { ok: false, message: lines.join('\n'), exitCode: (typeof err.status === 'number' && err.status !== 0) ? err.status : 1 };
|
|
}
|
|
const line = out.split('\n').find(l => l.includes(name)) ?? '';
|
|
return { ok: true, message: ` check-versions: ${line.trim() || '(no line)'}` };
|
|
}
|
|
|
|
// --- push-token gate ---------------------------------------------------------
|
|
//
|
|
// pre-push-gate.sh is a PreToolUse hook that matches `git push` in COMMAND TEXT — it
|
|
// cannot see a push issued via execFileSync inside this script's own process (pinned
|
|
// as GAP in the gate's header, and this script is the concretely-named example there).
|
|
// This script mints+pushes a plugin tag (--create-tag) and pushes the catalog itself
|
|
// (--push), both invisible to that gate. So it must require the SAME one-shot approval
|
|
// token the gate checks (`hooks/lib/cmd-parse.sh` token_path()) before either push, and
|
|
// consume it itself after a push succeeds — post-push-consume.sh (PostToolUse) never
|
|
// fires for a call the gate never saw. Tag-push and catalog-push share ONE token: one
|
|
// publish from the operator's perspective.
|
|
|
|
// Computes the token path EXACTLY like token_path(): `sed 's|/|_|g'` on $PWD — only
|
|
// '/' is rewritten, every other character (including '-' and '.') is left alone.
|
|
export function pushAuthorisation({ cwd, home, exists }) {
|
|
const tokenPath = join(home, '.claude', 'runtime', 'push-approvals', cwd.split('/').join('_'));
|
|
return { tokenPath, authorised: exists(tokenPath) };
|
|
}
|
|
|
|
export function requirePushAuthorisation({ cwd, home, exists }) {
|
|
const { tokenPath, authorised } = pushAuthorisation({ cwd, home, exists });
|
|
if (authorised) return { authorised: true, tokenPath };
|
|
const message = [
|
|
'BLOCKED: this run would push — release-plugin.mjs pushes a tag and/or the catalog itself',
|
|
"via execFileSync inside node, invisible to pre-push-gate.sh's command-text match.",
|
|
"Tag-push and catalog-push share ONE token: one publish from the operator's perspective.",
|
|
'',
|
|
'To approve exactly one publish from this run, the OPERATOR runs:',
|
|
` mkdir -p ${dirname(tokenPath)} && touch "${tokenPath}"`,
|
|
].join('\n');
|
|
return { authorised: false, tokenPath, message };
|
|
}
|
|
|
|
// Deletes the token if present. A no-op if it is already gone (idempotent across the
|
|
// two push sites that share it).
|
|
export function consumeToken({ tokenPath, exists, unlink }) {
|
|
if (exists(tokenPath)) unlink(tokenPath);
|
|
}
|
|
|
|
// A run-scoped push-token gate (Q3b, order 20260912T213049Z-5772222747 — fixes two
|
|
// defects in Q3's per-call pushWithToken):
|
|
//
|
|
// D1: --create-tag --write --commit --push does TWO pushes (tag, then catalog) in ONE
|
|
// run. pushWithToken checked-and-consumed per call, so the tag push spent the operator's
|
|
// one-shot token and the catalog push right after always saw blocked:true. ensure()
|
|
// checks the token ONCE per run and every later call reuses that same result — one
|
|
// token covers every push the run makes.
|
|
//
|
|
// D2: `git tag -a` used to run before any token check at all, so a blocked run left a
|
|
// local annotated tag behind (a retry after the operator drops the token then fails
|
|
// with "tag already exists", exit 128). Callers must call ensure() BEFORE the first
|
|
// write this run intends to push toward — including a local tag meant to precede a
|
|
// later push — not only immediately before the `git push` itself.
|
|
//
|
|
// consume() deletes the token once, after the run's LAST push has succeeded; it is a
|
|
// no-op if ensure() was never authorised (nothing pushed) or already consumed.
|
|
export function createPushGate({ cwd, home, exists, unlink }) {
|
|
let auth = null;
|
|
let consumed = false;
|
|
return {
|
|
// Set by a caller (runRelease) right after a `git push` it issued actually succeeds.
|
|
// Q3c/D3: consume() must fire whenever this is true, on EVERY exit from the run, not
|
|
// only the run's final line — a run can push a tag and then resolve to BLOCKED/NOOP/
|
|
// dry-run/red-pre-flight afterwards, and each of those used to skip consume() entirely.
|
|
pushed: false,
|
|
ensure() {
|
|
if (auth === null) auth = requirePushAuthorisation({ cwd, home, exists });
|
|
return auth;
|
|
},
|
|
consume() {
|
|
if (consumed) return;
|
|
if (auth && auth.authorised) consumeToken({ tokenPath: auth.tokenPath, exists, unlink });
|
|
consumed = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
// Single-push convenience wrapper over createPushGate: checks, pushes, consumes for
|
|
// exactly one push. Consumes the token only after `push()` returns without throwing —
|
|
// if it throws (a real push failure), the exception propagates and the token is left
|
|
// intact for the retry.
|
|
export function pushWithToken({ cwd, home, exists, unlink, push }) {
|
|
const gate = createPushGate({ cwd, home, exists, unlink });
|
|
const auth = gate.ensure();
|
|
if (!auth.authorised) return { pushed: false, blocked: true, message: auth.message, tokenPath: auth.tokenPath };
|
|
push();
|
|
gate.consume();
|
|
return { pushed: true, blocked: false, tokenPath: auth.tokenPath };
|
|
}
|
|
|
|
// --- Forgejo release object -------------------------------------------------
|
|
//
|
|
// Order 20260917T235642Z-730962924-from-from-ai-to-chitta. Measured 2026-09-18 against the
|
|
// instance's own API: 11 of the 21 tagged repos in org `open` had NO release object for
|
|
// their newest tag. Forgejo files a pushed tag under /tags and shows only an explicit
|
|
// release object under /releases — so this helper, which only ever made a git TAG, left
|
|
// every releases page one release behind. llm-security showed v7.8.3 while the catalog
|
|
// pinned v8.0.0: the same tag-vs-published drift the catalog-ref bump exists to prevent,
|
|
// one surface further out. A release is therefore not complete until this object exists.
|
|
//
|
|
// SYNCHRONOUS ON PURPOSE (curl via execFileSync, not fetch) — the same reason
|
|
// check-versions.mjs's checkHomepage is, and it bites harder here: runRelease is called
|
|
// from main() WITHOUT an await and returns an exit code that main() hands to process.exit.
|
|
// An async step here would return a Promise nobody awaits, so a rejected POST would
|
|
// surface as an unhandled rejection AFTER the run had already exited 0 and reported the
|
|
// release as complete — "unmeasured reads as green", in the gate built to stop exactly that.
|
|
|
|
const FORGEJO_API = 'https://git.fromaitochitta.com/api/v1';
|
|
|
|
// "https://git.fromaitochitta.com/open/llm-security" -> { owner: 'open', repo: 'llm-security' }.
|
|
// null when the URL is not a plain <host>/<owner>/<repo> — the helper must never GUESS an
|
|
// owner, because a guessed owner POSTs a release into somebody else's repository.
|
|
export function parseForgejoRepo(url) {
|
|
if (typeof url !== 'string') return null;
|
|
const cleaned = url.trim().replace(/\/+$/, '').replace(/\.git$/, '');
|
|
const m = cleaned.match(/^https?:\/\/[^/]+\/([^/]+)\/([^/]+)$/);
|
|
return m ? { owner: m[1], repo: m[2] } : null;
|
|
}
|
|
|
|
// The release body is the TAG'S OWN message, verbatim, or empty. Never generated prose:
|
|
// an invented release note is a claim about the release that nobody actually made.
|
|
export function planForgejoRelease({ url, tag, releaseTags = [], tagMessage = '', body = null }) {
|
|
const loc = parseForgejoRepo(url);
|
|
if (!loc) return { verdict: 'BLOCKED', reason: `cannot derive owner/repo from the catalog source url: ${JSON.stringify(url)}` };
|
|
if (!tag) return { verdict: 'BLOCKED', reason: 'no tag to file a release object for' };
|
|
if (releaseTags.includes(tag)) return { verdict: 'NOOP', ...loc, tag, reason: `a release object for ${tag} already exists` };
|
|
return { verdict: 'CREATE', ...loc, tag, name: tag, body: body ?? (tagMessage ?? '').trim() };
|
|
}
|
|
|
|
export function ensureForgejoRelease(plan, api) {
|
|
if (plan.verdict !== 'CREATE') return { created: false, verdict: plan.verdict, reason: plan.reason ?? null };
|
|
const res = api.createRelease(plan.owner, plan.repo, { tag_name: plan.tag, name: plan.name, body: plan.body });
|
|
return { created: true, verdict: 'CREATED', url: res?.html_url ?? null };
|
|
}
|
|
|
|
// Release notes come from the plugin's own CHANGELOG, not from the tag message.
|
|
//
|
|
// The first cut used the tag message, as the order asked. Measured against what it
|
|
// produced: llm-security v8.0.0's release page read "llm-security v8.0.0" and nothing
|
|
// else — because `--create-tag` mints `-m "<name> v<version>"`, so the tag message is
|
|
// MECHANICAL exactly where this helper made the tag. The org's own answer was already on
|
|
// the instance: llm-security v7.8.3's release body is byte-for-byte its CHANGELOG
|
|
// `## [7.8.3]` section. So this is the established source, not a new invention — and all
|
|
// 10 backfilled repos ship a CHANGELOG (measured 2026-09-18, 10/10).
|
|
//
|
|
// Three heading dialects are in live use, all three load-bearing:
|
|
// ## [6.0.0] - 2026-08-18 ## [0.2.0] — 2026-08-20 ## v1.0 (2026-08-18)
|
|
// ## v5.10.1 — 2026-09-03 — gemini-bridge dropped (trailing prose in the heading)
|
|
// The version token is matched EXACTLY, so `0.1.0-pre` is not `0.1.0`, `1.1.0` is not
|
|
// `1.10.0`, and `[Unreleased]` is never a release.
|
|
export function extractChangelogSection(text, version) {
|
|
if (typeof text !== 'string' || !text || !version) return null;
|
|
|
|
const headingVersion = (line) => {
|
|
const m = line.match(/^##\s+\[?v?([^\]\s(]+)\]?/);
|
|
return m ? m[1] : null;
|
|
};
|
|
|
|
const lines = text.split('\n');
|
|
let start = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (!/^##\s/.test(lines[i])) continue;
|
|
if (headingVersion(lines[i]) === version) { start = i + 1; break; }
|
|
}
|
|
if (start === -1) return null;
|
|
|
|
let end = lines.length;
|
|
for (let i = start; i < lines.length; i++) {
|
|
if (/^##\s/.test(lines[i])) { end = i; break; }
|
|
}
|
|
// An EMPTY section is not release notes — `## [Unreleased]` is the standing case, and a
|
|
// caller must fall through to the next source rather than publish a blank body.
|
|
const body = lines.slice(start, end).join('\n').trim();
|
|
return body || null;
|
|
}
|
|
|
|
// Source priority, and the source is REPORTED so a caller can say where the text came
|
|
// from rather than implying it wrote it: the CHANGELOG section, else the tag's own
|
|
// message, else nothing. Never generated prose.
|
|
export function releaseBodyFrom({ changelogText, tag, tagMessage }) {
|
|
const version = String(tag ?? '').replace(/^v/, '');
|
|
const section = extractChangelogSection(changelogText, version);
|
|
if (section) return { body: section, source: 'changelog' };
|
|
const msg = (tagMessage ?? '').trim();
|
|
if (msg) return { body: msg, source: 'tag-message' };
|
|
return { body: '', source: 'none' };
|
|
}
|
|
|
|
// A tag's message WITHOUT its signature. `%(contents)` would carry the whole
|
|
// "-----BEGIN SSH SIGNATURE-----" block into the release notes — ~/.gitconfig sets
|
|
// tag.gpgsign with gpg.format ssh, so every tag this helper mints is signed (verified
|
|
// 2026-09-18 on llm-security v8.0.0). subject+body is the message and nothing else.
|
|
export function readTagMessage(repoDir, tag) {
|
|
try {
|
|
const subject = execFileSync('git', ['-C', repoDir, 'tag', '-l', '--format=%(contents:subject)', tag], { encoding: 'utf8' }).trim();
|
|
const body = execFileSync('git', ['-C', repoDir, 'tag', '-l', '--format=%(contents:body)', tag], { encoding: 'utf8' }).trim();
|
|
return body ? `${subject}\n\n${body}` : subject;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// The token reaches curl through a 0600 header FILE, never through argv — argv is world
|
|
// readable via ps(1), and this token can write to every repository in the org.
|
|
export function forgejoApi({ baseUrl = FORGEJO_API, token, exec = execFileSync, mkHeaderFile } = {}) {
|
|
const headerFile = () => {
|
|
if (!token) {
|
|
throw new Error(
|
|
'FORGEJO_TOKEN is not set, so the release object cannot be filed.\n'
|
|
+ ' export FORGEJO_TOKEN="$(security find-generic-password -a ktg -s forgejo-token -w login.keychain-db)"',
|
|
);
|
|
}
|
|
if (mkHeaderFile) return mkHeaderFile(token);
|
|
const path = join(tmpdir(), `fj-hdr-${process.pid}-${Date.now()}`);
|
|
writeFileSync(path, `Authorization: token ${token}\n`, { mode: 0o600 });
|
|
return path;
|
|
};
|
|
|
|
// The instance sits behind nginx with a rate limit. Measured 2026-09-18: an unthrottled
|
|
// sweep of 24 repos drew 17 HTTP 429s — and the first version of that sweep coerced every
|
|
// one of them to an empty list, so "verified nothing" and "verified everything, all clean"
|
|
// looked identical (Verifiseringsloven ansikt 4). Retry 429 with backoff; never swallow it.
|
|
function callOnce(method, path, payload) {
|
|
const hdr = headerFile();
|
|
try {
|
|
const args = ['-sS', '-X', method, '-H', `@${hdr}`, '-H', 'Accept: application/json',
|
|
'-w', '\n%{http_code}', '--max-time', '30', `${baseUrl}${path}`];
|
|
if (payload !== undefined) args.push('-H', 'Content-Type: application/json', '-d', JSON.stringify(payload));
|
|
const out = exec('curl', args, { encoding: 'utf8' });
|
|
const nl = out.lastIndexOf('\n');
|
|
const status = Number(out.slice(nl + 1).trim());
|
|
const text = out.slice(0, nl);
|
|
// A network failure must never read as a definitive answer: curl writes http_code 0
|
|
// when it never got a response at all.
|
|
if (!Number.isFinite(status) || status === 0) throw new Error(`${method} ${path} -> no HTTP response (network failure, not a verdict)`);
|
|
return { status, text };
|
|
} finally {
|
|
try { unlinkSync(hdr); } catch { /* already gone */ }
|
|
}
|
|
}
|
|
|
|
// 429 (nginx rate limit) and the 502/503/504 gateway family are all "the server is not
|
|
// answering right now", not verdicts about the resource — both were seen live on
|
|
// 2026-09-18 during a single org sweep. Retried with backoff; anything else, including a
|
|
// 403 from a token without write:repository, is an answer and is raised as one.
|
|
const TRANSIENT = new Set([429, 502, 503, 504]);
|
|
|
|
function call(method, path, payload) {
|
|
let wait = 1000;
|
|
for (let attempt = 0; ; attempt++) {
|
|
const { status, text } = callOnce(method, path, payload);
|
|
if (TRANSIENT.has(status) && attempt < 5) { sleepMs(wait); wait *= 2; continue; }
|
|
if (status >= 400) throw new Error(`${method} ${path} -> HTTP ${status}: ${text.trim().slice(0, 300)}`);
|
|
return text.trim() ? JSON.parse(text) : null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
listOrgRepos(owner) {
|
|
const names = [];
|
|
for (let page = 1; ; page++) {
|
|
const d = call('GET', `/orgs/${owner}/repos?limit=50&page=${page}`);
|
|
if (!Array.isArray(d) || d.length === 0) break;
|
|
names.push(...d.map(r => r.name));
|
|
}
|
|
return names;
|
|
},
|
|
listTags(owner, repo) {
|
|
const d = call('GET', `/repos/${owner}/${repo}/tags?limit=100`);
|
|
return Array.isArray(d) ? d.map(t => ({ name: t.name, message: t.message ?? '' })) : [];
|
|
},
|
|
listReleaseTags(owner, repo) {
|
|
const d = call('GET', `/repos/${owner}/${repo}/releases?limit=100`);
|
|
return Array.isArray(d) ? d.map(r => r.tag_name) : [];
|
|
},
|
|
createRelease(owner, repo, payload) {
|
|
return call('POST', `/repos/${owner}/${repo}/releases`, payload);
|
|
},
|
|
// null when the path does not exist at that ref (a 404 is an ANSWER: "no CHANGELOG"),
|
|
// while every other error still raises.
|
|
getFileAtRef(owner, repo, ref, path) {
|
|
try {
|
|
const d = call('GET', `/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`);
|
|
if (!d?.content) return null;
|
|
return Buffer.from(d.content, d.encoding === 'base64' ? 'base64' : 'utf8').toString('utf8');
|
|
} catch (err) {
|
|
if (/HTTP 404/.test(err.message)) return null;
|
|
throw err;
|
|
}
|
|
},
|
|
getReleaseByTag(owner, repo, tag) {
|
|
try {
|
|
return call('GET', `/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`);
|
|
} catch (err) {
|
|
if (/HTTP 404/.test(err.message)) return null;
|
|
throw err;
|
|
}
|
|
},
|
|
updateRelease(owner, repo, id, payload) {
|
|
return call('PATCH', `/repos/${owner}/${repo}/releases/${id}`, payload);
|
|
},
|
|
};
|
|
}
|
|
|
|
// Blocking sleep — this whole path is synchronous on purpose (see the section header).
|
|
export function sleepMs(ms) {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
}
|
|
|
|
// 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), plan.name);
|
|
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; }
|
|
}
|
|
|
|
// Q3e/D1 I/O helpers — read-only, both null-safe (missing file/tag reads as "not checked").
|
|
function readFileSafe(path) {
|
|
try { return readFileSync(path, 'utf8'); } catch { return null; }
|
|
}
|
|
|
|
function readGitShow(repoDir, ref, path) {
|
|
if (!ref) return null;
|
|
try {
|
|
return execFileSync('git', ['-C', repoDir, 'show', `${ref}:${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
} 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;
|
|
}
|
|
|
|
// The body of main(), minus the top-level plumbing (read marketplace.json off disk,
|
|
// build the pushGate) and minus the final process.exit call — pulled out so it is
|
|
// testable against a real temp git repo (Q3c/S1) and so it can return an exit code
|
|
// instead of calling process.exit at each branch (Q3c/D3, see main() below for why).
|
|
export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, runCheckVersions, forgejo }) {
|
|
const checkVersionsRunner = runCheckVersions
|
|
|| (() => execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' }));
|
|
|
|
let obs = observePlugin(catalogDir, args.name);
|
|
const target = normalizeVersion(args.version ?? obs.pluginVersion ?? '');
|
|
|
|
// Q3e/D1: check the catalog's stat line against what this release is ABOUT TO MAKE
|
|
// current — BEFORE any tag or write, uncoupled from whether the ref is already
|
|
// consistent (the point is to catch it before --create-tag ever touches a remote).
|
|
if (target) {
|
|
const mismatches = preflightStatMismatches({
|
|
catalogReadmeText: readFileSafe(join(catalogDir, 'README.md')),
|
|
statSourceReadmeText: pickStatSource({
|
|
atRef: readGitShow(obs.repoDir, 'v' + target, 'README.md'),
|
|
atWorktree: readFileSafe(join(obs.repoDir, 'README.md')),
|
|
}),
|
|
name: args.name,
|
|
});
|
|
if (mismatches.length > 0) {
|
|
console.log(`\nrelease-plugin: ${args.name} — BLOCKED before any tag or write (stale catalog stat line):`);
|
|
for (const m of mismatches) console.log(` ✗ ${m}`);
|
|
console.log(' Fix the catalog stat line (or the plugin badge) first, then re-run.');
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// --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.
|
|
// pushGate is shared across BOTH push sites in this run (tag push, catalog push) — one
|
|
// operator token authorises the whole publish, not each push individually (D1). ensure()
|
|
// is called before the FIRST write this run intends to push toward, including the local
|
|
// `git tag -a` below, which must not run before the check passes (D2).
|
|
const tagStep = shouldCreateTag(args, obs, target);
|
|
if (tagStep === 'create') {
|
|
const auth = pushGate.ensure();
|
|
if (!auth.authorised) { console.error(auth.message); return 1; }
|
|
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' });
|
|
// Q3d/S: if the push itself fails (network, permissions, remote gone), delete the
|
|
// local tag we just made rather than leave an orphan behind — a retry after fixing
|
|
// the underlying problem must go through shouldCreateTag's tag-absent check again,
|
|
// not hit git's own "tag already exists" (exit 128).
|
|
try {
|
|
execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' });
|
|
} catch (err) {
|
|
execFileSync('git', ['-C', obs.repoDir, 'tag', '-d', tag], { stdio: 'inherit' });
|
|
throw err;
|
|
}
|
|
pushGate.pushed = true;
|
|
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.`);
|
|
}
|
|
|
|
// Q3c/D3: every branch below returns instead of calling process.exit. A tag push above
|
|
// may already have set pushGate.pushed = true, and process.exit() called from inside a
|
|
// try does not run its finally (verified live) — so main() must be the only place that
|
|
// calls process.exit, after its finally has had a chance to consume the token.
|
|
if (plan.verdict === 'BLOCKED') return 1;
|
|
if (plan.verdict === 'NOOP') { console.log(' ✓ catalog already pins this version — nothing to do.'); return 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.');
|
|
return 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.');
|
|
return 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.
|
|
// Q3e/D2: any failure here (a real ERROR, or the subprocess itself dying) becomes one
|
|
// precise message instead of an unhandled execFileSync exception over an already
|
|
// half-applied release (tag pushed + files written, nothing committed).
|
|
const confirm = reportPostWriteCheck({ name: args.name, applied, tagged: tagStep === 'create', willPush: args.push }, checkVersionsRunner);
|
|
console.log(confirm.message);
|
|
if (!confirm.ok) return confirm.exitCode;
|
|
|
|
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) {
|
|
const auth = pushGate.ensure();
|
|
if (!auth.authorised) { console.error(auth.message); return 1; }
|
|
console.log(' → pushing');
|
|
execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' });
|
|
pushGate.pushed = true;
|
|
console.log(' ✓ pushed');
|
|
}
|
|
}
|
|
|
|
// The release object. A run that PUBLISHES (pushed the tag, or pushed the catalog) is
|
|
// not finished until Forgejo's /releases page shows the tag it just made current — that
|
|
// page is what a human reads to answer "what version is out?". A run that publishes
|
|
// nothing leaves it alone and says so: filing a release object is itself a publish, and
|
|
// it must not slip past the operator's one-shot push token by riding along on a local
|
|
// --write.
|
|
const publishes = tagStep === 'create' || args.push;
|
|
if (!publishes) {
|
|
console.log(` · Forgejo release object for ${plan.newRef}: not filed — this run publishes nothing.`);
|
|
console.log(' File it with --push (or --create-tag --write), or with scripts/backfill-forgejo-releases.mjs.');
|
|
return 0;
|
|
}
|
|
|
|
const sourceUrl = marketplace.plugins?.find(x => x.name === args.name)?.source?.url ?? null;
|
|
const loc = parseForgejoRepo(sourceUrl);
|
|
if (!loc) {
|
|
console.log(` ✗ Forgejo release object NOT filed: cannot derive owner/repo from the catalog source url: ${JSON.stringify(sourceUrl)}`);
|
|
console.log(' The tag and the catalog are published; only the /releases page is behind.');
|
|
return 1;
|
|
}
|
|
|
|
// Never let this step throw upward: by here the tag — and possibly the catalog commit —
|
|
// are already public, and an unhandled exception would report that as a crash instead of
|
|
// as the one precise thing still undone.
|
|
try {
|
|
const notes = releaseBodyFrom({
|
|
changelogText: readGitShow(obs.repoDir, plan.newRef, 'CHANGELOG.md'),
|
|
tag: plan.newRef,
|
|
tagMessage: readTagMessage(obs.repoDir, plan.newRef),
|
|
});
|
|
const fjPlan = planForgejoRelease({
|
|
url: sourceUrl,
|
|
tag: plan.newRef,
|
|
releaseTags: (forgejo ?? (forgejo = forgejoApi({ token: process.env.FORGEJO_TOKEN }))).listReleaseTags(loc.owner, loc.repo),
|
|
body: notes.body,
|
|
});
|
|
const res = ensureForgejoRelease(fjPlan, forgejo);
|
|
if (res.created) {
|
|
console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`);
|
|
// Say where the text came from. `none` is the one worth seeing: the page went up
|
|
// with an empty body, which is honest but useless, and the CHANGELOG is the fix.
|
|
console.log(` release notes source: ${notes.source}${notes.source === 'none' ? ' — add a CHANGELOG section for this version' : ''}`);
|
|
} else console.log(` · Forgejo release object for ${plan.newRef} already exists — nothing to file.`);
|
|
} catch (err) {
|
|
console.log(` ✗ Forgejo release object NOT filed for ${plan.newRef}: ${err.message}`);
|
|
console.log(' The tag and the catalog are published — the /releases page is the only thing behind.');
|
|
console.log(` Retry just this step: node scripts/backfill-forgejo-releases.mjs --repo ${loc.owner}/${loc.repo} --write`);
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
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'));
|
|
const pushGate = createPushGate({ cwd: catalogDir, home: process.env.HOME, exists: existsSync, unlink: unlinkSync });
|
|
|
|
let exitCode;
|
|
try {
|
|
exitCode = runRelease({ args, catalogDir, mktPath, marketplace, pushGate });
|
|
} finally {
|
|
// Q3c/D3 (order 20260912T220453Z-5021715764): consume the shared token on ANY exit
|
|
// from this run once at least one push has succeeded — not just the final line, which
|
|
// four earlier exits (BLOCKED plan, NOOP, dry-run, red pre-flight) used to bypass
|
|
// entirely. This is the only place consume() is called from.
|
|
if (pushGate.pushed) pushGate.consume();
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
main();
|
|
}
|