mirror of
https://github.com/github/codeql-action.git
synced 2026-10-03 09:14:58 +00:00
Create changetool script for validating change-notes
This commit is contained in:
30
.github/workflows/pr-checks.yml
vendored
30
.github/workflows/pr-checks.yml
vendored
@@ -72,6 +72,36 @@ jobs:
|
||||
sarif_file: eslint.sarif
|
||||
category: eslint
|
||||
|
||||
changetool-tests:
|
||||
name: changetool unit tests
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || false }}
|
||||
group: pr-checks-changetool-tests-${{ github.ref }}-${{ github.event_name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
cache-dependency-path: scripts/changetool/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: scripts/changetool
|
||||
run: npm ci
|
||||
|
||||
- name: Run changetool unit tests
|
||||
working-directory: scripts/changetool
|
||||
run: npm test
|
||||
|
||||
# These checks do not need to be run as part of the same matrix that we use for the `unit-tests`
|
||||
# job.
|
||||
other-checks:
|
||||
|
||||
@@ -26,6 +26,7 @@ export default [
|
||||
"ava.config.mjs",
|
||||
"ava.setup.mjs",
|
||||
"eslint.config.mjs",
|
||||
"scripts/changetool/build/**/*",
|
||||
".github/**/*",
|
||||
],
|
||||
},
|
||||
@@ -209,4 +210,18 @@ export default [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["scripts/changetool/**/*.ts"],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: "./scripts/changetool/tsconfig.json",
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import/extensions": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
199
scripts/changetool/cli/validate.test.ts
Normal file
199
scripts/changetool/cli/validate.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
isValidChangenoteContent,
|
||||
isValidChangenoteFile,
|
||||
isValidChangenoteFilename,
|
||||
hasValidChangenoteCategory,
|
||||
VALID_CHANGE_NOTE_CATEGORIES,
|
||||
} from "./validate.ts";
|
||||
|
||||
async function withTmpFile<T>(
|
||||
baseFileName: string,
|
||||
contents: string,
|
||||
body: (filePath: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const tmpDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "changetool-validate-test-"),
|
||||
);
|
||||
try {
|
||||
const filePath = path.join(tmpDir, baseFileName);
|
||||
fs.writeFileSync(filePath, contents);
|
||||
return await body(filePath);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await describe("isValidChangenoteContent", async () => {
|
||||
await it("recognizes an unordered Markdown list", async () => {
|
||||
const inputs = [
|
||||
"- One changenote entry",
|
||||
"- First item\n- Second item",
|
||||
"\n\n\n\n- Fixed a bug\n- Added a feature",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
assert.equal(isValidChangenoteContent(input), true);
|
||||
}
|
||||
});
|
||||
|
||||
await it("does not recognize non-Markdown text", async () => {
|
||||
const inputs = [
|
||||
"This is not a list.",
|
||||
'["this", "is", "JSON"]',
|
||||
"---",
|
||||
"***",
|
||||
"___",
|
||||
"paragraph",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
assert.equal(isValidChangenoteContent(input), false);
|
||||
}
|
||||
});
|
||||
|
||||
await it("does not recognize ordered Markdown lists", async () => {
|
||||
const inputs = [
|
||||
"1. First item\n2. Second item",
|
||||
"\n\n\n1. First item\n1. Second item",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
assert.equal(isValidChangenoteContent(input), false);
|
||||
}
|
||||
});
|
||||
|
||||
await it("requires all list items to use a hyphen bullet", async () => {
|
||||
const inputs = [
|
||||
"* Fixed a bug\n* Added feature",
|
||||
"+ Fixed a bug\n+ Added feature",
|
||||
"- Fixed a bug\n* Added feature",
|
||||
"- Fixed a bug\n+ Added feature",
|
||||
"\n\n\n* Fixed a bug",
|
||||
"\n\n\n+ Fixed a bug",
|
||||
"---\n* Fixed a bug\n* Added feature",
|
||||
] as const;
|
||||
|
||||
for (const input of inputs) {
|
||||
assert.equal(isValidChangenoteContent(input), false);
|
||||
}
|
||||
});
|
||||
|
||||
await it("does not contain other Markdown elements", async () => {
|
||||
const inputs = [
|
||||
"- Fixed a bug\n\nParagraph of text",
|
||||
"- Fixed a bug\n\n* Added a feature",
|
||||
"# Header\n- Fixed a bug",
|
||||
"- Fixed a bug\n## Subheader",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
assert.equal(isValidChangenoteContent(input), false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await describe("isValidChangenoteFilename", async () => {
|
||||
await it("accepts valid filenames", async () => {
|
||||
const inputs = [
|
||||
"2023-01-01-fix-bug.md",
|
||||
"2023-12-31-add-feature.md",
|
||||
"2023-06-15-update-docs.md",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
assert.equal(isValidChangenoteFilename(input), true);
|
||||
}
|
||||
});
|
||||
|
||||
await it("rejects invalid filenames", async () => {
|
||||
const inputs = [
|
||||
"missing-date-from-filename.md",
|
||||
"2021-01-01.md",
|
||||
"2026-12-19-wrong-file-name-extension.txt",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
assert.equal(isValidChangenoteFilename(input), false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await describe("hasValidChangenoteCategory", async () => {
|
||||
await it("accepts valid categories", async () => {
|
||||
for (const category of Object.keys(VALID_CHANGE_NOTE_CATEGORIES)) {
|
||||
const frontmatter = { category };
|
||||
assert.equal(hasValidChangenoteCategory(frontmatter), true);
|
||||
}
|
||||
});
|
||||
|
||||
await it("rejects invalid categories", async () => {
|
||||
const inputs = [
|
||||
"",
|
||||
"invalid-category",
|
||||
"bug-fix",
|
||||
"new-feature",
|
||||
"security-patch",
|
||||
"miscellaneous",
|
||||
"documentation",
|
||||
];
|
||||
|
||||
for (const category of inputs) {
|
||||
const frontmatter = { category };
|
||||
assert.equal(hasValidChangenoteCategory(frontmatter), false);
|
||||
}
|
||||
});
|
||||
|
||||
await it("reject missing category", async () => {
|
||||
assert.equal(hasValidChangenoteCategory({}), false);
|
||||
assert.equal(hasValidChangenoteCategory({ category: null }), false);
|
||||
assert.equal(hasValidChangenoteCategory({ category: undefined }), false);
|
||||
});
|
||||
});
|
||||
|
||||
await describe("isValidChangenoteFile", async () => {
|
||||
await it("accepts a valid change-note file", async () => {
|
||||
await withTmpFile(
|
||||
"2026-01-01-fix-bug.md",
|
||||
"---\ncategory: fix\n---\n- Fixed a bug\n",
|
||||
async (filePath) => {
|
||||
assert.equal(isValidChangenoteFile(filePath), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await it("rejects invalid filename", async () => {
|
||||
await withTmpFile(
|
||||
"fix-bug.md",
|
||||
"---\ncategory: fix\n---\n- Fixed a bug\n",
|
||||
async (filePath) => {
|
||||
assert.equal(isValidChangenoteFile(filePath), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await it("rejects missing frontmatter", async () => {
|
||||
await withTmpFile(
|
||||
"2026-01-01-fix-bug.md",
|
||||
"- Fixed a bug\n",
|
||||
async (filePath) => {
|
||||
assert.equal(isValidChangenoteFile(filePath), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await it("rejects invalid Markdown", async () => {
|
||||
await withTmpFile(
|
||||
"2026-01-01-fix-bug.md",
|
||||
"---\ncategory: fix\n---\n* Fixed a bug\n",
|
||||
async (filePath) => {
|
||||
assert.equal(isValidChangenoteFile(filePath), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
104
scripts/changetool/cli/validate.ts
Normal file
104
scripts/changetool/cli/validate.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { matter } from "lite-matter";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
// Regex for filename: YYYY-MM-DD-id.md
|
||||
const VALID_CHANGE_NOTE_FILENAME_PATTERN =
|
||||
/^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
|
||||
|
||||
export const VALID_CHANGE_NOTE_CATEGORIES = {
|
||||
breaking: "Breaking Changes",
|
||||
feature: "New Features",
|
||||
improvement: "Improvements",
|
||||
securityFix: "Security Fixes",
|
||||
fix: "Bug Fixes",
|
||||
unship: "Removed Features",
|
||||
deprecation: "Deprecations",
|
||||
knownIssue: "Known Issues",
|
||||
misc: "Miscellaneous",
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the given Markdown string meets the criteria for a change-note, which is:
|
||||
* - A single unordered list
|
||||
* - Each list item must start with a hyphen (-)
|
||||
* - No other Markdown elements are allowed
|
||||
* @param content The Markdown string to validate
|
||||
* @returns True if the string is a valid change-note, false otherwise
|
||||
*/
|
||||
export function isValidChangenoteContent(content: string): boolean {
|
||||
const ast = fromMarkdown(content);
|
||||
|
||||
if (
|
||||
ast.children.length !== 1 ||
|
||||
ast.children[0].type !== "list" ||
|
||||
ast.children[0].ordered === true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
return ast.children[0].children.every((listItem) => {
|
||||
return lines[listItem.position!.start.line - 1].startsWith("-");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given filename meets the criteria for a change-note filename.
|
||||
* @param filename The name of the change-note file to validate.
|
||||
* @returns True if the filename is valid, false otherwise.
|
||||
*/
|
||||
export function isValidChangenoteFilename(filename: string): boolean {
|
||||
return filename.match(VALID_CHANGE_NOTE_FILENAME_PATTERN) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given frontmatter has a valid change-note category.
|
||||
* @param frontmatter The frontmatter object to validate.
|
||||
* @returns True if the frontmatter has a valid category, false otherwise.
|
||||
*/
|
||||
export function hasValidChangenoteCategory(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): boolean {
|
||||
const category = frontmatter["category"];
|
||||
return (
|
||||
typeof category === "string" && category in VALID_CHANGE_NOTE_CATEGORIES
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given change-note file meets all of the criteria for a change-note.
|
||||
* @param filename The name of the change-note file to validate.
|
||||
* @returns True if the file is a valid change-note, false otherwise.
|
||||
*/
|
||||
export function isValidChangenoteFile(filename: string): boolean {
|
||||
let isValid: boolean = true;
|
||||
|
||||
const { data: frontmatter, content } = matter(
|
||||
fs.readFileSync(filename, "utf8"),
|
||||
);
|
||||
|
||||
if (!isValidChangenoteFilename(path.basename(filename))) {
|
||||
isValid = false;
|
||||
console.error(
|
||||
`${filename}: invalid filename; must match pattern YYYY-MM-DD-id.md`,
|
||||
);
|
||||
}
|
||||
if (!hasValidChangenoteCategory(frontmatter)) {
|
||||
isValid = false;
|
||||
const categories = Object.keys(VALID_CHANGE_NOTE_CATEGORIES).join(", ");
|
||||
console.error(
|
||||
`${filename}: invalid category; must be one of: ${categories}`,
|
||||
);
|
||||
}
|
||||
if (!isValidChangenoteContent(content)) {
|
||||
isValid = false;
|
||||
console.error(
|
||||
`${filename}: invalid Markdown; content must be a single unordered list with hyphen bullets and no other Markdown elements`,
|
||||
);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
45
scripts/changetool/index.ts
Normal file
45
scripts/changetool/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { isValidChangenoteFile } from "./cli/validate.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): number {
|
||||
const { positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
strict: true,
|
||||
});
|
||||
const [command, ...paths] = positionals;
|
||||
switch (command) {
|
||||
case undefined:
|
||||
case "help":
|
||||
return usage();
|
||||
case "validate":
|
||||
return validate(paths);
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function usage(): number {
|
||||
console.log("Usage: changetool validate <path> [<path> ...]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function validate(paths: string[]): number {
|
||||
if (paths.length === 0) {
|
||||
console.error("error: no paths provided (see 'help' command for usage)");
|
||||
return 1;
|
||||
}
|
||||
return paths.every((path) => isValidChangenoteFile(path)) ? 0 : 1;
|
||||
}
|
||||
1548
scripts/changetool/package-lock.json
generated
Normal file
1548
scripts/changetool/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
scripts/changetool/package.json
Normal file
23
scripts/changetool/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "changetool",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Validates change-notes and merges them into CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx index.ts",
|
||||
"test": "node --test --experimental-strip-types --experimental-test-module-mocks cli/*.test.ts",
|
||||
"transpile": "tsc --build tsconfig.json",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"lite-matter": "^0.1.2",
|
||||
"mdast-util-from-markdown": "^2.0.3"
|
||||
}
|
||||
}
|
||||
36
scripts/changetool/tsconfig.json
Normal file
36
scripts/changetool/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
"lib": ["ES2022"],
|
||||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"outDir": "./build",
|
||||
"rootDir": ".",
|
||||
"sourceMap": false,
|
||||
"types": ["node"],
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Module Resolution Options */
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["build", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user