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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 02:41:18 +02:00
commit f9a99056fe
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 384 additions and 29 deletions

View file

@ -16,11 +16,14 @@
// 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 } from './release-plugin.mjs';
import { forgejoApi, planForgejoRelease, ensureForgejoRelease, sleepMs, releaseBodyFrom } from './release-plugin.mjs';
const ORG = 'open';
@ -58,6 +61,35 @@ export function planBackfill({ repos, excluded = EXCLUDED_TAGS }) {
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
@ -68,27 +100,80 @@ export function planBackfill({ repos, excluded = EXCLUDED_TAGS }) {
function paced(fn) { const v = fn(); sleepMs(400); return v; }
function parseArgs(argv) {
const out = { write: false, repo: null };
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 });
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();
}
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)),
@ -100,11 +185,15 @@ function main() {
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 sk of plan.skip) {
if (sk.excluded) console.log(`${sk.repo} ${sk.tag} — EXCEPTION: ${sk.reason}`);
}
for (const c of plan.create) {
console.log(` ${args.write ? '→' : '·'} ${c.repo} ${c.tag}${c.body ? '' : ' (empty tag message -> empty release body)'}`);
// 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) {
@ -114,10 +203,10 @@ function main() {
let filed = 0;
const failed = [];
for (const c of plan.create) {
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)), tagMessage: c.body });
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})` : ''}`); }