Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions .github/scripts/changelog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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)))
Expand Down
59 changes: 55 additions & 4 deletions .github/scripts/changelog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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-'))
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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), [])
})
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2324-changelog-link-definitions.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
the changelog's version headings link to their release comparisons again, and `[Unreleased]` compares from the newest release.
Loading