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

@ -367,12 +367,12 @@ export function parseForgejoRepo(url) {
// 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.
export function planForgejoRelease({ url, tag, releaseTags = [], tagMessage = '' }) {
export function planForgejoRelease({ url, tag, releaseTags = [], tagMessage = '', body = null }) {
const loc = parseForgejoRepo(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 (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) {
@ -381,6 +381,59 @@ export function ensureForgejoRelease(plan, api) {
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
// "-----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
@ -471,6 +524,29 @@ export function forgejoApi({ baseUrl = FORGEJO_API, token, exec = execFileSync,
createRelease(owner, repo, 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
// as the one precise thing still undone.
try {
const notes = releaseBodyFrom({
changelogText: readGitShow(obs.repoDir, plan.newRef, 'CHANGELOG.md'),
tag: plan.newRef,
tagMessage: readTagMessage(obs.repoDir, plan.newRef),
});
const fjPlan = planForgejoRelease({
url: sourceUrl,
tag: plan.newRef,
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);
if (res.created) console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`);
else console.log(` · Forgejo release object for ${plan.newRef} already exists — nothing to file.`);
if (res.created) {
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) {
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.');