diff --git a/.github/workflows/pr-target-validator.yml b/.github/workflows/pr-target-validator.yml
index ade2c8fea..a1bdddd2d 100644
--- a/.github/workflows/pr-target-validator.yml
+++ b/.github/workflows/pr-target-validator.yml
@@ -18,6 +18,7 @@ concurrency:
cancel-in-progress: true
permissions:
+ contents: read
pull-requests: write
jobs:
@@ -31,96 +32,91 @@ jobs:
# Phase-1: warn only. Phase-2: set to 'false' to enforce.
WARN_ONLY: 'false'
steps:
+ - name: Checkout base branch (trusted policy source)
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ github.event.pull_request.base.ref }}
+ persist-credentials: false
+
- name: Validate PR target branch
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
WARN_ONLY: ${{ env.WARN_ONLY }}
with:
script: |
+ const { classifyPrTarget } = require(`${process.env.GITHUB_WORKSPACE}/scripts/pr-target-policy.cjs`);
+
const pr = context.payload.pull_request;
const base = pr.base.ref;
const head = pr.head.ref;
const warnOnly = process.env.WARN_ONLY === 'true';
- // PRs targeting `next` are always fine.
- if (base === 'next') {
- core.info(`PR targets next — OK.`);
+ const { decision } = classifyPrTarget(base, head);
+
+ if (decision === 'allowed') {
+ if (base === 'next') {
+ core.info(`PR targets next — OK.`);
+ } else if (base === 'main') {
+ core.info(`PR from ${head} → main matches an allowed pattern.`);
+ } else {
+ core.info(`PR targets a release/hotfix branch — OK.`);
+ }
return;
}
- // PRs targeting `main`: only specific branch types allowed.
- if (base === 'main') {
- const mainAllowed = [
- /^release\/\d+\.\d+\.0$/, // release branches
- /^hotfix\/\d+\.\d+\.\d+$/, // hotfix branches
- /^fix\/critical-/, // production-down emergencies
- /^chore\/backmerge-/, // auto-backmerge from this workflow
- /^revert\/critical-/, // emergency reverts
- ];
- const allowed = mainAllowed.some(re => re.test(head));
- if (allowed) {
- core.info(`PR from ${head} → main matches an allowed pattern.`);
- return;
- }
+ if (decision === 'unusual') {
+ core.warning(`Unusual PR target: ${base}`);
+ return;
+ }
- const msg = [
- `### Wrong target branch`,
- ``,
- `This PR targets \`main\` but the source branch \`${head}\` is not a release, hotfix, critical-fix, or back-merge branch.`,
- ``,
- `**Most PRs should target \`next\`, not \`main\`.** See [docs/branching.md](../blob/main/docs/branching.md).`,
- ``,
- `**How to fix:** click "Edit" next to the PR title above and change the base branch from \`main\` to \`next\`. No need to recreate the PR.`,
- ``,
- `When IS it OK to target main?
`,
- ``,
- `- \`release/X.Y.0\` branches (cut by \`release.yml\`)`,
- `- \`hotfix/X.Y.Z\` branches (cut by \`hotfix.yml\`)`,
- `- \`fix/critical-*\` branches (production-down emergencies)`,
- `- \`chore/backmerge-*\` branches (automated back-merge from this workflow)`,
- `- \`revert/critical-*\` branches (emergency reverts of a bad merge on main)`,
- ``,
- ` `,
- ].join('\n');
+ // decision === 'blocked': base is main and head is not an allowed pattern.
+ const msg = [
+ `### Wrong target branch`,
+ ``,
+ `This PR targets \`main\` but the source branch \`${head}\` is not a release, hotfix, critical-fix, or back-merge branch.`,
+ ``,
+ `**Most PRs should target \`next\`, not \`main\`.** See [docs/branching.md](../blob/main/docs/branching.md).`,
+ ``,
+ `**How to fix:** click "Edit" next to the PR title above and change the base branch from \`main\` to \`next\`. No need to recreate the PR.`,
+ ``,
+ `When IS it OK to target main?
`,
+ ``,
+ `- \`release/X.Y.0\` branches (cut by \`release.yml\`)`,
+ `- \`hotfix/X.Y.Z\` branches (cut by \`hotfix.yml\`)`,
+ `- \`fix/critical-*\` branches (production-down emergencies)`,
+ `- \`chore/backmerge-*\` branches (automated back-merge from this workflow)`,
+ `- \`revert/critical-*\` branches (emergency reverts of a bad merge on main)`,
+ ``,
+ ` `,
+ ].join('\n');
- // Post or update a sticky comment.
- const { data: comments } = await github.rest.issues.listComments({
+ // Post or update a sticky comment.
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ });
+ const marker = '';
+ const existing = comments.find(c => c.body && c.body.includes(marker));
+ const body = `${marker}\n${msg}`;
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
+ body,
});
- const marker = '';
- const existing = comments.find(c => c.body && c.body.includes(marker));
- const body = `${marker}\n${msg}`;
- if (existing) {
- await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: existing.id,
- body,
- });
- } else {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr.number,
- body,
- });
- }
-
- if (warnOnly) {
- core.warning(`PR target mismatch (warning-only mode): ${head} → ${base}`);
- } else {
- core.setFailed(`PR target mismatch: ${head} should target next, not main.`);
- }
- return;
}
- // PRs targeting release/X.Y.0 or hotfix/X.Y.Z are fine (stabilization PRs).
- if (/^release\/\d+\.\d+\.0$/.test(base) || /^hotfix\/\d+\.\d+\.\d+$/.test(base)) {
- core.info(`PR targets a release/hotfix branch — OK.`);
- return;
+ if (warnOnly) {
+ core.warning(`PR target mismatch (warning-only mode): ${head} → ${base}`);
+ } else {
+ core.setFailed(`PR target mismatch: ${head} should target next, not main.`);
}
-
- // Any other target is unusual but not forbidden.
- core.warning(`Unusual PR target: ${base}`);
diff --git a/scripts/pr-target-policy.cjs b/scripts/pr-target-policy.cjs
new file mode 100644
index 000000000..ad192c91e
--- /dev/null
+++ b/scripts/pr-target-policy.cjs
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * PR target-branch policy — ADR-230.
+ *
+ * Extracted from .github/workflows/pr-target-validator.yml so the classification
+ * logic can be unit-tested independently and required by the workflow at checkout
+ * from the TRUSTED base-branch copy (fork-tamper-safe).
+ *
+ * Pure module: no I/O, no GitHub API calls, no side effects.
+ *
+ * See: docs/branching.md, docs/adr/230-introduce-next-integration-branch.md
+ */
+
+/**
+ * The five patterns that allow a PR to target `main`.
+ * Verbatim from the github-script in pr-target-validator.yml.
+ *
+ * @type {RegExp[]}
+ */
+const MAIN_ALLOWED_PATTERNS = [
+ /^release\/\d+\.\d+\.0$/, // release branches
+ /^hotfix\/\d+\.\d+\.\d+$/, // hotfix branches
+ /^fix\/critical-/, // production-down emergencies
+ /^chore\/backmerge-/, // auto-backmerge from this workflow
+ /^revert\/critical-/, // emergency reverts
+];
+
+/**
+ * Classify a pull request by its base and head branch names.
+ *
+ * @param {string} base - The PR's target branch (e.g. 'next', 'main', 'release/1.2.0').
+ * @param {string} head - The PR's source branch (e.g. 'feat/my-feature').
+ * @returns {{ decision: 'allowed' | 'blocked' | 'unusual' }}
+ */
+function classifyPrTarget(base, head) {
+ // PRs targeting `next` are always fine.
+ if (base === 'next') {
+ return { decision: 'allowed' };
+ }
+
+ // PRs targeting `main`: only specific branch types allowed.
+ if (base === 'main') {
+ const allowed = MAIN_ALLOWED_PATTERNS.some(re => re.test(head));
+ if (allowed) {
+ return { decision: 'allowed' };
+ }
+ return { decision: 'blocked' };
+ }
+
+ // PRs targeting release/X.Y.0 or hotfix/X.Y.Z are fine (stabilization PRs).
+ if (/^release\/\d+\.\d+\.0$/.test(base) || /^hotfix\/\d+\.\d+\.\d+$/.test(base)) {
+ return { decision: 'allowed' };
+ }
+
+ // Any other target is unusual but not forbidden.
+ return { decision: 'unusual' };
+}
+
+module.exports = {
+ MAIN_ALLOWED_PATTERNS,
+ classifyPrTarget,
+};
diff --git a/tests/adr-230-pr-target-policy.test.cjs b/tests/adr-230-pr-target-policy.test.cjs
new file mode 100644
index 000000000..7ef4e4de3
--- /dev/null
+++ b/tests/adr-230-pr-target-policy.test.cjs
@@ -0,0 +1,403 @@
+'use strict';
+
+// allow-test-rule: reads workflow YAML source as the security artifact under test #1190
+
+/**
+ * ADR-230 regression guard: PR target-branch policy.
+ *
+ * (A) Behavioral coverage of classifyPrTarget — every decision path, including
+ * boundary cases (wrong version format, partial prefix matches).
+ *
+ * (B) Equivalence oracle — the old inline decision logic from the workflow's
+ * github-script is embedded here verbatim as oracle(). Every (base, head)
+ * combo must produce the same decision from both the module and the oracle.
+ * This proves behavior-preservation across the refactor.
+ *
+ * (C) Structural assertions — pr-target-validator.yml must now check out the
+ * base ref (fork-tamper-safe) and require the policy module.
+ */
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const POLICY_PATH = path.join(__dirname, '..', 'scripts', 'pr-target-policy.cjs');
+const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'pr-target-validator.yml');
+
+const { classifyPrTarget, MAIN_ALLOWED_PATTERNS } = require(POLICY_PATH);
+
+// ---------------------------------------------------------------------------
+// Oracle: verbatim inline logic from the original github-script in the workflow.
+// This is intentionally NOT refactored — it is the canonical pre-refactor
+// behavior against which the extracted module is checked.
+// ---------------------------------------------------------------------------
+
+/**
+ * Oracle replicating the original inline decision logic.
+ * Returns 'allowed' | 'blocked' | 'unusual' — same vocabulary as classifyPrTarget.
+ *
+ * @param {string} base
+ * @param {string} head
+ * @returns {'allowed'|'blocked'|'unusual'}
+ */
+function oracle(base, head) {
+ if (base === 'next') {
+ return 'allowed';
+ }
+
+ if (base === 'main') {
+ const mainAllowed = [
+ /^release\/\d+\.\d+\.0$/, // release branches
+ /^hotfix\/\d+\.\d+\.\d+$/, // hotfix branches
+ /^fix\/critical-/, // production-down emergencies
+ /^chore\/backmerge-/, // auto-backmerge from this workflow
+ /^revert\/critical-/, // emergency reverts
+ ];
+ const allowed = mainAllowed.some(re => re.test(head));
+ return allowed ? 'allowed' : 'blocked';
+ }
+
+ if (/^release\/\d+\.\d+\.0$/.test(base) || /^hotfix\/\d+\.\d+\.\d+$/.test(base)) {
+ return 'allowed';
+ }
+
+ return 'unusual';
+}
+
+// ---------------------------------------------------------------------------
+// (A) Behavioral tests
+// ---------------------------------------------------------------------------
+
+describe('classifyPrTarget — allowed cases', () => {
+ test('base=next, any head → allowed', () => {
+ assert.deepStrictEqual(classifyPrTarget('next', 'feat/anything'), { decision: 'allowed' });
+ assert.deepStrictEqual(classifyPrTarget('next', 'main'), { decision: 'allowed' });
+ assert.deepStrictEqual(classifyPrTarget('next', ''), { decision: 'allowed' });
+ });
+
+ test('base=main, head=release/1.2.0 → allowed', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'release/1.2.0'), { decision: 'allowed' });
+ });
+
+ test('base=main, head=release/10.20.0 → allowed (multi-digit)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'release/10.20.0'), { decision: 'allowed' });
+ });
+
+ test('base=main, head=hotfix/1.2.3 → allowed', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'hotfix/1.2.3'), { decision: 'allowed' });
+ });
+
+ test('base=main, head=hotfix/10.20.30 → allowed (multi-digit)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'hotfix/10.20.30'), { decision: 'allowed' });
+ });
+
+ test('base=main, head=fix/critical-login → allowed', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'fix/critical-login'), { decision: 'allowed' });
+ });
+
+ test('base=main, head=fix/critical- (bare suffix) → allowed', () => {
+ // The regex is /^fix\/critical-/ (prefix match, no anchor), so bare suffix is allowed.
+ assert.deepStrictEqual(classifyPrTarget('main', 'fix/critical-'), { decision: 'allowed' });
+ });
+
+ test('base=main, head=chore/backmerge-next-to-main → allowed', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'chore/backmerge-next-to-main'), { decision: 'allowed' });
+ });
+
+ test('base=main, head=revert/critical-bad-deploy → allowed', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'revert/critical-bad-deploy'), { decision: 'allowed' });
+ });
+
+ test('base=release/1.2.0 → allowed (stabilization PR)', () => {
+ assert.deepStrictEqual(classifyPrTarget('release/1.2.0', 'fix/some-fix'), { decision: 'allowed' });
+ });
+
+ test('base=release/10.20.0 → allowed (multi-digit stabilization)', () => {
+ assert.deepStrictEqual(classifyPrTarget('release/10.20.0', 'chore/bump'), { decision: 'allowed' });
+ });
+
+ test('base=hotfix/1.2.3 → allowed (stabilization PR)', () => {
+ assert.deepStrictEqual(classifyPrTarget('hotfix/1.2.3', 'fix/patch'), { decision: 'allowed' });
+ });
+
+ test('base=hotfix/10.20.30 → allowed (multi-digit stabilization)', () => {
+ assert.deepStrictEqual(classifyPrTarget('hotfix/10.20.30', 'chore/stuff'), { decision: 'allowed' });
+ });
+});
+
+describe('classifyPrTarget — blocked cases', () => {
+ test('base=main, head=feat/x → blocked', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'feat/x'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=fix/non-critical → blocked (non-critical prefix)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'fix/non-critical'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=fix/x → blocked (not fix/critical-)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'fix/x'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=chore/cleanup → blocked (not chore/backmerge-)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'chore/cleanup'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=revert/safe → blocked (not revert/critical-)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'revert/safe'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=release/1.2.1 → blocked (patch release not allowed for main)', () => {
+ // The pattern is /^release\/\d+\.\d+\.0$/ — only .0 patch is allowed for main.
+ assert.deepStrictEqual(classifyPrTarget('main', 'release/1.2.1'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=release/1.2.0-beta → blocked (suffix after .0)', () => {
+ // The pattern anchors with $, so release/1.2.0-beta does not match.
+ assert.deepStrictEqual(classifyPrTarget('main', 'release/1.2.0-beta'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=hotfix/1.2 → blocked (only two version parts)', () => {
+ // Pattern requires \d+\.\d+\.\d+ (3 parts).
+ assert.deepStrictEqual(classifyPrTarget('main', 'hotfix/1.2'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=empty string → blocked', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', ''), { decision: 'blocked' });
+ });
+
+ // Hyphen-boundary negative tests: verify that the trailing hyphen is required.
+ // A regex weakening from /^fix\/critical-/ → /^fix\/critical/ would make these
+ // incorrectly pass; they must remain blocked.
+ test('base=main, head=fix/criticalfoo → blocked (missing required hyphen)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'fix/criticalfoo'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=chore/backmergefoo → blocked (missing required hyphen)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'chore/backmergefoo'), { decision: 'blocked' });
+ });
+
+ test('base=main, head=revert/criticalfoo → blocked (missing required hyphen)', () => {
+ assert.deepStrictEqual(classifyPrTarget('main', 'revert/criticalfoo'), { decision: 'blocked' });
+ });
+});
+
+describe('classifyPrTarget — unusual cases', () => {
+ test('base=develop → unusual', () => {
+ assert.deepStrictEqual(classifyPrTarget('develop', 'feat/x'), { decision: 'unusual' });
+ });
+
+ test('base=feature/something → unusual', () => {
+ assert.deepStrictEqual(classifyPrTarget('feature/something', 'feat/x'), { decision: 'unusual' });
+ });
+
+ test('base=release/1.2.1 (patch, not .0) → unusual (not a valid stabilization target)', () => {
+ // release/1.2.1 does not match /^release\/\d+\.\d+\.0$/, so it's unusual.
+ assert.deepStrictEqual(classifyPrTarget('release/1.2.1', 'fix/patch'), { decision: 'unusual' });
+ });
+
+ test('base=hotfix/1.2 (two parts) → unusual', () => {
+ // hotfix/1.2 does not match /^hotfix\/\d+\.\d+\.\d+$/, so it's unusual.
+ assert.deepStrictEqual(classifyPrTarget('hotfix/1.2', 'fix/patch'), { decision: 'unusual' });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// (B) Equivalence oracle — prove behavior-preservation across the refactor.
+// Every (base, head) combo must agree between the module and the oracle.
+// ---------------------------------------------------------------------------
+
+describe('equivalence oracle — module agrees with original inline logic', () => {
+ /** @type {[string, string][]} */
+ const BATTERY = [
+ // next → always allowed
+ ['next', 'feat/foo'],
+ ['next', 'fix/critical-x'],
+ ['next', 'main'],
+ // main → allowed patterns
+ ['main', 'release/1.2.0'],
+ ['main', 'release/0.0.0'],
+ ['main', 'hotfix/1.2.3'],
+ ['main', 'hotfix/0.1.2'],
+ ['main', 'fix/critical-login-failure'],
+ ['main', 'fix/critical-'],
+ ['main', 'chore/backmerge-next'],
+ ['main', 'revert/critical-bad-merge'],
+ // main → blocked
+ ['main', 'feat/new-thing'],
+ ['main', 'fix/typo'],
+ ['main', 'chore/lint'],
+ ['main', 'release/1.2.1'],
+ ['main', 'hotfix/1.2'],
+ ['main', ''],
+ // main → blocked: hyphen-boundary negatives (no trailing hyphen)
+ ['main', 'fix/criticalfoo'],
+ ['main', 'chore/backmergefoo'],
+ ['main', 'revert/criticalfoo'],
+ // stabilization bases → allowed
+ ['release/1.2.0', 'chore/bump-deps'],
+ ['hotfix/1.2.3', 'fix/critical-patch'],
+ // unusual bases
+ ['develop', 'feat/something'],
+ ['feature/my-thing', 'chore/minor'],
+ ['release/1.2.1', 'fix/patch'],
+ ['hotfix/1.2', 'fix/patch'],
+ ];
+
+ for (const [base, head] of BATTERY) {
+ test(`oracle agrees: base=${JSON.stringify(base)} head=${JSON.stringify(head)}`, () => {
+ const moduleDecision = classifyPrTarget(base, head).decision;
+ const oracleDecision = oracle(base, head);
+ assert.strictEqual(
+ moduleDecision,
+ oracleDecision,
+ `classifyPrTarget(${JSON.stringify(base)}, ${JSON.stringify(head)}) returned ` +
+ `'${moduleDecision}' but oracle returned '${oracleDecision}'`
+ );
+ });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// (C) Structural assertions — workflow wiring
+// ---------------------------------------------------------------------------
+
+describe('pr-target-validator.yml structural wiring', () => {
+ let workflowSrc;
+
+ test('workflow file exists', () => {
+ assert.ok(fs.existsSync(WORKFLOW_PATH), `workflow not found at ${WORKFLOW_PATH}`);
+ workflowSrc = fs.readFileSync(WORKFLOW_PATH, 'utf8');
+ });
+
+ test('workflow checks out the base ref (fork-tamper-safe)', () => {
+ assert.ok(
+ workflowSrc,
+ 'workflowSrc not loaded (prior test may have failed)'
+ );
+ // Must have a checkout step using the base ref so that a fork PR cannot
+ // supply its own copy of the policy module.
+ assert.match(
+ workflowSrc,
+ /ref:\s*\$\{\{\s*github\.event\.pull_request\.base\.ref\s*\}\}/,
+ 'checkout step must pin ref to github.event.pull_request.base.ref'
+ );
+ });
+
+ test('checkout step uses the repo-standard pinned SHA (de0fac2e...)', () => {
+ assert.ok(workflowSrc, 'workflowSrc not loaded');
+ assert.match(
+ workflowSrc,
+ /actions\/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd/,
+ 'checkout must use the repo-standard pinned SHA de0fac2e4500dabe0009e67214ff5f5447ce83dd'
+ );
+ });
+
+ test('workflow requires pr-target-policy.cjs', () => {
+ assert.ok(workflowSrc, 'workflowSrc not loaded');
+ assert.match(
+ workflowSrc,
+ /require\(.*scripts\/pr-target-policy\.cjs/,
+ 'github-script must require scripts/pr-target-policy.cjs'
+ );
+ });
+
+ test('workflow calls classifyPrTarget', () => {
+ assert.ok(workflowSrc, 'workflowSrc not loaded');
+ assert.match(
+ workflowSrc,
+ /classifyPrTarget\(/,
+ 'github-script must call classifyPrTarget()'
+ );
+ });
+
+ test('workflow no longer contains the inline mainAllowed array', () => {
+ assert.ok(workflowSrc, 'workflowSrc not loaded');
+ // The inline pattern array `mainAllowed` was the old classification code.
+ // It must have been removed to avoid the logic diverging from the module.
+ assert.doesNotMatch(
+ workflowSrc,
+ /const mainAllowed\s*=/,
+ 'old inline mainAllowed array must not appear in the rewired workflow'
+ );
+ });
+
+ test('WARN_ONLY env and setFailed/warning behavior preserved', () => {
+ assert.ok(workflowSrc, 'workflowSrc not loaded');
+ assert.match(workflowSrc, /WARN_ONLY/, 'WARN_ONLY env must still be present');
+ assert.match(workflowSrc, /core\.setFailed/, 'core.setFailed must still be called on block');
+ assert.match(workflowSrc, /core\.warning.*warning-only mode/, 'WARN_ONLY warning message must be preserved');
+ });
+
+ test('sticky-comment marker and post/update logic preserved', () => {
+ assert.ok(workflowSrc, 'workflowSrc not loaded');
+ assert.match(workflowSrc, /pr-target-validator/, 'comment marker must still reference pr-target-validator');
+ assert.match(workflowSrc, /listComments/, 'listComments call must be preserved');
+ assert.match(workflowSrc, /updateComment/, 'updateComment call must be preserved');
+ assert.match(workflowSrc, /createComment/, 'createComment call must be preserved');
+ });
+
+ test('maintainer carve-out (if: author_association) preserved', () => {
+ assert.ok(workflowSrc, 'workflowSrc not loaded');
+ assert.match(
+ workflowSrc,
+ /OWNER.*MEMBER.*COLLABORATOR|COLLABORATOR.*MEMBER.*OWNER/s,
+ 'maintainer association carve-out must still be present'
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// (D) MAIN_ALLOWED_PATTERNS export — verify array is exported and well-formed
+// ---------------------------------------------------------------------------
+
+describe('MAIN_ALLOWED_PATTERNS export', () => {
+ test('exports an array of 5 RegExp objects', () => {
+ assert.ok(Array.isArray(MAIN_ALLOWED_PATTERNS), 'MAIN_ALLOWED_PATTERNS must be an array');
+ assert.strictEqual(MAIN_ALLOWED_PATTERNS.length, 5, 'must export exactly 5 patterns');
+ for (const re of MAIN_ALLOWED_PATTERNS) {
+ assert.ok(re instanceof RegExp, `Expected RegExp, got ${typeof re}`);
+ }
+ });
+
+ test('pattern[0] matches release/X.Y.0 branches only', () => {
+ const re = MAIN_ALLOWED_PATTERNS[0];
+ assert.ok(re.test('release/1.2.0'));
+ assert.ok(re.test('release/10.20.0'));
+ assert.ok(!re.test('release/1.2.1'), 'should not match patch != 0');
+ assert.ok(!re.test('release/1.2.0-beta'), 'should not match suffix');
+ assert.ok(!re.test('hotfix/1.2.0'), 'should not match hotfix');
+ });
+
+ test('pattern[1] matches hotfix/X.Y.Z branches', () => {
+ const re = MAIN_ALLOWED_PATTERNS[1];
+ assert.ok(re.test('hotfix/1.2.3'));
+ assert.ok(re.test('hotfix/10.20.30'));
+ assert.ok(!re.test('hotfix/1.2'), 'must require 3 parts');
+ assert.ok(!re.test('hotfix/1.2.3-beta'), 'suffix should not match');
+ });
+
+ test('pattern[2] matches fix/critical- prefix', () => {
+ const re = MAIN_ALLOWED_PATTERNS[2];
+ assert.ok(re.test('fix/critical-login'));
+ assert.ok(re.test('fix/critical-'));
+ assert.ok(!re.test('fix/noncritical'));
+ assert.ok(!re.test('chore/critical-something'));
+ });
+
+ test('pattern[3] matches chore/backmerge- prefix', () => {
+ const re = MAIN_ALLOWED_PATTERNS[3];
+ assert.ok(re.test('chore/backmerge-next-to-main'));
+ assert.ok(re.test('chore/backmerge-'));
+ assert.ok(!re.test('chore/merge-back'));
+ assert.ok(!re.test('feat/backmerge-something'));
+ });
+
+ test('pattern[4] matches revert/critical- prefix', () => {
+ const re = MAIN_ALLOWED_PATTERNS[4];
+ assert.ok(re.test('revert/critical-bad-deploy'));
+ assert.ok(re.test('revert/critical-'));
+ assert.ok(!re.test('revert/safe'));
+ assert.ok(!re.test('fix/critical-something'));
+ });
+});