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
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -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
|
||||||
`%(contents:subject)` + `%(contents:body)`, **never `%(contents)`**: `~/.gitconfig` sets
|
is a claim about the release that nobody made.
|
||||||
`tag.gpgsign` with `gpg.format ssh`, so `%(contents)` drags the whole
|
**CORRECTED 2026-09-18, same day, after the first cut shipped.** The order said "use the
|
||||||
`-----BEGIN SSH SIGNATURE-----` block into the release notes (verified on `llm-security`
|
tag's own message", and that was followed literally — which produced a `v8.0.0` release
|
||||||
`v8.0.0`).
|
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
|
||||||
|
`tag.gpgsign` with `gpg.format ssh`, so `%(contents)` drags the whole
|
||||||
|
`-----BEGIN SSH SIGNATURE-----` block into the release notes (verified on `llm-security`
|
||||||
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -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})` : ''}`); }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue