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..e9d4daf7b --- /dev/null +++ b/actions/change-management-check/README.md @@ -0,0 +1,82 @@ +# change-management-check + +> Fails the check when a change-management section in the PR body is empty or +> missing + +Fails when a section required by the +[Infrastructure Change Management Standard](https://smartcontract-it.atlassian.net/wiki/spaces/ENG/pages/1417445401/Infrastructure+Change+Management+Standard) +is empty or missing. The failure message names the empty sections. The action +reads only the pull request body from the event payload. + +## Usage + +Call the action from a `pull_request_target` workflow, then add the job name +(`change-management-sections`) as a required status check. The workflow decides +whether the check applies: skip it for exempt head branches and when every +changed file is a docs or dev path, using +[dorny/paths-filter](https://github.com/dorny/paths-filter). The job needs the +`pull-requests: read` permission for the path filter. The `timeout-minutes` +setting turns a hung run into a fast failure; `concurrency` cancels stale runs +when the body changes. + +```yaml +name: Change management check +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + pull-requests: read + +concurrency: + group: change-management-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + change-management-sections: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Filter changed paths + id: filter + if: ${{ !startsWith(github.head_ref, 'renovate/') }} + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 + with: + # The negation rules apply only with some-with-excludes. + predicate-quantifier: some-with-excludes + filters: | + check: + - '**' + - '!**/*.md' + - '!projects/ingest-hub-preview/**' # dev paths, per repo + - name: Check change management sections + if: ${{ steps.filter.outputs.check == 'true' }} + uses: smartcontractkit/.github/actions/change-management-check@change-management-check/v1 + with: + sections: | + Testing evidence: testing evidence + Success / failure criteria: success / failure criteria + Rollback plan: rollback plan +``` + +## Inputs + +| Input | Default | Description | +| -------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| `sections` | required | Lines of `Display name: heading text`, one per required section. The heading text matches case-insensitively at any heading level. | +| `strip-lines` | `""` | Lines removed from a section before the empty test. Use for template default lines. | +| `standard-url` | the Standard | Link included in the failure message. | + +## Exemptions + +The workflow decides whether the check applies, not the action. Skip the check +step (or job) for exempt pull requests, for example `renovate/` head branches, +docs-only changes, or dev-only changes. Use a condition on the step or job: a +workflow skipped by path filtering leaves its check pending, which blocks a +required check. + +## Limitations + +- An unbalanced code fence hides later sections, the same way GitHub renders the + body. +- Heading text matches exactly, so a renamed heading reads as missing. diff --git a/actions/change-management-check/action.yml b/actions/change-management-check/action.yml new file mode 100644 index 000000000..e8845b00b --- /dev/null +++ b/actions/change-management-check/action.yml @@ -0,0 +1,34 @@ +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: "" + 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 }} + STANDARD_URL: ${{ inputs.standard-url }} + with: + script: | + const script = require(`${process.env.GITHUB_ACTION_PATH}/scripts/check.js`); + await script({ context, core }); diff --git a/actions/change-management-check/package.json b/actions/change-management-check/package.json new file mode 100644 index 000000000..3ab7665ed --- /dev/null +++ b/actions/change-management-check/package.json @@ -0,0 +1,11 @@ +{ + "name": "change-management-check", + "version": "0.0.1", + "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..32093803d --- /dev/null +++ b/actions/change-management-check/scripts/check.js @@ -0,0 +1,156 @@ +/** + * Fails the check when a change-management section in the PR body is empty + * or missing. The workflow decides whether the check applies. + * + * Inputs (via environment): + * SECTIONS: lines of "Display name: heading text" + * STRIP_LINES: lines removed from a section before the empty test + * 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})[ \t]+(.*)$/; +const FENCE_RE = /^\s{0,3}(```|~~~)/; + +function splitLines(value) { + return (value || "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +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(), + }; + }); +} + +// Heading text: drop a CommonMark closing sequence, then normalize. +function headingText(match) { + return match[2] + .replace(/\s+#+\s*$/, "") + .trim() + .toLowerCase(); +} + +// Section bodies keyed by lowercased heading text. Fenced code is not scanned. +// A heading starts a new section when it matches an alias or when its level is +// the same or higher than the current section's. Deeper headings are content. +function splitSections(body, aliases) { + const sections = new Map(); + let current = null; + let level = 0; + let fenced = false; + // Bodies can use CRLF line endings; parse them like LF bodies. + for (const line of body.replace(/\r\n?/g, "\n").split("\n")) { + if (FENCE_RE.test(line)) fenced = !fenced; + const heading = fenced ? null : line.match(HEADING_RE); + if (heading) { + const text = headingText(heading); + const startSection = + current === null || heading[1].length <= level || aliases?.has(text); + if (startSection) { + current = text; + level = heading[1].length; + if (!sections.has(current)) sections.set(current, []); + continue; + } + } + if (current !== null) sections.get(current).push(line); + } + return sections; +} + +// Removes HTML comments, including multiline ones. An unterminated comment +// blanks 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); + 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; + } +} + +// Specs whose section body is empty after comment and strip-line removal. +function missingSections(body, specs, stripLines) { + const aliases = new Set(specs.map((spec) => spec.alias)); + const sections = splitSections(stripComments(String(body || "")), aliases); + 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 === ""; + }); +} + +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 }) => { + 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); + if (specs.length === 0) { + throw new Error( + "The sections input is empty; at least one section is required.", + ); + } + 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)); + await core.summary.write(); + + 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.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 new file mode 100644 index 000000000..3b41dfd9f --- /dev/null +++ b/actions/change-management-check/scripts/check.test.js @@ -0,0 +1,447 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const check = require("./check.js"); +const { missingSections, parseSectionSpec, splitSections, stripComments } = + 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("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("sections: subheadings stay in the parent section", () => { + const body = `## Testing evidence + +### Dev + +ran in dev: PR #1 + +### Stage + +ran in stage: PR #2 + +## Rollback plan + +Revert this PR. +`; + assert.deepEqual(names(missingSections(body, SPECS, [])), [ + "Success / failure criteria", + ]); +}); + +test("sections: a peer heading ends the section", () => { + const body = `## Testing evidence + +## Checklist + +- [ ] done +`; + assert.deepEqual(names(missingSections(body, SPECS, [])), [ + "Testing evidence", + "Success / failure criteria", + "Rollback plan", + ]); +}); + +test("sections: an alias starts its section at any level", () => { + const body = `# Testing Evidence + +ran in stage: PR #1 + +### Rollback plan + +Revert this PR. +`; + assert.deepEqual(names(missingSections(body, SPECS, [])), [ + "Success / failure criteria", + ]); +}); + +test("sections: trailing hashes are handled like CommonMark", () => { + const sections = splitSections( + "## C#\n\nbody\n\n## Rollback plan ##\n\nrevert\n", + ); + assert.ok(sections.has("c#")); + assert.ok(sections.has("rollback plan")); +}); + +test("heading parsing stays linear on a hostile line", () => { + const hostile = `## a${" ".repeat(2000)}b${" ".repeat(2000)}x\n`; + const started = Date.now(); + missingSections(hostile, SPECS, []); + assert.ok(Date.now() - started < 1000, "heading scan took too long"); +}); + +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("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 + +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("an unterminated HTML comment drops the rest of the body", () => { + const body = `## Testing evidence + +!--").includes("