diff --git a/.github/scripts/changelog.mjs b/.github/scripts/changelog.mjs index eca90aa0e..77e5dcbe5 100644 --- a/.github/scripts/changelog.mjs +++ b/.github/scripts/changelog.mjs @@ -71,6 +71,43 @@ export function assemble(fragments) { const UNRELEASED = /^## \[Unreleased\][ \t]*$/m +// The headings are reference links: Markdown needs a "[x]: url" definition or it renders "## [x]" as +// literal brackets, and [Unreleased] compares from whatever tag its definition names. +const HOME = 'https://github.com/ThinkFlowLab/FlyBot' +const VERSION_HEADING = /^## \[([^\]]+)\](?:[ \t]+-[^\n]*)?[ \t]*$/gm +const DEFINITION = /^\[([^\]]+)\]:[ \t]*(\S+)[ \t]*$/gm +const baseOf = url => /^(.*?)\/(?:compare|releases)\//.exec(url ?? '')?.[1] ?? HOME +const escapeName = name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +// Every heading needs a definition, and [Unreleased] must compare from the newest release heading; +// each message names the heading it is about. +export function linkProblems(changelog) { + const problems = [] + const headings = [...changelog.matchAll(VERSION_HEADING)].map(match => match[1]) + const definitions = new Map([...changelog.matchAll(DEFINITION)].map(match => [match[1], match[2]])) + for (const name of headings) if (!definitions.has(name)) problems.push(`"## [${name}]" has no "[${name}]:" link definition`) + const newest = headings.find(name => name !== 'Unreleased') + const unreleased = definitions.get('Unreleased') + if (unreleased && newest && !new RegExp(`/compare/v${escapeName(newest)}\\.\\.\\.HEAD$`).test(unreleased)) { + problems.push(`"[Unreleased]:" compares from ${unreleased}, not from the newest release (v${newest})`) + } + return problems +} + +// The definition block Markdown needs, in the order the file already uses: [Unreleased], the version +// being released, then the older ones. They are stripped and re-appended so the block stays together. +function withLinks(text, version, previous) { + const definitions = [...text.matchAll(DEFINITION)].map(match => [match[1], match[2]]) + const base = baseOf(definitions.find(([name]) => name === 'Unreleased')?.[1] ?? definitions.find(([name]) => name === previous)?.[1]) + const rest = definitions.filter(([name]) => name !== 'Unreleased' && name !== version) + const block = [ + `[Unreleased]: ${base}/compare/v${version}...HEAD`, + `[${version}]: ${previous ? `${base}/compare/v${previous}...v${version}` : `${base}/releases/tag/v${version}`}`, + ...rest.map(([name, url]) => `[${name}]: ${url}`), + ] + return `${text.replace(DEFINITION, '').replace(/\s+$/, '')}\n\n${block.join('\n')}\n` +} + // New CHANGELOG.md text: [Unreleased] emptied, a "## [version] - date" section below it that holds the old // [Unreleased] entries first and then the fragments, merged per kind. export function release(changelog, fragments, version, date) { @@ -90,7 +127,8 @@ export function release(changelog, fragments, version, date) { } if (!kinds.size && !lead) throw new Error('nothing to release: [Unreleased] is empty and changelog.d/ has no fragments') const section = `## [${version}] - ${date}\n\n${lead ? `${lead}\n\n` : ''}${renderKinds(kinds)}\n\n` - return `${changelog.slice(0, afterHeading)}\n${section}${changelog.slice(end)}` + const previous = [...changelog.matchAll(VERSION_HEADING)].map(match => match[1]).find(name => name !== 'Unreleased') + return withLinks(`${changelog.slice(0, afterHeading)}\n${section}${changelog.slice(end)}`, version, previous) } function main(argv) { @@ -102,7 +140,10 @@ function main(argv) { try { if (command === 'check') { const fragments = readFragments(dir) - if (!UNRELEASED.test(readFileSync(file, 'utf8'))) fail('CHANGELOG.md has no "## [Unreleased]" section') + const text = readFileSync(file, 'utf8') + if (!UNRELEASED.test(text)) fail('CHANGELOG.md has no "## [Unreleased]" section') + const problems = linkProblems(text) + if (problems.length) fail(problems.join('\n')) console.log(`changelog: ${fragments.length} fragment(s) in changelog.d/ are well formed`) } else if (command === 'preview') { console.log(assemble(readFragments(dir))) diff --git a/.github/scripts/changelog.test.mjs b/.github/scripts/changelog.test.mjs index 1a246108c..a99004cc9 100644 --- a/.github/scripts/changelog.test.mjs +++ b/.github/scripts/changelog.test.mjs @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { assemble, release, readFragments } from './changelog.mjs' +import { assemble, linkProblems, release, readFragments } from './changelog.mjs' const CHANGELOG = `# Changelog @@ -21,6 +21,27 @@ Intro. ### Added - First. + +[Unreleased]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/ThinkFlowLab/FlyBot/releases/tag/v0.1.0 +` + +// The shape CHANGELOG.md has after a repair: [Unreleased] first, then the newest release, and the two +// are the only definitions this fixture needs for release() to keep them in step. +const LINKED = `# Changelog + +Intro. + +## [Unreleased] + +## [0.2.22] - 2026-11-04 + +### Added + +- Last release. + +[Unreleased]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.0...HEAD +[0.2.22]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.21...v0.2.22 ` const tmp = () => mkdtempSync(join(tmpdir(), 'changelog-')) @@ -63,7 +84,35 @@ test('release: Unreleased is emptied; old entries come first, fragments merge pe const out = release(CHANGELOG, [{ kind: 'changed', text: 'New change.' }, { kind: 'fixed', text: 'New fix.' }], '0.1.1', '2026-10-20') assert.match(out, /## \[Unreleased\]\n\n## \[0\.1\.1\] - 2026-10-20\n\n### Changed\n\n- Old hand-written entry\.\n- New change\.\n\n### Fixed\n\n- New fix\.\n\n## \[0\.1\.0\]/) assert.ok(out.startsWith('# Changelog\n\nIntro.\n\n## [Unreleased]\n')) - assert.ok(out.endsWith('- First.\n')) + assert.match(out, /\[Unreleased\]: [^\n]*compare\/v0\.1\.1\.\.\.HEAD\n\[0\.1\.1\]: [^\n]*compare\/v0\.1\.0\.\.\.v0\.1\.1\n\[0\.1\.0\]: [^\n]*releases\/tag\/v0\.1\.0\n$/) +}) + +test('release: writes the version link definition and moves [Unreleased] off the stale tag', () => { + const out = release(LINKED, [{ kind: 'added', text: 'Next.' }], '0.3.0', '2026-11-05') + assert.match(out, /## \[Unreleased\]\n\n## \[0\.3\.0\] - 2026-11-05\n\n### Added\n\n- Next\.\n\n## \[0\.2\.22\]/) + assert.match(out, /^\[0\.3\.0\]: https:\/\/github\.com\/ThinkFlowLab\/FlyBot\/compare\/v0\.2\.22\.\.\.v0\.3\.0$/m) + assert.match(out, /^\[Unreleased\]: https:\/\/github\.com\/ThinkFlowLab\/FlyBot\/compare\/v0\.3\.0\.\.\.HEAD$/m) + assert.match(out, /^\[Unreleased\]: [^\n]*\n\[0\.3\.0\]: [^\n]*\n\[0\.2\.22\]: /m) +}) + +test('CLI: check refuses a missing definition and a stale [Unreleased]', () => { + const root = tmp() + const file = join(root, 'CHANGELOG.md') + const run = (...args) => execFileSync(process.execPath, [new URL('./changelog.mjs', import.meta.url).pathname, ...args], { env: { ...process.env, CHANGELOG_ROOT: root }, encoding: 'utf8' }) + writeFileSync(file, `# Changelog\n\n## [Unreleased]\n\n## [0.2.0] - 2026-11-04\n\n- First.\n\n[Unreleased]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.1.0...HEAD\n`) + assert.throws(() => run('check'), (error) => error.status === 1 && /\[0\.2\.0\]/.test(error.stderr) && /v0\.1\.0/.test(error.stderr) && /\[Unreleased\]/.test(error.stderr)) + writeFileSync(file, '# Changelog\n\n## [Unreleased]\n\n## [0.2.0] - 2026-11-04\n\n- First.\n\n[Unreleased]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.0...HEAD\n[0.2.0]: https://github.com/ThinkFlowLab/FlyBot/releases/tag/v0.2.0\n') + assert.match(run('check'), /fragment/) + rmSync(root, { recursive: true }) +}) + +test('linkProblems: names a heading without a definition and a stale [Unreleased]', () => { + const broken = '# Changelog\n\n## [Unreleased]\n\n## [0.2.0] - 2026-11-04\n\n[Unreleased]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.1.0...HEAD\n' + assert.deepEqual(linkProblems(broken), [ + '"## [0.2.0]" has no "[0.2.0]:" link definition', + '"[Unreleased]:" compares from https://github.com/ThinkFlowLab/FlyBot/compare/v0.1.0...HEAD, not from the newest release (v0.2.0)', + ]) + assert.deepEqual(linkProblems(release(LINKED, [{ kind: 'fixed', text: 'Next.' }], '0.3.0', '2026-11-05')), []) }) test('release: refuses bad input and a duplicate version', () => { @@ -89,7 +138,9 @@ test('CLI: check, preview, then release writes the file and removes the fragment rmSync(root, { recursive: true }) }) -test('the repo: changelog.d/ is valid and CHANGELOG.md keeps its Unreleased heading', () => { +test('the repo: changelog.d/ is valid and every CHANGELOG.md heading has its definition', () => { readFragments(new URL('../../changelog.d', import.meta.url).pathname) - assert.match(readFileSync(new URL('../../CHANGELOG.md', import.meta.url), 'utf8'), /^## \[Unreleased\]$/m) + const text = readFileSync(new URL('../../CHANGELOG.md', import.meta.url), 'utf8') + assert.match(text, /^## \[Unreleased\]$/m) + assert.deepEqual(linkProblems(text), []) }) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee32153a..bdf0b5239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1142,7 +1142,29 @@ If you used the legacy `dsh-bot` package, remove it before you install `ds-bot`. - Only one DSH can change a team at a time; a second DSH on the same DSH home opens it read-only ([Data and backups](ds-bot/README.md#data-and-backups)). - Group chats use many tokens: each member reads the group and answers in turn. -[Unreleased]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.22...HEAD +[0.2.22]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.21...v0.2.22 +[0.2.21]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.20...v0.2.21 +[0.2.20]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.19...v0.2.20 +[0.2.19]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.18...v0.2.19 +[0.2.18]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.17...v0.2.18 +[0.2.17]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.16...v0.2.17 +[0.2.16]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.15...v0.2.16 +[0.2.15]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.14...v0.2.15 +[0.2.14]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.13...v0.2.14 +[0.2.13]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.12...v0.2.13 +[0.2.12]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.11...v0.2.12 +[0.2.11]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.10...v0.2.11 +[0.2.10]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.9...v0.2.10 +[0.2.9]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.8...v0.2.9 +[0.2.8]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.7...v0.2.8 +[0.2.7]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.6...v0.2.7 +[0.2.6]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.5...v0.2.6 +[0.2.5]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.1.0...v0.2.0 [0.1.5]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/ThinkFlowLab/FlyBot/compare/v0.1.3...v0.1.4 diff --git a/changelog.d/2324-changelog-link-definitions.fixed.md b/changelog.d/2324-changelog-link-definitions.fixed.md new file mode 100644 index 000000000..389de6cf0 --- /dev/null +++ b/changelog.d/2324-changelog-link-definitions.fixed.md @@ -0,0 +1 @@ +the changelog's version headings link to their release comparisons again, and `[Unreleased]` compares from the newest release.