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:
parent
ee2259f63f
commit
f9a99056fe
5 changed files with 384 additions and 29 deletions
|
|
@ -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})` : ''}`); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue