Skip to content

Commit eb65ce0

Browse files
committed
Create changetool script for validating change-notes
1 parent 486fec2 commit eb65ce0

8 files changed

Lines changed: 2000 additions & 0 deletions

File tree

.github/workflows/pr-checks.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,36 @@ jobs:
7272
sarif_file: eslint.sarif
7373
category: eslint
7474

75+
changetool-tests:
76+
name: changetool unit tests
77+
permissions:
78+
contents: read
79+
runs-on: ubuntu-slim
80+
timeout-minutes: 10
81+
82+
concurrency:
83+
cancel-in-progress: ${{ github.event_name == 'pull_request' || false }}
84+
group: pr-checks-changetool-tests-${{ github.ref }}-${{ github.event_name }}
85+
86+
steps:
87+
- name: Checkout repository
88+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
89+
90+
- name: Set up Node.js
91+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
92+
with:
93+
node-version: 24
94+
cache: 'npm'
95+
cache-dependency-path: scripts/changetool/package-lock.json
96+
97+
- name: Install dependencies
98+
working-directory: scripts/changetool
99+
run: npm ci
100+
101+
- name: Run changetool unit tests
102+
working-directory: scripts/changetool
103+
run: npm test
104+
75105
# These checks do not need to be run as part of the same matrix that we use for the `unit-tests`
76106
# job.
77107
other-checks:

eslint.config.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default [
2626
"ava.config.mjs",
2727
"ava.setup.mjs",
2828
"eslint.config.mjs",
29+
"scripts/changetool/build/**/*",
2930
".github/**/*",
3031
],
3132
},
@@ -209,4 +210,18 @@ export default [
209210
],
210211
},
211212
},
213+
{
214+
files: ["scripts/changetool/**/*.ts"],
215+
216+
languageOptions: {
217+
parserOptions: {
218+
project: "./scripts/changetool/tsconfig.json",
219+
},
220+
},
221+
222+
rules: {
223+
"no-console": "off",
224+
"import/extensions": "off",
225+
},
226+
},
212227
];
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import assert from "node:assert/strict";
2+
import * as fs from "node:fs";
3+
import * as os from "node:os";
4+
import * as path from "node:path";
5+
import { describe, it } from "node:test";
6+
7+
import {
8+
isValidChangenoteContent,
9+
isValidChangenoteFile,
10+
isValidChangenoteFilename,
11+
hasValidChangenoteCategory,
12+
VALID_CHANGE_NOTE_CATEGORIES,
13+
} from "./validate.ts";
14+
15+
async function withTmpFile<T>(
16+
baseFileName: string,
17+
contents: string,
18+
body: (filePath: string) => Promise<T>,
19+
): Promise<T> {
20+
const tmpDir = fs.mkdtempSync(
21+
path.join(os.tmpdir(), "changetool-validate-test-"),
22+
);
23+
try {
24+
const filePath = path.join(tmpDir, baseFileName);
25+
fs.writeFileSync(filePath, contents);
26+
return await body(filePath);
27+
} finally {
28+
fs.rmSync(tmpDir, { recursive: true, force: true });
29+
}
30+
}
31+
32+
await describe("isValidChangenoteContent", async () => {
33+
await it("recognizes an unordered Markdown list", async () => {
34+
const inputs = [
35+
"- One changenote entry",
36+
"- First item\n- Second item",
37+
"\n\n\n\n- Fixed a bug\n- Added a feature",
38+
];
39+
40+
for (const input of inputs) {
41+
assert.equal(isValidChangenoteContent(input), true);
42+
}
43+
});
44+
45+
await it("does not recognize non-Markdown text", async () => {
46+
const inputs = [
47+
"This is not a list.",
48+
'["this", "is", "JSON"]',
49+
"---",
50+
"***",
51+
"___",
52+
"paragraph",
53+
];
54+
55+
for (const input of inputs) {
56+
assert.equal(isValidChangenoteContent(input), false);
57+
}
58+
});
59+
60+
await it("does not recognize ordered Markdown lists", async () => {
61+
const inputs = [
62+
"1. First item\n2. Second item",
63+
"\n\n\n1. First item\n1. Second item",
64+
];
65+
66+
for (const input of inputs) {
67+
assert.equal(isValidChangenoteContent(input), false);
68+
}
69+
});
70+
71+
await it("requires all list items to use a hyphen bullet", async () => {
72+
const inputs = [
73+
"* Fixed a bug\n* Added feature",
74+
"+ Fixed a bug\n+ Added feature",
75+
"- Fixed a bug\n* Added feature",
76+
"- Fixed a bug\n+ Added feature",
77+
"\n\n\n* Fixed a bug",
78+
"\n\n\n+ Fixed a bug",
79+
"---\n* Fixed a bug\n* Added feature",
80+
] as const;
81+
82+
for (const input of inputs) {
83+
assert.equal(isValidChangenoteContent(input), false);
84+
}
85+
});
86+
87+
await it("does not contain other Markdown elements", async () => {
88+
const inputs = [
89+
"- Fixed a bug\n\nParagraph of text",
90+
"- Fixed a bug\n\n* Added a feature",
91+
"# Header\n- Fixed a bug",
92+
"- Fixed a bug\n## Subheader",
93+
];
94+
95+
for (const input of inputs) {
96+
assert.equal(isValidChangenoteContent(input), false);
97+
}
98+
});
99+
});
100+
101+
await describe("isValidChangenoteFilename", async () => {
102+
await it("accepts valid filenames", async () => {
103+
const inputs = [
104+
"2023-01-01-fix-bug.md",
105+
"2023-12-31-add-feature.md",
106+
"2023-06-15-update-docs.md",
107+
];
108+
109+
for (const input of inputs) {
110+
assert.equal(isValidChangenoteFilename(input), true);
111+
}
112+
});
113+
114+
await it("rejects invalid filenames", async () => {
115+
const inputs = [
116+
"missing-date-from-filename.md",
117+
"2021-01-01.md",
118+
"2026-12-19-wrong-file-name-extension.txt",
119+
];
120+
121+
for (const input of inputs) {
122+
assert.equal(isValidChangenoteFilename(input), false);
123+
}
124+
});
125+
});
126+
127+
await describe("hasValidChangenoteCategory", async () => {
128+
await it("accepts valid categories", async () => {
129+
for (const category of Object.keys(VALID_CHANGE_NOTE_CATEGORIES)) {
130+
const frontmatter = { category };
131+
assert.equal(hasValidChangenoteCategory(frontmatter), true);
132+
}
133+
});
134+
135+
await it("rejects invalid categories", async () => {
136+
const inputs = [
137+
"",
138+
"invalid-category",
139+
"bug-fix",
140+
"new-feature",
141+
"security-patch",
142+
"miscellaneous",
143+
"documentation",
144+
];
145+
146+
for (const category of inputs) {
147+
const frontmatter = { category };
148+
assert.equal(hasValidChangenoteCategory(frontmatter), false);
149+
}
150+
});
151+
152+
await it("reject missing category", async () => {
153+
assert.equal(hasValidChangenoteCategory({}), false);
154+
assert.equal(hasValidChangenoteCategory({ category: null }), false);
155+
assert.equal(hasValidChangenoteCategory({ category: undefined }), false);
156+
});
157+
});
158+
159+
await describe("isValidChangenoteFile", async () => {
160+
await it("accepts a valid change-note file", async () => {
161+
await withTmpFile(
162+
"2026-01-01-fix-bug.md",
163+
"---\ncategory: fix\n---\n- Fixed a bug\n",
164+
async (filePath) => {
165+
assert.equal(isValidChangenoteFile(filePath), true);
166+
},
167+
);
168+
});
169+
170+
await it("rejects invalid filename", async () => {
171+
await withTmpFile(
172+
"fix-bug.md",
173+
"---\ncategory: fix\n---\n- Fixed a bug\n",
174+
async (filePath) => {
175+
assert.equal(isValidChangenoteFile(filePath), false);
176+
},
177+
);
178+
});
179+
180+
await it("rejects missing frontmatter", async () => {
181+
await withTmpFile(
182+
"2026-01-01-fix-bug.md",
183+
"- Fixed a bug\n",
184+
async (filePath) => {
185+
assert.equal(isValidChangenoteFile(filePath), false);
186+
},
187+
);
188+
});
189+
190+
await it("rejects invalid Markdown", async () => {
191+
await withTmpFile(
192+
"2026-01-01-fix-bug.md",
193+
"---\ncategory: fix\n---\n* Fixed a bug\n",
194+
async (filePath) => {
195+
assert.equal(isValidChangenoteFile(filePath), false);
196+
},
197+
);
198+
});
199+
});

scripts/changetool/cli/validate.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
3+
4+
import { matter } from "lite-matter";
5+
import { fromMarkdown } from "mdast-util-from-markdown";
6+
7+
// Regex for filename: YYYY-MM-DD-id.md
8+
const VALID_CHANGE_NOTE_FILENAME_PATTERN =
9+
/^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
10+
11+
export const VALID_CHANGE_NOTE_CATEGORIES = {
12+
breaking: "Breaking Changes",
13+
feature: "New Features",
14+
improvement: "Improvements",
15+
securityFix: "Security Fixes",
16+
fix: "Bug Fixes",
17+
unship: "Removed Features",
18+
deprecation: "Deprecations",
19+
knownIssue: "Known Issues",
20+
misc: "Miscellaneous",
21+
};
22+
23+
/**
24+
* Validates that the given Markdown string meets the criteria for a change-note, which is:
25+
* - A single unordered list
26+
* - Each list item must start with a hyphen (-)
27+
* - No other Markdown elements are allowed
28+
* @param content The Markdown string to validate
29+
* @returns True if the string is a valid change-note, false otherwise
30+
*/
31+
export function isValidChangenoteContent(content: string): boolean {
32+
const ast = fromMarkdown(content);
33+
34+
if (
35+
ast.children.length !== 1 ||
36+
ast.children[0].type !== "list" ||
37+
ast.children[0].ordered === true
38+
) {
39+
return false;
40+
}
41+
42+
const lines = content.split("\n");
43+
return ast.children[0].children.every((listItem) => {
44+
return lines[listItem.position!.start.line - 1].startsWith("-");
45+
});
46+
}
47+
48+
/**
49+
* Validates that the given filename meets the criteria for a change-note filename.
50+
* @param filename The name of the change-note file to validate.
51+
* @returns True if the filename is valid, false otherwise.
52+
*/
53+
export function isValidChangenoteFilename(filename: string): boolean {
54+
return filename.match(VALID_CHANGE_NOTE_FILENAME_PATTERN) !== null;
55+
}
56+
57+
/**
58+
* Validates that the given frontmatter has a valid change-note category.
59+
* @param frontmatter The frontmatter object to validate.
60+
* @returns True if the frontmatter has a valid category, false otherwise.
61+
*/
62+
export function hasValidChangenoteCategory(
63+
frontmatter: Record<string, unknown>,
64+
): boolean {
65+
const category = frontmatter["category"];
66+
return (
67+
typeof category === "string" && category in VALID_CHANGE_NOTE_CATEGORIES
68+
);
69+
}
70+
71+
/**
72+
* Validates that the given change-note file meets all of the criteria for a change-note.
73+
* @param filename The name of the change-note file to validate.
74+
* @returns True if the file is a valid change-note, false otherwise.
75+
*/
76+
export function isValidChangenoteFile(filename: string): boolean {
77+
let isValid: boolean = true;
78+
79+
const { data: frontmatter, content } = matter(
80+
fs.readFileSync(filename, "utf8"),
81+
);
82+
83+
if (!isValidChangenoteFilename(path.basename(filename))) {
84+
isValid = false;
85+
console.error(
86+
`${filename}: invalid filename; must match pattern YYYY-MM-DD-id.md`,
87+
);
88+
}
89+
if (!hasValidChangenoteCategory(frontmatter)) {
90+
isValid = false;
91+
const categories = Object.keys(VALID_CHANGE_NOTE_CATEGORIES).join(", ");
92+
console.error(
93+
`${filename}: invalid category; must be one of: ${categories}`,
94+
);
95+
}
96+
if (!isValidChangenoteContent(content)) {
97+
isValid = false;
98+
console.error(
99+
`${filename}: invalid Markdown; content must be a single unordered list with hyphen bullets and no other Markdown elements`,
100+
);
101+
}
102+
103+
return isValid;
104+
}

0 commit comments

Comments
 (0)