From cb31eabcd8c75b939c159b0f04b821a0bfd130f6 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 18 Sep 2026 09:04:47 -0500 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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 771560691a49adf7ee17519f5091ca8f747d0ce9 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 21 Sep 2026 16:28:36 -0500 Subject: [PATCH 13/18] Update JSDoc comment with parameter `lines` Co-authored-by: Michael B. Gale --- pr-checks/changelog.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 18bffa51a3..45a7977126 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -135,9 +135,9 @@ export function parseChangelog(content: string): Changelog { } /** - * Inserts the changenotes `notes` under the `[UNRELEASED]` section of `changelog`. + * Inserts the changenotes `lines` in 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. + * `lines` will be inserted in place and the stock message will be deleted. * * This function will throw an exception if `[UNRELEASED]` does not exist. * From 598cda36cf3f4ab1d9ab514b3015be59b2b46eed Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 21 Sep 2026 16:28:52 -0500 Subject: [PATCH 14/18] Apply suggestion from @mbg Co-authored-by: Michael B. Gale --- pr-checks/changelog.ts | 502 ++++++++++++++++++++--------------------- 1 file changed, 251 insertions(+), 251 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 45a7977126..76a3093456 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -1,253 +1,253 @@ -import * as fs from "node:fs"; - -import { CHANGELOG_FILE, DryRunOption } from "./config"; - -/** The placeholder in the header for unreleased changes. */ -export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]"; - -/** The default contents for a section in the changelog. */ -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} - -`; - -/** - * Represents sections in a changelog. - */ -export interface ChangelogSection { - headerLine: string; - bodyLines: string[]; -} - -/** - * Represents a changelog. - */ -export interface Changelog { - preamble: string[]; - sections: ChangelogSection[]; -} - -/** - * 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`. */ -export function getReleaseDateString(today: Date = new Date()): string { - return today.toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }); -} - -export interface OpenChangelogOptions { - initChangelog?: boolean; -} - -export function withChangelog( - transformer: (contents: string) => string, - options: DryRunOption & OpenChangelogOptions, -): void { - let content: string; - - if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) { - content = EMPTY_CHANGELOG; - } else { - content = fs.readFileSync(CHANGELOG_FILE, "utf8"); - } - - if (!options.dryRun) { - fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8"); - } else { - console.info(`[DRY RUN] Would have written updated changelog.`); - } -} - -/** - * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version - * and today's date. - */ -export function setVersionAndDate( - version: string, - content: string, - date: Date = new Date(), -): string { - const versionAndDate = `${version} - ${getReleaseDateString(date)}`; - return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); -} - -/** - * Parses `content` into a structured representation of a changelog. - * - * @param content The contents of the changelog file. - */ -export function parseChangelog(content: string): Changelog { - const lines = content.split("\n"); - let i = 0; - - const preamble: string[] = []; - const sections: ChangelogSection[] = []; - let currentSection: ChangelogSection | undefined = undefined; - - // Process all lines of the input file. - while (i < lines.length) { - const line = lines[i]; - - // Sections of the changelog start with `## `. - if (line.startsWith("## ")) { - // We have discovered a new section. If `currentSection` is already defined, - // then this marks the end of that section. Push it to the array of sections - // in the changelog. - if (currentSection !== undefined) { - sections.push(currentSection); - } - - // Initialise the new section. - currentSection = { headerLine: line, bodyLines: [] }; - } else if (currentSection !== undefined) { - // Add lines between the section header and the next to the current section. - currentSection.bodyLines.push(line); - } else { - // This is neither a section header nor are we in a section already, - // so this line is part of the preamble. - preamble.push(line); - } - - i++; - } - - // Push the current section to the array of completed sections, if there is - // still one unfinished. - if (currentSection !== undefined) { - sections.push(currentSection); - } - - return { preamble, sections }; -} - -/** +import * as fs from "node:fs"; + +import { CHANGELOG_FILE, DryRunOption } from "./config"; + +/** The placeholder in the header for unreleased changes. */ +export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]"; + +/** The default contents for a section in the changelog. */ +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} + +`; + +/** + * Represents sections in a changelog. + */ +export interface ChangelogSection { + headerLine: string; + bodyLines: string[]; +} + +/** + * Represents a changelog. + */ +export interface Changelog { + preamble: string[]; + sections: ChangelogSection[]; +} + +/** + * 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`. */ +export function getReleaseDateString(today: Date = new Date()): string { + return today.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +export interface OpenChangelogOptions { + initChangelog?: boolean; +} + +export function withChangelog( + transformer: (contents: string) => string, + options: DryRunOption & OpenChangelogOptions, +): void { + let content: string; + + if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) { + content = EMPTY_CHANGELOG; + } else { + content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + } + + if (!options.dryRun) { + fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8"); + } else { + console.info(`[DRY RUN] Would have written updated changelog.`); + } +} + +/** + * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version + * and today's date. + */ +export function setVersionAndDate( + version: string, + content: string, + date: Date = new Date(), +): string { + const versionAndDate = `${version} - ${getReleaseDateString(date)}`; + return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); +} + +/** + * Parses `content` into a structured representation of a changelog. + * + * @param content The contents of the changelog file. + */ +export function parseChangelog(content: string): Changelog { + const lines = content.split("\n"); + let i = 0; + + const preamble: string[] = []; + const sections: ChangelogSection[] = []; + let currentSection: ChangelogSection | undefined = undefined; + + // Process all lines of the input file. + while (i < lines.length) { + const line = lines[i]; + + // Sections of the changelog start with `## `. + if (line.startsWith("## ")) { + // We have discovered a new section. If `currentSection` is already defined, + // then this marks the end of that section. Push it to the array of sections + // in the changelog. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + // Initialise the new section. + currentSection = { headerLine: line, bodyLines: [] }; + } else if (currentSection !== undefined) { + // Add lines between the section header and the next to the current section. + currentSection.bodyLines.push(line); + } else { + // This is neither a section header nor are we in a section already, + // so this line is part of the preamble. + preamble.push(line); + } + + i++; + } + + // Push the current section to the array of completed sections, if there is + // still one unfinished. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + return { preamble, sections }; +} + +/** * Inserts the changenotes `lines` in the `[UNRELEASED]` section of `changelog`. - * If the section contains the stock message {@link NO_CHANGES_STR}, then + * If the section contains the stock message {@link NO_CHANGES_STR}, then * `lines` 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[], -) { - // 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'"); - } - - if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) { - unreleasedSection.bodyLines = ["", ...lines, ""]; - return; - } - - unreleasedSection.bodyLines.pop(); // Remove the last empty line. - unreleasedSection.bodyLines.push(...lines); - unreleasedSection.bodyLines.push(""); -} - -/** - * Combines an array of lines into a single string by adding line breaks. - */ -export function unlines(lines: string[]): string { - return `${lines.join("\n")}`; -} - -/** - * Renders a given changelog to a string. - */ -export function renderChangelog(changelog: Changelog): string { - let result = unlines(changelog.preamble); - - for (const section of changelog.sections) { - result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`; - } - - return result; -} - -/** - * Processes changelog entries for a backport, converting version references - * from the source major version to the target major version and filtering - * entries that only apply to newer versions. - */ -export function processChangelogForBackports( - sourceBranchMajorVersion: string, - targetBranchMajorVersion: string, - content: string, -): string { - // Changelog entries can use the following format to indicate - // that they only apply to newer versions - const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; - - // Parse the changelog. - const changelog = parseChangelog(content); - - if (changelog.sections.length === 0) { - throw new Error("Could not find any change sections in CHANGELOG.md"); - } - - // Filter out changelog entries that only apply to newer versions and - // update the section headings with the backport major version for - // sections we keep. - for (const section of changelog.sections) { - // Update the section headings with the backport major version. - section.headerLine = section.headerLine.replace( - `## ${sourceBranchMajorVersion}`, - `## ${targetBranchMajorVersion}`, - ); - - const filteredEntries: string[] = []; - let foundContent = false; - - for (const line of section.bodyLines) { - // Skip the entry if `someVersionsOnlyRegex` matches and the major version - // of the target branch is smaller than the required version. - const match = someVersionsOnlyRegex.exec(line); - if ( - match && - Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) - ) { - continue; - } - - // Keep the line. - filteredEntries.push(line); - - // Set `foundContent` to `true` if the line is not empty. - if (line.trim() !== "") { - foundContent = true; - } - } - - // Update the section with the retained entries. - section.bodyLines = filteredEntries; - - // Add an entry if we didn't keep any. - if (!foundContent) { - section.bodyLines.push(NO_CHANGES_STR); - } - } - - return renderChangelog(changelog); -} + * + * 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[], +) { + // 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_PLACEHOLDER}' is not the first section of 'CHANGELOG.md'`); + } + + if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) { + unreleasedSection.bodyLines = ["", ...lines, ""]; + return; + } + + unreleasedSection.bodyLines.pop(); // Remove the last empty line. + unreleasedSection.bodyLines.push(...lines); + unreleasedSection.bodyLines.push(""); +} + +/** + * Combines an array of lines into a single string by adding line breaks. + */ +export function unlines(lines: string[]): string { + return `${lines.join("\n")}`; +} + +/** + * Renders a given changelog to a string. + */ +export function renderChangelog(changelog: Changelog): string { + let result = unlines(changelog.preamble); + + for (const section of changelog.sections) { + result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`; + } + + return result; +} + +/** + * Processes changelog entries for a backport, converting version references + * from the source major version to the target major version and filtering + * entries that only apply to newer versions. + */ +export function processChangelogForBackports( + sourceBranchMajorVersion: string, + targetBranchMajorVersion: string, + content: string, +): string { + // Changelog entries can use the following format to indicate + // that they only apply to newer versions + const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; + + // Parse the changelog. + const changelog = parseChangelog(content); + + if (changelog.sections.length === 0) { + throw new Error("Could not find any change sections in CHANGELOG.md"); + } + + // Filter out changelog entries that only apply to newer versions and + // update the section headings with the backport major version for + // sections we keep. + for (const section of changelog.sections) { + // Update the section headings with the backport major version. + section.headerLine = section.headerLine.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + + const filteredEntries: string[] = []; + let foundContent = false; + + for (const line of section.bodyLines) { + // Skip the entry if `someVersionsOnlyRegex` matches and the major version + // of the target branch is smaller than the required version. + const match = someVersionsOnlyRegex.exec(line); + if ( + match && + Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) + ) { + continue; + } + + // Keep the line. + filteredEntries.push(line); + + // Set `foundContent` to `true` if the line is not empty. + if (line.trim() !== "") { + foundContent = true; + } + } + + // Update the section with the retained entries. + section.bodyLines = filteredEntries; + + // Add an entry if we didn't keep any. + if (!foundContent) { + section.bodyLines.push(NO_CHANGES_STR); + } + } + + return renderChangelog(changelog); +} From c496c6cceb2cee0566ea8dd194c7915be1420850 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 21 Sep 2026 16:31:55 -0500 Subject: [PATCH 15/18] Rename `name` to `absolutePath` for clarity --- 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 9a5c21cf03..d19d2da83b 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -21,7 +21,7 @@ import { CHANGENOTES_DIR } from "./config"; * Describes a changenote file, including its file path, frontmatter, and content. */ interface ChangenoteFile { - name: string; + absolutePath: string; data: Record; content: string; } @@ -42,10 +42,10 @@ function listUnreleasedChangenoteDir(): string[] { * and returns a parsed listing of those changenote files. */ function getChangenotes(): ChangenoteFile[] { - return listUnreleasedChangenoteDir().map((name) => { + return listUnreleasedChangenoteDir().map((absolutePath) => { return { - name, - ...matter(fs.readFileSync(name, "utf-8")), + absolutePath, + ...matter(fs.readFileSync(absolutePath, "utf-8")), }; }); } @@ -93,7 +93,7 @@ function assemble(): ExitCode { try { const changenotes = getChangenotes(); const changenoteBodies = changenotes.map((c) => c.content); - const changenotePaths = changenotes.map((c) => c.name); + const changenotePaths = changenotes.map((c) => c.absolutePath); withChangelog((contents) => { const changelog = parseChangelog(contents); From 02631222091e92e55383c4e23534b590cadc795a Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 21 Sep 2026 16:32:44 -0500 Subject: [PATCH 16/18] Format code with `npm run lint-fix` --- pr-checks/changelog.ts | 508 +++++++++++++++++++++-------------------- 1 file changed, 255 insertions(+), 253 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 76a3093456..bee141d2fd 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -1,253 +1,255 @@ -import * as fs from "node:fs"; - -import { CHANGELOG_FILE, DryRunOption } from "./config"; - -/** The placeholder in the header for unreleased changes. */ -export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]"; - -/** The default contents for a section in the changelog. */ -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} - -`; - -/** - * Represents sections in a changelog. - */ -export interface ChangelogSection { - headerLine: string; - bodyLines: string[]; -} - -/** - * Represents a changelog. - */ -export interface Changelog { - preamble: string[]; - sections: ChangelogSection[]; -} - -/** - * 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`. */ -export function getReleaseDateString(today: Date = new Date()): string { - return today.toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }); -} - -export interface OpenChangelogOptions { - initChangelog?: boolean; -} - -export function withChangelog( - transformer: (contents: string) => string, - options: DryRunOption & OpenChangelogOptions, -): void { - let content: string; - - if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) { - content = EMPTY_CHANGELOG; - } else { - content = fs.readFileSync(CHANGELOG_FILE, "utf8"); - } - - if (!options.dryRun) { - fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8"); - } else { - console.info(`[DRY RUN] Would have written updated changelog.`); - } -} - -/** - * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version - * and today's date. - */ -export function setVersionAndDate( - version: string, - content: string, - date: Date = new Date(), -): string { - const versionAndDate = `${version} - ${getReleaseDateString(date)}`; - return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); -} - -/** - * Parses `content` into a structured representation of a changelog. - * - * @param content The contents of the changelog file. - */ -export function parseChangelog(content: string): Changelog { - const lines = content.split("\n"); - let i = 0; - - const preamble: string[] = []; - const sections: ChangelogSection[] = []; - let currentSection: ChangelogSection | undefined = undefined; - - // Process all lines of the input file. - while (i < lines.length) { - const line = lines[i]; - - // Sections of the changelog start with `## `. - if (line.startsWith("## ")) { - // We have discovered a new section. If `currentSection` is already defined, - // then this marks the end of that section. Push it to the array of sections - // in the changelog. - if (currentSection !== undefined) { - sections.push(currentSection); - } - - // Initialise the new section. - currentSection = { headerLine: line, bodyLines: [] }; - } else if (currentSection !== undefined) { - // Add lines between the section header and the next to the current section. - currentSection.bodyLines.push(line); - } else { - // This is neither a section header nor are we in a section already, - // so this line is part of the preamble. - preamble.push(line); - } - - i++; - } - - // Push the current section to the array of completed sections, if there is - // still one unfinished. - if (currentSection !== undefined) { - sections.push(currentSection); - } - - return { preamble, sections }; -} - -/** - * Inserts the changenotes `lines` in the `[UNRELEASED]` section of `changelog`. - * If the section contains the stock message {@link NO_CHANGES_STR}, then - * `lines` 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[], -) { - // 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_PLACEHOLDER}' is not the first section of 'CHANGELOG.md'`); - } - - if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) { - unreleasedSection.bodyLines = ["", ...lines, ""]; - return; - } - - unreleasedSection.bodyLines.pop(); // Remove the last empty line. - unreleasedSection.bodyLines.push(...lines); - unreleasedSection.bodyLines.push(""); -} - -/** - * Combines an array of lines into a single string by adding line breaks. - */ -export function unlines(lines: string[]): string { - return `${lines.join("\n")}`; -} - -/** - * Renders a given changelog to a string. - */ -export function renderChangelog(changelog: Changelog): string { - let result = unlines(changelog.preamble); - - for (const section of changelog.sections) { - result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`; - } - - return result; -} - -/** - * Processes changelog entries for a backport, converting version references - * from the source major version to the target major version and filtering - * entries that only apply to newer versions. - */ -export function processChangelogForBackports( - sourceBranchMajorVersion: string, - targetBranchMajorVersion: string, - content: string, -): string { - // Changelog entries can use the following format to indicate - // that they only apply to newer versions - const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; - - // Parse the changelog. - const changelog = parseChangelog(content); - - if (changelog.sections.length === 0) { - throw new Error("Could not find any change sections in CHANGELOG.md"); - } - - // Filter out changelog entries that only apply to newer versions and - // update the section headings with the backport major version for - // sections we keep. - for (const section of changelog.sections) { - // Update the section headings with the backport major version. - section.headerLine = section.headerLine.replace( - `## ${sourceBranchMajorVersion}`, - `## ${targetBranchMajorVersion}`, - ); - - const filteredEntries: string[] = []; - let foundContent = false; - - for (const line of section.bodyLines) { - // Skip the entry if `someVersionsOnlyRegex` matches and the major version - // of the target branch is smaller than the required version. - const match = someVersionsOnlyRegex.exec(line); - if ( - match && - Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) - ) { - continue; - } - - // Keep the line. - filteredEntries.push(line); - - // Set `foundContent` to `true` if the line is not empty. - if (line.trim() !== "") { - foundContent = true; - } - } - - // Update the section with the retained entries. - section.bodyLines = filteredEntries; - - // Add an entry if we didn't keep any. - if (!foundContent) { - section.bodyLines.push(NO_CHANGES_STR); - } - } - - return renderChangelog(changelog); -} +import * as fs from "node:fs"; + +import { CHANGELOG_FILE, DryRunOption } from "./config"; + +/** The placeholder in the header for unreleased changes. */ +export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]"; + +/** The default contents for a section in the changelog. */ +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} + +`; + +/** + * Represents sections in a changelog. + */ +export interface ChangelogSection { + headerLine: string; + bodyLines: string[]; +} + +/** + * Represents a changelog. + */ +export interface Changelog { + preamble: string[]; + sections: ChangelogSection[]; +} + +/** + * 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`. */ +export function getReleaseDateString(today: Date = new Date()): string { + return today.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +export interface OpenChangelogOptions { + initChangelog?: boolean; +} + +export function withChangelog( + transformer: (contents: string) => string, + options: DryRunOption & OpenChangelogOptions, +): void { + let content: string; + + if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) { + content = EMPTY_CHANGELOG; + } else { + content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + } + + if (!options.dryRun) { + fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8"); + } else { + console.info(`[DRY RUN] Would have written updated changelog.`); + } +} + +/** + * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version + * and today's date. + */ +export function setVersionAndDate( + version: string, + content: string, + date: Date = new Date(), +): string { + const versionAndDate = `${version} - ${getReleaseDateString(date)}`; + return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); +} + +/** + * Parses `content` into a structured representation of a changelog. + * + * @param content The contents of the changelog file. + */ +export function parseChangelog(content: string): Changelog { + const lines = content.split("\n"); + let i = 0; + + const preamble: string[] = []; + const sections: ChangelogSection[] = []; + let currentSection: ChangelogSection | undefined = undefined; + + // Process all lines of the input file. + while (i < lines.length) { + const line = lines[i]; + + // Sections of the changelog start with `## `. + if (line.startsWith("## ")) { + // We have discovered a new section. If `currentSection` is already defined, + // then this marks the end of that section. Push it to the array of sections + // in the changelog. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + // Initialise the new section. + currentSection = { headerLine: line, bodyLines: [] }; + } else if (currentSection !== undefined) { + // Add lines between the section header and the next to the current section. + currentSection.bodyLines.push(line); + } else { + // This is neither a section header nor are we in a section already, + // so this line is part of the preamble. + preamble.push(line); + } + + i++; + } + + // Push the current section to the array of completed sections, if there is + // still one unfinished. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + return { preamble, sections }; +} + +/** + * Inserts the changenotes `lines` in the `[UNRELEASED]` section of `changelog`. + * If the section contains the stock message {@link NO_CHANGES_STR}, then + * `lines` 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[], +) { + // 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_PLACEHOLDER}' is not the first section of 'CHANGELOG.md'`, + ); + } + + if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) { + unreleasedSection.bodyLines = ["", ...lines, ""]; + return; + } + + unreleasedSection.bodyLines.pop(); // Remove the last empty line. + unreleasedSection.bodyLines.push(...lines); + unreleasedSection.bodyLines.push(""); +} + +/** + * Combines an array of lines into a single string by adding line breaks. + */ +export function unlines(lines: string[]): string { + return `${lines.join("\n")}`; +} + +/** + * Renders a given changelog to a string. + */ +export function renderChangelog(changelog: Changelog): string { + let result = unlines(changelog.preamble); + + for (const section of changelog.sections) { + result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`; + } + + return result; +} + +/** + * Processes changelog entries for a backport, converting version references + * from the source major version to the target major version and filtering + * entries that only apply to newer versions. + */ +export function processChangelogForBackports( + sourceBranchMajorVersion: string, + targetBranchMajorVersion: string, + content: string, +): string { + // Changelog entries can use the following format to indicate + // that they only apply to newer versions + const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; + + // Parse the changelog. + const changelog = parseChangelog(content); + + if (changelog.sections.length === 0) { + throw new Error("Could not find any change sections in CHANGELOG.md"); + } + + // Filter out changelog entries that only apply to newer versions and + // update the section headings with the backport major version for + // sections we keep. + for (const section of changelog.sections) { + // Update the section headings with the backport major version. + section.headerLine = section.headerLine.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + + const filteredEntries: string[] = []; + let foundContent = false; + + for (const line of section.bodyLines) { + // Skip the entry if `someVersionsOnlyRegex` matches and the major version + // of the target branch is smaller than the required version. + const match = someVersionsOnlyRegex.exec(line); + if ( + match && + Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) + ) { + continue; + } + + // Keep the line. + filteredEntries.push(line); + + // Set `foundContent` to `true` if the line is not empty. + if (line.trim() !== "") { + foundContent = true; + } + } + + // Update the section with the retained entries. + section.bodyLines = filteredEntries; + + // Add an entry if we didn't keep any. + if (!foundContent) { + section.bodyLines.push(NO_CHANGES_STR); + } + } + + return renderChangelog(changelog); +} From c0bd54fdf4a49ae453d9ad729e6898dd6fd0a24b Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 21 Sep 2026 16:36:10 -0500 Subject: [PATCH 17/18] Replace JSDoc text with `@throws` --- pr-checks/changelog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index bee141d2fd..af79666d28 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -139,7 +139,7 @@ export function parseChangelog(content: string): Changelog { * If the section contains the stock message {@link NO_CHANGES_STR}, then * `lines` will be inserted in place and the stock message will be deleted. * - * This function will throw an exception if `[UNRELEASED]` does not exist. + * @throws Error -- if the [UNRELEASED] section does not exist. * * @param changelog The CHANGELOG object to modify. * @param lines The changenotes to insert. From b1668d6234eb740e49d28a1b1b78a3947d629516 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 21 Sep 2026 16:40:26 -0500 Subject: [PATCH 18/18] Flesh out a comment --- pr-checks/changelog.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index af79666d28..496310d21f 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -163,7 +163,9 @@ export function addBodyLinesToUnreleasedSection( return; } - unreleasedSection.bodyLines.pop(); // Remove the last empty line. + // The last body line should be a blank line (for spacing). + // Remove it so that we can add `lines` and then add the blank line back. + unreleasedSection.bodyLines.pop(); unreleasedSection.bodyLines.push(...lines); unreleasedSection.bodyLines.push(""); }