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