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.test.ts b/pr-checks/changelog.test.ts index 817852e3e1..8132e65766 100755 --- a/pr-checks/changelog.test.ts +++ b/pr-checks/changelog.test.ts @@ -9,17 +9,46 @@ 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"; 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(Section("foo"))); + assert.equal("- bar", getHeader(Section("- bar"))); + }); + await it("strips octothorpes", async () => { + 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(Section("# foo "))); + }); +}); + describe("getReleaseDateString", async () => { await it("formats dates as expected", async () => { assert.equal(getReleaseDateString(testDate), "14 Aug 2026"); @@ -70,3 +99,73 @@ 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 is not first", async () => { + const invalidChangelog = newChangelogWithSections([ + { + headerLine: "## Release 1.0.0", + bodyLines: [], + }, + { + headerLine: `## ${UNRELEASED_PLACEHOLDER}`, + bodyLines: [], + }, + ]); + assert.throws(() => + addBodyLinesToUnreleasedSection(invalidChangelog, ["foo"]), + ); + }); + + 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 4cf1e75494..496310d21f 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. @@ -31,6 +33,13 @@ export interface Changelog { 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", { @@ -125,6 +134,42 @@ export function parseChangelog(content: string): Changelog { 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. + * + * @throws Error -- if the [UNRELEASED] section 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; + } + + // 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(""); +} + /** * Combines an array of lines into a single string by adding line breaks. */ @@ -204,7 +249,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); } } diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index 2fb86b0cac..d19d2da83b 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -3,21 +3,64 @@ import * as fs from "node:fs"; 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 { + addBodyLinesToUnreleasedSection, + parseChangelog, + renderChangelog, + withChangelog, +} from "./changelog"; import { isValidAllChangenoteFiles } from "./changelog/validate.mjs"; import { CHANGENOTES_DIR } from "./config"; +/** + * Describes a changenote file, including its file path, frontmatter, and content. + */ +interface ChangenoteFile { + absolutePath: 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((absolutePath) => { + return { + absolutePath, + ...matter(fs.readFileSync(absolutePath, "utf-8")), + }; + }); +} + const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { try { 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, @@ -27,24 +70,55 @@ function main(): number { case undefined: case "help": return usage(); + case "assemble": + return assemble(); case "validate": return validate(); default: console.error(`Unknown command: ${command}`); - return 1; + return ExitCode.Failure; } } -function usage(): number { - console.log(`Usage: changenotes.mts validate`); - return 0; +function usage(): ExitCode { + const message = + "Usage: changenotes.mts assemble\n" + + " changenotes.mts validate\n" + + " changenotes.mts help"; + console.log(message); + return ExitCode.Success; +} + +function assemble(): ExitCode { + try { + const changenotes = getChangenotes(); + const changenoteBodies = changenotes.map((c) => c.content); + const changenotePaths = changenotes.map((c) => c.absolutePath); + + withChangelog((contents) => { + const changelog = parseChangelog(contents); + addBodyLinesToUnreleasedSection(changelog, changenoteBodies); + return renderChangelog(changelog); + }, {}); + + // Delete changenotes only after successful processing. + for (const p of changenotePaths) { + fs.unlinkSync(p); + } + + return ExitCode.Success; + } catch (e) { + console.error("Failed to assemble changenotes to 'CHANGELOG.md'", e); + } + + 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( @@ -52,5 +126,5 @@ function validate(): number { error, ); } - return 1; + return ExitCode.Failure; }