mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 16:23:11 +00:00
* ci: expand labeler globs and skip dependabot pr policy .NET product paths never matched app, so backend PRs stayed unlabeled. Dependabot PRs still ran commit-subject and pin checks on generated titles. Skip those PRs in the reusable policy job. * fix(labeler): match nested elastic beanstalk config paths Root-only .ebextensions and .platform globs miss api/.ebextensions in monorepos. Mirror the Dockerfile nested form.
2470 lines
103 KiB
JavaScript
2470 lines
103 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', 'isSyncMergeCommit', 'detectAiFooter', 'isWorkflowFilename',
|
|
'validateWorkflowContent', 'parseRetryAfterMs', 'checkCommitLimit',
|
|
'checkFilesLimit', 'normalizeViolationFingerprint', 'filterNewViolations',
|
|
'fnv1a32', 'stripHash', 'classifyFileStatus',
|
|
]) {
|
|
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('accepts a title without Jira key', () => {
|
|
assert.deepEqual(v.validateTitle('fix: resolve null pointer', false), []);
|
|
});
|
|
|
|
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('accepts a human PR title at exactly 120 chars', () => {
|
|
const long = 'feat: ' + 'a'.repeat(106) + ' (DEV-1)';
|
|
assert.equal(long.length, 120);
|
|
assert.deepEqual(v.validateTitle(long, false), []);
|
|
});
|
|
|
|
it('accepts a Dependabot PR title at exactly 120 chars', () => {
|
|
const long = 'chore(deps): ' + 'a'.repeat(107);
|
|
assert.equal(long.length, 120);
|
|
assert.deepEqual(v.validateTitle(long, true), []);
|
|
});
|
|
|
|
it('rejects a human PR title at exactly 121 chars', () => {
|
|
const long = 'feat: ' + 'a'.repeat(107) + ' (DEV-1)';
|
|
assert.equal(long.length, 121);
|
|
const errs = v.validateTitle(long, false);
|
|
assert.ok(errs.some(e => e.includes('120')), 'should mention 120 char limit');
|
|
});
|
|
|
|
it('rejects a Dependabot PR title at exactly 121 chars', () => {
|
|
const long = 'chore(deps): ' + 'a'.repeat(108);
|
|
assert.equal(long.length, 121);
|
|
const errs = v.validateTitle(long, true);
|
|
assert.ok(errs.some(e => e.includes('120')), 'should mention 120 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('accepts AP Jira keys', () => {
|
|
assert.deepEqual(v.validateTitle('feat(frontend): scaffold vite spa and ci (AP-4)', false), []);
|
|
});
|
|
|
|
it('does not treat INFRA as a valid Jira suffix', () => {
|
|
// INFRA is a closed archive, so (INFRA-1) is not a recognized key.
|
|
// validateTitle no longer fails on a missing key; Main warns instead.
|
|
assert.deepEqual(v.validateTitle('fix: patch (INFRA-1)', false), []);
|
|
assert.equal(v.getJiraKey('fix: patch (INFRA-1)'), null);
|
|
});
|
|
|
|
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');
|
|
}
|
|
});
|
|
|
|
// Missing Jira is a warning in Main, not a validateTitle error.
|
|
it('accepts revert title without Jira regardless of jiraMaybeExempt', () => {
|
|
assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, true), []);
|
|
assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, false), []);
|
|
assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false), []);
|
|
});
|
|
});
|
|
|
|
// ── 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('extracts AP key', () => assert.equal(v.getJiraKey('feat(frontend): scaffold (AP-4)'), 'AP-4'));
|
|
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('accepts subject with DEV Jira key suffix', () => {
|
|
assert.deepEqual(v.validateCommitSubject('fix: patch (DEV-123)'), []);
|
|
});
|
|
|
|
it('accepts subject with AP Jira key suffix', () => {
|
|
assert.deepEqual(v.validateCommitSubject('feat(frontend): scaffold vite spa and ci (AP-4)'), []);
|
|
});
|
|
|
|
it('accepts subject with PLAT Jira key suffix', () => {
|
|
assert.deepEqual(v.validateCommitSubject('chore: update config (PLAT-5)'), []);
|
|
});
|
|
|
|
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('; '));
|
|
});
|
|
});
|
|
|
|
describe('isSyncMergeCommit', () => {
|
|
it('recognizes a generated branch synchronization merge', () => {
|
|
const commit = {
|
|
parents: [{ sha: 'a' }, { sha: 'b' }],
|
|
commit: { message: "Merge branch 'main' into chore/pr-policy-rollout" },
|
|
};
|
|
assert.equal(v.isSyncMergeCommit(commit), true);
|
|
});
|
|
|
|
it('recognizes a generated remote-tracking synchronization merge', () => {
|
|
const commit = {
|
|
parents: [{ sha: 'a' }, { sha: 'b' }],
|
|
commit: { message: "Merge remote-tracking branch 'origin/main' into fix/example" },
|
|
};
|
|
assert.equal(v.isSyncMergeCommit(commit), true);
|
|
});
|
|
|
|
it('does not exempt an arbitrary multi-parent commit', () => {
|
|
const commit = {
|
|
parents: [{ sha: 'a' }, { sha: 'b' }],
|
|
commit: { message: 'Update policy files' },
|
|
};
|
|
assert.equal(v.isSyncMergeCommit(commit), false);
|
|
});
|
|
|
|
it('does not exempt a single-parent commit with a merge-shaped subject', () => {
|
|
const commit = {
|
|
parents: [{ sha: 'a' }],
|
|
commit: { message: "Merge branch 'main' into fix/example" },
|
|
};
|
|
assert.equal(v.isSyncMergeCommit(commit), false);
|
|
});
|
|
|
|
it('does not exempt commits with missing parent metadata', () => {
|
|
assert.equal(v.isSyncMergeCommit({}), false);
|
|
});
|
|
});
|
|
|
|
// ── 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('rejects local ./ ref (unsupported until recursive action-manifest validation)', () => {
|
|
const content = wf('uses: ./.github/workflows/sub.yaml');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('local action') || e.includes('not supported')), 'local ref must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('accepts docker:// ref with valid sha256 digest', () => {
|
|
const digest = 'a' .repeat(64);
|
|
const content = wf('uses: docker://alpine@sha256:' + digest);
|
|
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: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1',
|
|
].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('; '));
|
|
});
|
|
});
|
|
|
|
// ── validateWorkflowContent — docker digest pinning ──────────────────────────
|
|
describe('validateWorkflowContent — docker digest pinning', () => {
|
|
const BASE = 'f.yaml';
|
|
const GOOD_DIGEST = 'b'.repeat(64);
|
|
function wf(uses) {
|
|
return [
|
|
'on:',
|
|
' workflow_call:',
|
|
'permissions:',
|
|
' contents: read',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - ' + uses,
|
|
].join('\n');
|
|
}
|
|
|
|
it('accepts docker:// ref with valid lowercase 64-hex sha256 digest', () => {
|
|
assert.deepEqual(v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + GOOD_DIGEST), BASE), []);
|
|
});
|
|
|
|
it('accepts docker:// ref for image with tag+digest', () => {
|
|
assert.deepEqual(v.validateWorkflowContent(wf('uses: docker://ubuntu:22.04@sha256:' + GOOD_DIGEST), BASE), []);
|
|
});
|
|
|
|
it('rejects docker:// ref with mutable tag only', () => {
|
|
const errs = v.validateWorkflowContent(wf('uses: docker://alpine:3.19'), BASE);
|
|
assert.ok(errs.some(e => e.includes('sha256')), 'mutable tag must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects docker:// ref with :latest tag', () => {
|
|
const errs = v.validateWorkflowContent(wf('uses: docker://ubuntu:latest'), BASE);
|
|
assert.ok(errs.some(e => e.includes('sha256')), ':latest must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects bare docker:// ref with no tag or digest', () => {
|
|
const errs = v.validateWorkflowContent(wf('uses: docker://ubuntu'), BASE);
|
|
assert.ok(errs.some(e => e.includes('sha256')), 'bare image must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects docker:// ref with uppercase in sha256 digest', () => {
|
|
const errs = v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + 'A'.repeat(64)), BASE);
|
|
assert.ok(errs.some(e => e.includes('sha256')), 'uppercase hex must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects docker:// ref with short (63-char) sha256 digest', () => {
|
|
const errs = v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + 'a'.repeat(63)), BASE);
|
|
assert.ok(errs.some(e => e.includes('sha256')), 'short digest must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects docker:// ref with wrong digest prefix (md5:)', () => {
|
|
const errs = v.validateWorkflowContent(wf('uses: docker://alpine@md5:' + 'a'.repeat(32)), BASE);
|
|
assert.ok(errs.some(e => e.includes('sha256')), 'non-sha256 digest must fail: ' + errs.join('; '));
|
|
});
|
|
});
|
|
|
|
// ── validateWorkflowContent — anchored flow step ─────────────────────────────
|
|
describe('validateWorkflowContent — anchored flow step', () => {
|
|
const BASE = 'f.yaml';
|
|
function wf(step) {
|
|
return 'permissions: {}\n' + step;
|
|
}
|
|
|
|
it('rejects anchored flow-style step with uses (- &step { uses: ... })', () => {
|
|
const errs = v.validateWorkflowContent(wf(' - &step { uses: actions/checkout@v4 }'), BASE);
|
|
assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'anchored flow uses must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects anchored flow-style step with run (- &step { run: ... })', () => {
|
|
const errs = v.validateWorkflowContent(wf(' - &step { run: echo hi }'), BASE);
|
|
assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'anchored flow run must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects anchored flow-style even for non-structural keys', () => {
|
|
const errs = v.validateWorkflowContent(wf(' - &data { os: ubuntu, node: 24 }'), BASE);
|
|
assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'any anchored flow step must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('still rejects plain flow-style step with structural uses key', () => {
|
|
const errs = v.validateWorkflowContent(wf(' - { uses: actions/checkout@v4 }'), BASE);
|
|
assert.ok(errs.some(e => e.includes('flow-style')), 'plain flow uses must still fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects block-style step with standalone sequence-item anchor (- &ref)', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'steps:',
|
|
' - &ref',
|
|
' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'standalone - &anchor step must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects multiline anchored uses: - &step / { uses: ... }', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'steps:',
|
|
' - &step',
|
|
' { uses: actions/checkout@v4 }',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'multiline - &step / { uses } must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects multiline anchored run: - &step / { run: ... }', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'steps:',
|
|
' - &step',
|
|
' { run: echo hi }',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'multiline - &step / { run } must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('no regression: plain block-style step without anchor passes pin checks normally', () => {
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
const content = [
|
|
'permissions: {}',
|
|
'steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
|
|
});
|
|
|
|
it('non-sequence mapping-value anchor is not flagged (foo: &anchor value)', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'env:',
|
|
' TOKEN: &tok my-value',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(!errs.some(e => e.includes('sequence-item anchor')), 'mapping-value anchor must not trigger sequence-item rule: ' + 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('rejects uses block scalar before opaque block handling can hide it', () => {
|
|
const content = [
|
|
...wfHeader().split('\n'),
|
|
' - uses: >-',
|
|
' actions/checkout@v4',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('uses: block scalar')), 'uses: folded block scalar must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects quoted uses block scalar before pin validation can be bypassed', () => {
|
|
const content = [
|
|
...wfHeader().split('\n'),
|
|
' - "uses": |-',
|
|
' actions/checkout@v4',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('uses: block scalar')), 'quoted uses: literal block scalar must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
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: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1',
|
|
].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: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1',
|
|
].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"');
|
|
});
|
|
|
|
});
|
|
|
|
// ── normalizeViolationFingerprint ────────────────────────────────────────────
|
|
describe('normalizeViolationFingerprint', () => {
|
|
it('preserves :LINE: in per-line errors', () => {
|
|
const err = 'f.yaml:42: run: block contains expression — expressions must go through env:';
|
|
assert.equal(v.normalizeViolationFingerprint(err), err);
|
|
});
|
|
|
|
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('does not rewrite line-like message content', () => {
|
|
const err = 'f.yaml:10: escaped key: something';
|
|
assert.equal(v.normalizeViolationFingerprint(err), err);
|
|
});
|
|
|
|
it('line 10 and line 200 remain distinct fingerprints', () => {
|
|
const a = v.normalizeViolationFingerprint('f.yaml:10: run: block contains expression');
|
|
const b = v.normalizeViolationFingerprint('f.yaml:200: run: block contains expression');
|
|
assert.notEqual(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 is blocked when the same run expression moves', () => {
|
|
const head = FP_EXPR('w.yaml', 20);
|
|
const base = FP_EXPR('w.yaml', 10);
|
|
assert.deepEqual(v.filterNewViolations([head], [base]), [head]);
|
|
});
|
|
|
|
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', 10);
|
|
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 blocked', () => {
|
|
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.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'same expression on a different line must be reported as new');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── classifyFileStatus ────────────────────────────────────────────────────────
|
|
describe('classifyFileStatus', () => {
|
|
function isWf(f) { return v.isWorkflowFilename(f); }
|
|
const wfFile = '.github/workflows/deploy.yaml';
|
|
const nonWfFile = 'scripts/setup.yaml';
|
|
|
|
function file(status, filename, previous_filename) {
|
|
return { status, filename: filename || wfFile, previous_filename };
|
|
}
|
|
|
|
it('removed → skip', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('removed'), isWf), { action: 'skip' });
|
|
});
|
|
|
|
it('unchanged → skip', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('unchanged'), isWf), { action: 'skip' });
|
|
});
|
|
|
|
it('added → full', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('added'), isWf), { action: 'full' });
|
|
});
|
|
|
|
it('copied → full (even with previous_filename)', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('copied', wfFile, wfFile), isWf), { action: 'full' });
|
|
});
|
|
|
|
it('modified → diff with same filename as basePath', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('modified'), isWf), { action: 'diff', basePath: wfFile });
|
|
});
|
|
|
|
it('changed → diff with same filename as basePath', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('changed'), isWf), { action: 'diff', basePath: wfFile });
|
|
});
|
|
|
|
it('renamed from workflow path → diff with previous_filename as basePath', () => {
|
|
const prev = '.github/workflows/old.yaml';
|
|
assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, prev), isWf), { action: 'diff', basePath: prev });
|
|
});
|
|
|
|
it('renamed from non-workflow path → full (treat as added)', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, nonWfFile), isWf), { action: 'full' });
|
|
});
|
|
|
|
it('renamed with no previous_filename → full', () => {
|
|
assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, undefined), isWf), { action: 'full' });
|
|
});
|
|
|
|
it('unknown status → infra with reason string', () => {
|
|
const result = v.classifyFileStatus(file('bogus'), isWf);
|
|
assert.equal(result.action, 'infra');
|
|
assert.ok(result.reason.includes('bogus'), 'reason must name the unknown status: ' + result.reason);
|
|
assert.ok(result.reason.includes(wfFile), 'reason must include filename: ' + result.reason);
|
|
});
|
|
|
|
it('copied noncompliant workflow gets full validation (no baseline)', () => {
|
|
// Simulate: copied file has violations in head, previous_filename also exists.
|
|
// classifyFileStatus returns full, so filterNewViolations is called with empty base.
|
|
const noncompliantWf = [
|
|
'on:',
|
|
' workflow_call:',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - uses: actions/checkout@v4',
|
|
].join('\n');
|
|
const cls = v.classifyFileStatus({ status: 'copied', filename: wfFile, previous_filename: wfFile }, isWf);
|
|
assert.equal(cls.action, 'full', 'copied must be full');
|
|
const headErrs = v.validateWorkflowContent(noncompliantWf, wfFile);
|
|
assert.ok(headErrs.length > 0, 'noncompliant file must have violations');
|
|
assert.deepEqual(v.filterNewViolations(headErrs, []), headErrs, 'all violations reported with empty base');
|
|
});
|
|
|
|
it('copied compliant workflow produces no violations', () => {
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
const compliantWf = [
|
|
'on:',
|
|
' workflow_call:',
|
|
'permissions:',
|
|
' contents: read',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
const cls = v.classifyFileStatus({ status: 'copied', filename: wfFile }, isWf);
|
|
assert.equal(cls.action, 'full');
|
|
assert.deepEqual(v.validateWorkflowContent(compliantWf, wfFile), []);
|
|
});
|
|
|
|
it('unknown status result reason is usable as POLICY-INFRA message', () => {
|
|
const result = v.classifyFileStatus(file('merge'), isWf);
|
|
assert.equal(result.action, 'infra');
|
|
const infra = 'POLICY-INFRA: ' + result.reason + '; skipping workflow validation';
|
|
assert.ok(infra.includes('POLICY-INFRA'), 'infra message must have prefix: ' + infra);
|
|
});
|
|
});
|
|
|
|
// ── validateWorkflowContent — local action refs ───────────────────────────────
|
|
describe('validateWorkflowContent — local action refs', () => {
|
|
const BASE = 'f.yaml';
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
function wf(uses) {
|
|
return [
|
|
'on:',
|
|
' workflow_call:',
|
|
'permissions:',
|
|
' contents: read',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - uses: ' + uses,
|
|
].join('\n');
|
|
}
|
|
|
|
it('new local ref is blocked', () => {
|
|
const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
|
|
assert.ok(errs.some(e => e.includes('local action')), 'local ref must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('local ref error includes the path for content fingerprinting', () => {
|
|
const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
|
|
assert.ok(errs.some(e => e.includes('./.github/actions/my-action')), 'path must appear in error: ' + errs.join('; '));
|
|
});
|
|
|
|
it('changed local path fingerprints differently from original', () => {
|
|
const headErrs = v.validateWorkflowContent(wf('./.github/actions/new-action'), BASE);
|
|
const baseErrs = v.validateWorkflowContent(wf('./.github/actions/old-action'), BASE);
|
|
// Different paths → different fingerprints → changed path is new
|
|
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed local path must be reported as new');
|
|
});
|
|
|
|
it('unchanged local ref is grandfathered by diff mode', () => {
|
|
const headErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
|
|
const baseErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
|
|
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'same local ref must be grandfathered');
|
|
});
|
|
|
|
it('remote pinned ref is still accepted', () => {
|
|
assert.deepEqual(v.validateWorkflowContent(wf(PIN), BASE), []);
|
|
});
|
|
});
|
|
|
|
// ── validateWorkflowContent — flow steps array ────────────────────────────────
|
|
describe('validateWorkflowContent — flow steps array', () => {
|
|
const BASE = 'f.yaml';
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
function wfSteps(stepsLine) {
|
|
return [
|
|
'permissions: {}',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' ' + stepsLine,
|
|
].join('\n');
|
|
}
|
|
|
|
it('rejects steps: [{ uses: unpinned }] flow array', () => {
|
|
const errs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE);
|
|
assert.ok(errs.some(e => e.includes('flow-style')), 'inline uses flow steps must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects steps: [{ run: echo }] flow array with run', () => {
|
|
const errs = v.validateWorkflowContent(wfSteps('steps: [{ run: echo hi }]'), BASE);
|
|
assert.ok(errs.some(e => e.includes('flow-style')), 'inline run flow steps must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects "steps": [...] with double-quoted key', () => {
|
|
const errs = v.validateWorkflowContent(wfSteps('"steps": [{ uses: actions/checkout@v4 }]'), BASE);
|
|
assert.ok(errs.some(e => e.includes('flow-style')), 'double-quoted steps flow array must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects single-quoted \'steps\': [...] key', () => {
|
|
const errs = v.validateWorkflowContent(wfSteps("'steps': [{ uses: actions/checkout@v4 }]"), BASE);
|
|
assert.ok(errs.some(e => e.includes('flow-style')), 'single-quoted steps flow array must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects multiline flow opener steps: [', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps: [',
|
|
' { uses: actions/checkout@v4 }',
|
|
' ]',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('flow-style')), 'multiline flow steps opener must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('allows steps: [] (empty array, no execution)', () => {
|
|
const errs = v.validateWorkflowContent(wfSteps('steps: []'), BASE);
|
|
assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'empty steps: [] must pass: ' + errs.join('; '));
|
|
});
|
|
|
|
it('allows steps: [] with trailing comment', () => {
|
|
const errs = v.validateWorkflowContent(wfSteps('steps: [] # placeholder'), BASE);
|
|
assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'steps: [] with comment must pass: ' + errs.join('; '));
|
|
});
|
|
|
|
it('block-style steps list is not affected', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
|
|
});
|
|
|
|
it('changed flow steps payload fingerprints differently (content hash)', () => {
|
|
// Two different flow steps lines produce different FNV hashes → different fingerprints
|
|
const headErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE);
|
|
const baseErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/setup-node@v4 }]'), BASE);
|
|
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed flow payload must be new');
|
|
});
|
|
});
|
|
|
|
// ── isActionManifestFilename ──────────────────────────────────────────────────
|
|
describe('isActionManifestFilename', () => {
|
|
it('matches action.yml at repo root', () => {
|
|
assert.ok(v.isActionManifestFilename('action.yml'));
|
|
});
|
|
|
|
it('matches action.yaml at repo root', () => {
|
|
assert.ok(v.isActionManifestFilename('action.yaml'));
|
|
});
|
|
|
|
it('matches .github/actions/my-action/action.yml', () => {
|
|
assert.ok(v.isActionManifestFilename('.github/actions/my-action/action.yml'));
|
|
});
|
|
|
|
it('matches nested .github/actions/sub/deep/action.yaml', () => {
|
|
assert.ok(v.isActionManifestFilename('.github/actions/sub/deep/action.yaml'));
|
|
});
|
|
|
|
it('rejects workflow files', () => {
|
|
assert.ok(!v.isActionManifestFilename('.github/workflows/ci.yaml'));
|
|
});
|
|
|
|
it('rejects action-like filenames that are not action.yml/yaml', () => {
|
|
assert.ok(!v.isActionManifestFilename('.github/actions/my-action/entrypoint.js'));
|
|
assert.ok(!v.isActionManifestFilename('actions.yml'));
|
|
});
|
|
});
|
|
|
|
// ── isPolicyFilename ──────────────────────────────────────────────────────────
|
|
describe('isPolicyFilename', () => {
|
|
it('accepts workflow files', () => {
|
|
assert.ok(v.isPolicyFilename('.github/workflows/ci.yaml'));
|
|
assert.ok(v.isPolicyFilename('workflow-templates/pr-policy.yml'));
|
|
});
|
|
|
|
it('accepts action manifests', () => {
|
|
assert.ok(v.isPolicyFilename('action.yml'));
|
|
assert.ok(v.isPolicyFilename('.github/actions/setup/action.yaml'));
|
|
});
|
|
|
|
it('rejects unrelated files', () => {
|
|
assert.ok(!v.isPolicyFilename('src/index.js'));
|
|
assert.ok(!v.isPolicyFilename('.github/actions/setup/entrypoint.js'));
|
|
});
|
|
});
|
|
|
|
// ── validateWorkflowContent — action manifests ────────────────────────────────
|
|
describe('validateWorkflowContent — action manifests', () => {
|
|
const BASE = '.github/actions/my-action/action.yml';
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
|
|
function manifest(usesLine) {
|
|
return [
|
|
'name: My Action',
|
|
'description: Does something.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - uses: ' + usesLine,
|
|
].join('\n');
|
|
}
|
|
|
|
it('does NOT require top-level permissions: in action manifests', () => {
|
|
const content = manifest(PIN);
|
|
assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []);
|
|
});
|
|
|
|
it('still requires permissions: in workflow files (default)', () => {
|
|
const content = [
|
|
'on: workflow_call',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, '.github/workflows/test.yaml');
|
|
assert.ok(errs.some(e => e.includes('permissions')), 'workflow must require permissions: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects unpinned remote uses in action manifest', () => {
|
|
const content = manifest('actions/checkout@v4');
|
|
const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
|
|
assert.ok(errs.some(e => e.includes('40-char SHA') || e.includes('pinned')), 'unpinned must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('accepts fully pinned remote uses in action manifest', () => {
|
|
const content = manifest(PIN);
|
|
assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []);
|
|
});
|
|
|
|
it('rejects unsafe run expression in action manifest', () => {
|
|
const content = [
|
|
'name: My Action',
|
|
'description: Does something.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - run: echo ${{ github.event.pull_request.title }}',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
|
|
assert.ok(errs.some(e => e.includes('expression')), 'run expression must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects local action ref in action manifest (unsupported)', () => {
|
|
const content = manifest('./.github/actions/nested');
|
|
const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
|
|
assert.ok(errs.some(e => e.includes('local action')), 'local ref in manifest must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('diff mode: modified manifest adding unpinned ref is blocked', () => {
|
|
const headContent = manifest('actions/checkout@v4');
|
|
const baseContent = manifest(PIN);
|
|
const headErrs = v.validateWorkflowContent(headContent, BASE, { requirePermissions: false });
|
|
const baseErrs = v.validateWorkflowContent(baseContent, BASE, { requirePermissions: false });
|
|
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'new unpinned ref must block');
|
|
});
|
|
|
|
it('diff mode: unchanged unpinned ref in manifest is grandfathered', () => {
|
|
const content = manifest('actions/checkout@v4');
|
|
const headErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
|
|
const baseErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
|
|
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged violation must be grandfathered');
|
|
});
|
|
});
|
|
|
|
// ── classifyFileStatus — action manifests ─────────────────────────────────────
|
|
describe('classifyFileStatus — action manifests', () => {
|
|
function isWf(f) { return v.isPolicyFilename(f); }
|
|
|
|
it('added action manifest → full', () => {
|
|
const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'added' }, isWf);
|
|
assert.equal(result.action, 'full');
|
|
});
|
|
|
|
it('copied action manifest → full', () => {
|
|
const result = v.classifyFileStatus({ filename: '.github/actions/new/action.yml', status: 'copied', previous_filename: '.github/actions/old/action.yml' }, isWf);
|
|
assert.equal(result.action, 'full');
|
|
});
|
|
|
|
it('modified action manifest → diff with same path', () => {
|
|
const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'modified' }, isWf);
|
|
assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/setup/action.yml' });
|
|
});
|
|
|
|
it('renamed from action manifest path → diff with previous_filename', () => {
|
|
const result = v.classifyFileStatus({
|
|
filename: '.github/actions/new-name/action.yml',
|
|
status: 'renamed',
|
|
previous_filename: '.github/actions/old-name/action.yml',
|
|
}, isWf);
|
|
assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old-name/action.yml' });
|
|
});
|
|
|
|
it('renamed from non-policy path → full (treat as added)', () => {
|
|
const result = v.classifyFileStatus({
|
|
filename: '.github/actions/setup/action.yml',
|
|
status: 'renamed',
|
|
previous_filename: 'docs/action-template.yml',
|
|
}, isWf);
|
|
assert.equal(result.action, 'full');
|
|
});
|
|
});
|
|
|
|
// ── Exact bypass scenario ─────────────────────────────────────────────────────
|
|
// A modified composite action.yml that adds an unpinned uses: must block even
|
|
// when the referencing workflow file and its local uses: ./... line are unchanged.
|
|
describe('bypass scenario: modified action manifest adds unpinned ref', () => {
|
|
const ACTION_FILE = '.github/actions/setup/action.yml';
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
|
|
const baseManifest = [
|
|
'name: Setup',
|
|
'description: Sets up the environment.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
|
|
const headManifest = [
|
|
'name: Setup',
|
|
'description: Sets up the environment.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
' - uses: actions/setup-node@v4',
|
|
].join('\n');
|
|
|
|
it('base manifest (pinned only) has no violations', () => {
|
|
assert.deepEqual(v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false }), []);
|
|
});
|
|
|
|
it('head manifest (adds unpinned step) has a violation', () => {
|
|
const errs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false });
|
|
assert.ok(errs.length > 0, 'head must have violations: ' + errs.join('; '));
|
|
});
|
|
|
|
it('diff mode reports the new unpinned ref as a new violation', () => {
|
|
const headErrs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false });
|
|
const baseErrs = v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false });
|
|
const newViolations = v.filterNewViolations(headErrs, baseErrs);
|
|
assert.equal(newViolations.length, 1, 'exactly one new violation expected: ' + newViolations.join('; '));
|
|
assert.ok(newViolations[0].includes('setup-node'), 'violation must name the offending ref: ' + newViolations[0]);
|
|
});
|
|
|
|
it('new local ref inside composite action also fails closed', () => {
|
|
const manifest = [
|
|
'name: Setup',
|
|
'description: Sets up the environment.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - uses: ./.github/actions/nested',
|
|
].join('\n');
|
|
const errs = v.validateWorkflowContent(manifest, ACTION_FILE, { requirePermissions: false });
|
|
assert.ok(errs.some(e => e.includes('local action')), 'local ref in composite must fail: ' + errs.join('; '));
|
|
});
|
|
});
|
|
|
|
// ── policyFileKind ────────────────────────────────────────────────────────────
|
|
describe('policyFileKind', () => {
|
|
it('returns "workflow" for workflow files', () => {
|
|
assert.equal(v.policyFileKind('.github/workflows/ci.yaml'), 'workflow');
|
|
assert.equal(v.policyFileKind('workflow-templates/pr.yml'), 'workflow');
|
|
});
|
|
|
|
it('returns "action" for action manifests', () => {
|
|
assert.equal(v.policyFileKind('action.yml'), 'action');
|
|
assert.equal(v.policyFileKind('.github/actions/setup/action.yaml'), 'action');
|
|
});
|
|
|
|
it('returns null for non-policy files', () => {
|
|
assert.equal(v.policyFileKind('src/index.js'), null);
|
|
assert.equal(v.policyFileKind('.github/actions/setup/entrypoint.js'), null);
|
|
});
|
|
});
|
|
|
|
// ── classifyFileStatus — cross-type rename grandfathering ─────────────────────
|
|
describe('classifyFileStatus — cross-type rename grandfathering', () => {
|
|
function isWf(f) { return v.isPolicyFilename(f); }
|
|
|
|
it('action -> workflow rename → full (cross-kind, not grandfathered)', () => {
|
|
const result = v.classifyFileStatus({
|
|
filename: '.github/workflows/imported.yml',
|
|
status: 'renamed',
|
|
previous_filename: 'action.yml',
|
|
}, isWf);
|
|
assert.equal(result.action, 'full', 'action→workflow must be full, got: ' + JSON.stringify(result));
|
|
});
|
|
|
|
it('workflow -> action rename → full (cross-kind, not grandfathered)', () => {
|
|
const result = v.classifyFileStatus({
|
|
filename: '.github/actions/setup/action.yml',
|
|
status: 'renamed',
|
|
previous_filename: '.github/workflows/ci.yml',
|
|
}, isWf);
|
|
assert.equal(result.action, 'full', 'workflow→action must be full, got: ' + JSON.stringify(result));
|
|
});
|
|
|
|
it('workflow -> workflow rename → diff (same kind)', () => {
|
|
const result = v.classifyFileStatus({
|
|
filename: '.github/workflows/new.yml',
|
|
status: 'renamed',
|
|
previous_filename: '.github/workflows/old.yml',
|
|
}, isWf);
|
|
assert.deepEqual(result, { action: 'diff', basePath: '.github/workflows/old.yml' });
|
|
});
|
|
|
|
it('action -> action rename → diff (same kind)', () => {
|
|
const result = v.classifyFileStatus({
|
|
filename: '.github/actions/new/action.yml',
|
|
status: 'renamed',
|
|
previous_filename: '.github/actions/old/action.yml',
|
|
}, isWf);
|
|
assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old/action.yml' });
|
|
});
|
|
|
|
it('non-policy -> workflow rename → full', () => {
|
|
const result = v.classifyFileStatus({
|
|
filename: '.github/workflows/imported.yml',
|
|
status: 'renamed',
|
|
previous_filename: 'docs/template.yml',
|
|
}, isWf);
|
|
assert.equal(result.action, 'full');
|
|
});
|
|
});
|
|
|
|
// ── Exact bypass reproduction: action.yml renamed to workflow ─────────────────
|
|
describe('bypass reproduction: action renamed to workflow reports missing permissions', () => {
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
|
|
// Simulates: base is action.yml (no permissions, valid for action), head is
|
|
// .github/workflows/imported.yml (same content, but now a workflow file).
|
|
// Full validation must run because the policy kind changed (action → workflow).
|
|
it('action.yml content re-validated as workflow reports missing permissions', () => {
|
|
const actionContent = [
|
|
'name: Setup',
|
|
'description: Sets up the environment.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
|
|
// classifyFileStatus returns full → no baseline.
|
|
const isWf = f => v.isPolicyFilename(f);
|
|
const cls = v.classifyFileStatus({
|
|
filename: '.github/workflows/imported.yml',
|
|
status: 'renamed',
|
|
previous_filename: 'action.yml',
|
|
}, isWf);
|
|
assert.equal(cls.action, 'full', 'cross-kind rename must be full');
|
|
|
|
// Full validation as a workflow file — no opts.requirePermissions: false.
|
|
const errs = v.validateWorkflowContent(actionContent, '.github/workflows/imported.yml');
|
|
assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions must be reported: ' + errs.join('; '));
|
|
});
|
|
|
|
it('workflow.yml renamed to workflow.yml stays in diff mode and grandfathers unchanged violations', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
const isWf = f => v.isPolicyFilename(f);
|
|
const cls = v.classifyFileStatus({
|
|
filename: '.github/workflows/new.yml',
|
|
status: 'renamed',
|
|
previous_filename: '.github/workflows/old.yml',
|
|
}, isWf);
|
|
assert.deepEqual(cls, { action: 'diff', basePath: '.github/workflows/old.yml' }, 'same-kind rename must be diff');
|
|
// Use file.filename for both head/base so fingerprints match.
|
|
const headErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml');
|
|
const baseErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml');
|
|
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged content must be grandfathered');
|
|
});
|
|
});
|
|
|
|
// ── Whitespace-before-colon bypass ───────────────────────────────────────────
|
|
describe('validateWorkflowContent — whitespace before colon', () => {
|
|
const BASE = '.github/workflows/test.yaml';
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
|
|
function wfHeader() {
|
|
return 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps:';
|
|
}
|
|
|
|
it('rejects "uses : actions/checkout@v4" (space before colon)', () => {
|
|
const content = wfHeader() + '\n - uses : actions/checkout@v4';
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('whitespace before')), 'must reject uses with space: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects sequence-form "- uses : ..." (space before colon)', () => {
|
|
const content = wfHeader() + '\n - uses : actions/checkout@v4';
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'sequence uses space must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects "run : echo ${{ github.token }}" (space before colon with expression)', () => {
|
|
const content = wfHeader() + '\n - run : echo ${{ github.token }}';
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'run with space must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('rejects "steps : [{ uses : actions/checkout@v4 }]" (space before colon on steps)', () => {
|
|
const content = 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps : [{ uses : actions/checkout@v4 }]';
|
|
const errs = v.validateWorkflowContent(content, BASE);
|
|
assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'steps with space must fail: ' + errs.join('; '));
|
|
});
|
|
|
|
it('normal keys without space still work correctly', () => {
|
|
const content = [
|
|
'permissions: {}',
|
|
'jobs:',
|
|
' j:',
|
|
' runs-on: ubuntu-latest',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
|
|
});
|
|
|
|
it('changed whitespace-before-colon payload fingerprints differently', () => {
|
|
const h = wfHeader() + '\n - uses : actions/checkout@v4';
|
|
const b = wfHeader() + '\n - uses : actions/setup-node@v4';
|
|
const headErrs = v.validateWorkflowContent(h, BASE);
|
|
const baseErrs = v.validateWorkflowContent(b, BASE);
|
|
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed payload must be new');
|
|
});
|
|
});
|
|
|
|
// ── Workflow/action overlap: .github/workflows/action.yml ─────────────────────
|
|
describe('policyFileKind: workflow takes precedence over action-manifest pattern', () => {
|
|
it('policyFileKind returns "workflow" for .github/workflows/action.yml', () => {
|
|
assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow');
|
|
});
|
|
|
|
it('.github/workflows/action.yml requires permissions (workflow semantics)', () => {
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
const content = [
|
|
'name: Inline Action',
|
|
'description: Does something.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
// Default opts (requirePermissions: true) because policyFileKind === 'workflow'
|
|
const errs = v.validateWorkflowContent(content, '.github/workflows/action.yml');
|
|
assert.ok(errs.some(e => e.includes('permissions')), 'workflow-path action.yml must require permissions: ' + errs.join('; '));
|
|
});
|
|
|
|
it('.github/actions/foo/action.yml does NOT require permissions (action semantics)', () => {
|
|
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
|
|
const content = [
|
|
'name: Inline Action',
|
|
'description: Does something.',
|
|
'runs:',
|
|
' using: composite',
|
|
' steps:',
|
|
' - uses: ' + PIN,
|
|
].join('\n');
|
|
assert.deepEqual(v.validateWorkflowContent(content, '.github/actions/foo/action.yml', { requirePermissions: false }), []);
|
|
});
|
|
|
|
it('isActionManifestFilename still matches .github/workflows/action.yml (pattern overlap acknowledged)', () => {
|
|
assert.ok(v.isActionManifestFilename('.github/workflows/action.yml'), 'pattern overlap exists');
|
|
assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow', 'but kind is workflow');
|
|
});
|
|
});
|