ktg-plugin-marketplace/scripts/backfill-forgejo-releases.mjs
Kjell Tore Guttormsen ee2259f63f
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>
2026-09-18 02:23:52 +02:00

135 lines
5.9 KiB
JavaScript

#!/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();