ADR-230's branching-model gate (pr-target-validator.yml) decided allowed/blocked PR targets via inline regex in github-script — untestable. Extracted the decision into committed scripts/pr-target-policy.cjs (classifyPrTarget(base,head)->{decision}), and rewired the workflow to checkout the BASE ref (trusted; fork-tamper-safe) + require the module. Behavior-identical (Codex-verified char-by-char regex equivalence + all side-effects preserved). 70 tests incl. an equivalence oracle battery + hyphen-boundary negatives. Added contents:read for the checkout. Re-attribution: no ADR-230 test references exist (issue's '2 misattributed files' claim not borne out).
Closes #1190
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
16 KiB
JavaScript
404 lines
16 KiB
JavaScript
'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'));
|
|
});
|
|
});
|