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')); + }); +});