#!/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 // node scripts/backfill-forgejo-releases.mjs --repair # dry-run: release objects // node scripts/backfill-forgejo-releases.mjs --repair --write # whose body should be the // # CHANGELOG section but isn't // // Needs FORGEJO_TOKEN: // export FORGEJO_TOKEN="$(security find-generic-password -a ktg -s forgejo-token -w login.keychain-db)" import { forgejoApi, planForgejoRelease, ensureForgejoRelease, sleepMs, releaseBodyFrom } 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 }; } // Repair: a release object already filed with a body that says nothing. // // The first backfill used the tag message, and `release-plugin.mjs --create-tag` mints // `-m " v"` — so llm-security v8.0.0 went up reading "llm-security v8.0.0" // and nothing else. This replaces such a body with the CHANGELOG section that belongs there. // // It is a PATCH to a PUBLISHED page, so the bar is deliberately "strictly more informative", // never merely "different": no CHANGELOG section means no update (a body is never blanked), // and a hand-written body longer than the CHANGELOG section is left exactly as it is. export function planRepair({ releases }) { const update = []; const skip = []; for (const r of releases) { const current = (r.currentBody ?? '').trim(); const next = (r.changelogBody ?? '').trim(); if (!next) { skip.push({ repo: r.repo, tag: r.tag, reason: 'no CHANGELOG section for this version — nothing better to put there' }); continue; } if (next === current) { skip.push({ repo: r.repo, tag: r.tag, reason: 'body already is the CHANGELOG section' }); continue; } if (current.length >= next.length) { skip.push({ repo: r.repo, tag: r.tag, reason: `existing body is not more informative to replace (${current.length} chars vs ${next.length})` }); continue; } update.push({ repo: r.repo, tag: r.tag, body: next, was: current }); } return { update, skip, total: releases.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, repair: false }; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--write') out.write = true; else if (argv[i] === '--repair') out.repair = true; else if (argv[i] === '--repo') out.repo = argv[++i]; } return out; } function resolveNames(api, args) { if (!args.repo) return paced(() => api.listOrgRepos(ORG)).sort(); 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); } return [name]; } // The release text is the plugin's own CHANGELOG section at that tag, falling back to the // tag's message, falling back to empty. Never generated prose. function notesFor(api, repo, tag, tagMessage) { const changelogText = paced(() => api.getFileAtRef(ORG, repo, tag, 'CHANGELOG.md')); return releaseBodyFrom({ changelogText, tag, tagMessage }); } function runRepair(api, args) { const names = resolveNames(api, args); const rows = []; for (const name of names) { const rels = paced(() => api.listReleaseTags(ORG, name)); if (!rels.length) continue; const tags = paced(() => api.listTags(ORG, name)); const newest = tags[0]; if (!newest || !rels.includes(newest.name)) continue; const rel = paced(() => api.getReleaseByTag(ORG, name, newest.name)); if (!rel) continue; const notes = notesFor(api, name, newest.name, newest.message ?? ''); rows.push({ repo: name, tag: newest.name, id: rel.id, currentBody: rel.body ?? '', changelogBody: notes.source === 'changelog' ? notes.body : null, }); } const plan = planRepair({ releases: rows }); console.log(`\nbackfill-forgejo-releases --repair: ${plan.total} release object(s) inspected`); console.log(` ${plan.update.length} would be replaced by their CHANGELOG section\n`); for (const sk of plan.skip) console.log(` · ${sk.repo} ${sk.tag} — ${sk.reason}`); for (const u of plan.update) console.log(` ${args.write ? '→' : '·'} ${u.repo} ${u.tag} — ${u.was.length} chars -> ${u.body.length} chars`); if (!args.write) { console.log('\n (dry-run) re-run with --write to apply.'); return 0; } let done = 0; const failed = []; for (const u of plan.update) { const row = rows.find(r => r.repo === u.repo && r.tag === u.tag); try { paced(() => api.updateRelease(ORG, u.repo, row.id, { body: u.body })); done++; console.log(` ✓ ${u.repo} ${u.tag}`); } catch (err) { failed.push(u); console.log(` ✗ ${u.repo} ${u.tag} — ${err.message}`); } } console.log(`\n updated ${done}/${plan.update.length}; ${failed.length} failed`); return failed.length ? 1 : 0; } function main() { const args = parseArgs(process.argv.slice(2)); const api = forgejoApi({ token: process.env.FORGEJO_TOKEN }); if (args.repair) process.exit(runRepair(api, args)); const names = resolveNames(api, args); 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 sk of plan.skip) { if (sk.excluded) console.log(` ⊘ ${sk.repo} ${sk.tag} — EXCEPTION: ${sk.reason}`); } // Resolve the release text BEFORE deciding anything, so the dry-run shows the source it // would actually publish rather than a promise about it. const resolved = plan.create.map(c => ({ ...c, notes: notesFor(api, c.repo, c.tag, c.body) })); for (const c of resolved) { console.log(` ${args.write ? '→' : '·'} ${c.repo} ${c.tag} notes: ${c.notes.source}${c.notes.source === 'none' ? ' (EMPTY BODY — add a CHANGELOG section)' : ` (${c.notes.body.length} chars)`}`); } 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 resolved) { const url = `https://git.fromaitochitta.com/${ORG}/${c.repo}`; try { const p = planForgejoRelease({ url, tag: c.tag, releaseTags: paced(() => api.listReleaseTags(ORG, c.repo)), body: c.notes.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();