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

@ -96,12 +96,29 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
object for their newest tag** — llm-security's releases page said `v7.8.3` while the catalog object for their newest tag** — llm-security's releases page said `v7.8.3` while the catalog
pinned `v8.0.0`. That is the same tag-vs-published drift the catalog-`ref` bump exists to pinned `v8.0.0`. That is the same tag-vs-published drift the catalog-`ref` bump exists to
prevent, one surface further out, and it is what a human reads to answer "what version is out?". prevent, one surface further out, and it is what a human reads to answer "what version is out?".
- **The release body is the tag's own message, VERBATIM, or empty.** Never generated prose — - **The release body is the plugin's own CHANGELOG section for that version** — falling back
an invented release note is a claim about the release that nobody made. Read it with to the tag message, falling back to empty. Never generated prose: an invented release note
is a claim about the release that nobody made.
**CORRECTED 2026-09-18, same day, after the first cut shipped.** The order said "use the
tag's own message", and that was followed literally — which produced a `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 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. All 10
backfilled repos ship one (measured 2026-09-18, **10/10**).
- **Three heading dialects are live and all three are load-bearing:** `## [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 is matched **exactly**, so
`0.1.0-pre` is not `0.1.0`, `1.1.0` is not `1.10.0`, and an EMPTY section (the standing
`## [Unreleased]`) returns null so the caller falls through instead of publishing a blank.
- The **tag message** remains the fallback, and where it is used it is read with
`%(contents:subject)` + `%(contents:body)`, **never `%(contents)`**: `~/.gitconfig` sets `%(contents:subject)` + `%(contents:body)`, **never `%(contents)`**: `~/.gitconfig` sets
`tag.gpgsign` with `gpg.format ssh`, so `%(contents)` drags the whole `tag.gpgsign` with `gpg.format ssh`, so `%(contents)` drags the whole
`-----BEGIN SSH SIGNATURE-----` block into the release notes (verified on `llm-security` `-----BEGIN SSH SIGNATURE-----` block into the release notes (verified on `llm-security`
`v8.0.0`). `v8.0.0`).
- The run **reports which source it used** (`changelog` / `tag-message` / `none`). `none`
is the one worth seeing: the page went up with an empty body — honest, but useless.
- **It fires only on a run that PUBLISHES** (`--create-tag --write`, or `--push`). Filing a - **It fires only on a run that PUBLISHES** (`--create-tag --write`, or `--push`). Filing a
release object is itself a publish, so it must not ride along on a local `--write` and slip release object is itself a publish, so it must not ride along on a local `--write` and slip
past the operator's one-shot push token. A non-publishing `--write` says so and names the past the operator's one-shot push token. A non-publishing `--write` says so and names the
@ -122,7 +139,14 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
applies, `--repo <owner>/<name>` narrows). It reuses `release-plugin.mjs`'s own planner and API applies, `--repo <owner>/<name>` narrows). It reuses `release-plugin.mjs`'s own planner and API
shell rather than reimplementing them, so the backlog and the release path cannot drift apart. shell rather than reimplementing them, so the backlog and the release path cannot drift apart.
It considers **only the newest tag** (that is the FERDIG-criterion the order set), and reports It considers **only the newest tag** (that is the FERDIG-criterion the order set), and reports
the denominator. **Documented exception:** `ktg-plugin-marketplace` `pre-polyrepo-archive` the denominator. **`--repair` is the second mode:** it replaces a release body that should
have been the CHANGELOG section but isn't — the backlog the first cut created. Because it
PATCHes a **published** page, 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 at least as long as the CHANGELOG section is left exactly as it is
(measured 2026-09-18: that rule is what protects `portfolio-optimiser` `v1.1.0`, whose
4750-char hand-written body beats its 3704-char CHANGELOG section).
**Documented exception:** `ktg-plugin-marketplace` `pre-polyrepo-archive`
an archive marker for the monorepo before the polyrepo split, not a release (non-semver, and an archive marker for the monorepo before the polyrepo split, not a release (non-semver, and
no install path consumes it). The exclusion register is keyed by repo **AND tag**, never by no install path consumes it). The exclusion register is keyed by repo **AND tag**, never by
repo alone, so that repo's next real release is still backfilled. repo alone, so that repo's next real release is still backfilled.

View file

@ -16,11 +16,14 @@
// node scripts/backfill-forgejo-releases.mjs # dry-run: print the plan // 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 --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 --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: // Needs FORGEJO_TOKEN:
// export FORGEJO_TOKEN="$(security find-generic-password -a ktg -s forgejo-token -w login.keychain-db)" // 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'; 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 }; 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 --------------------------------------------------------------- // --- I/O shell ---------------------------------------------------------------
// The instance sits behind nginx with a rate limit: an unthrottled sweep of 24 repos // 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 paced(fn) { const v = fn(); sleepMs(400); return v; }
function parseArgs(argv) { 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++) { for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--write') out.write = true; if (argv[i] === '--write') out.write = true;
else if (argv[i] === '--repair') out.repair = true;
else if (argv[i] === '--repo') out.repo = argv[++i]; else if (argv[i] === '--repo') out.repo = argv[++i];
} }
return out; 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() { function main() {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
const api = forgejoApi({ token: process.env.FORGEJO_TOKEN }); const api = forgejoApi({ token: process.env.FORGEJO_TOKEN });
let names; if (args.repair) process.exit(runRepair(api, args));
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 names = resolveNames(api, args);
const repos = names.map(name => ({ const repos = names.map(name => ({
name, name,
tags: paced(() => api.listTags(ORG, 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(`\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`); console.log(` ${plan.create.length} newest tag(s) missing a release object\n`);
for (const s of plan.skip) { for (const sk of plan.skip) {
if (s.excluded) console.log(`${s.repo} ${s.tag} — EXCEPTION: ${s.reason}`); 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) { if (!args.write) {
@ -114,10 +203,10 @@ function main() {
let filed = 0; let filed = 0;
const failed = []; const failed = [];
for (const c of plan.create) { for (const c of resolved) {
const url = `https://git.fromaitochitta.com/${ORG}/${c.repo}`; const url = `https://git.fromaitochitta.com/${ORG}/${c.repo}`;
try { 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); const res = ensureForgejoRelease(p, api);
sleepMs(400); sleepMs(400);
if (res.created) { filed++; console.log(`${c.repo} ${c.tag}${res.url ? ` (${res.url})` : ''}`); } if (res.created) { filed++; console.log(`${c.repo} ${c.tag}${res.url ? ` (${res.url})` : ''}`); }

View file

@ -3,7 +3,7 @@
// forgejoApi) is exercised against the live instance, not here. // forgejoApi) is exercised against the live instance, not here.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { planBackfill, EXCLUDED_TAGS } from './backfill-forgejo-releases.mjs'; import { planBackfill, planRepair, EXCLUDED_TAGS } from './backfill-forgejo-releases.mjs';
const repo = (name, tags, releases = []) => ({ name, tags, releases }); const repo = (name, tags, releases = []) => ({ name, tags, releases });
const tag = (name, message = '') => ({ name, message }); const tag = (name, message = '') => ({ name, message });
@ -62,3 +62,51 @@ test('the summary reports the DENOMINATOR, so a run that verified nothing cannot
assert.equal(r.total, 3); assert.equal(r.total, 3);
assert.equal(r.tagged, 2); assert.equal(r.tagged, 2);
}); });
// --- Repair: a release object already filed with a mechanical body ----------
//
// The first backfill used the tag message, so llm-security v8.0.0 was filed with the body
// "llm-security v8.0.0" — the mechanical string `--create-tag` mints. Repair replaces such
// a body with the CHANGELOG section that should have been there. It is a PATCH to a
// published page, so the bar is "strictly more informative", never "different".
test('repair plans an update when a CHANGELOG section exists and the current body is the mechanical tag message', () => {
const r = planRepair({ releases: [
{ repo: 'llm-security', tag: 'v8.0.0', currentBody: 'llm-security v8.0.0', changelogBody: 'Major release. Breaking part is small.' },
] });
assert.equal(r.update.length, 1);
assert.equal(r.update[0].repo, 'llm-security');
assert.equal(r.update[0].body, 'Major release. Breaking part is small.');
});
test('repair leaves a release whose body ALREADY is the CHANGELOG section', () => {
const same = 'Security and correctness patch.';
const r = planRepair({ releases: [{ repo: 'llm-security', tag: 'v7.8.3', currentBody: same, changelogBody: same }] });
assert.equal(r.update.length, 0);
assert.equal(r.skip.length, 1);
assert.match(r.skip[0].reason, /already/);
});
test('repair NEVER blanks a body: no CHANGELOG section means no update', () => {
const r = planRepair({ releases: [{ repo: 'x', tag: 'v1.0.0', currentBody: 'hand written notes', changelogBody: null }] });
assert.equal(r.update.length, 0);
assert.match(r.skip[0].reason, /no CHANGELOG section/);
});
test('repair does not overwrite a body that is LONGER than the CHANGELOG section', () => {
// A hand-written release page that says more than the CHANGELOG is not a defect to fix.
const r = planRepair({ releases: [
{ repo: 'x', tag: 'v1.0.0', currentBody: 'a much longer hand written release note with detail', changelogBody: 'short' },
] });
assert.equal(r.update.length, 0);
assert.match(r.skip[0].reason, /not more informative/);
});
test('repair reports the denominator', () => {
const r = planRepair({ releases: [
{ repo: 'a', tag: 'v1', currentBody: 'a v1', changelogBody: 'real notes for a' },
{ repo: 'b', tag: 'v2', currentBody: 'real notes for b', changelogBody: 'real notes for b' },
] });
assert.equal(r.total, 2);
assert.equal(r.update.length, 1);
});

View file

@ -367,12 +367,12 @@ export function parseForgejoRepo(url) {
// The release body is the TAG'S OWN message, verbatim, or empty. Never generated prose: // The release body is the TAG'S OWN message, verbatim, or empty. Never generated prose:
// an invented release note is a claim about the release that nobody actually made. // an invented release note is a claim about the release that nobody actually made.
export function planForgejoRelease({ url, tag, releaseTags = [], tagMessage = '' }) { export function planForgejoRelease({ url, tag, releaseTags = [], tagMessage = '', body = null }) {
const loc = parseForgejoRepo(url); const loc = parseForgejoRepo(url);
if (!loc) return { verdict: 'BLOCKED', reason: `cannot derive owner/repo from the catalog source url: ${JSON.stringify(url)}` }; if (!loc) return { verdict: 'BLOCKED', reason: `cannot derive owner/repo from the catalog source url: ${JSON.stringify(url)}` };
if (!tag) return { verdict: 'BLOCKED', reason: 'no tag to file a release object for' }; if (!tag) return { verdict: 'BLOCKED', reason: 'no tag to file a release object for' };
if (releaseTags.includes(tag)) return { verdict: 'NOOP', ...loc, tag, reason: `a release object for ${tag} already exists` }; if (releaseTags.includes(tag)) return { verdict: 'NOOP', ...loc, tag, reason: `a release object for ${tag} already exists` };
return { verdict: 'CREATE', ...loc, tag, name: tag, body: (tagMessage ?? '').trim() }; return { verdict: 'CREATE', ...loc, tag, name: tag, body: body ?? (tagMessage ?? '').trim() };
} }
export function ensureForgejoRelease(plan, api) { export function ensureForgejoRelease(plan, api) {
@ -381,6 +381,59 @@ export function ensureForgejoRelease(plan, api) {
return { created: true, verdict: 'CREATED', url: res?.html_url ?? null }; return { created: true, verdict: 'CREATED', url: res?.html_url ?? null };
} }
// Release notes come from the plugin's own CHANGELOG, not from the tag message.
//
// The first cut used the tag message, as the order asked. Measured against what it
// produced: llm-security v8.0.0's release page read "llm-security v8.0.0" and nothing
// else — because `--create-tag` mints `-m "<name> v<version>"`, so the tag message is
// MECHANICAL exactly where this helper made the tag. The org's own answer was already on
// the instance: llm-security v7.8.3's release body is byte-for-byte its CHANGELOG
// `## [7.8.3]` section. So this is the established source, not a new invention — and all
// 10 backfilled repos ship a CHANGELOG (measured 2026-09-18, 10/10).
//
// Three heading dialects are in live use, all three load-bearing:
// ## [6.0.0] - 2026-08-18 ## [0.2.0] — 2026-08-20 ## v1.0 (2026-08-18)
// ## v5.10.1 — 2026-09-03 — gemini-bridge dropped (trailing prose in the heading)
// The version token is matched EXACTLY, so `0.1.0-pre` is not `0.1.0`, `1.1.0` is not
// `1.10.0`, and `[Unreleased]` is never a release.
export function extractChangelogSection(text, version) {
if (typeof text !== 'string' || !text || !version) return null;
const headingVersion = (line) => {
const m = line.match(/^##\s+\[?v?([^\]\s(]+)\]?/);
return m ? m[1] : null;
};
const lines = text.split('\n');
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (!/^##\s/.test(lines[i])) continue;
if (headingVersion(lines[i]) === version) { start = i + 1; break; }
}
if (start === -1) return null;
let end = lines.length;
for (let i = start; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) { end = i; break; }
}
// An EMPTY section is not release notes — `## [Unreleased]` is the standing case, and a
// caller must fall through to the next source rather than publish a blank body.
const body = lines.slice(start, end).join('\n').trim();
return body || null;
}
// Source priority, and the source is REPORTED so a caller can say where the text came
// from rather than implying it wrote it: the CHANGELOG section, else the tag's own
// message, else nothing. Never generated prose.
export function releaseBodyFrom({ changelogText, tag, tagMessage }) {
const version = String(tag ?? '').replace(/^v/, '');
const section = extractChangelogSection(changelogText, version);
if (section) return { body: section, source: 'changelog' };
const msg = (tagMessage ?? '').trim();
if (msg) return { body: msg, source: 'tag-message' };
return { body: '', source: 'none' };
}
// A tag's message WITHOUT its signature. `%(contents)` would carry the whole // A tag's message WITHOUT its signature. `%(contents)` would carry the whole
// "-----BEGIN SSH SIGNATURE-----" block into the release notes — ~/.gitconfig sets // "-----BEGIN SSH SIGNATURE-----" block into the release notes — ~/.gitconfig sets
// tag.gpgsign with gpg.format ssh, so every tag this helper mints is signed (verified // tag.gpgsign with gpg.format ssh, so every tag this helper mints is signed (verified
@ -471,6 +524,29 @@ export function forgejoApi({ baseUrl = FORGEJO_API, token, exec = execFileSync,
createRelease(owner, repo, payload) { createRelease(owner, repo, payload) {
return call('POST', `/repos/${owner}/${repo}/releases`, payload); return call('POST', `/repos/${owner}/${repo}/releases`, payload);
}, },
// null when the path does not exist at that ref (a 404 is an ANSWER: "no CHANGELOG"),
// while every other error still raises.
getFileAtRef(owner, repo, ref, path) {
try {
const d = call('GET', `/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`);
if (!d?.content) return null;
return Buffer.from(d.content, d.encoding === 'base64' ? 'base64' : 'utf8').toString('utf8');
} catch (err) {
if (/HTTP 404/.test(err.message)) return null;
throw err;
}
},
getReleaseByTag(owner, repo, tag) {
try {
return call('GET', `/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`);
} catch (err) {
if (/HTTP 404/.test(err.message)) return null;
throw err;
}
},
updateRelease(owner, repo, id, payload) {
return call('PATCH', `/repos/${owner}/${repo}/releases/${id}`, payload);
},
}; };
} }
@ -714,15 +790,24 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
// are already public, and an unhandled exception would report that as a crash instead of // are already public, and an unhandled exception would report that as a crash instead of
// as the one precise thing still undone. // as the one precise thing still undone.
try { try {
const notes = releaseBodyFrom({
changelogText: readGitShow(obs.repoDir, plan.newRef, 'CHANGELOG.md'),
tag: plan.newRef,
tagMessage: readTagMessage(obs.repoDir, plan.newRef),
});
const fjPlan = planForgejoRelease({ const fjPlan = planForgejoRelease({
url: sourceUrl, url: sourceUrl,
tag: plan.newRef, tag: plan.newRef,
releaseTags: (forgejo ?? (forgejo = forgejoApi({ token: process.env.FORGEJO_TOKEN }))).listReleaseTags(loc.owner, loc.repo), releaseTags: (forgejo ?? (forgejo = forgejoApi({ token: process.env.FORGEJO_TOKEN }))).listReleaseTags(loc.owner, loc.repo),
tagMessage: readTagMessage(obs.repoDir, plan.newRef), body: notes.body,
}); });
const res = ensureForgejoRelease(fjPlan, forgejo); const res = ensureForgejoRelease(fjPlan, forgejo);
if (res.created) console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`); if (res.created) {
else console.log(` · Forgejo release object for ${plan.newRef} already exists — nothing to file.`); console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`);
// Say where the text came from. `none` is the one worth seeing: the page went up
// with an empty body, which is honest but useless, and the CHANGELOG is the fix.
console.log(` release notes source: ${notes.source}${notes.source === 'none' ? ' — add a CHANGELOG section for this version' : ''}`);
} else console.log(` · Forgejo release object for ${plan.newRef} already exists — nothing to file.`);
} catch (err) { } catch (err) {
console.log(` ✗ Forgejo release object NOT filed for ${plan.newRef}: ${err.message}`); console.log(` ✗ Forgejo release object NOT filed for ${plan.newRef}: ${err.message}`);
console.log(' The tag and the catalog are published — the /releases page is the only thing behind.'); console.log(' The tag and the catalog are published — the /releases page is the only thing behind.');

View file

@ -13,6 +13,7 @@ import {
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate, pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate,
runRelease, preflightStatMismatches, reportPostWriteCheck, runRelease, preflightStatMismatches, reportPostWriteCheck,
parseForgejoRepo, planForgejoRelease, ensureForgejoRelease, parseForgejoRepo, planForgejoRelease, ensureForgejoRelease,
extractChangelogSection, releaseBodyFrom,
} from './release-plugin.mjs'; } from './release-plugin.mjs';
import { classifyPlugin } from './check-versions.mjs'; import { classifyPlugin } from './check-versions.mjs';
@ -1100,3 +1101,111 @@ test('R-FJ2 (real git): a failed release-object create reports precisely and ret
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
} }
}); });
// --- Release notes come from the CHANGELOG ----------------------------------
//
// The first cut used the tag's own message, which the order asked for. Measured against
// what it produced: llm-security v8.0.0's release page read "llm-security v8.0.0" and
// nothing else — because release-plugin.mjs mints tags with `-m "<name> <tag>"`, so the
// tag message is mechanical EXACTLY where this helper made the tag. The org's own answer
// was already 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 established source, not a new
// invention — and all 10 backfilled repos ship one (measured 2026-09-18, 10/10).
//
// Three heading dialects are in live use and all three are load-bearing here:
// ## [6.0.0] - 2026-08-18 bracketed, hyphen
// ## [0.2.0] — 2026-08-20 bracketed, em-dash
// ## v1.0 (2026-08-18) bare v-prefix, parenthesised date
// ## v5.10.1 — 2026-09-03 — ... bare v-prefix, trailing prose in the heading
const CL_BRACKET = `# Changelog
## [Unreleased]
## [8.0.0] - 2026-09-18
Major release. The breaking part is small.
### Added
- llms.txt at the repository root.
## [7.8.3] - 2026-07-18
Security and correctness patch.
`;
test('extractChangelogSection pulls the bracketed section and stops at the next release', () => {
const out = extractChangelogSection(CL_BRACKET, '8.0.0');
assert.match(out, /^Major release\./);
assert.match(out, /llms\.txt/);
assert.doesNotMatch(out, /Security and correctness patch/, 'the next release must not bleed in');
assert.doesNotMatch(out, /^## /m, 'the section heading itself is not part of the body');
});
test('extractChangelogSection never matches [Unreleased]', () => {
assert.equal(extractChangelogSection(CL_BRACKET, 'Unreleased'), null);
});
test('extractChangelogSection: a pre-release heading is not a match for the plain version', () => {
const cl = '## [0.1.0-pre] — 2026-05-15\n\npre stuff\n';
assert.equal(extractChangelogSection(cl, '0.1.0'), null, '0.1.0-pre is a different version');
});
test('extractChangelogSection reads the em-dash dialect', () => {
const cl = '# C\n\n## [0.2.0] — 2026-08-20\n\ntwo point oh\n\n## [0.1.0] — 2026-05-17\n\none\n';
assert.equal(extractChangelogSection(cl, '0.2.0'), 'two point oh');
});
test('extractChangelogSection reads the bare v-prefix dialect with a parenthesised date', () => {
const cl = '# C\n\n## v1.0 (2026-08-18)\n\nfemlagsstruktur\n\n## v0.13 (2026-08-18)\n\nolder\n';
assert.equal(extractChangelogSection(cl, '1.0'), 'femlagsstruktur');
});
test('extractChangelogSection reads a heading that carries trailing prose', () => {
const cl = '## v5.10.1 — 2026-09-03 — gemini-bridge dropped\n\nbody here\n\n## v5.10.0 — 2026-08-18 — STORM\n\nolder\n';
assert.equal(extractChangelogSection(cl, '5.10.1'), 'body here');
});
test('extractChangelogSection returns null when the version has no section — never a neighbour', () => {
assert.equal(extractChangelogSection(CL_BRACKET, '9.9.9'), null);
assert.equal(extractChangelogSection('', '1.0.0'), null);
assert.equal(extractChangelogSection(null, '1.0.0'), null);
});
test('extractChangelogSection does not confuse 1.1.0 with 1.10.0', () => {
const cl = '## [1.10.0] - 2026-01-01\n\nten\n\n## [1.1.0] - 2026-01-01\n\none\n';
assert.equal(extractChangelogSection(cl, '1.1.0'), 'one');
assert.equal(extractChangelogSection(cl, '1.10.0'), 'ten');
});
test('releaseBodyFrom prefers the CHANGELOG section over the tag message', () => {
const r = releaseBodyFrom({ changelogText: CL_BRACKET, tag: 'v8.0.0', tagMessage: 'llm-security v8.0.0' });
assert.equal(r.source, 'changelog');
assert.match(r.body, /^Major release\./);
});
test('releaseBodyFrom falls back to the tag message when the CHANGELOG has no such section', () => {
const r = releaseBodyFrom({ changelogText: CL_BRACKET, tag: 'v9.9.9', tagMessage: 'a real hand-written tag message' });
assert.equal(r.source, 'tag-message');
assert.equal(r.body, 'a real hand-written tag message');
});
test('releaseBodyFrom falls back with NO changelog at all', () => {
const r = releaseBodyFrom({ changelogText: null, tag: 'v1.0.0', tagMessage: 'msg' });
assert.equal(r.source, 'tag-message');
assert.equal(r.body, 'msg');
});
test('releaseBodyFrom reports an EMPTY body as its own source — it never invents prose', () => {
const r = releaseBodyFrom({ changelogText: null, tag: 'v1.0.0', tagMessage: '' });
assert.equal(r.source, 'none');
assert.equal(r.body, '');
});
test('releaseBodyFrom: a MECHANICAL tag message loses to the CHANGELOG — that was the whole defect', () => {
// `release-plugin.mjs --create-tag` mints `-m "<name> v<version>"`, so this exact shape
// is what the helper's own tags carry, and it is what made v8.0.0's page say nothing.
const r = releaseBodyFrom({ changelogText: CL_BRACKET, tag: 'v8.0.0', tagMessage: 'llm-security v8.0.0' });
assert.notEqual(r.body, 'llm-security v8.0.0');
});