From b1c1bbb1e0919382be6387b3ea7d88226ec0130f Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 10 Sep 2026 22:24:57 -0500 Subject: [PATCH 1/6] Extend ESLint rules to `*.mts` files --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index a64e92701d..115da235c7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -158,7 +158,7 @@ export default [ }, }, { - files: ["**/*.ts", "**/*.js"], + files: ["**/*.ts", "**/*.js", "**/*.mts"], rules: { "@typescript-eslint/no-explicit-any": "off", From a691c1611521f74e81b2fcd54be612e559ef3f65 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 31 Aug 2026 11:22:54 -0500 Subject: [PATCH 2/6] Extend `changetool` to support directories --- pr-checks/changelog/validate.mts | 11 +++++++ pr-checks/changelog/validate.test.mts | 45 ++++++++++++++++++++++++++- pr-checks/changenotes.mts | 28 ++++++++--------- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/pr-checks/changelog/validate.mts b/pr-checks/changelog/validate.mts index 3c83276f38..2e28a4ab13 100644 --- a/pr-checks/changelog/validate.mts +++ b/pr-checks/changelog/validate.mts @@ -119,3 +119,14 @@ export function isValidChangenoteFile(filename: string): boolean { return isValid; } + +/** + * Validates the change-note files of the given list of file paths, ignoring ".gitkeep". + * @param filepaths A list of filepaths to validate + * @returns True if all the paths are valid, false otherwise. + */ +export function isValidAllChangenoteFiles(filepaths: string[]): boolean { + return filepaths + .filter((f) => f !== ".gitkeep") + .reduce((r, filePath) => r && isValidChangenoteFile(filePath), true); +} diff --git a/pr-checks/changelog/validate.test.mts b/pr-checks/changelog/validate.test.mts index 917e7403f3..f6fb43680b 100644 --- a/pr-checks/changelog/validate.test.mts +++ b/pr-checks/changelog/validate.test.mts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { describe, it } from "node:test"; -import { withTmpFile } from "../../src/util"; +import { withTmpDir, withTmpFile } from "../../src/util"; import { hasValidChangenoteCategory, + isValidAllChangenoteFiles, isValidChangenoteContent, isValidChangenoteFile, isValidChangenoteFilename, @@ -150,6 +153,10 @@ await describe("isValidChangenoteFile", async () => { ); }); + await it("rejects a non-existent path", async () => { + assert.equal(isValidChangenoteFile("non-existent-file.md"), false); + }); + await it("rejects invalid filename", async () => { await withTmpFile( "fix-bug.md", @@ -180,3 +187,39 @@ await describe("isValidChangenoteFile", async () => { ); }); }); + +await describe("isValidAllChangenoteFiles", async () => { + await it("accepts a directory of valid change-note files", async () => { + await withTmpDir(async (tmpDir) => { + const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md"); + const fileName2 = path.join(tmpDir, "2026-01-02-add-feature.md"); + fs.writeFileSync(fileName1, "---\ncategory: fix\n---\n- Fixed a bug\n"); + fs.writeFileSync( + fileName2, + "---\ncategory: feature\n---\n- Added a feature\n", + ); + assert.equal(isValidAllChangenoteFiles([fileName1, fileName2]), true); + }); + }); + + await it("accepts an empty list", async () => { + assert.equal(isValidAllChangenoteFiles([]), true); + }); + + await it("accepts a list of .gitkeep only", async () => { + assert.equal(isValidAllChangenoteFiles([".gitkeep"]), true); + }); + + await it("rejects directory with an invalid change-note file", async () => { + await withTmpDir(async (tmpDir) => { + const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md"); + const fileName2 = path.join(tmpDir, "2026-01-02-wrong-category.md"); + fs.writeFileSync(fileName1, "---\ncategory: fix\n---\n- Fixed a bug\n"); + fs.writeFileSync( + fileName2, + "---\ncategory: foobar\n---\n- Added a feature\n", + ); + assert.equal(isValidAllChangenoteFiles([fileName1, fileName2]), false); + }); + }); +}); diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index 200a1de997..37e00338fc 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -1,9 +1,11 @@ #!/usr/bin/env npx tsx +import * as fs from "node:fs"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; -import { isValidChangenoteFile } from "./changelog/validate.mjs"; +import { isValidAllChangenoteFiles } from "./changelog/validate.mjs"; +import { CHANGENOTES_DIR } from "./config"; const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { @@ -20,13 +22,13 @@ function main(): number { allowPositionals: true, strict: true, }); - const [command, ...paths] = positionals; + const [command] = positionals; switch (command) { case undefined: case "help": return usage(); case "validate": - return validate(paths); + return validate(); default: console.error(`Unknown command: ${command}`); return 1; @@ -34,20 +36,18 @@ function main(): number { } function usage(): number { - console.log(`Usage: changenotes.mts validate [ ...]`); + console.log(`Usage: changenotes.mts validate`); return 0; } -function validate(paths: string[]): number { - let valid = true; - if (paths.length === 0) { - console.error("error: no paths provided (see 'help' command for usage)"); +function validate(): number { + try { + return isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR)) ? 0 : 1; + } catch (error) { + console.error( + `${CHANGENOTES_DIR}: failed to read file or directory`, + error, + ); return 1; } - for (const path of paths) { - if (!isValidChangenoteFile(path)) { - valid = false; - } - } - return valid ? 0 : 1; } From f60f4d950069b26d3b0a9b09036de175a9410dc2 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 31 Aug 2026 11:38:54 -0500 Subject: [PATCH 3/6] Validate change-notes in PRs This commit also introduces the 'empty' home directory for future change-notes. --- .github/workflows/pr-checks.yml | 4 ++++ pr-checks/config.ts | 3 +++ unreleased-change-notes/.gitkeep | 0 3 files changed, 7 insertions(+) create mode 100644 unreleased-change-notes/.gitkeep diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index ed514c9cd9..c752c5dc77 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -114,6 +114,10 @@ jobs: working-directory: pr-checks run: npx tsx --test + - name: Run `pr-checks/changenotes.mts` to ensure that all unreleased change notes are valid + if: ${{ !cancelled() && steps.install-deps.outcome == 'success' }} + run: npx tsx pr-checks/changenotes.mts validate + - name: Verify all Actions use the same Node version id: head-version run: | diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 356fe665f9..05bcbfe3ce 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -18,6 +18,9 @@ export const PACKAGE_JSON = path.join(REPO_ROOT, "package.json"); /** The path of the changelog. */ export const CHANGELOG_FILE = path.join(REPO_ROOT, "CHANGELOG.md"); +/** The path to the unreleased change-notes directory. */ +export const CHANGENOTES_DIR = path.join(REPO_ROOT, "unreleased-change-notes"); + /** The path to the esbuild metadata file. */ export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); diff --git a/unreleased-change-notes/.gitkeep b/unreleased-change-notes/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 From f45ef9d1c58f7283bea5ead2865244d7136b30a6 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 14 Sep 2026 12:17:28 -0500 Subject: [PATCH 4/6] Log something on successful changenote validation Also, slightly improve the log statement of the validation error case. --- pr-checks/changenotes.mts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index 37e00338fc..c04ccb83c1 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -42,12 +42,15 @@ function usage(): number { function validate(): number { try { - return isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR)) ? 0 : 1; + if (isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR))) { + console.log(`All changenote files in '${CHANGENOTES_DIR}' are valid.`); + return 0; + } } catch (error) { console.error( - `${CHANGENOTES_DIR}: failed to read file or directory`, + `Failed to read change-notes directory (${CHANGENOTES_DIR})`, error, ); - return 1; } + return 1; } From 6041eff66bfa4ba328da62f09d4bc3598b964504 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Mon, 14 Sep 2026 13:40:10 -0500 Subject: [PATCH 5/6] Update validate.test.mts test names to reflect new `isValidAllChangenoteFiles` The `isValidAllChangenoteFiles` now accepts a list of file paths rather than a file path string. --- pr-checks/changelog/validate.test.mts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pr-checks/changelog/validate.test.mts b/pr-checks/changelog/validate.test.mts index f6fb43680b..a38339d1c9 100644 --- a/pr-checks/changelog/validate.test.mts +++ b/pr-checks/changelog/validate.test.mts @@ -189,7 +189,7 @@ await describe("isValidChangenoteFile", async () => { }); await describe("isValidAllChangenoteFiles", async () => { - await it("accepts a directory of valid change-note files", async () => { + await it("accepts list of file paths of valid change-notes", async () => { await withTmpDir(async (tmpDir) => { const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md"); const fileName2 = path.join(tmpDir, "2026-01-02-add-feature.md"); @@ -202,15 +202,15 @@ await describe("isValidAllChangenoteFiles", async () => { }); }); - await it("accepts an empty list", async () => { + await it("accepts the empty list", async () => { assert.equal(isValidAllChangenoteFiles([]), true); }); - await it("accepts a list of .gitkeep only", async () => { + await it("accepts list of .gitkeep", async () => { assert.equal(isValidAllChangenoteFiles([".gitkeep"]), true); }); - await it("rejects directory with an invalid change-note file", async () => { + await it("rejects list containing a file path to an invalid change-note", async () => { await withTmpDir(async (tmpDir) => { const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md"); const fileName2 = path.join(tmpDir, "2026-01-02-wrong-category.md"); From e26a5c41396509f88d9a48ca05aa84f3695a846f Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Tue, 15 Sep 2026 09:18:42 -0500 Subject: [PATCH 6/6] Be consistent with language and conventions --- pr-checks/changenotes.mts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pr-checks/changenotes.mts b/pr-checks/changenotes.mts index c04ccb83c1..2fb86b0cac 100755 --- a/pr-checks/changenotes.mts +++ b/pr-checks/changenotes.mts @@ -43,12 +43,12 @@ function usage(): number { function validate(): number { try { if (isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR))) { - console.log(`All changenote files in '${CHANGENOTES_DIR}' are valid.`); + console.log(`All changenotes in '${CHANGENOTES_DIR}' are valid.`); return 0; } } catch (error) { console.error( - `Failed to read change-notes directory (${CHANGENOTES_DIR})`, + `Failed to read changenotes directory '${CHANGENOTES_DIR}'`, error, ); }