chore: drop upstream gsd issue and pr governance
Some checks failed
Tests / lint-tests (push) Has been cancelled
Tests / plugin-validate (push) Has been cancelled
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Has been cancelled
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Has been cancelled
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Has been cancelled
Tests / test (ubuntu-latest, 24) (push) Has been cancelled
Tests / test (inert CI) (push) Has been cancelled
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Coverage gate (merged shards) (push) Has been cancelled
Tests / QA loop walk (smell ratchet) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
Tests / Publish emitted-baseline artifact (push) Has been cancelled
Tests / PR mergeability (push) Has been cancelled
Tests / Base branch health (push) Has been cancelled
Tests / Detect test scope (push) Has been cancelled

MSD does not use an issue tracker, and the inherited GSD community gates
auto-closed PR #1 for lacking an issue-numbered title. Remove them:

- workflows: require-issue-link, pr-title-validator,
  auto-close-unsolicited-prs, pr-template-format, close-draft-prs(-sweep),
  auto-label-issues, auto-branch, duplicate-check, duplicate-sweep,
  remove-duplicate-label
- GSD PR and issue templates, plus the scripts only they used
  (require-issue-link-policy, pr-template-policy, pr-changed-files) and
  their tests
- workflow lists in ci-test-scope, ci-pr-mergeability,
  workflow-maintainer-skip, lint-pr-check-project-dir, the docs-guard
  baseline and the conformance tiers
- CONTRIBUTING: PR-against-next flow with no issue or template; PR titles
  are conventional without an issue ref; ADR/PRD files use the PR number
- CONTEXT.md: drop the PR-template predicates (k328, templates-mandatory)
This commit is contained in:
Jakub Zych
2026-10-09 10:53:54 +02:00
parent b7e36d5d38
commit bb581803ad
40 changed files with 44 additions and 4294 deletions

View File

@@ -667,12 +667,7 @@ const GATED = Object.freeze({
/** Lanes that must keep running on a conflicted PR — security and policy. */
const NEVER_GATED = Object.freeze([
'auto-close-unsolicited-prs.yml',
'close-draft-prs.yml',
'pr-target-validator.yml',
'pr-title-validator.yml',
'pr-template-format.yml',
'require-issue-link.yml',
'dismiss-unauthorized-pr-approvals.yml',
]);

View File

@@ -392,10 +392,8 @@ describe('INERT_WORKFLOWS allowlist integrity guard', () => {
// Canonical inert workflow list — reused by both tests below.
const knownInert = [
'stale.yml', 'branch-cleanup.yml', 'branch-naming.yml', 'auto-label-issues.yml',
'auto-branch.yml', 'auto-backmerge.yml', 'close-draft-prs.yml',
'dismiss-unauthorized-pr-approvals.yml', 'pr-target-validator.yml',
'pr-template-format.yml', 'require-issue-link.yml', 'changeset-required.yml',
'stale.yml', 'branch-cleanup.yml', 'branch-naming.yml', 'auto-backmerge.yml',
'dismiss-unauthorized-pr-approvals.yml', 'pr-target-validator.yml', 'changeset-required.yml',
'docs-required.yml', 'discord-changelog.yml',
];

View File

@@ -462,50 +462,6 @@ describe('#4709 no shipped surface mints a retired runtime id', () => {
}
});
test('PR template runtime checklists name only supported runtimes', () => {
// #1928's follow-up dropped Gemini CLI from .github/ISSUE_TEMPLATE/*.yml but missed the PR
// templates, which kept offering it under "Runtimes tested" -- a contributor-facing surface
// still advertising a retired runtime two releases later. Labels here are DISPLAY names
// ("Claude Code", not "claude"), so they are checked against the label table, not the id set.
const labels = new Set(
Object.keys(registry.runtimes).map((id) => getRuntimeLabel(id)),
);
// Non-runtime checklist entries that legitimately appear in the same list.
const NON_RUNTIME = /^(Other:|N\/A\b)/;
const offenders = [];
const templateDir = path.join(ROOT, '.github', 'PULL_REQUEST_TEMPLATE');
for (const name of fs.readdirSync(templateDir).filter((f) => f.endsWith('.md'))) {
const file = path.join(templateDir, name);
const fileLines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
let inRuntimeSection = false;
fileLines.forEach((line, i) => {
if (/^#+\s*Runtimes tested/i.test(line)) {
inRuntimeSection = true;
return;
}
// The section ends at the next heading or horizontal rule.
if (inRuntimeSection && /^(#+\s|---\s*$)/.test(line)) {
inRuntimeSection = false;
return;
}
if (!inRuntimeSection) return;
const m = /^\s*-\s*\[\s*\]\s*(.+?)\s*$/.exec(line);
if (!m) return;
const label = m[1];
if (NON_RUNTIME.test(label)) return;
if (!labels.has(label)) {
offenders.push(`.github/PULL_REQUEST_TEMPLATE/${name}:${i + 1} offers "${label}"`);
}
});
}
assert.deepStrictEqual(offenders, [],
'a PR template asks contributors which runtime they tested and lists one MSD does not '
+ 'support. Labels must match the runtime label table (src/runtime-name-policy.cts '
+ `RUNTIME_LABELS), so a retired runtime cannot linger here. Offenders:\n ${offenders.join('\n ')}`);
});
/**
* #4709 Phase 3 — the Gemini CLI reviewer lane is retired.
*

View File

@@ -1,333 +0,0 @@
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { evaluatePrTemplate, allPathsAreTooling, hasExemptMarker, TOOLING_PATH_ALLOWLIST, EXEMPT_MARKER_REGEX } = require('../scripts/pr-template-policy.cjs');
const { FILE_LIST_PAGE_LIMIT } = require('../scripts/pr-changed-files.cjs');
const fixBody = [
'## Fix PR',
'',
'## Linked Issue',
'Fixes #123',
'',
'## What was broken',
'The thing was broken.',
'',
'## What this fix does',
'The thing now works.',
'',
'## Root cause',
'A missing guard.',
'',
'## Testing',
'node --test tests/example.test.cjs',
'',
'## Checklist',
'- [x] Issue linked above with `Fixes #NNN`',
].join('\n');
const enhancementBody = [
'## Enhancement PR',
'',
'## Linked Issue',
'Closes #123',
'',
'## What this enhancement improves',
'Existing output.',
'',
'## Before / After',
'**Before:** noisy',
'**After:** clear',
'',
'## How it was implemented',
'Small refactor.',
'',
'## Testing',
'node --test tests/example.test.cjs',
'',
'## Scope confirmation',
'- [x] Matches approved issue.',
'',
'## Checklist',
'- [x] Tests pass',
].join('\n');
const featureBody = [
'## Feature PR',
'',
'## Linked Issue',
'Closes #123',
'',
'## Feature summary',
'Adds a new thing.',
'',
'## What changed',
'### New files',
'None.',
'### Modified files',
'One file.',
'',
'## Implementation notes',
'Implemented as approved.',
'',
'## Spec compliance',
'- [x] Criterion met',
'',
'## Testing',
'node --test tests/example.test.cjs',
'',
'## Scope confirmation',
'- [x] Exact scope.',
'',
'## Checklist',
'- [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('<!-- msd-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');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'fix');
});
test('passes PR bodies that use the enhancement template', () => {
const result = evaluatePrTemplate(enhancementBody, 'FIRST_TIMER');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'enhancement');
});
test('does not flag default-template marker phrase inside a valid enhancement template', () => {
const body = enhancementBody.replace(
'## Linked Issue',
[
'> **Using the wrong template?**',
'> - Bug fix: use [fix.md](?template=fix.md)',
'> - New feature: use [feature.md](?template=feature.md)',
'',
'## Linked Issue',
].join('\n'),
);
const result = evaluatePrTemplate(body, 'COLLABORATOR');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'enhancement');
});
test('passes PR bodies that use the feature template', () => {
const result = evaluatePrTemplate(featureBody, 'FIRST_TIME_CONTRIBUTOR');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'feature');
});
test('closes first-time PRs that keep the default template', () => {
const result = evaluatePrTemplate([
'## Wrong template - please use the correct one for your PR type',
'',
'Every PR must use a typed template.',
].join('\n'), 'FIRST_TIMER');
assert.equal(result.valid, false);
assert.equal(result.action, 'close');
assert.equal(result.trusted, false);
assert.match(result.reason, /default wrong-template guidance/);
});
test('warns contributors instead of closing when the template is missing', () => {
const result = evaluatePrTemplate('This is a free-form PR body.', 'CONTRIBUTOR');
assert.equal(result.valid, false);
assert.equal(result.action, 'warn');
assert.equal(result.trusted, true);
});
test('warns collaborators, members, and owners instead of closing', () => {
for (const association of ['COLLABORATOR', 'MEMBER', 'OWNER']) {
const result = evaluatePrTemplate('This is a free-form PR body.', association);
assert.equal(result.valid, false);
assert.equal(result.action, 'warn');
assert.equal(result.trusted, true);
}
});
test('does not close for an unfilled issue slug when the template is present', () => {
const body = fixBody.replace('Fixes #123', 'Fixes #');
const result = evaluatePrTemplate(body, 'FIRST_TIMER');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'fix');
});
test('closes first-time PRs that remove required template sections', () => {
const result = evaluatePrTemplate(fixBody.replace('## What was broken', '## Background'), 'NONE');
assert.equal(result.valid, false);
assert.equal(result.action, 'close');
assert.deepEqual(result.missingHeadings, ['What was broken']);
});
test('closes first-time PRs with empty body', () => {
const result = evaluatePrTemplate('', 'FIRST_TIMER');
assert.equal(result.valid, false);
assert.equal(result.action, 'close');
assert.match(result.reason, /PR body is empty; a typed pull request template is required\./);
});
test('warns trusted contributors with empty body', () => {
const result = evaluatePrTemplate('', 'CONTRIBUTOR');
assert.equal(result.valid, false);
assert.equal(result.action, 'warn');
assert.equal(result.trusted, true);
assert.match(result.reason, /PR body is empty; a typed pull request template is required\./);
});
});
// `gh pr view --json files` returns at most 100 paths and does not
// paginate, while the PR's `changedFiles` field reports the true total.
// The tooling-paths carve-out RELAXES template enforcement, so trusting a
// possibly-truncated list would let a >100-file PR skip enforcement on the
// strength of its first 100 (all-tooling) paths. Verified live: PR
// golem15/msd-core#3202 returns 100 paths for 118 changed files.
describe('pr-template-policy carve-out — truncated file lists (#3211)', () => {
const toolingPaths = (n) => Array.from({ length: n }, (_, i) => `docs/generated-${i}.md`);
// A. Forgery vector — a truncated 100-of-118 list must not be trusted
test('a truncated 100-of-118 tooling file list does not skip enforcement', () => {
const result = evaluatePrTemplate('no template here', 'NONE', toolingPaths(FILE_LIST_PAGE_LIMIT), 118);
assert.equal(result.skipped, undefined);
// author association 'NONE' is untrusted, so enforcement closes the PR.
assert.equal(result.action, 'close');
});
// B. A 100-file tooling list whose total agrees is genuinely complete
test('a 100-file tooling list whose total agrees still skips enforcement', () => {
const result = evaluatePrTemplate('no template here', 'NONE', toolingPaths(FILE_LIST_PAGE_LIMIT), 100);
assert.equal(result.skipped, 'tooling-paths');
assert.equal(result.valid, true);
});
// C. A 100-file tooling list with no corroborating total fails closed
test('a 100-file tooling list with no corroborating total fails closed', () => {
const result = evaluatePrTemplate('no template here', 'NONE', toolingPaths(FILE_LIST_PAGE_LIMIT), undefined);
assert.equal(result.skipped, undefined);
});
// D. Back-compat boundary — every existing caller passes no total, and
// lists shorter than the page cap cannot have been truncated.
test('a 99-file tooling list is trusted without a total (below the page cap)', () => {
const result = evaluatePrTemplate('no template here', 'NONE', toolingPaths(FILE_LIST_PAGE_LIMIT - 1), undefined);
assert.equal(result.skipped, 'tooling-paths');
});
});

View File

@@ -1,126 +0,0 @@
'use strict';
/**
* Property-based tests for scripts/require-issue-link-policy.cjs (#3211).
*
* See CLAUDE.md "Property-Based Testing": parsers/budget-limit/bijective
* contracts require at least one fast-check property test. This module has
* three such contracts: the closing-keyword parse, the tests/docs-only
* carve-out, and the truncation-detection fail-closed guarantee.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('fast-check');
const { ISSUE_LINK_REASON, evaluateIssueLink } = require('../scripts/require-issue-link-policy.cjs');
describe('evaluateIssueLink — properties', () => {
test('P1: any body containing a closing keyword yields OK_CLOSING_KEYWORD', () => {
fc.assert(
fc.property(
fc.string(),
fc.string(),
fc.integer({ min: 1, max: 99999 }),
(pre, post, n) => {
// Guard: a leading/trailing newline stops `pre`/`post` from gluing
// onto the "Closes #<n>" token and changing what CLOSING_KEYWORD_REGEX
// sees — the regex has no word-boundary anchors, so word characters
// immediately adjacent to "Closes" are irrelevant to it either way,
// but the newline keeps the constructed body unambiguous to read.
const body = `${pre}\nCloses #${n}\n${post}`;
const result = evaluateIssueLink({
prBody: body,
headRef: 'fix/1-something',
sameRepo: false,
changedFiles: ['src/init.cts'],
changedFilesTotal: 1,
});
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_CLOSING_KEYWORD);
},
),
{ numRuns: 200 },
);
});
test('P2: a non-exempt path in the diff never yields OK_FOLLOWUP_REFERENCE', () => {
fc.assert(
fc.property(
fc.array(
fc.oneof(
fc.constantFrom('tests/a.test.cjs', 'docs/guide.md'),
fc.string({ minLength: 1, maxLength: 20 }).map((s) => `src/${s}.cts`),
),
{ minLength: 1, maxLength: 8 },
),
(paths) => {
// At least one path must actually be non-exempt for the property
// to be meaningful; skip runs where fc happened to draw only
// tests/docs paths. The exempt set is tests/, docs/, and root-level
// markdown (see EXEMPT_PATH_PREFIXES / isRootLevelDoc), so this
// filter names the non-exempt generator (`src/*.cts`) directly
// rather than re-deriving the exempt predicate — re-deriving it
// here would silently go stale the next time the exempt set grows.
fc.pre(paths.some((p) => p.startsWith('src/')));
const result = evaluateIssueLink({
prBody: 'Refs #1',
headRef: 'fix/1-something',
sameRepo: false,
changedFiles: paths,
changedFilesTotal: paths.length,
});
assert.notStrictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
},
),
{ numRuns: 200 },
);
});
test('P3: changedFiles at/above the page cap with a mismatched total never yields OK_FOLLOWUP_REFERENCE', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 150 }),
fc.integer({ min: 0, max: 300 }),
(length, total) => {
fc.pre(total !== length);
const changedFiles = Array.from({ length }, (_, i) => `tests/generated-${i}.test.cjs`);
const result = evaluateIssueLink({
prBody: 'Refs #1',
headRef: 'fix/1-something',
sameRepo: false,
changedFiles,
changedFilesTotal: total,
});
assert.notStrictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
},
),
{ numRuns: 200 },
);
});
test('P4: changedFiles below the page cap with a mismatched total never yields OK_FOLLOWUP_REFERENCE', () => {
// The sub-100 counterpart to P3, and the property the reviewed blocker
// violated: fileListIsComplete used to only consult the total once
// length >= FILE_LIST_PAGE_LIMIT, so any mismatch below the cap
// (heredoc truncation, newline inflation) went undetected below 100.
fc.assert(
fc.property(
fc.integer({ min: 1, max: 60 }),
fc.integer({ min: 0, max: 60 }),
(len, total) => {
fc.pre(total !== len);
const changedFiles = Array.from({ length: len }, (_, i) => `tests/generated-${i}.test.cjs`);
const result = evaluateIssueLink({
prBody: 'Refs #1',
headRef: 'fix/1-something',
sameRepo: false,
changedFiles,
changedFilesTotal: total,
});
assert.notStrictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
},
),
{ numRuns: 200 },
);
});
});

View File

@@ -1,454 +0,0 @@
'use strict';
// docs-guard-exempt: allPathsAreTestsOrDocs(['tests/a.cjs', 'docs/b.md']) below
// spot-checks a pure path-classifier predicate with string literals — no real
// docs/ file is ever read or asserted on for content.
/**
* Tests for scripts/require-issue-link-policy.cjs (#3211, preserving #1389).
*
* All assertions are on the typed ISSUE_LINK_REASON enum, never on free
* text — see CLAUDE.md "Mutation Score" / test conventions.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const {
ISSUE_LINK_REASON,
hasClosingKeyword,
hasFollowUpReference,
allPathsAreTestsOrDocs,
evaluateIssueLink,
EXEMPT_PATH_PREFIXES,
EXCLUDED_ROOT_DOCS,
} = require('../scripts/require-issue-link-policy.cjs');
const { fileListIsComplete } = require('../scripts/pr-changed-files.cjs');
function forkPr(overrides = {}) {
return {
prBody: '',
headRef: 'fix/123-something',
sameRepo: false,
changedFiles: ['src/init.cts'],
changedFilesTotal: 1,
...overrides,
};
}
function testPaths(n) {
return Array.from({ length: n }, (_, i) => `tests/generated-${i}.test.cjs`);
}
describe('evaluateIssueLink', () => {
// 1. Real-world vector: a fork PR that adds regression coverage for an
// issue without closing it.
test('fork PR referencing an issue with a tests-only diff is OK_FOLLOWUP_REFERENCE', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269 — regression coverage.',
changedFiles: ['tests/commit-files-pathspec.test.cjs'],
changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
assert.strictEqual(result.ok, true);
});
// 2. Every accepted reference form, including a lowercase variant.
test('every accepted follow-up reference form passes with a tests-only diff', () => {
const forms = [
'Refs #1', 'Ref #1', 'References #1', 'Relates to #1',
'Related to #1', 'Follow-up to #1', 'Follow up to #1', 'refs #1',
];
for (const body of forms) {
const result = evaluateIssueLink(forkPr({ prBody: body, changedFiles: ['tests/a.test.cjs'], changedFilesTotal: 1 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE, `form: ${body}`);
}
});
// 3. Docs-only and mixed tests+docs diffs are both allowed.
test('docs-only and mixed tests+docs diffs pass', () => {
const docsOnly = evaluateIssueLink(forkPr({
prBody: 'Refs #1', changedFiles: ['docs/CONFIGURATION.md'], changedFilesTotal: 1,
}));
assert.strictEqual(docsOnly.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
const mixed = evaluateIssueLink(forkPr({
prBody: 'Refs #1', changedFiles: ['tests/a.test.cjs', 'docs/guide.md'], changedFilesTotal: 2,
}));
assert.strictEqual(mixed.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
});
// 4. Windows-style backslash path is normalized before the prefix check.
test('backslash path is normalized and recognized as tests/', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #1', changedFiles: ['tests\\windows\\a.test.cjs'], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
});
// 5. CRLF vs LF bodies must produce the same reason.
test('CRLF and LF bodies with the same reference produce the same reason', () => {
const lf = evaluateIssueLink(forkPr({ prBody: 'Refs #1\n\nMore prose.', changedFiles: ['tests/a.test.cjs'], changedFilesTotal: 1 }));
const crlf = evaluateIssueLink(forkPr({ prBody: 'Refs #1\r\n\r\nMore prose.', changedFiles: ['tests/a.test.cjs'], changedFilesTotal: 1 }));
assert.strictEqual(lf.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
assert.strictEqual(crlf.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
});
// 6. No reference at all, even with a docs-only diff, fails.
test('no reference at all fails even with a docs-only diff', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Just a description, no issue mentioned.', changedFiles: ['docs/guide.md'], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE);
});
// 7 & 8. A reference (not a closing keyword) touching source files needs
// an actual closing keyword instead.
test('reference-only PR touching a source file fails needs-closing', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269', changedFiles: ['src/init.cts'], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING);
});
test('reference-only PR with a mixed tests+source diff fails needs-closing', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269',
changedFiles: ['tests/a.test.cjs', 'tests/b.test.cjs', 'src/b.cts'],
changedFilesTotal: 3,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING);
});
// 9. Bodies that must NOT be recognized as any kind of issue reference.
test('non-reference bodies fail with FAIL_NO_ISSUE_REFERENCE', () => {
const bodies = ['see #123', '#123', 'unlike #123', 'issue 123', 'a #123 b'];
for (const body of bodies) {
const result = evaluateIssueLink(forkPr({ prBody: body, changedFiles: ['tests/a.test.cjs'], changedFilesTotal: 1 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE, `body: ${JSON.stringify(body)}`);
}
});
// 10. Lookalike words that embed "ref"/"reference" inside a longer word
// must not be treated as a reference.
test('lookalike embedded-word bodies fail with FAIL_NO_ISSUE_REFERENCE', () => {
const bodies = ['prefs #1', 'unreferenced #1', 'xref#1', 'preferences #1'];
for (const body of bodies) {
const result = evaluateIssueLink(forkPr({ prBody: body, changedFiles: ['tests/a.test.cjs'], changedFilesTotal: 1 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE, `body: ${JSON.stringify(body)}`);
}
});
// 11. Directory-lookalike paths (tests-e2e/, src/tests/, testsuite/,
// docsite/) must NOT be treated as tests/ or docs/.
test('directory-lookalike paths are rejected, forcing needs-closing', () => {
const paths = ['tests-e2e/src/a.ts', 'src/tests/x.ts', 'testsuite/y.cjs', 'docsite/z.md'];
for (const p of paths) {
const result = evaluateIssueLink(forkPr({ prBody: 'Refs #1', changedFiles: [p], changedFilesTotal: 1 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING, `path: ${p}`);
}
});
// 12. A closing keyword on a source diff passes outright.
test('Closes #123 with a source diff is OK_CLOSING_KEYWORD', () => {
const result = evaluateIssueLink(forkPr({ prBody: 'Closes #123', changedFiles: ['src/init.cts'], changedFilesTotal: 1 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_CLOSING_KEYWORD);
});
// 13. Closing keyword case/whitespace variants.
test('closing keyword variants (case, whitespace) all pass', () => {
const bodies = ['closes #1', 'FIXES #1', 'Resolves #1', 'resolves\t#1'];
for (const body of bodies) {
const result = evaluateIssueLink(forkPr({ prBody: body, changedFiles: ['src/init.cts'], changedFilesTotal: 1 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_CLOSING_KEYWORD, `body: ${JSON.stringify(body)}`);
}
});
// 14 & 15. #1389 anti-forgery property: the backmerge exemption requires
// BOTH the branch prefix AND sameRepo === true.
test('backmerge branch + sameRepo true is exempt with no reference at all', () => {
const result = evaluateIssueLink(forkPr({
prBody: '', headRef: 'chore/backmerge-main-to-next-20260101', sameRepo: true,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_BACKMERGE_EXEMPT);
});
test('#1389 anti-forgery: backmerge branch name from a FORK (sameRepo false) is NOT exempt', () => {
const result = evaluateIssueLink(forkPr({
prBody: '', headRef: 'chore/backmerge-main-to-next-20260101', sameRepo: false,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE);
});
// #4196: Dependabot has no mechanism to link a PR it opens to a repo
// issue (its alerts live in the Security tab, not as issues) — exempt by
// authenticated author login, with no reference and no source-file
// restriction, mirroring the backmerge exemption's structure above.
test('#4196: dependabot[bot] author is exempt with no reference at all, even on a source diff', () => {
const result = evaluateIssueLink(forkPr({
prBody: '', authorLogin: 'dependabot[bot]', changedFiles: ['src/init.cts'], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_DEPENDABOT_EXEMPT);
assert.strictEqual(result.ok, true);
});
test('#4196 anti-forgery: an author login that merely CONTAINS "dependabot" is NOT exempt', () => {
const lookalikes = ['dependabot', 'Dependabot[bot]', 'not-dependabot[bot]', 'dependabot[bot] '];
for (const authorLogin of lookalikes) {
const result = evaluateIssueLink(forkPr({ prBody: '', authorLogin, changedFiles: ['src/init.cts'], changedFilesTotal: 1 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE, `authorLogin: ${JSON.stringify(authorLogin)}`);
}
});
// 16. hasClosingKeyword corpus parity — expected values come from the
// shipped shell grep this regex replaces:
// grep -qiE '(closes|fixes|resolves)\s+#[0-9]+'
test('hasClosingKeyword corpus parity with the replaced shell grep', () => {
const cases = [
['Closes #2269', true],
['closes #1', true],
['Fixes #12', true],
['Resolves #3', true],
['fixes #4', true],
['Closes #123 and more prose', true],
['Refs #2269', false],
['Follow-up to #2269', false],
['no reference at all', false],
['Closes #', false],
['Closes123', false],
['closes issue 5', false],
];
for (const [body, expected] of cases) {
assert.strictEqual(hasClosingKeyword(body), expected, `body: ${JSON.stringify(body)}`);
}
});
// 17-18. BOUNDARY: exactly at and just below the page cap, with a total
// that matches, must pass.
test('BOUNDARY 99 tests-only paths, total 99 passes', () => {
const paths = testPaths(99);
const result = evaluateIssueLink(forkPr({ prBody: 'Refs #1', changedFiles: paths, changedFilesTotal: 99 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
});
test('BOUNDARY 100 tests-only paths, total 100 passes', () => {
const paths = testPaths(100);
const result = evaluateIssueLink(forkPr({ prBody: 'Refs #1', changedFiles: paths, changedFilesTotal: 100 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
});
// 19. Real vector: PR #3202 — gh returns 100 of 118 changed files.
test('BOUNDARY 100 tests-only paths, total 118 fails FAIL_FILE_LIST_INCOMPLETE (PR #3202 vector)', () => {
const paths = testPaths(100);
const result = evaluateIssueLink(forkPr({ prBody: 'Refs #1', changedFiles: paths, changedFilesTotal: 118 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_FILE_LIST_INCOMPLETE);
});
test('100 tests-only paths, total undefined fails FAIL_FILE_LIST_INCOMPLETE', () => {
const paths = testPaths(100);
const result = evaluateIssueLink(forkPr({ prBody: 'Refs #1', changedFiles: paths, changedFilesTotal: undefined }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_FILE_LIST_INCOMPLETE);
});
// Reviewed BLOCKER: the old fileListIsComplete only consulted the total
// when length >= FILE_LIST_PAGE_LIMIT, so ANY mechanism that shortens the
// list below 100 went undetected. A $GITHUB_OUTPUT heredoc terminated
// early by a file named after the delimiter is exactly such a mechanism —
// it truncates the list well below the page cap, with the true total
// still available from the separate, non-paginated `changedFiles` field.
test('a list shorter than its authoritative total fails closed', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #1', changedFiles: ['CONTRIBUTING.md'], changedFilesTotal: 3,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_FILE_LIST_INCOMPLETE);
});
// A path containing a literal newline can inflate the parsed list past the
// true total (e.g. a filename that itself looks like another path once
// split on newlines) — the total is the authority in both directions.
test('a list longer than its authoritative total fails closed', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #1',
changedFiles: ['tests/a.test.cjs', 'tests/b.test.cjs', 'tests/c.test.cjs'],
changedFilesTotal: 2,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_FILE_LIST_INCOMPLETE);
});
// 21. An empty changedFiles list cannot confirm anything.
test('empty changedFiles fails FAIL_FILE_LIST_INCOMPLETE', () => {
const result = evaluateIssueLink(forkPr({ prBody: 'Refs #1', changedFiles: [], changedFilesTotal: 0 }));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_FILE_LIST_INCOMPLETE);
});
});
describe('fileListIsComplete', () => {
// 22. Direct table of (changedFiles, changedFilesTotal) -> expected.
test('boundary table', () => {
const cases = [
[['a', 'b', 'c'], 3, true],
[['a', 'b', 'c'], undefined, true],
[testPaths(100), 100, true],
[testPaths(100), 101, false],
[testPaths(100), undefined, false],
[[], 0, false],
[undefined, 0, false],
[testPaths(3), 5, false],
[testPaths(5), 3, false],
[testPaths(3), 3, true],
[testPaths(3), undefined, true],
];
for (const [changedFiles, changedFilesTotal, expected] of cases) {
assert.strictEqual(
fileListIsComplete(changedFiles, changedFilesTotal),
expected,
`changedFiles.length=${Array.isArray(changedFiles) ? changedFiles.length : changedFiles}, total=${changedFilesTotal}`,
);
}
});
});
describe('hasFollowUpReference', () => {
// 23. Direct spot checks on the raw predicate.
test('rejects a closing keyword, accepts a reference', () => {
assert.strictEqual(hasFollowUpReference('Closes #1'), false);
assert.strictEqual(hasFollowUpReference('Refs #1'), true);
});
});
describe('allPathsAreTestsOrDocs', () => {
// 24. Direct spot checks on the raw predicate.
test('true for tests/docs mix, false for a non-exempt path, false for empty', () => {
assert.strictEqual(allPathsAreTestsOrDocs(['tests/a.cjs', 'docs/b.md']), true);
assert.strictEqual(allPathsAreTestsOrDocs(['tests/a.cjs', '.github/workflows/x.yml']), false);
assert.strictEqual(allPathsAreTestsOrDocs([]), false);
});
// 25. Root-level markdown is a third accepted shape; CHANGELOG.md is
// excluded from it even though it is root-level markdown.
test('root-level markdown is accepted, CHANGELOG.md and subdirectory markdown are not', () => {
assert.strictEqual(allPathsAreTestsOrDocs(['CONTRIBUTING.md']), true);
assert.strictEqual(allPathsAreTestsOrDocs(['CHANGELOG.md']), false);
assert.strictEqual(allPathsAreTestsOrDocs(['agents/x.md']), false);
assert.strictEqual(allPathsAreTestsOrDocs(['package.json']), false);
});
// The exclusion of CHANGELOG.md must not be defeatable by casing — the
// extension test (`/\.md$/i`) is already case-insensitive, so the
// exclusion lookup must match it rather than silently letting a
// differently-cased CHANGELOG.md ride in on the docs carve-out.
test('CHANGELOG.md exclusion is case-insensitive', () => {
assert.strictEqual(allPathsAreTestsOrDocs(['changelog.md']), false);
assert.strictEqual(allPathsAreTestsOrDocs(['CHANGELOG.MD']), false);
});
});
describe('require-issue-link policy — root-level documentation (#2290 shape)', () => {
// #2290 is the motivating PR the follow-up-reference exemption failed to
// cover: its diff is CONTRIBUTING.md (root-level markdown) plus a tests/
// file, and the old EXEMPT_PATH_PREFIXES-only predicate rejected it because
// CONTRIBUTING.md is neither tests/ nor docs/-prefixed.
test('the motivating PR #2290 shape qualifies', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269',
changedFiles: ['CONTRIBUTING.md', 'tests/commit-files-pathspec.test.cjs'],
changedFilesTotal: 2,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
});
test('a root-level README change qualifies', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269', changedFiles: ['README.md'], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE);
});
// scripts/changeset/lint.cjs classes a direct CHANGELOG.md edit as
// user-facing specifically to close a bypass — the docs carve-out here
// must not undo that by treating CHANGELOG.md as ordinary documentation.
test('a direct CHANGELOG.md edit does NOT qualify', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269', changedFiles: ['CHANGELOG.md'], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING);
});
test('CHANGELOG.md alongside real docs still disqualifies', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269', changedFiles: ['docs/a.md', 'CHANGELOG.md'], changedFilesTotal: 2,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING);
});
// These are runtime-loaded text, deliberately gated by the same root-only
// anchor pre-pr-gate.sh uses — a subdirectory .md file is not root-level.
test('subdirectory markdown is not root-level documentation', () => {
const paths = ['msd-core/workflows/next.md', 'agents/reviewer.md', 'commands/msd/plan.md', 'src/notes.md'];
for (const p of paths) {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269', changedFiles: [p], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING, `path: ${p}`);
}
});
test('a root-level non-markdown file does not qualify', () => {
const result = evaluateIssueLink(forkPr({
prBody: 'Refs #2269', changedFiles: ['package.json'], changedFilesTotal: 1,
}));
assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING);
});
});
describe('require-issue-link policy — the workflow guidance matches the rule', () => {
// Parity assertion (CLAUDE.md "Generative Fix Divergence"): the sticky
// comment's guidance text is generated separately from the predicate it
// describes, and the two have already drifted once (the predicate widened
// to accept root-level markdown while the guidance kept saying "nothing
// outside tests/ and docs/"). Every expectation below is derived from the
// module's actual exports, never hardcoded, so a future widening of the
// predicate without a guidance update fails this test.
const workflowPath = path.join(__dirname, '..', '.github', 'workflows', 'require-issue-link.yml');
const workflowDoc = yaml.load(fs.readFileSync(workflowPath, 'utf8'));
const job = workflowDoc.jobs['check-issue-link'];
const steps = job.steps;
const lastStep = steps[steps.length - 1];
const script = lastStep.with.script;
// Non-vacuous guards: if these fail, the assertions below would otherwise
// silently pass against zero-length input.
test('the resolved script text and export lists are non-empty (guard)', () => {
assert.strictEqual(typeof script, 'string');
assert.ok(script.length > 200, `expected script.length > 200, got ${script.length}`);
assert.ok(EXEMPT_PATH_PREFIXES.length > 0, 'EXEMPT_PATH_PREFIXES must be non-empty');
assert.ok(EXCLUDED_ROOT_DOCS instanceof Set, 'EXCLUDED_ROOT_DOCS must be a Set');
assert.ok(EXCLUDED_ROOT_DOCS.size > 0, 'EXCLUDED_ROOT_DOCS must be non-empty');
});
test('guidance names every EXEMPT_PATH_PREFIXES entry verbatim', () => {
for (const prefix of EXEMPT_PATH_PREFIXES) {
assert.ok(script.includes(prefix), `guidance script missing prefix: ${prefix}`);
}
});
// isRootLevelDoc accepts root-level *.md — the guidance must say so; this
// is the exact shape that drifted before.
test('guidance mentions root-level markdown', () => {
assert.ok(script.includes('root-level'), 'guidance script missing "root-level"');
});
test('guidance names every EXCLUDED_ROOT_DOCS entry verbatim', () => {
for (const doc of EXCLUDED_ROOT_DOCS) {
assert.ok(script.includes(doc), `guidance script missing excluded doc: ${doc}`);
}
});
test('guidance mentions an accepted non-closing reference marker', () => {
assert.ok(script.includes('Refs #'), 'guidance script missing "Refs #"');
});
});

View File

@@ -7,9 +7,6 @@ const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const { evaluateIssueLink, ISSUE_LINK_REASON } = require('../scripts/require-issue-link-policy.cjs');
const MAINTAINER_SKIP_EXPR = 'contains(fromJSON(\'["OWNER","MEMBER","COLLABORATOR"]\'), github.event.pull_request.author_association) == false';
@@ -53,42 +50,20 @@ function assertMaintainerSkip(source) {
}
describe('PR policy workflow maintainer carve-outs', () => {
test('draft PR auto-close does not run for maintainer-authored PRs', () => {
const workflow = readWorkflow('.github/workflows/close-draft-prs.yml');
assert.match(workflow, /github\.event\.pull_request\.draft == true/);
assertMaintainerSkip(workflow);
});
test('draft PR auto-close triggers on pull_request_target so fork PRs cannot bypass it', () => {
const workflow = readWorkflow('.github/workflows/close-draft-prs.yml');
// A bare `pull_request` trigger hands fork PRs (how first-time/external
// contributors contribute) a read-only GITHUB_TOKEN, so the close/comment
// API calls 403 and the draft PR survives — bypassing the auto-close.
// `pull_request_target` runs in the base-repo context with a write-capable
// token. Guard against a regression back to the bypassable trigger.
assert.match(workflow, /^\s*pull_request_target:/m);
assert.doesNotMatch(workflow, /^\s*pull_request:\s*$/m);
});
test('PR target validator does not run for maintainer-authored PRs', () => {
const workflow = readWorkflow('.github/workflows/pr-target-validator.yml');
assertMaintainerSkip(workflow);
});
// #2331: same defect class as the close-draft-prs.yml trigger lock above,
// swept across the three PR-policy workflows it had never covered. Each one
// #2331: a PR-policy workflow that comments and THEN emits its verdict. Each one
// comments on the PR and THEN emits its verdict; on a bare `pull_request`
// trigger a fork PR's read-only GITHUB_TOKEN 403s the comment call, the
// unhandled rejection kills the github-script step, and the verdict
// (core.setFailed) never runs — the contributor gets an API stack trace
// instead of the instructions the comment exists to deliver.
for (const { file, name, scope, otherScope } of [
{ file: '.github/workflows/pr-title-validator.yml', name: 'PR title validator', scope: 'pull-requests', otherScope: 'issues' },
{ file: '.github/workflows/pr-target-validator.yml', name: 'PR target validator', scope: 'pull-requests', otherScope: 'issues' },
{ file: '.github/workflows/require-issue-link.yml', name: 'Require issue link', scope: 'issues', otherScope: 'pull-requests' },
]) {
test(`${name} triggers on pull_request_target so fork PRs get the verdict, not a 403`, () => {
const workflow = readWorkflow(file);
@@ -108,9 +83,9 @@ describe('PR policy workflow maintainer carve-outs', () => {
//
// Each file needs only ONE: GitHub accepts either `issues: write` or
// `pull-requests: write` for issues.createComment when the target is a
// PR. Verified from this repo's history, not the docs — pr-title-validator
// has posted on `pull-requests: write` alone, require-issue-link on
// `issues: write` alone. The 403 was the fork downgrade, not the scope.
// PR. Verified from this repo's history, not the docs — pr-target-validator
// has posted on `pull-requests: write` alone. The 403 was the fork
// downgrade, not the scope.
//
// The negative half is the point of this test: a pull_request_target
// workflow must not carry privilege it never exercises, so adding the
@@ -149,13 +124,12 @@ describe('PR policy workflow maintainer carve-outs', () => {
});
}
// #2331: these two workflows echo attacker-controlled text (PR title / fork
// branch name) into a bot-authored comment posted with a write token. Raw
// #2331: this workflow echoes attacker-controlled text (the fork branch
// name) into a bot-authored comment posted with a write token. Raw
// interpolation into an inline-code span lets a single backtick close the span
// so the remainder renders as live Markdown — on a PR title (no charset limit)
// that is enough to autolink an arbitrary URL from github-actions[bot].
for (const { file, name, varName } of [
{ file: '.github/workflows/pr-title-validator.yml', name: 'PR title validator', varName: 'titleForMarkdown' },
{ file: '.github/workflows/pr-target-validator.yml', name: 'PR target validator', varName: 'headForMarkdown' },
]) {
test(`${name} strips backticks before echoing untrusted text into the comment`, () => {
@@ -200,8 +174,8 @@ describe('PR policy workflow maintainer carve-outs', () => {
// The exact trap that made the first cut of the ordering test fail against
// correct code: these workflows name `core.setFailed` in their own comments,
// before the call, so a raw-source indexOf compares the comment.
const raw = readWorkflow('.github/workflows/pr-title-validator.yml');
const code = readWorkflowCode('.github/workflows/pr-title-validator.yml');
const raw = readWorkflow('.github/workflows/pr-target-validator.yml');
const code = readWorkflowCode('.github/workflows/pr-target-validator.yml');
assert.ok(
raw.indexOf('core.setFailed') < raw.indexOf('Could not post'),
@@ -230,100 +204,6 @@ describe('PR policy workflow maintainer carve-outs', () => {
assert.equal(sanitize('``a``'), "''a''");
});
test('draft PR sweep enforces the same policy as the event-driven close', () => {
const workflow = readWorkflow('.github/workflows/close-draft-prs-sweep.yml');
// Timer-driven in base-repo context, plus a manual dispatch for testing.
// It must NOT be a fork-triggered event (no pull_request / pull_request_target trigger).
assert.match(workflow, /schedule:/);
assert.match(workflow, /cron:\s*'0 \*\/6 \* \* \*'/);
assert.match(workflow, /workflow_dispatch:/);
assert.doesNotMatch(workflow, /^\s*pull_request(_target)?:/m);
// Write-capable token (needed to close PRs from base context). Tolerant of
// intervening blank lines or additional permission keys.
assert.match(workflow, /permissions:\s+pull-requests:\s*write/);
// Identical maintainer carve-out to close-draft-prs.yml — a Set membership
// test over author_association, negated (no github.event.pull_request in a
// scheduled run).
assert.match(workflow, /new Set\(\['OWNER', 'MEMBER', 'COLLABORATOR'\]\)/);
assert.match(workflow, /!MAINTAINER_ASSOCIATIONS\.has\([^)]*\.author_association\)/);
// Paginates over open PRs and filters to drafts.
assert.match(workflow, /github\.paginate\(github\.rest\.pulls\.list/);
assert.match(workflow, /state:\s*'open'/);
assert.match(workflow, /pr\.draft === true/);
// Same user-facing policy message as close-draft-prs.yml (locks the core
// content so the sweep cannot silently drift to a weaker message).
assert.match(workflow, /## Draft PRs are not accepted/);
assert.match(workflow, /npm run test:coverage/);
assert.match(workflow, /CONTRIBUTING\.md#pull-request-guidelines/);
});
});
describe('Require Issue Link back-merge automation carve-out', () => {
// #3211 moved the whole verdict — including the #1389 backmerge carve-out —
// out of the YAML `if:` and into scripts/require-issue-link-policy.cjs
// (evaluateIssueLink), renaming the step from `check`/`found` to
// `policy`/`ok`. The old assertions here measured the carve-out's PREVIOUS
// location (raw `startsWith(github.head_ref, ...)` / `head.repo.full_name`
// text inside the workflow's `if:`) and went stale once that logic
// relocated — the property they guarded still holds, just not at that
// text. This test locks the property at its new home: (a)+(b) assert the
// workflow now delegates to the policy module at the correct step-level
// `if:`, (c) asserts the carve-out itself BEHAVIORALLY against the real
// module, and (d) keeps the bootstrap fallback's legacy grep locked so the
// introducing-PR path (before the module exists on the base branch) is
// never silently open.
test('the backmerge carve-out survives the move into the policy module', () => {
const workflow = readWorkflow('.github/workflows/require-issue-link.yml');
// (a) The failing step's `if:` is keyed on the policy output and is
// STEP-level, not job-level. A job-level `if:` would make the required
// "Issue link required" check report `skipped` instead of `success` on
// an exempt PR, which blocks branch protection (#1389).
assert.match(workflow, /if: steps\.policy\.outputs\.ok != 'true'/);
const doc = yaml.load(workflow);
assert.equal(
doc.jobs['check-issue-link'].if,
undefined,
'job-level `if:` would report "skipped" on an exempt PR and block branch protection (#1389)'
);
// (b) The workflow delegates the verdict to the policy module.
assert.match(workflow, /node scripts\/require-issue-link-policy\.cjs/);
// (c) The carve-out itself still holds — asserted BEHAVIORALLY against
// the real module rather than by grepping YAML, since the verdict no
// longer lives in the YAML text at all.
const backmergeArgs = {
prBody: 'Automated backmerge.',
headRef: 'chore/backmerge-main-to-next-20260101',
changedFiles: ['src/a.cts'],
changedFilesTotal: 1,
};
assert.equal(
evaluateIssueLink({ ...backmergeArgs, sameRepo: true }).reason,
ISSUE_LINK_REASON.OK_BACKMERGE_EXEMPT
);
// #1389 anti-forgery: a fork must not be able to forge the exemption by
// branch name alone — dropping the `sameRepo` conjunct would let a fork
// PR name its branch `chore/backmerge-main-to-next-*` and bypass the
// issue-link requirement entirely.
assert.equal(
evaluateIssueLink({ ...backmergeArgs, sameRepo: false }).reason,
ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE
);
// (d) The bootstrap fallback (used only on the PR that first introduces
// the policy module, before it exists on the base branch) still carries
// the legacy closing-keyword grep, so the gate is never silently open
// during the changeover.
assert.match(workflow, /\(closes\|fixes\|resolves\)/);
});
});
describe('Auto-backmerge needs_review version-manifest carve-out (#1404)', () => {