.github/test/pr-policy.test.mjs

1610 lines
66 KiB
JavaScript

/**
* pr-policy.test.mjs
*
* Extracts the exact `script: |` block from callable-pr-policy.yaml and
* exercises the pure validation functions via the PR_POLICY_TEST=1 escape.
* No npm dependencies — uses only Node.js built-ins.
*
* Run: node --test test/pr-policy.test.mjs
*/
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// ── Script extraction ────────────────────────────────────────────────────────
// Reads the YAML file and extracts the literal block scalar under `script: |`.
// The strip amount is determined from the indentation of the first non-empty
// line after the `script: |` marker.
function extractScriptBlock(yamlText) {
const lines = yamlText.split('\n');
let state = 'looking';
let strip = 0;
const scriptLines = [];
for (let i = 0; i < lines.length; i++) {
if (state === 'looking') {
if (/^\s+script:\s*\|/.test(lines[i])) {
state = 'content';
}
} else {
const line = lines[i];
if (line.trim() === '') {
scriptLines.push('');
continue;
}
const indent = (line.match(/^(\s*)/) || ['', ''])[1].length;
if (strip === 0) {
strip = indent;
}
if (indent >= strip) {
scriptLines.push(line.slice(strip));
} else {
break;
}
}
}
while (scriptLines.length && !scriptLines[scriptLines.length - 1].trim()) {
scriptLines.pop();
}
return scriptLines.join('\n');
}
// ── Pure-function loader ─────────────────────────────────────────────────────
// Runs the extracted script with PR_POLICY_TEST=1, which causes the script to
// return the pure validator functions before making any API calls.
let v; // shared validator object
async function loadValidators() {
const yamlPath = join(__dirname, '../.github/workflows/callable-pr-policy.yaml');
const yamlText = readFileSync(yamlPath, 'utf8');
const scriptCode = extractScriptBlock(yamlText);
assert.ok(scriptCode.length > 100, 'script block must be non-trivially long');
assert.ok(scriptCode.includes('PR_POLICY_TEST'), 'script block must contain PR_POLICY_TEST escape');
process.env.PR_POLICY_TEST = '1';
try {
// Wrap in an async IIFE matching how actions/github-script executes it.
// Pure functions do not call github/context/core, so empty mocks suffice.
const asyncFn = new Function(
'github', 'context', 'core', 'process',
'return (async function() {\n' + scriptCode + '\n})()'
);
const mockCore = {
error: () => {},
setFailed: () => {},
warning: () => {},
summary: { addRaw: () => ({ write: async () => {} }) },
};
v = await asyncFn({}, {}, mockCore, process);
} finally {
delete process.env.PR_POLICY_TEST;
}
assert.ok(v && typeof v === 'object', 'PR_POLICY_TEST escape must return an object');
for (const fn of [
'validateTitle', 'getJiraKey', 'validateBranch', 'validateBody',
'validateCommitSubject', 'detectAiFooter', 'isWorkflowFilename',
'validateWorkflowContent', 'parseRetryAfterMs', 'checkCommitLimit',
'checkFilesLimit', 'normalizeViolationFingerprint', 'filterNewViolations',
'fnv1a32', 'stripHash',
]) {
assert.equal(typeof v[fn], 'function', fn + ' must be exported');
}
}
// ── Test suite ───────────────────────────────────────────────────────────────
before(async () => { await loadValidators(); });
// ── validateTitle ────────────────────────────────────────────────────────────
describe('validateTitle', () => {
it('accepts a valid non-Dependabot title', () => {
assert.deepEqual(v.validateTitle('feat(auth): add login endpoint (DEV-123)', false), []);
});
it('accepts a valid title without scope', () => {
assert.deepEqual(v.validateTitle('fix: resolve null pointer (PLAT-42)', false), []);
});
it('accepts a valid Dependabot title without Jira key', () => {
assert.deepEqual(v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21', true), []);
});
it('rejects missing Jira key for non-Dependabot', () => {
const errs = v.validateTitle('fix: resolve null pointer', false);
assert.ok(errs.length > 0, 'should have errors');
assert.ok(errs.some(e => e.includes('Jira')), 'should mention Jira: ' + errs.join('; '));
});
it('rejects unknown type', () => {
const errs = v.validateTitle('update: something (DEV-1)', false);
assert.ok(errs.length > 0, 'should have errors for unknown type');
assert.ok(errs.some(e => e.includes('type') || e.includes('match')), 'should mention type');
});
it('rejects title over 72 chars', () => {
const long = 'feat: ' + 'a'.repeat(60) + ' (DEV-1)';
const errs = v.validateTitle(long, false);
assert.ok(errs.some(e => e.includes('72')), 'should mention 72 char limit');
});
it('rejects title ending with a period (Dependabot, no Jira required)', () => {
// Use a Dependabot title so only the period error fires.
const errs = v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21.', true);
assert.ok(errs.some(e => e.includes('period')), 'should mention period: ' + errs.join('; '));
});
it('rejects description ending with period before Jira suffix', () => {
const errs = v.validateTitle('fix: resolve issue. (DEV-1)', false);
assert.ok(errs.some(e => e.includes('period')), 'desc period before Jira: ' + errs.join('; '));
});
it('rejects description starting with uppercase', () => {
const errs = v.validateTitle('fix: Resolve issue (DEV-1)', false);
assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; '));
});
it('accepts PLAT and SEC Jira keys', () => {
assert.deepEqual(v.validateTitle('chore: update deps (PLAT-99)', false), []);
assert.deepEqual(v.validateTitle('docs: add runbook (SEC-7)', false), []);
});
it('rejects inactive Jira projects (INFRA)', () => {
const errs = v.validateTitle('fix: patch (INFRA-1)', false);
assert.ok(errs.length > 0, 'INFRA is inactive and should fail');
});
it('accepts all valid types', () => {
const types = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release'];
for (const t of types) {
const errs = v.validateTitle(t + ': do something (DEV-1)', false);
assert.deepEqual(errs, [], t + ' should be a valid type');
}
});
// Emergency-revert candidate: jiraMaybeExempt skips the Jira key requirement.
it('accepts revert title without Jira when jiraMaybeExempt is true', () => {
assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, true), []);
});
it('rejects revert title without Jira when jiraMaybeExempt is false', () => {
const errs = v.validateTitle('revert: emergency rollback of payment service', false, false);
assert.ok(errs.some(e => e.includes('Jira')), 'missing Jira should fail without exemption: ' + errs.join('; '));
});
it('rejects revert title without Jira when jiraMaybeExempt is omitted (default false)', () => {
const errs = v.validateTitle('revert: emergency rollback of payment service', false);
assert.ok(errs.some(e => e.includes('Jira')), 'default should behave like false: ' + errs.join('; '));
});
});
// ── getJiraKey ───────────────────────────────────────────────────────────────
describe('getJiraKey', () => {
it('extracts DEV key', () => assert.equal(v.getJiraKey('fix: thing (DEV-42)'), 'DEV-42'));
it('extracts PLAT key', () => assert.equal(v.getJiraKey('chore: thing (PLAT-1)'), 'PLAT-1'));
it('extracts SEC key', () => assert.equal(v.getJiraKey('docs: thing (SEC-999)'), 'SEC-999'));
it('returns null when no key', () => assert.equal(v.getJiraKey('chore: thing'), null));
it('returns null for mid-title key', () => assert.equal(v.getJiraKey('fix (DEV-1): something'), null));
});
// ── validateBranch ───────────────────────────────────────────────────────────
describe('validateBranch', () => {
it('accepts valid feature branch', () => {
assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []);
});
it('accepts all valid prefixes', () => {
const prefixes = ['feature','fix','hotfix','chore','docs','refactor','release'];
for (const p of prefixes) {
assert.deepEqual(v.validateBranch(p + '/my-thing', false), [], p + '/ should be valid');
}
});
it('skips validation for Dependabot', () => {
assert.deepEqual(v.validateBranch('dependabot/npm_and_yarn/lodash-4.17.21', true), []);
});
it('rejects unknown prefix', () => {
const errs = v.validateBranch('bugfix/my-thing', false);
assert.ok(errs.length > 0, 'bugfix/ is not a valid prefix');
});
it('rejects nested path (two slashes)', () => {
const errs = v.validateBranch('feature/team/my-thing', false);
assert.ok(errs.length > 0, 'nested paths should be rejected');
});
it('rejects uppercase in segment', () => {
const errs = v.validateBranch('feature/myThing', false);
assert.ok(errs.length > 0, 'uppercase in segment should be rejected');
});
it('rejects Jira key in segment (case-insensitive)', () => {
const errs = v.validateBranch('fix/dev-123', false);
assert.ok(errs.length > 0, 'Jira-key pattern in segment should be rejected');
});
it('rejects uppercase Jira key in segment', () => {
const errs = v.validateBranch('fix/DEV-123', false);
assert.ok(errs.length > 0, 'uppercase Jira key should be rejected');
});
it('rejects branch with no segment after prefix', () => {
const errs = v.validateBranch('feature/', false);
assert.ok(errs.length > 0, 'empty segment should be rejected');
});
});
// ── validateBody ─────────────────────────────────────────────────────────────
describe('validateBody', () => {
const goodBody = '## Summary\nSomething changed.\n\n## Validation\nRan tests.\n\n## Tests\nUnit tests pass.\n\n## Notes\nNone.';
it('accepts a valid body', () => {
assert.deepEqual(v.validateBody(goodBody, false), []);
});
it('accepts None. under Notes', () => {
assert.deepEqual(v.validateBody(goodBody, false), []);
});
it('skips validation for Dependabot', () => {
assert.deepEqual(v.validateBody('', true), []);
});
it('rejects empty body', () => {
const errs = v.validateBody('', false);
assert.ok(errs.length > 0, 'empty body should fail');
});
it('rejects wrong heading order', () => {
const body = '## Validation\nOK\n\n## Summary\nOK\n\n## Tests\nOK\n\n## Notes\nNone.';
const errs = v.validateBody(body, false);
assert.ok(errs.some(e => e.includes('Validation') || e.includes('Summary')), 'wrong order: ' + errs.join('; '));
});
it('rejects fifth H2 heading', () => {
const body = goodBody + '\n\n## Extra\nwhoops';
const errs = v.validateBody(body, false);
assert.ok(errs.some(e => e.includes('5') || e.includes('4')), 'fifth heading: ' + errs.join('; '));
});
it('rejects empty section', () => {
const body = '## Summary\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.';
const errs = v.validateBody(body, false);
assert.ok(errs.some(e => e.includes('Summary') && e.includes('empty')), 'empty summary: ' + errs.join('; '));
});
it('strips HTML comments before heading scan', () => {
const body = '<!-- ## Fake\n-->\n## Summary\nreal.\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.';
assert.deepEqual(v.validateBody(body, false), [], 'HTML comment heading should not count');
});
it('strips fenced code blocks before heading scan', () => {
const TICK = String.fromCharCode(0x60);
const body = `## Summary\nSee below.\n\n${TICK.repeat(3)}\n## Fake heading inside fence\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`;
assert.deepEqual(v.validateBody(body, false), [], 'fenced heading should not count');
});
it('handles tilde fences', () => {
const body = '## Summary\nSee below.\n\n~~~\n## Fake inside tilde fence\n~~~\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.';
assert.deepEqual(v.validateBody(body, false), [], 'tilde-fenced heading should not count');
});
it('handles fences with 1 leading space', () => {
const TICK = String.fromCharCode(0x60);
const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`;
assert.deepEqual(v.validateBody(body, false), [], '1-space indented fence should strip fake heading');
});
it('handles fences with 3 leading spaces', () => {
const TICK = String.fromCharCode(0x60);
const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`;
assert.deepEqual(v.validateBody(body, false), [], '3-space indented fence should strip fake heading');
});
it('does not treat 4-space-indented fence as a code fence', () => {
const TICK = String.fromCharCode(0x60);
const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Extra Heading\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`;
// 4-space indent exceeds the 0-3 space fence rule; heading is not stripped → error.
const errs = v.validateBody(body, false);
assert.ok(errs.length > 0, '4-space fence should not strip heading (counted as extra heading)');
});
it('rejects missing Notes heading', () => {
const body = '## Summary\nOK.\n\n## Validation\nOK.\n\n## Tests\nOK.';
const errs = v.validateBody(body, false);
assert.ok(errs.length > 0, 'missing Notes should fail');
});
});
// ── validateCommitSubject ─────────────────────────────────────────────────────
describe('validateCommitSubject', () => {
it('accepts a valid commit subject', () => {
assert.deepEqual(v.validateCommitSubject('feat(auth): add login endpoint'), []);
});
it('accepts subject without scope', () => {
assert.deepEqual(v.validateCommitSubject('fix: resolve null pointer'), []);
});
it('accepts breaking change marker', () => {
assert.deepEqual(v.validateCommitSubject('feat!: remove deprecated api'), []);
});
it('rejects subject with Jira key suffix', () => {
const errs = v.validateCommitSubject('fix: patch (DEV-123)');
assert.ok(errs.some(e => e.includes('Jira')), 'should reject Jira suffix: ' + errs.join('; '));
});
it('rejects subject with lowercase Jira key suffix (case-insensitive)', () => {
const errs = v.validateCommitSubject('fix: patch (dev-123)');
assert.ok(errs.some(e => e.includes('Jira')), 'lowercase Jira suffix should also be rejected: ' + errs.join('; '));
});
it('rejects subject with plat Jira key suffix', () => {
const errs = v.validateCommitSubject('chore: update config (plat-5)');
assert.ok(errs.some(e => e.includes('Jira')), 'plat Jira suffix should be rejected: ' + errs.join('; '));
});
it('rejects non-conventional subject', () => {
const errs = v.validateCommitSubject('Update readme');
assert.ok(errs.length > 0, 'should reject non-conventional subject');
});
it('rejects uppercase description start', () => {
const errs = v.validateCommitSubject('fix: Resolve issue');
assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; '));
});
it('rejects subject over 72 chars', () => {
const long = 'feat: ' + 'a'.repeat(70);
const errs = v.validateCommitSubject(long);
assert.ok(errs.some(e => e.includes('72')), 'should mention 72: ' + errs.join('; '));
});
it('rejects description ending with a period', () => {
const errs = v.validateCommitSubject('fix: resolve issue.');
assert.ok(errs.some(e => e.includes('period')), 'trailing period in description: ' + errs.join('; '));
});
});
// ── detectAiFooter ────────────────────────────────────────────────────────────
describe('detectAiFooter', () => {
it('detects Claude Co-authored-by', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Claude <noreply@example.com>'));
});
it('detects ChatGPT Co-authored-by', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: ChatGPT <noreply@openai.com>'));
});
it('detects "Generated with Claude Code"', () => {
assert.ok(v.detectAiFooter('feat: add feature\n\nGenerated with Claude Code'));
});
it('detects "Generated by GitHub Copilot"', () => {
assert.ok(v.detectAiFooter('feat: add feature\n\nGenerated by GitHub Copilot'));
});
it('detects "Generated by Codex"', () => {
assert.ok(v.detectAiFooter('feat: add\n\nGenerated by Codex'));
});
it('detects emoji robot marker', () => {
assert.ok(v.detectAiFooter('fix: thing\n\n🤖 Generated by tool'));
});
it('returns false for clean commit', () => {
assert.ok(!v.detectAiFooter('fix: resolve null pointer\n\nThis was hand-written.'));
});
it('returns false for unrelated Co-authored-by', () => {
assert.ok(!v.detectAiFooter('fix: thing\n\nCo-authored-by: Alice <alice@example.com>'));
});
it('detects AI footer in PR body', () => {
assert.ok(v.detectAiFooter('## Summary\nDone.\n\nCo-authored-by: Gemini <noreply@google.com>'));
});
it('detects Co-authored-by case-insensitively (all-caps header)', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCO-AUTHORED-BY: Claude <x>'), 'all-caps header should match');
});
it('detects Co-authored-by case-insensitively (all-caps identity)', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: CLAUDE <x>'), 'all-caps identity should match');
});
it('detects Cursor identity', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Cursor <x>'), 'Cursor co-authored-by');
assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Cursor'), 'Generated by Cursor');
});
it('detects Anthropic identity', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Anthropic <x>'), 'Anthropic co-authored-by');
});
it('detects Codeium identity', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codeium <x>'), 'Codeium co-authored-by');
assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Codeium'), 'Generated by Codeium');
});
it('detects OpenAI identity', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: OpenAI <x>'), 'OpenAI co-authored-by');
});
it('does not flag generic AI discussion in prose', () => {
assert.ok(!v.detectAiFooter('fix: thing\n\nThis uses AI to improve performance.'), 'generic AI mention');
assert.ok(!v.detectAiFooter('fix: thing\n\nUpdated AI model configuration.'), 'AI model config mention');
assert.ok(!v.detectAiFooter('feat: add AI-powered search (DEV-1)\n\n## Summary\nAI search.'), 'AI in title/body prose');
});
});
// ── isWorkflowFilename ────────────────────────────────────────────────────────
describe('isWorkflowFilename', () => {
it('matches .github/workflows/*.yaml', () => {
assert.ok(v.isWorkflowFilename('.github/workflows/ci.yaml'));
});
it('matches .github/workflows/*.yml', () => {
assert.ok(v.isWorkflowFilename('.github/workflows/deploy.yml'));
});
it('matches workflow-templates/*.yml', () => {
assert.ok(v.isWorkflowFilename('workflow-templates/ci-node.yml'));
});
it('rejects nested path in workflows', () => {
assert.ok(!v.isWorkflowFilename('.github/workflows/subdir/ci.yaml'));
});
it('rejects non-YAML files', () => {
assert.ok(!v.isWorkflowFilename('.github/workflows/ci.json'));
});
it('rejects unrelated files', () => {
assert.ok(!v.isWorkflowFilename('src/foo.yaml'));
});
});
// ── validateWorkflowContent ───────────────────────────────────────────────────
describe('validateWorkflowContent', () => {
const BASE = '.github/workflows/test.yaml';
const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3';
const ZERO_SHA = '0'.repeat(40);
function wf(uses, extra = '') {
return [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - ' + uses,
extra,
].join('\n');
}
it('accepts a fully pinned remote action', () => {
const content = wf(`uses: actions/checkout@${VALID_SHA} # v9.0.0`);
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('accepts local ./ ref without SHA requirement', () => {
const content = wf('uses: ./.github/workflows/sub.yaml');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('accepts docker:// ref without SHA requirement', () => {
const content = wf('uses: docker://alpine:3.19');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('rejects floating tag ref (v1, v2)', () => {
const content = wf('uses: actions/checkout@v4');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('40-char SHA')), 'floating tag should fail: ' + errs.join('; '));
});
it('rejects SHA without version comment', () => {
const content = wf(`uses: actions/checkout@${VALID_SHA}`);
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'no comment should fail: ' + errs.join('; '));
});
it('rejects SHA with wrong comment format', () => {
const content = wf(`uses: actions/checkout@${VALID_SHA} # latest`);
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'wrong comment should fail');
});
it('rejects all-zero placeholder SHA', () => {
const content = wf(`uses: actions/checkout@${ZERO_SHA} # v1.0.0`);
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('placeholder') || e.includes('zero') || e.includes('all-zero')), 'all-zero SHA should fail: ' + errs.join('; '));
});
it('rejects v0.0.0 placeholder version', () => {
const content = wf(`uses: actions/checkout@${VALID_SHA} # v0.0.0`);
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('v0.0.0') || e.includes('placeholder')), 'v0.0.0 should fail: ' + errs.join('; '));
});
it('rejects both all-zero SHA and v0.0.0', () => {
const content = wf(`uses: actions/checkout@${ZERO_SHA} # v0.0.0`);
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.length >= 2, 'should report two errors (zero SHA + v0.0.0): ' + errs.join('; '));
});
it('rejects missing top-level permissions', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions should fail: ' + errs.join('; '));
});
it('accepts top-level permissions: {} (explicit empty)', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions: {}',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('accepts quoted uses: value with valid SHA', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
` - uses: "${VALID_SHA} # v9.0.0"`,
].join('\n');
// The quoted value doesn't have an owner/repo@ prefix — just validate that
// the quote-stripping doesn't break the parser. A quoted SHA without an owner
// won't parse as a remote action at all (no @ before sha). Confirm no crash.
// Use a proper quoted action ref:
const content2 = [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
` - uses: "actions/checkout@${VALID_SHA} # v9.0.0"`,
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content2, BASE), [], 'double-quoted valid ref should pass');
});
it('accepts single-quoted uses: value with valid SHA', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
` - uses: 'actions/checkout@${VALID_SHA} # v9.0.0'`,
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted valid ref should pass');
});
it('does not treat uses: inside a run: block as an action reference', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - name: shell-step',
' run: |',
' echo "uses: actions/checkout@v4"',
' uses: some-other@v1',
' echo done',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block must not be flagged');
});
it('rejects ${{ }} in run: inline value', () => {
const EXPR = '$' + '{{ github.token }}';
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - name: bad',
' run: echo ' + EXPR,
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('env:')), 'inline ${{ }} should fail: ' + errs.join('; '));
});
it('rejects ${{ }} in run: block scalar', () => {
const EXPR = '$' + '{{ secrets.OTHER }}';
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - name: bad',
' env:',
' TOKEN: $' + '{{ secrets.TOKEN }}',
' run: |',
' echo ' + EXPR,
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('env:')), 'block ${{ }} should fail: ' + errs.join('; '));
});
it('does not flag ${{ }} in env: above run:', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - name: ok',
' env:',
' MY_VAR: $' + '{{ secrets.TOKEN }}',
' run: |',
' echo "$MY_VAR"',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('accumulates multiple violations', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: actions/checkout@v4',
' - uses: actions/setup-node@v4',
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.length >= 3, 'should have at least 3 errors (no perms + 2 bad pins): ' + errs.join('; '));
});
});
// ── checkCommitLimit ──────────────────────────────────────────────────────────
describe('checkCommitLimit', () => {
it('returns null for exactly 250 commits', () => {
assert.equal(v.checkCommitLimit(250), null);
});
it('returns null for fewer than 250 commits', () => {
assert.equal(v.checkCommitLimit(1), null);
assert.equal(v.checkCommitLimit(100), null);
});
it('returns an infra error string for 251 commits', () => {
const result = v.checkCommitLimit(251);
assert.ok(result !== null, 'should return error for >250');
assert.ok(result.includes('250'), 'error should mention the limit: ' + result);
});
it('returns an infra error string for large commit count', () => {
const result = v.checkCommitLimit(1000);
assert.ok(result !== null, 'should return error for large count');
assert.ok(result.includes('1000'), 'error should include the actual count: ' + result);
});
});
// ── checkFilesLimit ───────────────────────────────────────────────────────────
describe('checkFilesLimit', () => {
it('returns null for exactly 3000 files', () => {
assert.equal(v.checkFilesLimit(3000), null);
});
it('returns null for fewer than 3000 files', () => {
assert.equal(v.checkFilesLimit(1), null);
assert.equal(v.checkFilesLimit(500), null);
});
it('returns an infra error string for 3001 files', () => {
const result = v.checkFilesLimit(3001);
assert.ok(result !== null, 'should return error for >3000');
assert.ok(result.includes('3000'), 'error should mention the limit: ' + result);
});
it('returns an infra error string for large file count', () => {
const result = v.checkFilesLimit(5000);
assert.ok(result !== null, 'should return error for large count');
assert.ok(result.includes('5000'), 'error should include the actual count: ' + result);
});
});
// ── parseRetryAfterMs ─────────────────────────────────────────────────────────
describe('parseRetryAfterMs', () => {
it('parses integer seconds', () => {
assert.equal(v.parseRetryAfterMs('30'), 30000);
assert.equal(v.parseRetryAfterMs('1'), 1000);
});
it('returns 0 for zero seconds', () => {
assert.equal(v.parseRetryAfterMs('0'), 0);
});
it('caps at 60000ms for large integer values', () => {
assert.equal(v.parseRetryAfterMs('999'), 60000);
assert.equal(v.parseRetryAfterMs('61'), 60000);
});
it('parses HTTP-date format and returns positive ms', () => {
const nowMs = Date.now();
const futureDate = new Date(nowMs + 10000).toUTCString();
const result = v.parseRetryAfterMs(futureDate, nowMs);
assert.ok(result > 9000 && result <= 10000, 'HTTP-date ~10s in future should produce ~10000ms, got ' + result);
});
it('returns 0 for past HTTP-date', () => {
const nowMs = Date.now();
const pastDate = new Date(nowMs - 5000).toUTCString();
assert.equal(v.parseRetryAfterMs(pastDate, nowMs), 0);
});
it('returns 0 for invalid header string', () => {
assert.equal(v.parseRetryAfterMs('not-a-number'), 0);
assert.equal(v.parseRetryAfterMs('banana'), 0);
});
it('returns 0 for empty string', () => {
assert.equal(v.parseRetryAfterMs(''), 0);
});
it('returns 0 for missing header (falsy)', () => {
assert.equal(v.parseRetryAfterMs(undefined), 0);
assert.equal(v.parseRetryAfterMs(null), 0);
});
it('caps HTTP-date result at 60000ms', () => {
const nowMs = Date.now();
const farFutureDate = new Date(nowMs + 120000).toUTCString();
assert.equal(v.parseRetryAfterMs(farFutureDate, nowMs), 60000);
});
});
// ── Additional branch-segment hygiene tests (fix 7) ──────────────────────────
describe('validateBranch — consecutive and trailing hyphens', () => {
it('rejects consecutive hyphens in segment', () => {
const errs = v.validateBranch('feature/my--feature', false);
assert.ok(errs.length > 0, 'consecutive hyphens should be rejected');
assert.ok(errs.some(e => e.includes('feature/my--feature')), 'error should name the bad branch: ' + errs.join('; '));
});
it('rejects trailing hyphen in segment', () => {
const errs = v.validateBranch('feature/my-feature-', false);
assert.ok(errs.length > 0, 'trailing hyphen should be rejected');
});
it('rejects segment that is just a hyphen', () => {
const errs = v.validateBranch('feature/-', false);
assert.ok(errs.length > 0, 'bare hyphen segment should be rejected');
});
it('accepts proper kebab-case with multiple words', () => {
assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []);
assert.deepEqual(v.validateBranch('fix/patch-null-check', false), []);
assert.deepEqual(v.validateBranch('chore/update-deps', false), []);
});
it('accepts single-word segment', () => {
assert.deepEqual(v.validateBranch('feature/auth', false), []);
assert.deepEqual(v.validateBranch('fix/login', false), []);
});
});
// ── AI-footer extended patterns (fix 6) ──────────────────────────────────────
describe('detectAiFooter — extended AI identities', () => {
it('detects Codex in Co-authored-by', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codex <noreply@openai.com>'), 'Codex Co-authored-by');
assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: codex <x>'), 'lowercase codex');
});
it('detects Generated by Codex', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Codex'), 'Generated by Codex');
assert.ok(v.detectAiFooter('fix: thing\n\nGenerated with Codex'), 'Generated with Codex');
});
it('detects GPT-5 Co-authored-by', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-5 <noreply@openai.com>'), 'GPT-5 Co-authored-by');
assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: gpt-5 <x>'), 'lowercase gpt-5');
});
it('detects GPT-4o Co-authored-by', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-4o <noreply@openai.com>'), 'GPT-4o Co-authored-by');
});
it('detects Generated by GPT-5', () => {
assert.ok(v.detectAiFooter('feat: add\n\nGenerated by GPT-5'), 'Generated by GPT-5');
assert.ok(v.detectAiFooter('feat: add\n\nGenerated by gpt-5'), 'lowercase generated by gpt-5');
});
it('detects Generated by GPT-4o', () => {
assert.ok(v.detectAiFooter('feat: add\n\nGenerated by GPT-4o'), 'Generated by GPT-4o');
});
it('detects GPT-3 and GPT-4 (existing coverage preserved)', () => {
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-3 <x>'), 'GPT-3');
assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-4 <x>'), 'GPT-4');
});
it('does not flag "GPT" without version as generic prose', () => {
// "GPT" alone as a word in prose should not be flagged — there must be a dash+version.
assert.ok(!v.detectAiFooter('fix: thing\n\nUpdated GPT configuration.'), 'bare GPT in prose is not a trailer');
});
});
// ── validateWorkflowContent — quoted keys, block-scalar improvements (fixes 1-3) ──
describe('validateWorkflowContent — quoted keys and block-scalar tracking', () => {
const BASE = '.github/workflows/test.yaml';
const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3';
function wfHeader() {
return [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
].join('\n');
}
it('recognises double-quoted "uses" key and validates pin', () => {
// "uses": floating-tag should still be rejected
const content = wfHeader() + '\n - "uses": actions/checkout@v4';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('40-char SHA')), 'quoted "uses" floating tag must fail: ' + errs.join('; '));
});
it('accepts double-quoted "uses" key with valid pinned SHA', () => {
const content = wfHeader() + `\n - "uses": actions/checkout@${VALID_SHA} # v9.0.0`;
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'quoted "uses" with valid SHA should pass');
});
it('recognises single-quoted uses key and validates pin', () => {
const content = wfHeader() + "\n - 'uses': actions/checkout@v4";
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('40-char SHA')), "single-quoted 'uses' floating tag must fail: " + errs.join('; '));
});
it('accepts single-quoted uses key with valid pinned SHA', () => {
const content = wfHeader() + `\n - 'uses': actions/checkout@${VALID_SHA} # v9.0.0`;
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted 'uses' with valid SHA should pass");
});
it('recognises sequence-form "- run: |" and does not flag uses: inside as action ref', () => {
// The sequence item `- run: |` opens a run block scalar.
// uses: lines inside must not be treated as action references.
const content = [
...wfHeader().split('\n'),
' - name: build',
' run: |',
' echo "uses: actions/checkout@v4"',
' uses: some/action@v1',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block (sequence step) must not be flagged');
});
it('recognises sequence-form "- run: echo hi" inline and does not flag uses: on next step', () => {
const content = [
...wfHeader().split('\n'),
` - run: echo hello`,
` - uses: actions/checkout@${VALID_SHA} # v9.0.0`,
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'run: inline does not swallow uses: on next step');
});
it('does not flag uses: inside a non-run block scalar (e.g. script: |)', () => {
// script: | is a block scalar that is NOT a run block.
// uses: lines inside must not be treated as action references.
// Expressions inside script: | must not be flagged as run: injection.
const EXPR = '$' + '{{ github.token }}';
const content = [
...wfHeader().split('\n'),
' - name: js-step',
' uses: actions/github-script@' + VALID_SHA + ' # v9.0.0',
' with:',
' script: |',
' // uses: actions/checkout@v4 (this is JS comment, not YAML)',
' uses: some/action@v1',
' const tok = ' + EXPR + ';',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: and expressions inside script: block must not be flagged');
});
it('accepts quoted action ref with version comment OUTSIDE the quotes', () => {
// uses: "owner/repo@sha" # vX.Y.Z — comment is a YAML comment, not inside quotes.
const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}" # v9.0.0`;
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'quoted ref with external comment should pass');
});
it('accepts single-quoted action ref with version comment OUTSIDE the quotes', () => {
const content = wfHeader() + `\n - uses: 'actions/checkout@${VALID_SHA}' # v9.0.0`;
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted ref with external comment should pass');
});
it('rejects quoted action ref with no comment at all', () => {
const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}"`;
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'quoted ref with no comment must fail: ' + errs.join('; '));
});
it('recognises quoted "permissions" key at column 0 for top-level check', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'"permissions":',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'double-quoted "permissions": at col 0 should count');
});
});
// ── validateTitle — Jira-backed revert is not an emergency candidate (fix 5) ──
describe('validateTitle — Jira-backed revert semantics', () => {
it('accepts revert title WITH Jira key regardless of jiraMaybeExempt', () => {
// A revert that already carries a Jira key needs no emergency exemption.
assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, false), []);
assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, true), []);
});
it('getJiraKey extracts key from Jira-backed revert title', () => {
// Confirms that getJiraKey correctly identifies a revert title with Jira key,
// which is what the main logic uses to decide isEmergencyCandidate = false.
assert.equal(v.getJiraKey('revert: rollback payment service (DEV-42)'), 'DEV-42');
});
it('getJiraKey returns null for revert title without Jira key', () => {
// Null result means isEmergencyCandidate COULD be true (if label is also present).
assert.equal(v.getJiraKey('revert: emergency rollback of payment service'), null);
});
});
// ── Scanner fail-closed cases (fixes: |2, flow, escaped keys, aliases) ───────
describe('validateWorkflowContent — scanner fail-closed cases', () => {
const BASE = '.github/workflows/test.yaml';
const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3';
function wfHeader() {
return [
'name: Test',
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
].join('\n');
}
// ── Fix 1: explicit block indent/chomp indicators |2, |2-, |-2, >+2 ──────
it('enters run block on "run: |2" and detects expression injection inside', () => {
const EXPR = '$' + '{{ github.token }}';
const content = [
...wfHeader().split('\n'),
' - name: x',
' run: |2',
' echo hi',
' echo ' + EXPR,
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('env:')), 'run |2 block should flag expression: ' + errs.join('; '));
});
it('enters run block on "run: |2-" and detects expression injection', () => {
const EXPR = '$' + '{{ secrets.TOKEN }}';
const content = [
...wfHeader().split('\n'),
' - name: x',
' run: |2-',
' echo ' + EXPR,
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('env:')), 'run |2- block should flag expression: ' + errs.join('; '));
});
it('enters run block on "run: |-2" and detects expression injection', () => {
const EXPR = '$' + '{{ github.ref }}';
const content = [
...wfHeader().split('\n'),
' - name: x',
' run: |-2',
' echo ' + EXPR,
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('env:')), 'run |-2 block should flag expression: ' + errs.join('; '));
});
it('enters run block on "run: >+2" and detects expression injection', () => {
const EXPR = '$' + '{{ github.actor }}';
const content = [
...wfHeader().split('\n'),
' - name: x',
' run: >+2',
' echo ' + EXPR,
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('env:')), 'run >+2 block should flag expression: ' + errs.join('; '));
});
it('does NOT flag uses: inside run: |2 block as an action reference', () => {
const content = [
...wfHeader().split('\n'),
' - name: x',
' run: |2',
' uses: actions/checkout@v4',
' echo done',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run |2 must not be flagged');
});
it('sequence-form "- run: |2" correctly enters run block', () => {
const EXPR = '$' + '{{ github.sha }}';
const content = [
...wfHeader().split('\n'),
' - run: |2',
' echo ' + EXPR,
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('env:')), 'sequence - run: |2 should detect expression: ' + errs.join('; '));
});
// ── Fix 2: flow-style sequence steps ─────────────────────────────────────
it('rejects flow-style step "- { uses: ... }"', () => {
const content = wfHeader() + '\n - { uses: actions/checkout@v4, with: { token: x } }';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'flow uses step should fail: ' + errs.join('; '));
});
it('rejects flow-style step "- { run: ... }"', () => {
const content = wfHeader() + '\n - { run: echo hello }';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'flow run step should fail: ' + errs.join('; '));
});
it('rejects flow-style step with any nonempty mapping', () => {
const content = wfHeader() + '\n - { name: my-step, uses: actions/checkout@v4 }';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'any nonempty flow step should fail: ' + errs.join('; '));
});
it('does NOT reject permissions: {} (mapping value, not sequence item)', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions: {}',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'permissions: {} must not be rejected');
});
// ── Fix 3: escaped/encoded structural keys ─────────────────────────────────
it('rejects double-quoted key with Unicode escape "u\\u0073es" (encodes "uses")', () => {
// In the YAML source, the key is literally "u\u0073es": — the scanner sees \u
const escapedUses = '"u\\u0073es": actions/checkout@v4';
const content = wfHeader() + '\n - ' + escapedUses;
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped uses key must be rejected: ' + errs.join('; '));
});
it('rejects double-quoted key with Unicode escape "r\\u0075n" (encodes "run")', () => {
const escapedRun = '"r\\u0075n": echo hello';
const content = wfHeader() + '\n ' + escapedRun;
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped run key must be rejected: ' + errs.join('; '));
});
it('does NOT reject literal double-quoted "uses" key (no backslash)', () => {
const content = wfHeader() + `\n - "uses": actions/checkout@${VALID_SHA} # v9.0.0`;
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "uses" key should pass');
});
it('does NOT reject literal double-quoted "run" key (no backslash)', () => {
const content = wfHeader() + '\n "run": echo hello';
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "run" key should pass');
});
// ── Fix 4: YAML aliases/anchors on structural values ──────────────────────
it('rejects YAML alias in "run:" value (run: *command)', () => {
const content = wfHeader() + '\n run: *deploy_script';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must fail: ' + errs.join('; '));
});
it('rejects YAML alias in "uses:" value (uses: *action_ref)', () => {
const content = wfHeader() + '\n - uses: *checkout_action';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: *alias must fail: ' + errs.join('; '));
});
it('rejects YAML anchor definition in "run:" value (run: &anchor |)', () => {
// &anchor before the block indicator means the run block has an anchor definition.
// The line scanner cannot safely resolve what aliases to *anchor will execute.
const content = wfHeader() + '\n run: &deploy_script |';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must fail: ' + errs.join('; '));
});
it('rejects YAML anchor definition in "uses:" value (uses: &anchor ref)', () => {
const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`;
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must fail: ' + errs.join('; '));
});
it('rejects YAML alias for top-level permissions: value', () => {
const content = [
'name: Test',
'on:',
' workflow_call:',
'permissions: *read_perms',
'jobs:',
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor') || e.includes('permissions')), 'permissions: *alias must fail: ' + errs.join('; '));
});
// ── Fix: flow mapping false-positive — non-step data must pass ────────────
it('allows flow-style sequence item without structural uses/run key', () => {
const content = wfHeader() + '\n - { os: ubuntu-latest, node: 24 }';
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'matrix data object must not be rejected');
});
it('allows flow-style sequence item with only name key', () => {
const content = wfHeader() + '\n - { name: my-step, timeout: 10 }';
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'non-step flow object with name/timeout must pass');
});
it('still rejects flow-style step with uses: key inside', () => {
const content = wfHeader() + '\n - { uses: actions/checkout@v4 }';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('flow-style')), '- { uses: ... } must still fail: ' + errs.join('; '));
});
it('still rejects flow-style step with run: key inside', () => {
const content = wfHeader() + '\n - { run: echo hi }';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('flow-style')), '- { run: ... } must still fail: ' + errs.join('; '));
});
it('still rejects flow-style step with uses: after a comma', () => {
const content = wfHeader() + '\n - { name: checkout, uses: actions/checkout@v4 }';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('flow-style')), '- { name: x, uses: ... } must still fail: ' + errs.join('; '));
});
// ── Fix: anchor/alias false-positive — ordinary shell & must pass ─────────
it('allows run: value containing & in a shell command (not a YAML anchor)', () => {
const content = wfHeader() + '\n run: echo "R&D build"';
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell & in run value must not be rejected');
});
it('allows run: value with && (shell AND operator)', () => {
const content = wfHeader() + '\n run: make build && make test';
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell && must not be rejected');
});
it('allows single-quoted run: value with & inside', () => {
const content = wfHeader() + "\n run: 'echo R&D'";
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted run with & must pass");
});
it('still rejects run: *alias (YAML alias token)', () => {
const content = wfHeader() + '\n run: *deploy_script';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must still fail: ' + errs.join('; '));
});
it('still rejects run: &anchor | (YAML anchor before block indicator)', () => {
const content = wfHeader() + '\n run: &deploy_script |';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must still fail: ' + errs.join('; '));
});
it('still rejects uses: &anchor ref (YAML anchor in action ref)', () => {
const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`;
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must still fail: ' + errs.join('; '));
});
});
// ── Static assertions on the YAML file ───────────────────────────────────────
describe('static YAML assertions', () => {
let yamlText;
before(() => {
yamlText = readFileSync(
join(__dirname, '../.github/workflows/callable-pr-policy.yaml'),
'utf8'
);
});
it('does not use pull_request_target as a trigger', () => {
// The word may appear in comments, but must never be a YAML on: trigger key.
assert.ok(
!/^\s*pull_request_target\s*:/m.test(yamlText),
'callable-pr-policy.yaml must not declare pull_request_target as an on: trigger'
);
});
it('pins github-script to the exact SHA', () => {
assert.ok(
yamlText.includes('actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'),
'must pin github-script to 3a2844b7e9c422d3c10d287c895573f7108da1b3'
);
});
it('includes the v9.0.0 version comment', () => {
assert.ok(yamlText.includes('# v9.0.0'), 'must have # v9.0.0 comment');
});
it('declares job id "pr"', () => {
assert.ok(/^ pr:$/m.test(yamlText), 'job id must be "pr"');
});
it('policy.yaml uses job id "policy"', () => {
const policyYaml = readFileSync(join(__dirname, '../.github/workflows/policy.yaml'), 'utf8');
assert.ok(/^ policy:$/m.test(policyYaml), 'caller job id must be "policy"');
});
it('policy.yaml does not use pull_request_target as a trigger', () => {
const policyYaml = readFileSync(join(__dirname, '../.github/workflows/policy.yaml'), 'utf8');
assert.ok(
!/^\s*pull_request_target\s*:/m.test(policyYaml),
'policy.yaml must not declare pull_request_target as an on: trigger'
);
});
});
// ── normalizeViolationFingerprint ────────────────────────────────────────────
describe('normalizeViolationFingerprint', () => {
it('strips :LINE: from per-line errors', () => {
const err = 'f.yaml:42: run: block contains expression — expressions must go through env:';
assert.equal(v.normalizeViolationFingerprint(err), 'f.yaml: run: block contains expression — expressions must go through env:');
});
it('leaves uses: violations unchanged (no line number in error)', () => {
const err = 'f.yaml: "uses: actions/checkout@v4" must be pinned to a 40-char SHA with "# vX.Y.Z" comment';
assert.equal(v.normalizeViolationFingerprint(err), err);
});
it('leaves missing-permissions unchanged (no line number)', () => {
const err = 'f.yaml: missing top-level "permissions:" key';
assert.equal(v.normalizeViolationFingerprint(err), err);
});
it('removes only the first :LINE: occurrence', () => {
const err = 'f.yaml:10: escaped key: something';
assert.equal(v.normalizeViolationFingerprint(err), 'f.yaml: escaped key: something');
});
it('line 10 and line 200 normalize to the same fingerprint', () => {
const a = v.normalizeViolationFingerprint('f.yaml:10: run: block contains expression');
const b = v.normalizeViolationFingerprint('f.yaml:200: run: block contains expression');
assert.equal(a, b);
});
});
// ── filterNewViolations ───────────────────────────────────────────────────────
describe('filterNewViolations', () => {
const FP_UNPIN = (f, ref) => `${f}: "uses: ${ref}" must be pinned to a 40-char SHA with "# vX.Y.Z" comment`;
const FP_PERM = (f) => `${f}: missing top-level "permissions:" key`;
const FP_EXPR = (f, ln) => `${f}:${ln}: run: block contains expression — expressions must go through env:`;
it('unchanged floating action is ignored', () => {
const err = FP_UNPIN('w.yaml', 'actions/checkout@v4');
assert.deepEqual(v.filterNewViolations([err], [err]), []);
});
it('changed floating ref is treated as new', () => {
const head = FP_UNPIN('w.yaml', 'actions/setup-node@v4');
const base = FP_UNPIN('w.yaml', 'actions/checkout@v4');
assert.deepEqual(v.filterNewViolations([head], [base]), [head]);
});
it('new floating action (no base violation) is blocked', () => {
const err = FP_UNPIN('w.yaml', 'actions/checkout@v4');
assert.deepEqual(v.filterNewViolations([err], []), [err]);
});
it('unchanged missing permissions is ignored', () => {
const err = FP_PERM('w.yaml');
assert.deepEqual(v.filterNewViolations([err], [err]), []);
});
it('added file missing permissions is blocked (empty base)', () => {
const err = FP_PERM('w.yaml');
assert.deepEqual(v.filterNewViolations([err], []), [err]);
});
it('unchanged run expression at same line is ignored', () => {
const err = FP_EXPR('w.yaml', 10);
assert.deepEqual(v.filterNewViolations([err], [err]), []);
});
it('line shift ignored — same run expression moved to different line', () => {
const head = FP_EXPR('w.yaml', 20);
const base = FP_EXPR('w.yaml', 10);
assert.deepEqual(v.filterNewViolations([head], [base]), []);
});
it('newly added run expression is blocked', () => {
const e1 = FP_EXPR('w.yaml', 10);
const e2 = FP_EXPR('w.yaml', 20);
const result = v.filterNewViolations([e1, e2], [e1]);
assert.equal(result.length, 1);
});
it('unchanged run expression ignored; a second new one blocked', () => {
const base = FP_EXPR('w.yaml', 10);
const head1 = FP_EXPR('w.yaml', 12);
const head2 = FP_EXPR('w.yaml', 50);
const result = v.filterNewViolations([head1, head2], [base]);
assert.equal(result.length, 1);
assert.equal(result[0], head2);
});
it('multiple violation types: unchanged ones are ignored', () => {
const perm = FP_PERM('w.yaml');
const unpin = FP_UNPIN('w.yaml', 'actions/checkout@v4');
const newUnpin = FP_UNPIN('w.yaml', 'actions/upload-artifact@v4');
const result = v.filterNewViolations([perm, unpin, newUnpin], [perm, unpin]);
assert.deepEqual(result, [newUnpin]);
});
it('renamed file: fingerprints use head filename for both sides', () => {
const headV = FP_PERM('new-name.yaml');
const baseV = FP_PERM('new-name.yaml');
assert.deepEqual(v.filterNewViolations([headV], [baseV]), []);
});
it('returns empty array when head has no violations', () => {
const base = FP_UNPIN('w.yaml', 'actions/checkout@v4');
assert.deepEqual(v.filterNewViolations([], [base]), []);
});
it('returns all head violations when base is empty (added file)', () => {
const v1 = FP_PERM('w.yaml');
const v2 = FP_UNPIN('w.yaml', 'actions/checkout@v4');
assert.deepEqual(v.filterNewViolations([v1, v2], []), [v1, v2]);
});
});
// ── fnv1a32 ───────────────────────────────────────────────────────────────────
describe('fnv1a32', () => {
it('returns 8 hex chars', () => {
assert.match(v.fnv1a32('hello'), /^[0-9a-f]{8}$/);
});
it('is deterministic', () => {
assert.equal(v.fnv1a32('run: echo hi'), v.fnv1a32('run: echo hi'));
});
it('different strings produce different hashes', () => {
assert.notEqual(v.fnv1a32('run: echo ${{ github.ref }}'), v.fnv1a32('run: echo ${{ github.event.pull_request.title }}'));
});
it('empty string returns known value', () => {
assert.equal(v.fnv1a32(''), '811c9dc5');
});
});
// ── stripHash ─────────────────────────────────────────────────────────────────
describe('stripHash', () => {
it('strips [fnv:XXXXXXXX] at end of message', () => {
assert.equal(
v.stripHash('f.yaml:42: run: block contains ${{ }} [fnv:a1b2c3d4]'),
'f.yaml:42: run: block contains ${{ }}'
);
});
it('is a no-op when no hash suffix present', () => {
const msg = 'f.yaml: missing top-level "permissions:" key';
assert.equal(v.stripHash(msg), msg);
});
it('does not strip [fnv:...] appearing in the middle of a message', () => {
const msg = 'f.yaml: some [fnv:a1b2c3d4] middle text';
assert.equal(v.stripHash(msg), msg);
});
});
// ── Content fingerprint integration (Finding 1) ───────────────────────────────
describe('content fingerprint: changed payload is new', () => {
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
const BASE_WF = (runLine) => [
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + PIN,
' - run: ' + runLine,
].join('\n');
it('changed run expression is treated as new (block scalar)', () => {
const wfRef = [
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + PIN,
' - run: |',
' echo ${{ github.ref }}',
].join('\n');
const wfTitle = wfRef.replace('echo ${{ github.ref }}', 'echo ${{ github.event.pull_request.title }}');
const headErrs = v.validateWorkflowContent(wfTitle, 'f.yaml');
const baseErrs = v.validateWorkflowContent(wfRef, 'f.yaml');
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed expression must be reported as new');
});
it('unchanged run expression at a shifted line is grandfathered', () => {
const wfA = BASE_WF('echo ${{ github.ref }}');
// wfB inserts a blank step before the run step, shifting its line number
const wfB = [
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + PIN,
' - name: placeholder',
' run: echo noop',
' - run: echo ${{ github.ref }}',
].join('\n');
const headErrs = v.validateWorkflowContent(wfB, 'f.yaml');
const baseErrs = v.validateWorkflowContent(wfA, 'f.yaml');
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'same expression on a different line must be grandfathered');
});
it('flow-style run changed to flow-style uses is new', () => {
const wfRun = 'permissions: {}\n - { run: echo hi }';
const wfUses = 'permissions: {}\n - { uses: actions/checkout@v4 }';
const headErrs = v.validateWorkflowContent(wfUses, 'f.yaml');
const baseErrs = v.validateWorkflowContent(wfRun, 'f.yaml');
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed flow mapping must be reported as new');
});
});
// ── Renamed from non-workflow path (Finding 2) ───────────────────────────────
describe('isWorkflowFilename: rename routing', () => {
it('non-workflow source path is not a workflow filename', () => {
assert.equal(v.isWorkflowFilename('scripts/deploy.yaml'), false);
assert.equal(v.isWorkflowFilename('infra/template.yml'), false);
assert.equal(v.isWorkflowFilename('.github/deploy.yaml'), false);
});
it('workflow target paths are workflow filenames', () => {
assert.equal(v.isWorkflowFilename('.github/workflows/deploy.yaml'), true);
assert.equal(v.isWorkflowFilename('workflow-templates/ci.yml'), true);
});
it('rename from non-workflow treated as added: filterNewViolations with empty base captures all', () => {
// When previous_filename is not a workflow file, the runtime uses [] as baseErrs.
// This test verifies that all head violations are surfaced (same as added file).
const noncompliantWf = [
'on:',
' workflow_call:',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: actions/checkout@v4',
].join('\n');
const headErrs = v.validateWorkflowContent(noncompliantWf, '.github/workflows/new.yaml');
assert.ok(headErrs.length > 0, 'noncompliant file must have violations');
// With empty base (simulating rename from non-workflow), all violations are new
assert.deepEqual(v.filterNewViolations(headErrs, []), headErrs);
});
});