From 18d9d1d24df6c47a78f6f103f9178eea4c24eebe Mon Sep 17 00:00:00 2001 From: Jeff Welling Date: Tue, 20 Oct 2026 14:18:52 -0700 Subject: [PATCH 1/8] Create change-management-check composite action Fails the check when a Testing evidence, Success / failure criteria, or Rollback plan section in the PR body is empty or missing. Exempt: Renovate branches, docs-only PRs, dev-only PRs. --- .changeset/change-management-check.md | 5 + actions/change-management-check/README.md | 4 + actions/change-management-check/action.yml | 49 ++ actions/change-management-check/package.json | 11 + actions/change-management-check/project.json | 16 + .../change-management-check/scripts/check.js | 214 ++++++++ .../scripts/check.test.js | 462 ++++++++++++++++++ pnpm-lock.yaml | 2 + 8 files changed, 763 insertions(+) create mode 100644 .changeset/change-management-check.md create mode 100644 actions/change-management-check/README.md create mode 100644 actions/change-management-check/action.yml create mode 100644 actions/change-management-check/package.json create mode 100644 actions/change-management-check/project.json create mode 100644 actions/change-management-check/scripts/check.js create mode 100644 actions/change-management-check/scripts/check.test.js diff --git a/.changeset/change-management-check.md b/.changeset/change-management-check.md new file mode 100644 index 000000000..51d24bcfd --- /dev/null +++ b/.changeset/change-management-check.md @@ -0,0 +1,5 @@ +--- +"change-management-check": major +--- + +Add the change-management-check composite action: fails the check when a change-management section in the PR body is empty or missing. diff --git a/actions/change-management-check/README.md b/actions/change-management-check/README.md new file mode 100644 index 000000000..759a19abb --- /dev/null +++ b/actions/change-management-check/README.md @@ -0,0 +1,4 @@ +# change-management-check + +> Fails the check when a change-management section in the PR body is empty or +> missing diff --git a/actions/change-management-check/action.yml b/actions/change-management-check/action.yml new file mode 100644 index 000000000..44c981219 --- /dev/null +++ b/actions/change-management-check/action.yml @@ -0,0 +1,49 @@ +name: change-management-check +description: + "Fails the check when a change-management section in the PR body is empty or + missing" + +inputs: + sections: + description: + "Lines of 'Display name: heading text'. One line per required section." + required: true + strip-lines: + description: + "Lines removed from a section before the empty test. Use for template + default lines." + required: false + default: "" + docs-paths: + description: "Glob lines. A PR whose files all match is exempt (docs-only)." + required: false + default: "**/*.md" + dev-paths: + description: "Glob lines. A PR whose files all match is exempt (dev-only)." + required: false + default: "" + head-prefixes: + description: "Comma separated head-branch prefixes that are exempt." + required: false + default: "renovate/" + standard-url: + description: "Link to the Standard, included in the failure message." + required: false + default: "https://smartcontract-it.atlassian.net/wiki/spaces/ENG/pages/1417445401/Infrastructure+Change+Management+Standard" + +runs: + using: composite + steps: + - name: Check change management sections + uses: actions/github-script@v9 + env: + SECTIONS: ${{ inputs.sections }} + STRIP_LINES: ${{ inputs.strip-lines }} + DOCS_PATHS: ${{ inputs.docs-paths }} + DEV_PATHS: ${{ inputs.dev-paths }} + HEAD_PREFIXES: ${{ inputs.head-prefixes }} + STANDARD_URL: ${{ inputs.standard-url }} + with: + script: | + const script = require(`${process.env.GITHUB_ACTION_PATH}/scripts/check.js`); + await script({ context, core, github }); diff --git a/actions/change-management-check/package.json b/actions/change-management-check/package.json new file mode 100644 index 000000000..9c1d47391 --- /dev/null +++ b/actions/change-management-check/package.json @@ -0,0 +1,11 @@ +{ + "name": "change-management-check", + "version": "0.0.0", + "description": "", + "private": true, + "scripts": {}, + "author": "@smartcontractkit", + "license": "MIT", + "dependencies": {}, + "repository": "https://github.com/smartcontractkit/.github" +} diff --git a/actions/change-management-check/project.json b/actions/change-management-check/project.json new file mode 100644 index 000000000..4b19ca6a7 --- /dev/null +++ b/actions/change-management-check/project.json @@ -0,0 +1,16 @@ +{ + "name": "change-management-check", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "actions/change-management-check", + "targets": { + "test": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "color": true, + "command": "node --test" + } + } + } +} diff --git a/actions/change-management-check/scripts/check.js b/actions/change-management-check/scripts/check.js new file mode 100644 index 000000000..8bc2305a0 --- /dev/null +++ b/actions/change-management-check/scripts/check.js @@ -0,0 +1,214 @@ +/** + * Fails the check when a change-management section in the PR body is empty + * or missing. Exempt: Renovate branches, docs-only PRs, dev-only PRs. + * + * Inputs (via environment): + * SECTIONS: lines of "Display name: heading text" + * STRIP_LINES: lines removed from a section before the empty test + * DOCS_PATHS: glob lines; a PR whose files all match is docs-only + * DEV_PATHS: glob lines; a PR whose files all match is dev-only + * HEAD_PREFIXES: comma-separated head-branch prefixes that are exempt + * STANDARD_URL: link included in the failure message + */ + +const DEFAULT_STANDARD_URL = + "https://smartcontract-it.atlassian.net/wiki/spaces/ENG/pages/1417445401" + + "/Infrastructure+Change+Management+Standard"; + +const HEADING_RE = /^ {0,3}#{1,6}\s+(.*?)\s*#*\s*$/; +const FENCE_RE = /^\s{0,3}(```|~~~)/; +const COMMENT_RE = //g; + +function splitLines(value) { + return (value || "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +// Converts a glob to an anchored RegExp. `**` crosses `/`, `*` does not. +function globToRegExp(glob) { + let out = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { + if (glob[i + 2] === "/") { + out += "(?:.*/)?"; + i += 2; + } else { + out += ".*"; + i += 1; + } + } else { + out += "[^/]*"; + } + } else if (c === "?") { + out += "[^/]"; + } else if ("\\^$.|+()[]{}".includes(c)) { + out += `\\${c}`; + } else { + out += c; + } + } + return new RegExp(`^${out}$`); +} + +function parseSectionSpec(sections) { + return splitLines(sections).map((line) => { + const i = line.indexOf(":"); + if (i === -1) { + throw new Error( + `Invalid sections line (want "Display: heading"): ${line}`, + ); + } + return { + display: line.slice(0, i).trim(), + alias: line + .slice(i + 1) + .trim() + .toLowerCase(), + }; + }); +} + +// Section bodies keyed by lowercased heading text. Fenced code is not scanned. +function splitSections(body) { + const sections = new Map(); + let current = null; + let fenced = false; + for (const line of body.split("\n")) { + if (FENCE_RE.test(line)) fenced = !fenced; + const heading = fenced ? null : line.match(HEADING_RE); + if (heading) { + current = heading[1].trim().toLowerCase(); + if (!sections.has(current)) sections.set(current, []); + continue; + } + if (current !== null) sections.get(current).push(line); + } + return sections; +} + +// Specs whose section body is empty after comment and strip-line removal. +function missingSections(body, specs, stripLines) { + const sections = splitSections(String(body || "").replace(COMMENT_RE, "")); + const strip = new Set((stripLines || []).map((line) => line.trim())); + return specs.filter((spec) => { + const content = (sections.get(spec.alias) || []) + .filter((line) => !strip.has(line.trim())) + .join("\n") + .trim(); + return content === ""; + }); +} + +async function listFiles(github, context) { + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const files = []; + // The API caps at 3000 files; 30 pages of 100. + for (let page = 1; page <= 30; page++) { + const { data } = await github.rest.pulls.listFiles({ + owner, + repo, + pull_number, + per_page: 100, + page, + }); + files.push(...data.map((file) => file.filename)); + if (data.length < 100) break; + } + return files; +} + +// Reason string when every changed file matches one glob set, else null. +function classifyFiles(files, docsPaths, devPaths) { + if (files.length === 0) return null; + const allMatch = (regexps) => + regexps.length > 0 && + files.every((file) => regexps.some((re) => re.test(file))); + if (allMatch(docsPaths)) return "docs-only"; + if (allMatch(devPaths)) return "dev-only"; + return null; +} + +// Reason string when the PR is exempt, else null. +async function findExemption({ + github, + context, + headPrefixes, + docsPaths, + devPaths, +}) { + const headRef = context.payload.pull_request.head.ref || ""; + const prefix = headPrefixes.find((p) => headRef.startsWith(p)); + if (prefix) return `head branch starts with ${prefix}`; + + return classifyFiles(await listFiles(github, context), docsPaths, devPaths); +} + +function summarize(specs, missing) { + const empty = new Set(missing.map((spec) => spec.display)); + return [ + "### Change management check", + "", + ...specs.map( + (spec) => + `- ${spec.display}: ${empty.has(spec.display) ? "empty" : "present"}`, + ), + ].join("\n"); +} + +module.exports = async ({ context, core, github }) => { + const pr = context.payload.pull_request; + if (!pr) { + core.info("This action is only applicable to pull requests."); + return; + } + + const specs = parseSectionSpec(process.env.SECTIONS); + const stripLines = splitLines(process.env.STRIP_LINES); + const docsPaths = splitLines(process.env.DOCS_PATHS || "**/*.md").map( + globToRegExp, + ); + const devPaths = splitLines(process.env.DEV_PATHS).map(globToRegExp); + const headPrefixes = (process.env.HEAD_PREFIXES || "renovate/") + .split(",") + .map((prefix) => prefix.trim()) + .filter(Boolean); + const standardUrl = process.env.STANDARD_URL || DEFAULT_STANDARD_URL; + + const exempt = await findExemption({ + github, + context, + headPrefixes, + docsPaths, + devPaths, + }); + if (exempt) { + core.info( + `Exempt (${exempt}): the change-management check does not apply.`, + ); + return; + } + + const missing = missingSections(pr.body, specs, stripLines); + core.summary.addRaw(summarize(specs, missing)); + + if (missing.length > 0) { + core.setFailed( + `Please fill in: ${missing.map((spec) => spec.display).join(", ")}. ` + + `See the Infrastructure Change Management Standard: ${standardUrl}`, + ); + return; + } + + core.info("All change-management sections are present."); +}; + +module.exports.classifyFiles = classifyFiles; +module.exports.globToRegExp = globToRegExp; +module.exports.missingSections = missingSections; +module.exports.parseSectionSpec = parseSectionSpec; +module.exports.splitSections = splitSections; diff --git a/actions/change-management-check/scripts/check.test.js b/actions/change-management-check/scripts/check.test.js new file mode 100644 index 000000000..3f377645f --- /dev/null +++ b/actions/change-management-check/scripts/check.test.js @@ -0,0 +1,462 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const check = require("./check.js"); +const { + classifyFiles, + globToRegExp, + missingSections, + parseSectionSpec, + splitSections, +} = check; + +// infra-k8s pull_request_template.md at origin/main, 2026-10-20. +const INFRA_K8S_TEMPLATE = `## What + + + +## Why + + + +## Testing evidence + + + +## Success / failure criteria + + + +## Rollback plan + + + + + + + +## Checklist + +- [ ] [pre-commit hooks](https://github.com/smartcontractkit/infra-k8s#pre-commit-hooks) set up and run +- [ ] Relevant PRs and tickets linked +`; + +// infra .github/pull_request_template.md at origin/master, 2026-10-20. +const INFRA_TEMPLATE = `# Description + +1. Why is this change necessary? + +2. What are the key changes introduced? + + + +# Related Issues/Tickets + +- Jira ticket: https://smartcontract-it.atlassian.net/browse/ + +# Testing Evidence + + + +# Success / Failure Criteria + + + +# Rollback Plan + + + +# Checklist + +- [ ] **Tagging:** All resources are properly tagged in the metadata.hcl file for the directory being modified. Follow the tagging instructions [here](https://github.com/smartcontractkit/infra/tree/master?tab=readme-ov-file#tagging) +- [ ] **Code Owners:** The [CODEOWNERS file](https://github.com/smartcontractkit/infra/blob/master/.github/CODEOWNERS) is updated to ensure the correct team is assigned for reviewing this PR. If you're unfamiliar with Code Owners, see the GitHub [documentation](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners). + + +`; + +// infra-management pull_request_template.md at origin/main, 2026-10-20. +const INFRA_MGMT_TEMPLATE = `## Why is this change necessary? + +Jira Ticket: https://smartcontract-it.atlassian.net/browse/ + +## What's changing? + + +## How was this tested? + +- [] Tested successfully in dev/stage or I'm releasing to dev only. + - testing evidence: + +## What are the success / failure criteria? + + +## What is the rollback plan? + + + +`; + +const SPECS = parseSectionSpec(`Testing evidence: testing evidence +Success / failure criteria: success / failure criteria +Rollback plan: rollback plan`); + +const MGMT_SPECS = parseSectionSpec(`Testing evidence: how was this tested? +Success / failure criteria: what are the success / failure criteria? +Rollback plan: what is the rollback plan?`); + +const MGMT_STRIP = [ + "- [] Tested successfully in dev/stage or I'm releasing to dev only.", + "- testing evidence:", +]; + +const names = (missing) => missing.map((spec) => spec.display); + +test("glob: **/ matches the root and nested paths", () => { + const re = globToRegExp("**/*.md"); + assert.ok(re.test("README.md")); + assert.ok(re.test("docs/guide/intro.md")); + assert.ok(!re.test("README.txt")); +}); + +test("glob: trailing /** matches under the directory only", () => { + const re = globToRegExp("projects/ingest-hub-preview/**"); + assert.ok(re.test("projects/ingest-hub-preview/a.yaml")); + assert.ok(re.test("projects/ingest-hub-preview/apps/a.yaml")); + assert.ok(!re.test("projects/other/a.yaml")); +}); + +test("glob: * does not cross a slash", () => { + const re = globToRegExp("clusters/*.sand.yaml"); + assert.ok(re.test("clusters/main.sand.yaml")); + assert.ok(!re.test("clusters/sub/main.sand.yaml")); + assert.ok(!re.test("clusters/main.prod.yaml")); +}); + +test("spec: parses display and alias", () => { + assert.deepEqual(parseSectionSpec("Testing evidence: testing evidence"), [ + { display: "Testing evidence", alias: "testing evidence" }, + ]); +}); + +test("spec: rejects a line without a colon", () => { + assert.throws(() => parseSectionSpec("Testing evidence")); +}); + +test("sections: any heading level, case-insensitive", () => { + const sections = splitSections("# TESTING EVIDENCE\n\nran in stage\n"); + assert.deepEqual(sections.get("testing evidence"), ["", "ran in stage", ""]); +}); + +test("sections: fenced code is not scanned for headings", () => { + const body = "## Testing evidence\n\n```yaml\n# not a heading\n```\n"; + const sections = splitSections(body); + assert.ok(sections.has("testing evidence")); + assert.ok(!sections.has("not a heading")); +}); + +test("fresh infra-k8s template: all three sections empty", () => { + assert.deepEqual(names(missingSections(INFRA_K8S_TEMPLATE, SPECS, [])), [ + "Testing evidence", + "Success / failure criteria", + "Rollback plan", + ]); +}); + +test("fresh infra template: all three sections empty", () => { + assert.deepEqual(names(missingSections(INFRA_TEMPLATE, SPECS, [])), [ + "Testing evidence", + "Success / failure criteria", + "Rollback plan", + ]); +}); + +test("fresh infra-management template: all three sections empty", () => { + assert.deepEqual( + names(missingSections(INFRA_MGMT_TEMPLATE, MGMT_SPECS, MGMT_STRIP)), + ["Testing evidence", "Success / failure criteria", "Rollback plan"], + ); +}); + +test("infra-management template without strip-lines: defaults count", () => { + assert.deepEqual( + names(missingSections(INFRA_MGMT_TEMPLATE, MGMT_SPECS, [])), + ["Success / failure criteria", "Rollback plan"], + ); +}); + +test("infra-management: text after the testing evidence label counts", () => { + const body = INFRA_MGMT_TEMPLATE.replace( + " - testing evidence:", + " - testing evidence: https://github.com/smartcontractkit/infra-management/pull/1", + ); + assert.deepEqual(names(missingSections(body, MGMT_SPECS, MGMT_STRIP)), [ + "Success / failure criteria", + "Rollback plan", + ]); +}); + +test("filled sections pass", () => { + const body = `# Testing Evidence + +Ran in stage: PR #1. + +# Success / Failure Criteria + +Error rate stays flat for 30 minutes. + +# Rollback Plan + +Revert this PR. +`; + assert.deepEqual(missingSections(body, SPECS, []), []); +}); + +test("partial fill names only the missing section", () => { + const body = `## Testing evidence + +Ran in stage: PR #1. + +## Rollback plan + +Revert this PR. +`; + assert.deepEqual(names(missingSections(body, SPECS, [])), [ + "Success / failure criteria", + ]); +}); + +test("content inside an HTML comment does not count", () => { + const body = `## Testing evidence + + + +## Success / failure criteria + + + +## Rollback plan + + +`; + assert.equal(missingSections(body, SPECS, []).length, 3); +}); + +test("commented-out headings are ignored", () => { + const body = `## Testing evidence + +ran in stage + + +`; + assert.deepEqual(names(missingSections(body, SPECS, [])), [ + "Success / failure criteria", + "Rollback plan", + ]); +}); + +test("null body fails all sections", () => { + assert.equal(missingSections(null, SPECS, []).length, 3); +}); + +test("classifyFiles: docs-only, dev-only, mixed, empty", () => { + const docs = [globToRegExp("**/*.md")]; + const dev = [globToRegExp("projects/ingest-hub-preview/**")]; + assert.equal(classifyFiles(["README.md"], docs, dev), "docs-only"); + assert.equal(classifyFiles(["a.md", "b.yaml"], docs, dev), null); + assert.equal( + classifyFiles(["projects/ingest-hub-preview/a.yaml"], docs, dev), + "dev-only", + ); + assert.equal(classifyFiles(["README.md", "a.yaml"], docs, dev), null); + assert.equal(classifyFiles([], docs, dev), null); +}); + +const ENV_KEYS = [ + "SECTIONS", + "STRIP_LINES", + "DOCS_PATHS", + "DEV_PATHS", + "HEAD_PREFIXES", + "STANDARD_URL", +]; + +function setEnv(env) { + for (const key of ENV_KEYS) delete process.env[key]; + Object.assign(process.env, env); +} + +function fakeCore() { + const calls = { failed: [], info: [], summary: [] }; + return { + calls, + info: (msg) => calls.info.push(msg), + setFailed: (msg) => calls.failed.push(msg), + summary: { addRaw: (msg) => calls.summary.push(msg) }, + }; +} + +function fakeContext({ ref = "feat/x", body = "", number = 1 } = {}) { + return { + repo: { owner: "o", repo: "r" }, + payload: { pull_request: { number, body, head: { ref } } }, + }; +} + +function fakeGithub(filenames) { + return { + rest: { + pulls: { + listFiles: async () => + filenames.length + ? { data: filenames.map((filename) => ({ filename })) } + : { data: [] }, + }, + }, + }; +} + +const PROD_FILES = ["clusters/main.prod.yaml"]; + +test("run: empty template fails and names every section", async () => { + setEnv({ + SECTIONS: + "Testing evidence: testing evidence\nRollback plan: rollback plan", + }); + const core = fakeCore(); + await check({ + context: fakeContext({ body: INFRA_K8S_TEMPLATE }), + core, + github: fakeGithub(PROD_FILES), + }); + assert.equal(core.calls.failed.length, 1); + assert.match( + core.calls.failed[0], + /^Please fill in: Testing evidence, Rollback plan\. /, + ); + assert.match( + core.calls.failed[0], + /Infrastructure\+Change\+Management\+Standard$/, + ); + assert.match(core.calls.summary[0], /- Testing evidence: empty/); +}); + +test("run: filled body passes", async () => { + setEnv({ + SECTIONS: + "Testing evidence: testing evidence\nRollback plan: rollback plan", + }); + const body = + "# Testing Evidence\n\nRan in stage: PR #1.\n\n# Rollback Plan\n\nRevert.\n"; + const core = fakeCore(); + await check({ + context: fakeContext({ body }), + core, + github: fakeGithub(PROD_FILES), + }); + assert.equal(core.calls.failed.length, 0); + assert.deepEqual(core.calls.info, [ + "All change-management sections are present.", + ]); +}); + +test("run: renovate branch is exempt without an API call", async () => { + setEnv({ SECTIONS: "Testing evidence: testing evidence" }); + const core = fakeCore(); + const github = { + rest: { + pulls: { + listFiles: async () => { + throw new Error("must not be called"); + }, + }, + }, + }; + await check({ + context: fakeContext({ ref: "renovate/lodash-4.x", body: "" }), + core, + github, + }); + assert.equal(core.calls.failed.length, 0); + assert.match( + core.calls.info[0], + /^Exempt \(head branch starts with renovate\/\)/, + ); +}); + +test("run: docs-only PR is exempt", async () => { + setEnv({ SECTIONS: "Testing evidence: testing evidence" }); + const core = fakeCore(); + await check({ + context: fakeContext({ body: "" }), + core, + github: fakeGithub(["docs/readme.md"]), + }); + assert.equal(core.calls.failed.length, 0); + assert.match(core.calls.info[0], /^Exempt \(docs-only\)/); +}); + +test("run: dev-only PR is exempt when dev-paths are set", async () => { + setEnv({ + SECTIONS: "Testing evidence: testing evidence", + DEV_PATHS: "projects/ingest-hub-preview/**", + }); + const core = fakeCore(); + await check({ + context: fakeContext({ body: "" }), + core, + github: fakeGithub(["projects/ingest-hub-preview/apps/a.yaml"]), + }); + assert.equal(core.calls.failed.length, 0); + assert.match(core.calls.info[0], /^Exempt \(dev-only\)/); +}); + +test("run: non-pull-request payload is skipped", async () => { + const core = fakeCore(); + await check({ + context: { repo: { owner: "o", repo: "r" }, payload: {} }, + core, + github: fakeGithub(PROD_FILES), + }); + assert.equal(core.calls.failed.length, 0); + assert.equal( + core.calls.info[0], + "This action is only applicable to pull requests.", + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 804c82bd1..ab188a68e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,8 @@ importers: actions/bump-mcms-tools: {} + actions/change-management-check: {} + actions/changed-modules-go: dependencies: '@actions/core': From e98321783ca1730b30f81bfc93d7e179b619683c Mon Sep 17 00:00:00 2001 From: Jeff Welling Date: Tue, 20 Oct 2026 14:45:17 -0700 Subject: [PATCH 2/8] fix: write the job summary and harden comment stripping Strip HTML comments with an indexOf loop instead of a regex (CodeQL js/incomplete-multi-character-sanitization). An unterminated comment now drops the rest of the body, matching how GitHub renders it. Call core.summary.write(); addRaw alone leaves the buffer unwritten. Treat an explicit empty docs-paths or head-prefixes input as no exemption instead of falling back to a default. --- .../change-management-check/scripts/check.js | 27 +++++++--- .../scripts/check.test.js | 53 +++++++++++++++++-- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/actions/change-management-check/scripts/check.js b/actions/change-management-check/scripts/check.js index 8bc2305a0..f8c24e375 100644 --- a/actions/change-management-check/scripts/check.js +++ b/actions/change-management-check/scripts/check.js @@ -17,7 +17,6 @@ const DEFAULT_STANDARD_URL = const HEADING_RE = /^ {0,3}#{1,6}\s+(.*?)\s*#*\s*$/; const FENCE_RE = /^\s{0,3}(```|~~~)/; -const COMMENT_RE = //g; function splitLines(value) { return (value || "") @@ -90,9 +89,24 @@ function splitSections(body) { return sections; } +// Removes HTML comments, including multiline ones. An unterminated comment +// drops the rest of the text, matching how GitHub renders the body. +function stripComments(text) { + let out = ""; + let i = 0; + for (;;) { + const start = text.indexOf("", start + 4); + if (end === -1) return out; + i = end + 3; + } +} + // Specs whose section body is empty after comment and strip-line removal. function missingSections(body, specs, stripLines) { - const sections = splitSections(String(body || "").replace(COMMENT_RE, "")); + const sections = splitSections(stripComments(String(body || ""))); const strip = new Set((stripLines || []).map((line) => line.trim())); return specs.filter((spec) => { const content = (sections.get(spec.alias) || []) @@ -169,11 +183,11 @@ module.exports = async ({ context, core, github }) => { const specs = parseSectionSpec(process.env.SECTIONS); const stripLines = splitLines(process.env.STRIP_LINES); - const docsPaths = splitLines(process.env.DOCS_PATHS || "**/*.md").map( - globToRegExp, - ); + // action.yml supplies the defaults. An explicit empty input disables the + // exemption. + const docsPaths = splitLines(process.env.DOCS_PATHS).map(globToRegExp); const devPaths = splitLines(process.env.DEV_PATHS).map(globToRegExp); - const headPrefixes = (process.env.HEAD_PREFIXES || "renovate/") + const headPrefixes = (process.env.HEAD_PREFIXES || "") .split(",") .map((prefix) => prefix.trim()) .filter(Boolean); @@ -195,6 +209,7 @@ module.exports = async ({ context, core, github }) => { const missing = missingSections(pr.body, specs, stripLines); core.summary.addRaw(summarize(specs, missing)); + await core.summary.write(); if (missing.length > 0) { core.setFailed( diff --git a/actions/change-management-check/scripts/check.test.js b/actions/change-management-check/scripts/check.test.js index 3f377645f..2be25f745 100644 --- a/actions/change-management-check/scripts/check.test.js +++ b/actions/change-management-check/scripts/check.test.js @@ -295,6 +295,14 @@ test("null body fails all sections", () => { assert.equal(missingSections(null, SPECS, []).length, 3); }); +test("an unterminated HTML comment drops the rest of the body", () => { + const body = `## Testing evidence + +", start + 4); - if (end === -1) return out; - i = end + 3; + const stop = end === -1 ? text.length : end + 3; + // Blank the span in place so the text before and after it cannot join. + out += text.slice(start, stop).replace(/[^\n]/g, " "); + i = stop; } } @@ -129,51 +107,6 @@ function missingSections(body, specs, stripLines) { }); } -async function listFiles(github, context) { - const { owner, repo } = context.repo; - const pull_number = context.payload.pull_request.number; - const files = []; - // The API caps at 3000 files; 30 pages of 100. - for (let page = 1; page <= 30; page++) { - const { data } = await github.rest.pulls.listFiles({ - owner, - repo, - pull_number, - per_page: 100, - page, - }); - files.push(...data.map((file) => file.filename)); - if (data.length < 100) break; - } - return files; -} - -// Reason string when every changed file matches one glob set, else null. -function classifyFiles(files, docsPaths, devPaths) { - if (files.length === 0) return null; - const allMatch = (regexps) => - regexps.length > 0 && - files.every((file) => regexps.some((re) => re.test(file))); - if (allMatch(docsPaths)) return "docs-only"; - if (allMatch(devPaths)) return "dev-only"; - return null; -} - -// Reason string when the PR is exempt, else null. -async function findExemption({ - github, - context, - headPrefixes, - docsPaths, - devPaths, -}) { - const headRef = context.payload.pull_request.head.ref || ""; - const prefix = headPrefixes.find((p) => headRef.startsWith(p)); - if (prefix) return `head branch starts with ${prefix}`; - - return classifyFiles(await listFiles(github, context), docsPaths, devPaths); -} - function summarize(specs, missing) { const empty = new Set(missing.map((spec) => spec.display)); return [ @@ -186,7 +119,7 @@ function summarize(specs, missing) { ].join("\n"); } -module.exports = async ({ context, core, github }) => { +module.exports = async ({ context, core }) => { const pr = context.payload.pull_request; if (!pr) { core.info("This action is only applicable to pull requests."); @@ -194,30 +127,13 @@ module.exports = async ({ context, core, github }) => { } const specs = parseSectionSpec(process.env.SECTIONS); - const stripLines = splitLines(process.env.STRIP_LINES); - // action.yml supplies the defaults. An explicit empty input disables the - // exemption. - const docsPaths = splitLines(process.env.DOCS_PATHS).map(globToRegExp); - const devPaths = splitLines(process.env.DEV_PATHS).map(globToRegExp); - const headPrefixes = (process.env.HEAD_PREFIXES || "") - .split(",") - .map((prefix) => prefix.trim()) - .filter(Boolean); - const standardUrl = process.env.STANDARD_URL || DEFAULT_STANDARD_URL; - - const exempt = await findExemption({ - github, - context, - headPrefixes, - docsPaths, - devPaths, - }); - if (exempt) { - core.info( - `Exempt (${exempt}): the change-management check does not apply.`, + if (specs.length === 0) { + throw new Error( + "The sections input is empty; at least one section is required.", ); - return; } + const stripLines = splitLines(process.env.STRIP_LINES); + const standardUrl = process.env.STANDARD_URL || DEFAULT_STANDARD_URL; const missing = missingSections(pr.body, specs, stripLines); core.summary.addRaw(summarize(specs, missing)); @@ -234,8 +150,7 @@ module.exports = async ({ context, core, github }) => { core.info("All change-management sections are present."); }; -module.exports.classifyFiles = classifyFiles; -module.exports.globToRegExp = globToRegExp; module.exports.missingSections = missingSections; module.exports.parseSectionSpec = parseSectionSpec; module.exports.splitSections = splitSections; +module.exports.stripComments = stripComments; diff --git a/actions/change-management-check/scripts/check.test.js b/actions/change-management-check/scripts/check.test.js index cd9f19b76..3b41dfd9f 100644 --- a/actions/change-management-check/scripts/check.test.js +++ b/actions/change-management-check/scripts/check.test.js @@ -2,13 +2,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const check = require("./check.js"); -const { - classifyFiles, - globToRegExp, - missingSections, - parseSectionSpec, - splitSections, -} = check; +const { missingSections, parseSectionSpec, splitSections, stripComments } = + check; // infra-k8s pull_request_template.md at origin/main, 2026-10-20. const INFRA_K8S_TEMPLATE = `## What @@ -145,34 +140,6 @@ const MGMT_STRIP = [ const names = (missing) => missing.map((spec) => spec.display); -test("glob: **/ matches the root and nested paths", () => { - const re = globToRegExp("**/*.md"); - assert.ok(re.test("README.md")); - assert.ok(re.test("docs/guide/intro.md")); - assert.ok(!re.test("README.txt")); -}); - -test("glob: trailing /** matches under the directory only", () => { - const re = globToRegExp("projects/ingest-hub-preview/**"); - assert.ok(re.test("projects/ingest-hub-preview/a.yaml")); - assert.ok(re.test("projects/ingest-hub-preview/apps/a.yaml")); - assert.ok(!re.test("projects/other/a.yaml")); -}); - -test("glob: * does not cross a slash", () => { - const re = globToRegExp("clusters/*.sand.yaml"); - assert.ok(re.test("clusters/main.sand.yaml")); - assert.ok(!re.test("clusters/sub/main.sand.yaml")); - assert.ok(!re.test("clusters/main.prod.yaml")); -}); - -test("glob: ? matches one character that is not a slash", () => { - const re = globToRegExp("clusters/?.yaml"); - assert.ok(re.test("clusters/a.yaml")); - assert.ok(!re.test("clusters/ab.yaml")); - assert.ok(!re.test("clusters/a/b.yaml")); -}); - test("spec: parses display and alias", () => { assert.deepEqual(parseSectionSpec("Testing evidence: testing evidence"), [ { display: "Testing evidence", alias: "testing evidence" }, @@ -315,6 +282,21 @@ Revert this PR. assert.deepEqual(missingSections(body, SPECS, []), []); }); +test("CRLF body: filled sections pass, empty template still fails", () => { + const filled = + "# Testing Evidence\r\n\r\nRan in stage: PR #1.\r\n\r\n" + + "# Success / Failure Criteria\r\n\r\nError rate stays flat.\r\n\r\n" + + "# Rollback Plan\r\n\r\nRevert this PR.\r\n"; + assert.deepEqual(missingSections(filled, SPECS, []), []); + const crlfTemplate = INFRA_K8S_TEMPLATE.replace(/\n/g, "\r\n"); + assert.ok(splitSections(crlfTemplate).has("testing evidence")); + assert.deepEqual(names(missingSections(crlfTemplate, SPECS, [])), [ + "Testing evidence", + "Success / failure criteria", + "Rollback plan", + ]); +}); + test("partial fill names only the missing section", () => { const body = `## Testing evidence @@ -373,27 +355,11 @@ test("an unterminated HTML comment drops the rest of the body", () => { assert.equal(missingSections(body, SPECS, []).length, 3); }); -test("classifyFiles: docs-only, dev-only, mixed, empty", () => { - const docs = [globToRegExp("**/*.md")]; - const dev = [globToRegExp("projects/ingest-hub-preview/**")]; - assert.equal(classifyFiles(["README.md"], docs, dev), "docs-only"); - assert.equal(classifyFiles(["a.md", "b.yaml"], docs, dev), null); - assert.equal( - classifyFiles(["projects/ingest-hub-preview/a.yaml"], docs, dev), - "dev-only", - ); - assert.equal(classifyFiles(["README.md", "a.yaml"], docs, dev), null); - assert.equal(classifyFiles([], docs, dev), null); +test("comment stripping cannot rebuild a comment opener", () => { + assert.ok(!stripComments("!--").includes("