From cb31eabcd8c75b939c159b0f04b821a0bfd130f6 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 09:04:47 -0500 Subject: [PATCH 01/13] Do not include trailing newlines in `NO_CHANGES_STR` Changing `NO_CHANGES_STR` to just be the text will make it easier to insert/use. To not break anything, I added the deleted newlines to the locations where `NO_CHANGES_STR` was used. --- pr-checks/bundle-changelog.test.ts | 4 ++-- pr-checks/changelog.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pr-checks/bundle-changelog.test.ts b/pr-checks/bundle-changelog.test.ts index 6cc4d096ba..fad06826a6 100644 --- a/pr-checks/bundle-changelog.test.ts +++ b/pr-checks/bundle-changelog.test.ts @@ -112,7 +112,7 @@ ${NO_CHANGES_STR}`; describe("updateChangelog", async () => { await it("removes `NO_CHANGES_STR` if present in [UNRELEASED] section", async () => { const result = updateChangelog(EMPTY_CHANGELOG, ""); - assert.ok(!result.includes(NO_CHANGES_STR.trim())); + assert.ok(!result.includes(NO_CHANGES_STR)); }); await it("doesn't remove `NO_CHANGES_STR` if present in versioned section", async () => { @@ -120,7 +120,7 @@ describe("updateChangelog", async () => { EMPTY_CHANGELOG.replace(UNRELEASED_PLACEHOLDER, "1.2.3"), "", ); - assert.ok(result.includes(NO_CHANGES_STR.trim())); + assert.ok(result.includes(NO_CHANGES_STR)); }); await it("throws if there are no sections", async () => { diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 4cf1e75494..fc17199d6a 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -6,14 +6,16 @@ import { CHANGELOG_FILE, DryRunOption } from "./config"; export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]"; /** The default contents for a section in the changelog. */ -export const NO_CHANGES_STR = "No user facing changes.\n\n"; +export const NO_CHANGES_STR = "No user facing changes."; /** Placeholder changelog content for a new release. */ export const EMPTY_CHANGELOG = `# CodeQL Action Changelog ## ${UNRELEASED_PLACEHOLDER} -${NO_CHANGES_STR}`; +${NO_CHANGES_STR} + +`; /** * Represents sections in a changelog. @@ -204,7 +206,7 @@ export function processChangelogForBackports( // Add an entry if we didn't keep any. if (!foundContent) { - section.bodyLines.push(NO_CHANGES_STR.trim()); + section.bodyLines.push(NO_CHANGES_STR); } } From 47d607e2c1eebf3c9382b1872b2f902a7915bbc4 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 09:06:31 -0500 Subject: [PATCH 02/13] Add changelog parsing helper `getHeader` --- pr-checks/changelog.test.ts | 19 +++++++++++++++++++ pr-checks/changelog.ts | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts index 817852e3e1..45658154fc 100755 --- a/pr-checks/changelog.test.ts +++ b/pr-checks/changelog.test.ts @@ -10,6 +10,7 @@ import { describe, it } from "node:test"; import { EMPTY_CHANGELOG, + getHeader, getReleaseDateString, parseChangelog, processChangelogForBackports, @@ -20,6 +21,24 @@ import { CHANGELOG_FILE } from "./config"; const testDate = new Date(2026, 7, 14); +describe("getHeader", async () => { + await it("returns non-headers unchanged", () => { + assert.equal("foo", getHeader("foo")); + assert.equal("- bar", getHeader("- bar")); + }); + await it("strips octothorpes", async () => { + assert.equal("foo", getHeader("# foo")); + assert.equal("foo", getHeader("## foo")); + assert.equal("foo", getHeader("### foo")); + assert.equal("foo", getHeader("#### foo")); + assert.equal("foo", getHeader("##### foo")); + assert.equal("foo", getHeader("###### foo")); + }); + await it("strips whitespace", async () => { + assert.equal("foo", getHeader("# foo ")); + }); +}); + describe("getReleaseDateString", async () => { await it("formats dates as expected", async () => { assert.equal(getReleaseDateString(testDate), "14 Aug 2026"); diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index fc17199d6a..8265430243 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -33,6 +33,11 @@ export interface Changelog { sections: ChangelogSection[]; } +/** Returns the text of a CHANGELOG.md header (without the '## ' prefix). */ +export function getHeader(headerLine: string): string { + return headerLine.replace(/^#+\s+/, "").trimEnd(); +} + /** Returns `date` formatted as `DD Mon YYYY`. */ export function getReleaseDateString(today: Date = new Date()): string { return today.toLocaleDateString("en-GB", { From bb1dc5460bab614d5c8c05120b7e5465e04d8417 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 09:07:44 -0500 Subject: [PATCH 03/13] Add CHANGELOG function `addBodyLinesToUnreleasedSection` This will be used by the `pr-checks/changenotes.mts` script to "compile" the latest release entry of CHANGELOG.md. --- pr-checks/changelog.test.ts | 79 +++++++++++++++++++++++++++++++++++++ pr-checks/changelog.ts | 53 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts index 45658154fc..b88f9b0c07 100755 --- a/pr-checks/changelog.test.ts +++ b/pr-checks/changelog.test.ts @@ -9,13 +9,17 @@ import * as fs from "node:fs"; import { describe, it } from "node:test"; import { + addBodyLinesToUnreleasedSection, + ChangelogSection, EMPTY_CHANGELOG, getHeader, getReleaseDateString, + NO_CHANGES_STR, parseChangelog, processChangelogForBackports, renderChangelog, setVersionAndDate, + UNRELEASED_PLACEHOLDER, } from "./changelog"; import { CHANGELOG_FILE } from "./config"; @@ -89,3 +93,78 @@ describe("processChangelogForBackports", async () => { assert.deepEqual(result.split("\n"), testChangelogResult.split("\n")); }); }); + +describe("addBodyLinesToUnreleasedSection", async () => { + function newChangelogWithSections(sections: ChangelogSection[]) { + return { + preamble: [], + sections, + }; + } + + await it("throws error if '[UNRELEASED]' section does not exist", async () => { + const emptyChangelog = newChangelogWithSections([]); + assert.throws(() => addBodyLinesToUnreleasedSection(emptyChangelog, [])); + + const releasedChangelog = newChangelogWithSections([ + { + headerLine: "## Release 1.0.0", + bodyLines: [], + }, + { + headerLine: "## Release 2.0.0", + bodyLines: [], + }, + { + headerLine: "## Release 3.0.0", + bodyLines: [], + }, + ]); + assert.throws(() => addBodyLinesToUnreleasedSection(releasedChangelog, [])); + }); + + await it("overwrites 'No user facing changes.'", async () => { + const changelog = newChangelogWithSections([ + { + headerLine: `## ${UNRELEASED_PLACEHOLDER}`, + bodyLines: ["", NO_CHANGES_STR, ""], + }, + ]); + + addBodyLinesToUnreleasedSection(changelog, ["- foo"]); + + assert.equal(changelog.sections[0].bodyLines.length, 3); + assert.deepEqual(changelog.sections[0].bodyLines, ["", "- foo", ""]); + }); + + await it("does nothing if lines is empty", async () => { + const changelog = newChangelogWithSections([ + { + headerLine: `## ${UNRELEASED_PLACEHOLDER}`, + bodyLines: ["", NO_CHANGES_STR, ""], + }, + ]); + const changelogClone = structuredClone(changelog); + + addBodyLinesToUnreleasedSection(changelog, []); + + assert.deepEqual(changelog, changelogClone); + }); + + await it("inserts a line", async () => { + const changelog = newChangelogWithSections([ + { + headerLine: `## ${UNRELEASED_PLACEHOLDER}`, + bodyLines: ["", "- Added a new dependency.", ""], + }, + ]); + const lineToInsert = "- foo"; + + addBodyLinesToUnreleasedSection(changelog, [lineToInsert]); + + assert.equal(changelog.sections[0].bodyLines.length, 4); + assert.ok( + changelog.sections[0].bodyLines.some((line) => line === lineToInsert), + ); + }); +}); diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 8265430243..ec765b4c8f 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -132,6 +132,59 @@ export function parseChangelog(content: string): Changelog { return { preamble, sections }; } +/** + * Inserts the changenotes `notes` under the `[UNRELEASED]` section of `changelog`. + * If the section contains the stock message {@link NO_CHANGES_STR}, then + * `notes` will be inserted in place and the stock message will be deleted. + * + * This function will throw an exception if `[UNRELEASED]` does not exist. + * + * @param changelog The CHANGELOG object to modify. + * @param lines The changenotes to insert. + */ +export function addBodyLinesToUnreleasedSection( + changelog: Changelog, + lines: string[], +) { + // Find the '[UNRELEASED]' section. + let unreleasedSection: ChangelogSection | undefined; + for (const section of changelog.sections) { + if (getHeader(section.headerLine) === UNRELEASED_PLACEHOLDER) { + unreleasedSection = section; + break; + } + } + + // Ensure that the '[UNRELEASED]' section exists first. + if (unreleasedSection === undefined) { + throw Error( + "Cannot put changenotes into CHANGELOG.md's '[UNRELEASED]' section because it does not exist", + ); + } + + let insertAtIndex = 0; + let deleteCount = 0; + + // If the section contains an empty line, preserve it -- insert afterward. + if ( + unreleasedSection.bodyLines.length > 0 && + unreleasedSection.bodyLines[0] === "" + ) { + insertAtIndex++; + } + + // If the section contains the stock message 'No user facing changes.' + if ( + lines.length > 0 && + unreleasedSection.bodyLines.length > insertAtIndex && + unreleasedSection.bodyLines[insertAtIndex].trim() === NO_CHANGES_STR + ) { + deleteCount++; // Delete the line by incrementing the delete marker. + } + + unreleasedSection.bodyLines.splice(insertAtIndex, deleteCount, ...lines); +} + /** * Combines an array of lines into a single string by adding line breaks. */ From 1ee32652624bab3b2447ddd6f0d5794ec2ea6f1a Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 09:23:03 -0500 Subject: [PATCH 04/13] Add `changenotes.mts flush` command This command will "flush" or move the changenotes in the `unreleased-change-notes` directory to the `[UNRELEASED]` section of the CHANGELOG.md file. --- pr-checks/changenotes.mts | 52 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index 2fb86b0cac..c7a10d0229 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -1,11 +1,20 @@ #!/usr/bin/env npx tsx import * as fs from "node:fs"; +import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import path from "path"; +import { matter } from "lite-matter"; + +import { + addBodyLinesToUnreleasedSection, + parseChangelog, + renderChangelog, +} from "./changelog"; import { isValidAllChangenoteFiles } from "./changelog/validate.mjs"; -import { CHANGENOTES_DIR } from "./config"; +import { CHANGELOG_FILE, CHANGENOTES_DIR } from "./config"; const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { @@ -27,6 +36,8 @@ function main(): number { case undefined: case "help": return usage(); + case "flush": + return flush(); case "validate": return validate(); default: @@ -36,10 +47,47 @@ function main(): number { } function usage(): number { - console.log(`Usage: changenotes.mts validate`); + const message = + "Usage: changenotes.mts flush\n" + + " changenotes.mts validate\n" + + " changenotes.mts help"; + console.log(message); return 0; } +function flush(): number { + try { + // Get the file paths to our changenotes; these will be useful later. + const changenotePaths = fs + .readdirSync(CHANGENOTES_DIR) + .filter((name) => name !== ".gitkeep") + .map((name) => path.join(CHANGENOTES_DIR, name)); + + // From the file paths, we read the files to obtain the actual notes themselves. + const changenotes = changenotePaths.map((filePath) => { + const fileBody = readFileSync(filePath).toString(); + const { content } = matter(fileBody); + return content.trim(); + }); + + const changelogContents = fs.readFileSync(CHANGELOG_FILE).toString(); + const changelog = parseChangelog(changelogContents); + addBodyLinesToUnreleasedSection(changelog, changenotes); + fs.writeFileSync(CHANGELOG_FILE, renderChangelog(changelog)); + + // Delete changenotes only after successful processing. + for (const p of changenotePaths) { + fs.unlinkSync(p); + } + + return 0; + } catch (e) { + console.error("Failed to flush changenotes to 'CHANGELOG.md'", e); + } + + return 1; +} + function validate(): number { try { if (isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR))) { From bffae1c4b8f56e1f31092864ec57102c0c026ac2 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 11:22:39 -0500 Subject: [PATCH 05/13] Use `withChangelog` I/O helper --- pr-checks/changenotes.mts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index c7a10d0229..06b10da8eb 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -12,9 +12,10 @@ import { addBodyLinesToUnreleasedSection, parseChangelog, renderChangelog, + withChangelog, } from "./changelog"; import { isValidAllChangenoteFiles } from "./changelog/validate.mjs"; -import { CHANGELOG_FILE, CHANGENOTES_DIR } from "./config"; +import { CHANGENOTES_DIR } from "./config"; const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { @@ -70,10 +71,11 @@ function flush(): number { return content.trim(); }); - const changelogContents = fs.readFileSync(CHANGELOG_FILE).toString(); - const changelog = parseChangelog(changelogContents); - addBodyLinesToUnreleasedSection(changelog, changenotes); - fs.writeFileSync(CHANGELOG_FILE, renderChangelog(changelog)); + withChangelog((contents) => { + const changelog = parseChangelog(contents); + addBodyLinesToUnreleasedSection(changelog, changenotes); + return renderChangelog(changelog); + }, {}); // Delete changenotes only after successful processing. for (const p of changenotePaths) { From b246e5606946f9e44193fd31a37458b82052507a Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 11:28:17 -0500 Subject: [PATCH 06/13] Use `ExitCode` instead of `0`/`1` --- pr-checks/changenotes.mts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index 06b10da8eb..fd3225b911 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import path from "path"; +import { ExitCode } from "@actions/core"; import { matter } from "lite-matter"; import { @@ -23,11 +24,11 @@ if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { process.exit(main()); } catch (error) { console.error(error); - process.exit(1); + process.exit(ExitCode.Failure); } } -function main(): number { +function main(): ExitCode { const { positionals } = parseArgs({ allowPositionals: true, strict: true, @@ -43,20 +44,20 @@ function main(): number { return validate(); default: console.error(`Unknown command: ${command}`); - return 1; + return ExitCode.Failure; } } -function usage(): number { +function usage(): ExitCode { const message = "Usage: changenotes.mts flush\n" + " changenotes.mts validate\n" + " changenotes.mts help"; console.log(message); - return 0; + return ExitCode.Success; } -function flush(): number { +function flush(): ExitCode { try { // Get the file paths to our changenotes; these will be useful later. const changenotePaths = fs @@ -82,19 +83,19 @@ function flush(): number { fs.unlinkSync(p); } - return 0; + return ExitCode.Success; } catch (e) { console.error("Failed to flush changenotes to 'CHANGELOG.md'", e); } - return 1; + return ExitCode.Failure; } -function validate(): number { +function validate(): ExitCode { try { if (isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR))) { console.log(`All changenotes in '${CHANGENOTES_DIR}' are valid.`); - return 0; + return ExitCode.Success; } } catch (error) { console.error( @@ -102,5 +103,5 @@ function validate(): number { error, ); } - return 1; + return ExitCode.Failure; } From d63b2a40db843ae6d4d33fcca9dbf0cd3e5384e0 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 11:42:46 -0500 Subject: [PATCH 07/13] Assume '[UNRELEASED]' section is first section --- pr-checks/changelog.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index ec765b4c8f..e738f2a4a1 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -146,20 +146,9 @@ export function addBodyLinesToUnreleasedSection( changelog: Changelog, lines: string[], ) { - // Find the '[UNRELEASED]' section. - let unreleasedSection: ChangelogSection | undefined; - for (const section of changelog.sections) { - if (getHeader(section.headerLine) === UNRELEASED_PLACEHOLDER) { - unreleasedSection = section; - break; - } - } - - // Ensure that the '[UNRELEASED]' section exists first. - if (unreleasedSection === undefined) { - throw Error( - "Cannot put changenotes into CHANGELOG.md's '[UNRELEASED]' section because it does not exist", - ); + const unreleasedSection = changelog.sections[0]; + if (getHeader(unreleasedSection.headerLine) !== UNRELEASED_PLACEHOLDER) { + throw Error("'[UNRELEASED]' is not the first section of 'CHANGELOG.md'"); } let insertAtIndex = 0; From 128614ad8b661f91e7038d97c93e89fbe6c26857 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 12:24:19 -0500 Subject: [PATCH 08/13] Simplify `getHeader` to operate on `ChangelogSection`s --- pr-checks/changelog.test.ts | 24 +++++++++++++++--------- pr-checks/changelog.ts | 10 ++++++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts index b88f9b0c07..2e8712ea8b 100755 --- a/pr-checks/changelog.test.ts +++ b/pr-checks/changelog.test.ts @@ -26,20 +26,26 @@ import { CHANGELOG_FILE } from "./config"; const testDate = new Date(2026, 7, 14); describe("getHeader", async () => { + function Section(headerLine: string): ChangelogSection { + return { + headerLine, + bodyLines: [], + }; + } await it("returns non-headers unchanged", () => { - assert.equal("foo", getHeader("foo")); - assert.equal("- bar", getHeader("- bar")); + assert.equal("foo", getHeader(Section("foo"))); + assert.equal("- bar", getHeader(Section("- bar"))); }); await it("strips octothorpes", async () => { - assert.equal("foo", getHeader("# foo")); - assert.equal("foo", getHeader("## foo")); - assert.equal("foo", getHeader("### foo")); - assert.equal("foo", getHeader("#### foo")); - assert.equal("foo", getHeader("##### foo")); - assert.equal("foo", getHeader("###### foo")); + assert.equal("foo", getHeader(Section("# foo"))); + assert.equal("foo", getHeader(Section("## foo"))); + assert.equal("foo", getHeader(Section("### foo"))); + assert.equal("foo", getHeader(Section("#### foo"))); + assert.equal("foo", getHeader(Section("##### foo"))); + assert.equal("foo", getHeader(Section("###### foo"))); }); await it("strips whitespace", async () => { - assert.equal("foo", getHeader("# foo ")); + assert.equal("foo", getHeader(Section("# foo "))); }); }); diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index e738f2a4a1..fa992d6eda 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -33,9 +33,11 @@ export interface Changelog { sections: ChangelogSection[]; } -/** Returns the text of a CHANGELOG.md header (without the '## ' prefix). */ -export function getHeader(headerLine: string): string { - return headerLine.replace(/^#+\s+/, "").trimEnd(); +/** + * Returns the text of the header (without the '## ' prefix) of the given section. + * */ +export function getHeader(section: ChangelogSection): string { + return section.headerLine.replace(/^#+\s+/, "").trimEnd(); } /** Returns `date` formatted as `DD Mon YYYY`. */ @@ -147,7 +149,7 @@ export function addBodyLinesToUnreleasedSection( lines: string[], ) { const unreleasedSection = changelog.sections[0]; - if (getHeader(unreleasedSection.headerLine) !== UNRELEASED_PLACEHOLDER) { + if (getHeader(unreleasedSection) !== UNRELEASED_PLACEHOLDER) { throw Error("'[UNRELEASED]' is not the first section of 'CHANGELOG.md'"); } From bc0efd6d9138ed65d251d7762a29ddd22614fc6a Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 12:35:41 -0500 Subject: [PATCH 09/13] Simplify `addBodyLinesToUnreleasedSection` --- pr-checks/changelog.test.ts | 17 ++++++----------- pr-checks/changelog.ts | 27 ++++++++------------------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts index 2e8712ea8b..8132e65766 100755 --- a/pr-checks/changelog.test.ts +++ b/pr-checks/changelog.test.ts @@ -108,25 +108,20 @@ describe("addBodyLinesToUnreleasedSection", async () => { }; } - await it("throws error if '[UNRELEASED]' section does not exist", async () => { - const emptyChangelog = newChangelogWithSections([]); - assert.throws(() => addBodyLinesToUnreleasedSection(emptyChangelog, [])); - - const releasedChangelog = newChangelogWithSections([ + await it("throws error if '[UNRELEASED]' section is not first", async () => { + const invalidChangelog = newChangelogWithSections([ { headerLine: "## Release 1.0.0", bodyLines: [], }, { - headerLine: "## Release 2.0.0", - bodyLines: [], - }, - { - headerLine: "## Release 3.0.0", + headerLine: `## ${UNRELEASED_PLACEHOLDER}`, bodyLines: [], }, ]); - assert.throws(() => addBodyLinesToUnreleasedSection(releasedChangelog, [])); + assert.throws(() => + addBodyLinesToUnreleasedSection(invalidChangelog, ["foo"]), + ); }); await it("overwrites 'No user facing changes.'", async () => { diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index fa992d6eda..e159a06a98 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -148,32 +148,21 @@ export function addBodyLinesToUnreleasedSection( changelog: Changelog, lines: string[], ) { + // Do nothing if there is nothing to insert. + if (lines.length === 0) return; + const unreleasedSection = changelog.sections[0]; if (getHeader(unreleasedSection) !== UNRELEASED_PLACEHOLDER) { throw Error("'[UNRELEASED]' is not the first section of 'CHANGELOG.md'"); } - let insertAtIndex = 0; - let deleteCount = 0; - - // If the section contains an empty line, preserve it -- insert afterward. - if ( - unreleasedSection.bodyLines.length > 0 && - unreleasedSection.bodyLines[0] === "" - ) { - insertAtIndex++; - } - - // If the section contains the stock message 'No user facing changes.' - if ( - lines.length > 0 && - unreleasedSection.bodyLines.length > insertAtIndex && - unreleasedSection.bodyLines[insertAtIndex].trim() === NO_CHANGES_STR - ) { - deleteCount++; // Delete the line by incrementing the delete marker. + if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) { + unreleasedSection.bodyLines = ["", ...lines, ""]; + return; } - unreleasedSection.bodyLines.splice(insertAtIndex, deleteCount, ...lines); + // Insert `lines` after the first blank line. + unreleasedSection.bodyLines.splice(1, 0, ...lines); } /** From 977b29b897eab1468a089ac1278b1b7c351bcffd Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 13:47:06 -0500 Subject: [PATCH 10/13] Replace `splice` with `push` and `pop` --- pr-checks/changelog.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index e159a06a98..18bffa51a3 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -161,8 +161,9 @@ export function addBodyLinesToUnreleasedSection( return; } - // Insert `lines` after the first blank line. - unreleasedSection.bodyLines.splice(1, 0, ...lines); + unreleasedSection.bodyLines.pop(); // Remove the last empty line. + unreleasedSection.bodyLines.push(...lines); + unreleasedSection.bodyLines.push(""); } /** From 7f54212a01b1fcd5c22896032d6c677591788874 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 13:49:18 -0500 Subject: [PATCH 11/13] Rename `flush` command to `assemble` --- pr-checks/changenotes.mts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index fd3225b911..6480b19537 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -38,8 +38,8 @@ function main(): ExitCode { case undefined: case "help": return usage(); - case "flush": - return flush(); + case "assemble": + return assemble(); case "validate": return validate(); default: @@ -50,14 +50,14 @@ function main(): ExitCode { function usage(): ExitCode { const message = - "Usage: changenotes.mts flush\n" + + "Usage: changenotes.mts assemble\n" + " changenotes.mts validate\n" + " changenotes.mts help"; console.log(message); return ExitCode.Success; } -function flush(): ExitCode { +function assemble(): ExitCode { try { // Get the file paths to our changenotes; these will be useful later. const changenotePaths = fs @@ -85,7 +85,7 @@ function flush(): ExitCode { return ExitCode.Success; } catch (e) { - console.error("Failed to flush changenotes to 'CHANGELOG.md'", e); + console.error("Failed to assemble changenotes to 'CHANGELOG.md'", e); } return ExitCode.Failure; From a92f7fb68832cf532975c676aac5741c34cc63b2 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 15:01:59 -0500 Subject: [PATCH 12/13] Refactor changenote file listing into function to D.R.Y. --- pr-checks/changenotes.mts | 51 ++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index 6480b19537..9a5c21cf03 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -1,7 +1,6 @@ #!/usr/bin/env npx tsx import * as fs from "node:fs"; -import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import path from "path"; @@ -18,6 +17,39 @@ import { import { isValidAllChangenoteFiles } from "./changelog/validate.mjs"; import { CHANGENOTES_DIR } from "./config"; +/** + * Describes a changenote file, including its file path, frontmatter, and content. + */ +interface ChangenoteFile { + name: string; + data: Record; + content: string; +} + +/** + * Returns the absolute file paths of all files in + * {@link CHANGENOTES_DIR} (except ".gitkeep"). + * */ +function listUnreleasedChangenoteDir(): string[] { + return fs + .readdirSync(CHANGENOTES_DIR) + .filter((name) => name !== ".gitkeep") + .map((name) => path.join(CHANGENOTES_DIR, name)); +} + +/** + * Scans the {@link CHANGENOTES_DIR} directory for changenote files + * and returns a parsed listing of those changenote files. + */ +function getChangenotes(): ChangenoteFile[] { + return listUnreleasedChangenoteDir().map((name) => { + return { + name, + ...matter(fs.readFileSync(name, "utf-8")), + }; + }); +} + const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { try { @@ -59,22 +91,13 @@ function usage(): ExitCode { function assemble(): ExitCode { try { - // Get the file paths to our changenotes; these will be useful later. - const changenotePaths = fs - .readdirSync(CHANGENOTES_DIR) - .filter((name) => name !== ".gitkeep") - .map((name) => path.join(CHANGENOTES_DIR, name)); - - // From the file paths, we read the files to obtain the actual notes themselves. - const changenotes = changenotePaths.map((filePath) => { - const fileBody = readFileSync(filePath).toString(); - const { content } = matter(fileBody); - return content.trim(); - }); + const changenotes = getChangenotes(); + const changenoteBodies = changenotes.map((c) => c.content); + const changenotePaths = changenotes.map((c) => c.name); withChangelog((contents) => { const changelog = parseChangelog(contents); - addBodyLinesToUnreleasedSection(changelog, changenotes); + addBodyLinesToUnreleasedSection(changelog, changenoteBodies); return renderChangelog(changelog); }, {}); From bf6da6ffba61752babed406cb43f8e61797b75da Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 12:50:23 -0500 Subject: [PATCH 13/13] Refactor `changenotes validate` to use helper `getChangenotes` This reduces duplicate code between `assemble` and `validate`. It also has the benefit of fixing a bug in the current implementation of `validate`, where `isValidChangenoteFile` receives a relative file name where it should receive an absolute one. --- pr-checks/changelog/validate.mts | 11 ------- pr-checks/changelog/validate.test.mts | 41 +-------------------------- pr-checks/changenotes.mts | 8 ++++-- 3 files changed, 7 insertions(+), 53 deletions(-) diff --git a/pr-checks/changelog/validate.mts b/pr-checks/changelog/validate.mts index 2e28a4ab13..3c83276f38 100644 --- a/pr-checks/changelog/validate.mts +++ b/pr-checks/changelog/validate.mts @@ -119,14 +119,3 @@ export function isValidChangenoteFile(filename: string): boolean { return isValid; } - -/** - * Validates the change-note files of the given list of file paths, ignoring ".gitkeep". - * @param filepaths A list of filepaths to validate - * @returns True if all the paths are valid, false otherwise. - */ -export function isValidAllChangenoteFiles(filepaths: string[]): boolean { - return filepaths - .filter((f) => f !== ".gitkeep") - .reduce((r, filePath) => r && isValidChangenoteFile(filePath), true); -} diff --git a/pr-checks/changelog/validate.test.mts b/pr-checks/changelog/validate.test.mts index a38339d1c9..b8b1e33bb0 100644 --- a/pr-checks/changelog/validate.test.mts +++ b/pr-checks/changelog/validate.test.mts @@ -1,13 +1,10 @@ import assert from "node:assert/strict"; -import * as fs from "node:fs"; -import * as path from "node:path"; import { describe, it } from "node:test"; -import { withTmpDir, withTmpFile } from "../../src/util"; +import { withTmpFile } from "../../src/util"; import { hasValidChangenoteCategory, - isValidAllChangenoteFiles, isValidChangenoteContent, isValidChangenoteFile, isValidChangenoteFilename, @@ -187,39 +184,3 @@ await describe("isValidChangenoteFile", async () => { ); }); }); - -await describe("isValidAllChangenoteFiles", async () => { - await it("accepts list of file paths of valid change-notes", async () => { - await withTmpDir(async (tmpDir) => { - const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md"); - const fileName2 = path.join(tmpDir, "2026-01-02-add-feature.md"); - fs.writeFileSync(fileName1, "---\ncategory: fix\n---\n- Fixed a bug\n"); - fs.writeFileSync( - fileName2, - "---\ncategory: feature\n---\n- Added a feature\n", - ); - assert.equal(isValidAllChangenoteFiles([fileName1, fileName2]), true); - }); - }); - - await it("accepts the empty list", async () => { - assert.equal(isValidAllChangenoteFiles([]), true); - }); - - await it("accepts list of .gitkeep", async () => { - assert.equal(isValidAllChangenoteFiles([".gitkeep"]), true); - }); - - await it("rejects list containing a file path to an invalid change-note", async () => { - await withTmpDir(async (tmpDir) => { - const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md"); - const fileName2 = path.join(tmpDir, "2026-01-02-wrong-category.md"); - fs.writeFileSync(fileName1, "---\ncategory: fix\n---\n- Fixed a bug\n"); - fs.writeFileSync( - fileName2, - "---\ncategory: foobar\n---\n- Added a feature\n", - ); - assert.equal(isValidAllChangenoteFiles([fileName1, fileName2]), false); - }); - }); -}); diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index 9a5c21cf03..259dbae851 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -14,7 +14,7 @@ import { renderChangelog, withChangelog, } from "./changelog"; -import { isValidAllChangenoteFiles } from "./changelog/validate.mjs"; +import { isValidChangenoteFile } from "./changelog/validate.mjs"; import { CHANGENOTES_DIR } from "./config"; /** @@ -116,7 +116,11 @@ function assemble(): ExitCode { function validate(): ExitCode { try { - if (isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR))) { + const allChangenotesValid = getChangenotes().reduce( + (r, c) => r && isValidChangenoteFile(c.name), + true, + ); + if (allChangenotesValid) { console.log(`All changenotes in '${CHANGENOTES_DIR}' are valid.`); return ExitCode.Success; }