fix(3696): pr-template enforcer recognises CI/tooling carve-out (#3697)

* fix(3696): pr-template enforcer recognises CI/tooling carve-out

The enforcer in scripts/pr-template-policy.cjs only validated against
three typed templates and three hard-coded DEFAULT_TEMPLATE_MARKERS.
It had zero awareness of the documented CI/tooling/dep/doc-only exception
in .github/pull_request_template.md, meaning external contributors who
followed the documented escape hatch still had their PRs auto-closed.

Fix:
1. Path-scope auto-skip: if every changed file matches a tooling glob
   allowlist (.github/**, scripts/**, docs/**, *.md, .changeset/**,
   dependency manifests), skip enforcement and exit success with no
   comment posted.
2. Explicit exemption marker: if the PR body contains
   <!-- pr-template-exempt: <non-empty reason> -->, skip enforcement.
3. Workflow updated to fetch changed file paths via gh and pass them
   as CHANGED_FILES env to the policy script.
4. Pull request template updated to document both mechanisms and retire
   the old "delete this file" prose carve-out.
5. 14 new tests (TDD red→green); all 25 tests pass.

Closes #3696

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3696): allow hyphenated pr-template exemption reasons

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-18 12:06:46 -04:00
committed by GitHub
parent ae4c31426a
commit e50ad8127f
4 changed files with 220 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full process.
---
<!-- If you believe your PR genuinely does not fit any of the above categories (e.g., CI/tooling changes,
dependency updates, or doc-only fixes with no linked issue), delete this file and describe your PR below.
Add a note explaining why none of the typed templates apply. -->
<!-- CI/tooling, dependency, and doc-only PRs are auto-detected from the changed file paths — no template
needed. For other cross-cutting exempt PRs, paste the line below into your PR body with a non-empty reason:
<!-- pr-template-exempt: <reason> -->

View File

@@ -18,11 +18,23 @@ jobs:
- name: Check out policy from base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get changed files
id: changed_files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
files=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json files --jq '.files[].path' | tr '\n' '\n')
echo "files<<EOF" >> "$GITHUB_OUTPUT"
echo "$files" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Evaluate PR template format
id: policy
env:
PR_BODY: ${{ github.event.pull_request.body }}
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
CHANGED_FILES: ${{ steps.changed_files.outputs.files }}
run: node scripts/pr-template-policy.cjs
- name: Warn trusted contributor about missing template

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node
const { minimatch } = require('minimatch');
const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
'CONTRIBUTOR',
'COLLABORATOR',
@@ -13,6 +15,42 @@ const DEFAULT_TEMPLATE_MARKERS = [
'Select the template that matches your PR',
];
/**
* Glob patterns for files that are considered CI/tooling/docs scope.
* If ALL changed files in a PR match at least one of these patterns,
* template enforcement is skipped automatically.
*/
const TOOLING_PATH_ALLOWLIST = [
'.github/**',
'scripts/**',
'docs/**',
'*.md',
'.changeset/**',
'package.json',
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'Pipfile',
'Pipfile.lock',
'requirements.txt',
'requirements*.txt',
'poetry.lock',
'Gemfile',
'Gemfile.lock',
'go.sum',
'go.mod',
];
/**
* Matches an explicit PR-template exemption marker of the form:
* <!-- pr-template-exempt: <non-empty reason> -->
*
* Capture group 1 is the reason (trimmed). Empty/whitespace-only reasons
* do NOT match because the pattern requires at least one non-whitespace
* character in the captured body.
*/
const EXEMPT_MARKER_REGEX = /<!--\s*pr-template-exempt:\s*([\s\S]*?\S)\s*-->/;
const TEMPLATES = [
{
name: 'fix',
@@ -99,11 +137,61 @@ function matchingTemplate(body) {
};
}
function evaluatePrTemplate(body, authorAssociation) {
/**
* Returns true iff every path in changedFiles matches at least one glob
* pattern in the allowlist. Returns false for an empty file list (no
* files means we cannot confirm it is a tooling-only PR).
*/
function allPathsAreTooling(changedFiles, allowlist) {
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return false;
return changedFiles.every((file) =>
allowlist.some((pattern) => minimatch(file, pattern, { matchBase: false, dot: true })),
);
}
/**
* Returns true iff the body contains an explicit exemption marker with a
* non-empty (non-whitespace) reason.
*/
function hasExemptMarker(body, regex) {
const match = regex.exec(String(body || ''));
if (!match) return false;
return match[1].trim().length > 0;
}
function evaluatePrTemplate(body, authorAssociation, changedFiles) {
const association = String(authorAssociation || '').toUpperCase();
const trusted = TRUSTED_AUTHOR_ASSOCIATIONS.has(association);
const normalizedBody = String(body || '').trim();
// --- Carve-out 1: all changed files are in the tooling allowlist ---
if (allPathsAreTooling(changedFiles, TOOLING_PATH_ALLOWLIST)) {
return {
valid: true,
action: 'pass',
trusted,
authorAssociation: association || 'UNKNOWN',
template: null,
reason: null,
missingHeadings: [],
skipped: 'tooling-paths',
};
}
// --- Carve-out 2: explicit exemption marker in the PR body ---
if (hasExemptMarker(normalizedBody, EXEMPT_MARKER_REGEX)) {
return {
valid: true,
action: 'pass',
trusted,
authorAssociation: association || 'UNKNOWN',
template: null,
reason: null,
missingHeadings: [],
skipped: 'exempt-marker',
};
}
let valid = true;
let reason = 'PR body uses a typed pull request template.';
let template = null;
@@ -147,7 +235,14 @@ function evaluatePrTemplate(body, authorAssociation) {
}
function main() {
const result = evaluatePrTemplate(process.env.PR_BODY || '', process.env.AUTHOR_ASSOCIATION || '');
const changedFiles = process.env.CHANGED_FILES
? process.env.CHANGED_FILES.split('\n').map((f) => f.trim()).filter(Boolean)
: undefined;
const result = evaluatePrTemplate(
process.env.PR_BODY || '',
process.env.AUTHOR_ASSOCIATION || '',
changedFiles,
);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (process.env.GITHUB_OUTPUT) {
const fs = require('fs');
@@ -166,4 +261,8 @@ module.exports = {
extractHeadings,
includesDefaultTemplate,
matchingTemplate,
allPathsAreTooling,
hasExemptMarker,
TOOLING_PATH_ALLOWLIST,
EXEMPT_MARKER_REGEX,
};

View File

@@ -1,7 +1,7 @@
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { evaluatePrTemplate } = require('../scripts/pr-template-policy.cjs');
const { evaluatePrTemplate, allPathsAreTooling, hasExemptMarker, TOOLING_PATH_ALLOWLIST, EXEMPT_MARKER_REGEX } = require('../scripts/pr-template-policy.cjs');
const fixBody = [
'## Fix PR',
@@ -82,6 +82,108 @@ const featureBody = [
'- [x] Tests pass',
].join('\n');
describe('pr-template-policy carve-out', () => {
// A. Path-scope auto-skip — CI-only PR is accepted
test('auto-skips enforcement for CI-only changed files', () => {
const result = evaluatePrTemplate('This is a CI change with no template.', 'NONE', ['.github/workflows/test.yml']);
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.skipped, 'tooling-paths');
});
// B. Path-scope auto-skip — docs-only PR is accepted
test('auto-skips enforcement for docs-only changed files', () => {
const result = evaluatePrTemplate('Updated documentation.', 'NONE', ['docs/CONFIGURATION.md', 'README.md']);
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.skipped, 'tooling-paths');
});
// C. Path-scope auto-skip — dependency-bump PR is accepted
test('auto-skips enforcement for dependency-bump changed files', () => {
const result = evaluatePrTemplate('Bump deps.', 'NONE', ['package.json', 'package-lock.json']);
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.skipped, 'tooling-paths');
});
// D. Path-scope auto-skip is path-strict — mixed PR still enforced
test('does NOT skip enforcement when any changed file is outside the tooling allowlist', () => {
const result = evaluatePrTemplate('Mixed change.', 'NONE', ['.github/workflows/test.yml', 'src/feature.ts']);
assert.equal(result.valid, false);
assert.equal(result.action, 'close');
});
// E. Explicit marker accepted (non-empty reason)
test('auto-skips enforcement when PR body contains a valid exempt marker with a reason', () => {
const body = '<!-- pr-template-exempt: dropping node 26 lane -->\n\nThis removes the Node 26 CI lane.';
const result = evaluatePrTemplate(body, 'NONE', ['src/anything.ts']);
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.skipped, 'exempt-marker');
});
// F. Explicit marker requires non-empty reason
test('does NOT skip enforcement when exempt marker has an empty reason', () => {
const bodyEmpty = '<!-- pr-template-exempt: -->\n\nSome description.';
const bodyNoReason = '<!-- pr-template-exempt: -->\n\nSome description.';
const r1 = evaluatePrTemplate(bodyEmpty, 'NONE', ['src/anything.ts']);
const r2 = evaluatePrTemplate(bodyNoReason, 'NONE', ['src/anything.ts']);
assert.equal(r1.valid, false);
assert.equal(r2.valid, false);
});
// G. Regression — DEFAULT_TEMPLATE_MARKERS still rejected
test('regression: DEFAULT_TEMPLATE_MARKERS body is still rejected when no carve-out applies', () => {
const body = 'Wrong template — please use a typed template.\n\nEvery PR must use a typed template.';
const result = evaluatePrTemplate(body, 'NONE', ['src/feature.ts']);
assert.equal(result.valid, false);
assert.match(result.reason, /default wrong-template guidance/);
});
// H. Regression — fix template still accepted
test('regression: fix template PR body is still accepted', () => {
const result = evaluatePrTemplate(fixBody, 'NONE', ['src/feature.ts']);
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'fix');
});
});
describe('pr-template-policy helper: allPathsAreTooling', () => {
test('returns true when all paths match the allowlist', () => {
assert.equal(allPathsAreTooling(['.github/workflows/ci.yml'], TOOLING_PATH_ALLOWLIST), true);
assert.equal(allPathsAreTooling(['package.json', 'package-lock.json'], TOOLING_PATH_ALLOWLIST), true);
assert.equal(allPathsAreTooling(['README.md'], TOOLING_PATH_ALLOWLIST), true);
});
test('returns false when any path does not match the allowlist', () => {
assert.equal(allPathsAreTooling(['.github/workflows/ci.yml', 'src/index.ts'], TOOLING_PATH_ALLOWLIST), false);
});
test('returns false for an empty file list', () => {
assert.equal(allPathsAreTooling([], TOOLING_PATH_ALLOWLIST), false);
});
});
describe('pr-template-policy helper: hasExemptMarker', () => {
test('matches a marker with a non-empty reason', () => {
assert.equal(hasExemptMarker('<!-- pr-template-exempt: ci -->', EXEMPT_MARKER_REGEX), true);
assert.equal(hasExemptMarker('<!-- pr-template-exempt: dropping node 26 lane -->', EXEMPT_MARKER_REGEX), true);
assert.equal(hasExemptMarker('<!-- pr-template-exempt: drop node-26 lane -->', EXEMPT_MARKER_REGEX), true);
});
test('does not match a marker with empty or whitespace-only reason', () => {
assert.equal(hasExemptMarker('<!-- pr-template-exempt: -->', EXEMPT_MARKER_REGEX), false);
assert.equal(hasExemptMarker('<!-- pr-template-exempt: -->', EXEMPT_MARKER_REGEX), false);
});
test('does not match a marker with no pr-template-exempt keyword', () => {
assert.equal(hasExemptMarker('<!-- gsd-pr-template-policy -->', EXEMPT_MARKER_REGEX), false);
assert.equal(hasExemptMarker('some random text', EXEMPT_MARKER_REGEX), false);
});
});
describe('pr-template-policy', () => {
test('passes PR bodies that use the fix template', () => {
const result = evaluatePrTemplate(fixBody, 'NONE');