Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/change-management-check.md
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.
82 changes: 82 additions & 0 deletions actions/change-management-check/README.md
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]
Comment on lines +25 to +26

Copy link
Copy Markdown
Contributor

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_target events. Why do you need this over pull_request events?

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This is understandable. We typically use pull_request regardless - but make sure that the CODEOWNERS is properly set and therefore gates unwanted merges that bypass a workflow.

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.

Is this expected to run against PR from forks?

@tvc-jeffwelling tvc-jeffwelling Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

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.


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.
34 changes: 34 additions & 0 deletions actions/change-management-check/action.yml
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 });
11 changes: 11 additions & 0 deletions actions/change-management-check/package.json
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"
}
16 changes: 16 additions & 0 deletions actions/change-management-check/project.json
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"
}
}
}
}
156 changes: 156 additions & 0 deletions actions/change-management-check/scripts/check.js
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;
Loading
Loading