Merge pull request #4155 from github/mario-campos/merge-changenotes

Add `changenotes.mts assemble` command to move changenotes to CHANGELOG.md
This commit is contained in:
Mario Campos
2026-09-22 12:52:41 +00:00
committed by GitHub
4 changed files with 232 additions and 14 deletions

View File

@@ -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 () => {

View File

@@ -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),
);
});
});

View File

@@ -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);
}
}

View File

@@ -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<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((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 validate(): number {
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(): 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;
}