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

@ -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;
}