mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 09:23:11 +00:00
1357 lines
56 KiB
JavaScript
1357 lines
56 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',
|
|
]) {
|
|
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'
|
|
);
|
|
});
|
|
});
|