Merge pull request #4116 from github/mario-campos/validate-cn-workflow

Validate change-notes as a PR check
This commit is contained in:
Mario Campos
2026-09-16 15:12:11 +00:00
committed by GitHub
7 changed files with 80 additions and 16 deletions

View File

@@ -114,6 +114,10 @@ jobs:
working-directory: pr-checks working-directory: pr-checks
run: npx tsx --test 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 - name: Verify all Actions use the same Node version
id: head-version id: head-version
run: | run: |

View File

@@ -158,7 +158,7 @@ export default [
}, },
}, },
{ {
files: ["**/*.ts", "**/*.js"], files: ["**/*.ts", "**/*.js", "**/*.mts"],
rules: { rules: {
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",

View File

@@ -119,3 +119,14 @@ export function isValidChangenoteFile(filename: string): boolean {
return isValid; 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);
}

View File

@@ -1,10 +1,13 @@
import assert from "node:assert/strict"; 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 { describe, it } from "node:test";
import { withTmpFile } from "../../src/util"; import { withTmpDir, withTmpFile } from "../../src/util";
import { import {
hasValidChangenoteCategory, hasValidChangenoteCategory,
isValidAllChangenoteFiles,
isValidChangenoteContent, isValidChangenoteContent,
isValidChangenoteFile, isValidChangenoteFile,
isValidChangenoteFilename, 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 it("rejects invalid filename", async () => {
await withTmpFile( await withTmpFile(
"fix-bug.md", "fix-bug.md",
@@ -180,3 +187,39 @@ await describe("isValidChangenoteFile", async () => {
); );
}); });
}); });
await describe("isValidAllChangenoteFiles", 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");
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 the empty list", async () => {
assert.equal(isValidAllChangenoteFiles([]), true);
});
await it("accepts list of .gitkeep", async () => {
assert.equal(isValidAllChangenoteFiles([".gitkeep"]), true);
});
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");
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);
});
});
});

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env npx tsx #!/usr/bin/env npx tsx
import * as fs from "node:fs";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util"; 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]; const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
@@ -20,13 +22,13 @@ function main(): number {
allowPositionals: true, allowPositionals: true,
strict: true, strict: true,
}); });
const [command, ...paths] = positionals; const [command] = positionals;
switch (command) { switch (command) {
case undefined: case undefined:
case "help": case "help":
return usage(); return usage();
case "validate": case "validate":
return validate(paths); return validate();
default: default:
console.error(`Unknown command: ${command}`); console.error(`Unknown command: ${command}`);
return 1; return 1;
@@ -34,20 +36,21 @@ function main(): number {
} }
function usage(): number { function usage(): number {
console.log(`Usage: changenotes.mts validate <path> [<path> ...]`); console.log(`Usage: changenotes.mts validate`);
return 0; return 0;
} }
function validate(paths: string[]): number { function validate(): number {
let valid = true; try {
if (paths.length === 0) { if (isValidAllChangenoteFiles(fs.readdirSync(CHANGENOTES_DIR))) {
console.error("error: no paths provided (see 'help' command for usage)"); console.log(`All changenotes in '${CHANGENOTES_DIR}' are valid.`);
return 1; return 0;
}
for (const path of paths) {
if (!isValidChangenoteFile(path)) {
valid = false;
} }
} catch (error) {
console.error(
`Failed to read changenotes directory '${CHANGENOTES_DIR}'`,
error,
);
} }
return valid ? 0 : 1; return 1;
} }

View File

@@ -18,6 +18,9 @@ export const PACKAGE_JSON = path.join(REPO_ROOT, "package.json");
/** The path of the changelog. */ /** The path of the changelog. */
export const CHANGELOG_FILE = path.join(REPO_ROOT, "CHANGELOG.md"); 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. */ /** The path to the esbuild metadata file. */
export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json");

View File