feat(release): file the Forgejo release object as part of a release

A pushed git tag is filed by Forgejo under /tags; only an explicit release
object appears under /releases. release-plugin.mjs only ever made a tag, so
every plugin's public releases page sat a version behind the ref the catalog
pinned -- llm-security showed v7.8.3 against a v8.0.0 tag.

Measured 2026-09-18 against the instance API: 24 repos in org `open`, 21 with
at least one tag, 11 of those 21 with no release object for their newest tag.
That reproduces the order's own independently-measured list exactly.

- parseForgejoRepo / planForgejoRelease / ensureForgejoRelease: pure, tested.
  The release body is the tag's own message VERBATIM or empty -- never
  generated prose. Read via %(contents:subject)+%(contents:body), never
  %(contents), which drags the SSH signature block into the notes.
- The step fires only on a run that PUBLISHES (--create-tag --write, or
  --push): filing a release object is itself a publish and must not ride
  along on a local --write past the operator's one-shot push token.
- Synchronous (curl via execFileSync), like check-versions.mjs's
  checkHomepage: runRelease is called without an await and its return value
  becomes the exit code, so an async step would let a rejected POST surface
  after the run had already exited 0 and called the release complete.
- 429 and the 502/503/504 family are retried with backoff, never swallowed.
  An unthrottled sweep drew 17 HTTP 429s and the first version of that sweep
  read every one as an empty list -- "verified nothing" was indistinguishable
  from "verified everything, all clean".
- The token reaches curl through a 0600 header file, never argv.

scripts/backfill-forgejo-releases.mjs covers the backlog and retries the one
step, reusing the same planner and API shell so the two cannot drift. Only
the newest tag is considered. Documented exception: ktg-plugin-marketplace
pre-polyrepo-archive, an archive marker, not a release; the register is keyed
by repo AND tag so that repo's next real release is still backfilled.

Tests written red first: 20 new (12 release path, 8 backfill), and the two
real-git integration tests were probed known-negative -- breaking the wiring
turns 68/0 into 66/2. Suite 193/193; check-versions 12/12 OK.

The backfill of the 10 outstanding release objects is NOT done: it was denied
in-session as a public-surface write and is the operator's call.

Order: 20260917T235642Z-730962924-from-from-ai-to-chitta

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 02:23:52 +02:00
commit ee2259f63f
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 615 additions and 3 deletions

View file

@ -73,8 +73,10 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
orthogonal to `TAG-ANNOTATED`: it is the `-a` that makes the tag a real tag object, not the
signature. Do not treat `tag.gpgsign` as the enforcement for annotation.
- **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
a release is a THREE-part act — tag the plugin repo, bump the catalog `ref`, AND file the
Forgejo **release object**. Forgetting the catalog `ref` strands users on the old version;
forgetting the release object leaves the plugin's public releases page a version behind
(both are 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 --write` mints+pushes a missing
@ -84,6 +86,46 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
consistent version, `check-versions.mjs` is green by construction. Never hand-edit a `ref` or a
README label for a release — use this. Pure planner + label reconciler + pre-flight/write step
covered by `scripts/release-plugin.test.mjs`.
- **The Forgejo RELEASE OBJECT is the third part of a release (added 2026-09-18, order
`20260917T235642Z-730962924-from-from-ai-to-chitta`).** A pushed git tag is filed by Forgejo
under `/tags`; **only an explicit release object appears under `/releases`**. Until this was
added, `release-plugin.mjs` made a tag and nothing else — `grep releases scripts/*.mjs`
(excluding `.test.`) returned **0**, against **6** hits for `tags` in the same file as the
control that the query could find. Consequence, measured 2026-09-18 against the instance's
own API: **24 repos in org `open`, 21 with at least one tag, and 11 of those 21 had NO release
object for their newest tag** — llm-security's releases page said `v7.8.3` while the catalog
pinned `v8.0.0`. That is the same tag-vs-published drift the catalog-`ref` bump exists to
prevent, one surface further out, and it is what a human reads to answer "what version is out?".
- **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 made. Read it with
`%(contents:subject)` + `%(contents:body)`, **never `%(contents)`**: `~/.gitconfig` sets
`tag.gpgsign` with `gpg.format ssh`, so `%(contents)` drags the whole
`-----BEGIN SSH SIGNATURE-----` block into the release notes (verified on `llm-security`
`v8.0.0`).
- **It fires only on a run that PUBLISHES** (`--create-tag --write`, or `--push`). Filing a
release object is itself a publish, so it must not ride along on a local `--write` and slip
past the operator's one-shot push token. A non-publishing `--write` says so and names the
backfill instead of going quiet.
- **The step is synchronous (curl via `execFileSync`), like `check-versions.mjs`'s
`checkHomepage`, and for a sharper reason:** `runRelease` is called from `main()` WITHOUT an
`await` and returns the code `main()` hands to `process.exit`. An async step would return a
Promise nobody awaits, so a rejected POST would surface as an unhandled rejection *after* the
run had already exited 0 and called the release complete.
- **`429` and the `502`/`503`/`504` gateway family are retried with backoff, never swallowed.**
Both were seen live on 2026-09-18 — an unthrottled sweep of the 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" were indistinguishable. Anything
else, including a `403` from a token without `write:repository`, is an ANSWER and is raised.
- **The token reaches curl through a 0600 header file, never through `argv`**`argv` is
readable via `ps(1)`, and this token can write to every repository in the org.
- **Backfill / retry: `scripts/backfill-forgejo-releases.mjs`** (dry-run by default, `--write`
applies, `--repo <owner>/<name>` narrows). It reuses `release-plugin.mjs`'s own planner and API
shell rather than reimplementing them, so the backlog and the release path cannot drift apart.
It considers **only the newest tag** (that is the FERDIG-criterion the order set), and reports
the denominator. **Documented exception:** `ktg-plugin-marketplace` `pre-polyrepo-archive`
an archive marker for the monorepo before the polyrepo split, not a release (non-semver, and
no install path consumes it). The exclusion register is keyed by repo **AND tag**, never by
repo alone, so that repo's next real release is still backfilled.
- **`--create-tag --write` and `--push` each require the operator's push-approval token FIRST**
(Q3, decided 2026-09-12): `~/.claude/hooks/pre-push-gate.sh` matches `git push` in command
text and cannot see a push this script issues via `execFileSync` inside node — the script

View file

@ -0,0 +1,135 @@
#!/usr/bin/env node
// Backfill Forgejo RELEASE OBJECTS for tags that already exist.
//
// Order 20260917T235642Z-730962924-from-from-ai-to-chitta. The operator noticed that
// llm-security's /releases page showed v7.8.3 while its newest tag was v8.0.0. That was
// not an llm-security bug: release-plugin.mjs only ever made a git TAG, and Forgejo files
// a pushed tag under /tags — only an explicit release object appears under /releases.
// Measured 2026-09-18 against the instance's own API: 24 repos in org `open`, 21 with at
// least one tag, and 11 of those 21 had NO release object for their newest tag.
//
// release-plugin.mjs now files the release object as part of a release, so this script is
// for the backlog and for retrying that one step. It reuses the SAME planner and API shell
// as the release path — one behaviour, not a second implementation that can drift.
//
// Usage:
// node scripts/backfill-forgejo-releases.mjs # dry-run: print the plan
// node scripts/backfill-forgejo-releases.mjs --write # file the missing release objects
// node scripts/backfill-forgejo-releases.mjs --repo open/voyage # limit to one repo
//
// Needs FORGEJO_TOKEN:
// export FORGEJO_TOKEN="$(security find-generic-password -a ktg -s forgejo-token -w login.keychain-db)"
import { forgejoApi, planForgejoRelease, ensureForgejoRelease, sleepMs } from './release-plugin.mjs';
const ORG = 'open';
// Tags that are NOT releases. Keyed by repo AND by tag, never by repo alone — a blanket
// repo exclusion would silently swallow that repo's next real release too.
export const EXCLUDED_TAGS = {
'ktg-plugin-marketplace': {
'pre-polyrepo-archive': 'archive marker for the monorepo before the polyrepo split, not a release '
+ '(non-semver; the catalog pins no plugin to it and no install path consumes it)',
},
};
// The newest tag is the FIRST one the Forgejo tags API returns. Measured 2026-09-18: that
// ordering matched the known-newest tag for all 21 tagged repos in the org, and the 11-repo
// result it produces reproduces the order's own independently-measured list exactly.
export function planBackfill({ repos, excluded = EXCLUDED_TAGS }) {
const create = [];
const skip = [];
for (const r of repos) {
const newest = r.tags?.[0] ?? null;
if (!newest) { skip.push({ repo: r.name, tag: null, excluded: false, reason: 'no tags — nothing to release' }); continue; }
const exception = excluded[r.name]?.[newest.name];
if (exception) { skip.push({ repo: r.name, tag: newest.name, excluded: true, reason: exception }); continue; }
if ((r.releases ?? []).includes(newest.name)) {
skip.push({ repo: r.name, tag: newest.name, excluded: false, reason: `already has a release object for ${newest.name}` });
continue;
}
create.push({ repo: r.name, tag: newest.name, name: newest.name, body: (newest.message ?? '').trim() });
}
return { create, skip, total: repos.length, tagged: repos.filter(r => (r.tags?.length ?? 0) > 0).length };
}
// --- I/O shell ---------------------------------------------------------------
// The instance sits behind nginx with a rate limit: an unthrottled sweep of 24 repos
// returned 17 HTTP 429s, and the first version of this measurement read every one of them
// as "no tags" — a run that measured nothing was indistinguishable from a clean one
// (Verifiseringsloven ansikt 4). Pace the sweep and let forgejoApi surface any 429 as an
// error rather than as an empty list.
function paced(fn) { const v = fn(); sleepMs(400); return v; }
function parseArgs(argv) {
const out = { write: false, repo: null };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--write') out.write = true;
else if (argv[i] === '--repo') out.repo = argv[++i];
}
return out;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const api = forgejoApi({ token: process.env.FORGEJO_TOKEN });
let names;
if (args.repo) {
const [owner, name] = args.repo.includes('/') ? args.repo.split('/') : [ORG, args.repo];
if (owner !== ORG) { console.error(`this script only sweeps org "${ORG}" (got ${owner})`); process.exit(2); }
names = [name];
} else {
names = paced(() => api.listOrgRepos(ORG)).sort();
}
const repos = names.map(name => ({
name,
tags: paced(() => api.listTags(ORG, name)),
releases: paced(() => api.listReleaseTags(ORG, name)),
}));
const plan = planBackfill({ repos });
console.log(`\nbackfill-forgejo-releases: org "${ORG}" — ${plan.total} repo, ${plan.tagged} with at least one tag`);
console.log(` ${plan.create.length} newest tag(s) missing a release object\n`);
for (const s of plan.skip) {
if (s.excluded) console.log(`${s.repo} ${s.tag} — EXCEPTION: ${s.reason}`);
}
for (const c of plan.create) {
console.log(` ${args.write ? '→' : '·'} ${c.repo} ${c.tag}${c.body ? '' : ' (empty tag message -> empty release body)'}`);
}
if (!args.write) {
console.log('\n (dry-run) re-run with --write to file them.');
process.exit(0);
}
let filed = 0;
const failed = [];
for (const c of plan.create) {
const url = `https://git.fromaitochitta.com/${ORG}/${c.repo}`;
try {
const p = planForgejoRelease({ url, tag: c.tag, releaseTags: paced(() => api.listReleaseTags(ORG, c.repo)), tagMessage: c.body });
const res = ensureForgejoRelease(p, api);
sleepMs(400);
if (res.created) { filed++; console.log(`${c.repo} ${c.tag}${res.url ? ` (${res.url})` : ''}`); }
else console.log(` · ${c.repo} ${c.tag}${res.reason}`);
} catch (err) {
failed.push({ repo: c.repo, tag: c.tag, error: err.message });
console.log(`${c.repo} ${c.tag}${err.message}`);
}
}
console.log(`\n filed ${filed}/${plan.create.length}; ${failed.length} failed`);
process.exit(failed.length ? 1 : 0);
}
if (process.argv[1] && process.argv[1].endsWith('backfill-forgejo-releases.mjs')) main();

View file

@ -0,0 +1,64 @@
// Tests for the Forgejo release-object backfill.
// Pure selector is the unit under test — the API shell (curl via release-plugin.mjs's
// forgejoApi) is exercised against the live instance, not here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planBackfill, EXCLUDED_TAGS } from './backfill-forgejo-releases.mjs';
const repo = (name, tags, releases = []) => ({ name, tags, releases });
const tag = (name, message = '') => ({ name, message });
test('plans a CREATE for a repo whose newest tag has no release object', () => {
const r = planBackfill({ repos: [repo('llm-security', [tag('v8.0.0', 'llm-security v8.0.0'), tag('v7.8.3')], ['v7.8.3'])] });
assert.equal(r.create.length, 1);
assert.equal(r.create[0].repo, 'llm-security');
assert.equal(r.create[0].tag, 'v8.0.0');
});
test('the release body is the tag message VERBATIM — the backfill invents no release notes', () => {
const msg = '0.10.0 — a bundle carries the images its sources declare\n\nFive readers place them.';
const r = planBackfill({ repos: [repo('llm-ingestion-okf', [tag('v0.10.0', msg)], [])] });
assert.equal(r.create[0].body, msg);
});
test('skips a repo whose newest tag already has a release object (idempotent re-run)', () => {
const r = planBackfill({ repos: [repo('ai-psychosis', [tag('v1.2.2')], ['v1.2.2'])] });
assert.equal(r.create.length, 0);
assert.equal(r.skip.length, 1);
assert.match(r.skip[0].reason, /already has a release/);
});
test('skips a repo with no tags at all, and NAMES the reason — never a silent drop', () => {
const r = planBackfill({ repos: [repo('jobbsok', [])] });
assert.equal(r.create.length, 0);
assert.equal(r.skip.length, 1);
assert.equal(r.skip[0].repo, 'jobbsok');
assert.match(r.skip[0].reason, /no tags/);
});
test('only the NEWEST tag is backfilled — an older tag without a release stays untouched', () => {
const r = planBackfill({ repos: [repo('config-audit', [tag('v6.0.0'), tag('v5.9.0'), tag('v5.8.0')], [])] });
assert.deepEqual(r.create.map(c => c.tag), ['v6.0.0']);
});
test('an excluded tag is reported as a documented EXCEPTION, not dropped silently', () => {
const r = planBackfill({ repos: [repo('ktg-plugin-marketplace', [tag('pre-polyrepo-archive', 'Archive of the monorepo')], [])] });
assert.equal(r.create.length, 0);
assert.equal(r.skip.length, 1);
assert.equal(r.skip[0].excluded, true);
assert.match(r.skip[0].reason, /archive/i);
});
test('the exclusion register names the repo AND the tag — it can never blanket-skip a repo', () => {
const entry = EXCLUDED_TAGS['ktg-plugin-marketplace'];
assert.ok(entry, 'the archive tag exception must be registered');
assert.ok(entry['pre-polyrepo-archive'], 'the exception is keyed by TAG, so a future real release in that repo is still backfilled');
const r = planBackfill({ repos: [repo('ktg-plugin-marketplace', [tag('v9.0.0')], [])] });
assert.equal(r.create.length, 1, 'a normal tag in an excluded repo is still a release');
});
test('the summary reports the DENOMINATOR, so a run that verified nothing cannot read as clean', () => {
const r = planBackfill({ repos: [repo('a', [tag('v1')], ['v1']), repo('b', [tag('v2')], []), repo('c', [])] });
assert.equal(r.total, 3);
assert.equal(r.tagged, 2);
});

View file

@ -35,6 +35,7 @@
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,
@ -335,6 +336,149 @@ export function pushWithToken({ cwd, home, exists, unlink, push }) {
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 = '' }) {
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: (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 };
}
// 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);
},
};
}
// 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.
@ -427,7 +571,7 @@ function parseArgs(argv) {
// 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 }) {
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' }));
@ -544,6 +688,48 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
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 fjPlan = planForgejoRelease({
url: sourceUrl,
tag: plan.newRef,
releaseTags: (forgejo ?? (forgejo = forgejoApi({ token: process.env.FORGEJO_TOKEN }))).listReleaseTags(loc.owner, loc.repo),
tagMessage: readTagMessage(obs.repoDir, plan.newRef),
});
const res = ensureForgejoRelease(fjPlan, forgejo);
if (res.created) console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`);
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;
}

View file

@ -12,6 +12,7 @@ import {
planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag,
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate,
runRelease, preflightStatMismatches, reportPostWriteCheck,
parseForgejoRepo, planForgejoRelease, ensureForgejoRelease,
} from './release-plugin.mjs';
import { classifyPlugin } from './check-versions.mjs';
@ -915,3 +916,187 @@ test('D2 (Q3e, real git): a post-write check-versions failure reports precisely
rmSync(root, { recursive: true, force: true });
}
});
// --- 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, because this helper only ever made a
// git TAG. Forgejo files a pushed tag under /tags; only an explicit release object appears
// under /releases. So a "released" plugin could show v7.8.3 on its releases page while
// v8.0.0 was the tag the catalog pinned — the same tag-vs-published drift the catalog-ref
// bump exists to prevent, one surface further out.
test('parseForgejoRepo pulls owner/repo out of a Forgejo repo URL', () => {
assert.deepEqual(
parseForgejoRepo('https://git.fromaitochitta.com/open/llm-security'),
{ owner: 'open', repo: 'llm-security' },
);
});
test('parseForgejoRepo tolerates a .git suffix and a trailing slash', () => {
assert.deepEqual(parseForgejoRepo('https://git.fromaitochitta.com/open/repo-mailbox.git'), { owner: 'open', repo: 'repo-mailbox' });
assert.deepEqual(parseForgejoRepo('https://git.fromaitochitta.com/open/voyage/'), { owner: 'open', repo: 'voyage' });
});
test('parseForgejoRepo returns null for a URL it cannot read — it never guesses an owner', () => {
assert.equal(parseForgejoRepo('x'), null);
assert.equal(parseForgejoRepo('https://git.fromaitochitta.com/open'), null);
assert.equal(parseForgejoRepo(null), null);
assert.equal(parseForgejoRepo(undefined), null);
});
test('planForgejoRelease: CREATE when no release object exists for the tag', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-security',
tag: 'v8.0.0',
releaseTags: ['v7.8.3'],
tagMessage: 'llm-security v8.0.0',
});
assert.equal(p.verdict, 'CREATE');
assert.equal(p.owner, 'open');
assert.equal(p.repo, 'llm-security');
assert.equal(p.tag, 'v8.0.0');
assert.equal(p.name, 'v8.0.0');
});
test('planForgejoRelease: NOOP when a release object for the tag already exists (idempotent re-run)', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-security',
tag: 'v8.0.0',
releaseTags: ['v8.0.0', 'v7.8.3'],
tagMessage: 'llm-security v8.0.0',
});
assert.equal(p.verdict, 'NOOP');
});
test('planForgejoRelease: BLOCKED when the source url cannot be parsed', () => {
const p = planForgejoRelease({ url: 'x', tag: 'v1.0.0', releaseTags: [], tagMessage: 'm' });
assert.equal(p.verdict, 'BLOCKED');
assert.match(p.reason, /url/);
});
test('planForgejoRelease: BLOCKED when there is no tag to release', () => {
const p = planForgejoRelease({ url: 'https://git.fromaitochitta.com/open/x', tag: null, releaseTags: [], tagMessage: '' });
assert.equal(p.verdict, 'BLOCKED');
});
test('planForgejoRelease: the body is the tag message VERBATIM — no invented release notes', () => {
const msg = '0.10.0 — a bundle carries the images its sources declare\n\nFive readers place them.';
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-ingestion-okf', tag: 'v0.10.0', releaseTags: [], tagMessage: msg,
});
assert.equal(p.body, msg, 'the tag message is the release text; the helper must not write prose of its own');
});
test('planForgejoRelease: an empty tag message yields an EMPTY body, never invented prose', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/x', tag: 'v1.0.0', releaseTags: [], tagMessage: '',
});
assert.equal(p.verdict, 'CREATE');
assert.equal(p.body, '');
});
test('ensureForgejoRelease: CREATE posts exactly once with tag_name/name/body', () => {
const calls = [];
const api = { createRelease: (owner, repo, payload) => { calls.push({ owner, repo, payload }); return { html_url: 'https://h/r' }; } };
const r = ensureForgejoRelease(
{ verdict: 'CREATE', owner: 'open', repo: 'llm-security', tag: 'v8.0.0', name: 'v8.0.0', body: 'llm-security v8.0.0' },
api,
);
assert.equal(r.created, true);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], {
owner: 'open', repo: 'llm-security',
payload: { tag_name: 'v8.0.0', name: 'v8.0.0', body: 'llm-security v8.0.0' },
});
assert.equal(r.url, 'https://h/r');
});
test('ensureForgejoRelease: NOOP and BLOCKED never call the API', () => {
const api = { createRelease: () => { throw new Error('BUG: must not post'); } };
assert.equal(ensureForgejoRelease({ verdict: 'NOOP', reason: 'exists' }, api).created, false);
assert.equal(ensureForgejoRelease({ verdict: 'BLOCKED', reason: 'bad url' }, api).created, false);
});
test('R-FJ1 (real git): a publishing run files the Forgejo release object for the tag it just pushed', () => {
const root = makeTempRoot('release-plugin-fj1-');
try {
const bare = join(root, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare]);
const repoDir = join(root, 'demo-plugin');
const catalogDir = join(root, 'catalog');
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
initPluginRepo(repoDir, { version: '1.1.0', remote: bare });
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'v1.0.0']);
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const url = 'https://git.fromaitochitta.com/open/demo-plugin';
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url, ref: 'v1.0.0' }, description: 'd' }] };
fsWriteFileSync(mktPath, JSON.stringify(marketplace, null, 2));
fsWriteFileSync(join(catalogDir, 'README.md'), '### [Demo Plugin](https://x/open/demo-plugin) `v1.0.0`\n');
const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => true, unlink: () => {} });
const created = [];
const forgejo = {
listReleaseTags: () => ['v1.0.0'],
createRelease: (owner, repo, payload) => { created.push({ owner, repo, payload }); return { html_url: 'https://h/rel' }; },
};
const code = runRelease({
args: { name: 'demo-plugin', version: '1.1.0', createTag: true, write: true, commit: false, push: false },
catalogDir, mktPath, marketplace, pushGate, forgejo,
runCheckVersions: () => '1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
});
assert.equal(code, 0);
assert.equal(created.length, 1, 'the release object is part of the release, not an afterthought');
assert.equal(created[0].owner, 'open');
assert.equal(created[0].repo, 'demo-plugin');
assert.equal(created[0].payload.tag_name, 'v1.1.0');
assert.equal(created[0].payload.body, 'demo-plugin v1.1.0', 'body is the tag message this run wrote');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('R-FJ2 (real git): a failed release-object create reports precisely and returns non-zero — the release is NOT complete', () => {
const root = makeTempRoot('release-plugin-fj2-');
try {
const bare = join(root, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare]);
const repoDir = join(root, 'demo-plugin');
const catalogDir = join(root, 'catalog');
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
initPluginRepo(repoDir, { version: '1.1.0', remote: bare });
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'v1.0.0']);
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const url = 'https://git.fromaitochitta.com/open/demo-plugin';
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url, ref: 'v1.0.0' }, description: 'd' }] };
fsWriteFileSync(mktPath, JSON.stringify(marketplace, null, 2));
fsWriteFileSync(join(catalogDir, 'README.md'), '### [Demo Plugin](https://x/open/demo-plugin) `v1.0.0`\n');
const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => true, unlink: () => {} });
const forgejo = {
listReleaseTags: () => ['v1.0.0'],
createRelease: () => { throw new Error('POST /releases -> HTTP 403: token lacks write:repository'); },
};
let code;
let threw = false;
try {
code = runRelease({
args: { name: 'demo-plugin', version: '1.1.0', createTag: true, write: true, commit: false, push: false },
catalogDir, mktPath, marketplace, pushGate, forgejo,
runCheckVersions: () => '1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
});
} catch { threw = true; }
assert.equal(threw, false, 'a release-object failure must become a message, never an unhandled exception over a half-done release');
assert.notEqual(code, 0, 'a release without its release object is not complete');
} finally {
rmSync(root, { recursive: true, force: true });
}
});