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

@@ -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,
};