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:
parent
328e92feb9
commit
ee2259f63f
5 changed files with 615 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue