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

@ -13,6 +13,7 @@ import {
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate,
runRelease, preflightStatMismatches, reportPostWriteCheck,
parseForgejoRepo, planForgejoRelease, ensureForgejoRelease,
extractChangelogSection, releaseBodyFrom,
} from './release-plugin.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 });
}
});
// --- 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');
});