Repository navigation
Create change-management-check composite action #1712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
18d9d1d
Create change-management-check composite action
tvc-jeffwelling e983217
fix: write the job summary and harden comment stripping
tvc-jeffwelling 408eacc
refactor: rewrite globToRegExp as a token table
tvc-jeffwelling cc9b3cd
refactor: convert globs in a single regex pass
tvc-jeffwelling a2b4941
docs: document the change-management-check inputs and usage
tvc-jeffwelling 0c61c6f
fix: bound the heading regex and keep subheadings in their section
tvc-jeffwelling 2409b38
chore: start the package at 0.0.1
tvc-jeffwelling b589330
fix: check the PR body only; move exemptions to the caller workflow
tvc-jeffwelling 6d85e40
Merge branch 'main' into feat/IP-4105-change-management-check
tvc-jeffwelling File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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("<!--", i); | ||
| if (start === -1) return out + text.slice(i); | ||
| out += text.slice(i, start); | ||
| const end = 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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should almost never use
pull_request_targetevents. Why do you need this overpull_requestevents?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two reasons. First, tamper resistance: the check will be required, so a PR must not be able to alter its own gate. With pull_request_target the workflow and the action (pinned to the change-management-check/v1 tag) come from the base branch; with pull_request the workflow comes from the PR head, so a PR could rename the job or edit the workflow to pass. Second, fork PRs: with pull_request a fork PR's workflow waits for maintainer approval, and the required check shows Expected until then; pull_request_target runs without that step. The usual pull_request_target risk does not apply here: no checkout of PR code, no secrets, the only permission is pull-requests: read (used by the path filter), and the action itself reads only the event payload. The action is trigger-agnostic; this is a recommendation for the required-check wiring.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is understandable. We typically use
pull_requestregardless - but make sure that the CODEOWNERS is properly set and therefore gates unwanted merges that bypass a workflow.Is this expected to run against PR from forks?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not expected. All target repos are private, and the last 300 PRs in each (900 total) include zero fork PRs. If one appeared, the workflow would wait for maintainer approval before running, and the required check would sit Expected until then.
On CODEOWNERS: agreed, and the catch-all rules already cover the workflow path in each target repo. The wiring step will confirm the code-owner review requirement is actually enforced; the enforcement audit found gaps in the current rulesets, and closing them is part of this rollout.