ktg-plugin-marketplace/scripts/backfill-forgejo-releases.mjs
Kjell Tore Guttormsen f9a99056fe
fix(release): release notes come from the CHANGELOG, not the tag message
Same-day correction to ee2259f. That commit followed the order literally --
"use the tag's own message" -- and the result was an llm-security v8.0.0
release page reading "llm-security v8.0.0" and nothing else.

The defect is structural, not a typo: --create-tag mints -m "<name>
v<version>", so the tag message is mechanical EXACTLY where this helper made
the tag. The tag message is a good source only for tags written by hand.

The right source was already proven on the instance: llm-security v7.8.3's
release body is byte-for-byte its CHANGELOG `## [7.8.3]` section. So the
CHANGELOG is the org's established source, not a new invention -- and all 10
backfilled repos ship one (measured, 10/10).

- extractChangelogSection / releaseBodyFrom: pure, tested. Priority is
  CHANGELOG section -> tag message -> empty, and the source is REPORTED so a
  run says where the text came from rather than implying it wrote it.
- Three heading dialects are live and all three are covered: `## [6.0.0] -
  date`, `## [0.2.0] -- date` (em-dash), `## v1.0 (date)`, and voyage's
  `## v5.10.1 -- date -- trailing prose`. The version token matches exactly,
  so 0.1.0-pre is not 0.1.0 and 1.1.0 is not 1.10.0. An empty section (the
  standing `## [Unreleased]`) returns null so the caller falls through
  instead of publishing a blank body.
- backfill gains --repair for the backlog the first cut created. It PATCHes
  a PUBLISHED page, so the bar is strictly more informative, never merely
  different: no CHANGELOG section means no update, and a hand-written body at
  least as long as the section is left alone. Measured: that rule is what
  protects portfolio-optimiser v1.1.0 (4750 hand-written chars vs 3704).

Dry-run over the org: 14 release objects would gain real notes, e.g.
llm-security v8.0.0 19 chars -> 10530, config-audit v6.0.0 19 -> 27309.

18 new tests, written red first. Suite 211/211; check-versions 12/12 OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:41:18 +02:00

224 lines
10 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
// 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 "<name> v<version>"` — 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();