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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pr-checks/bundle-changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,15 @@ ${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 () => {
const result = updateChangelog(
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 () => {
Expand Down
99 changes: 99 additions & 0 deletions pr-checks/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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),
);
});
});
47 changes: 44 additions & 3 deletions pr-checks/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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", {
Expand Down Expand Up @@ -125,6 +134,38 @@ 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[],
) {
// 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.
*/
Expand Down Expand Up @@ -204,7 +245,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);
}
}

Expand Down
92 changes: 83 additions & 9 deletions pr-checks/changenotes.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
name: string;
data: Record<string, any>;
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 {
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,
Expand All @@ -27,30 +70,61 @@ 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.name);

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(
`Failed to read changenotes directory '${CHANGENOTES_DIR}'`,
error,
);
}
return 1;
return ExitCode.Failure;
}
Loading