diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1210f19..b7393d5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: "/" schedule: interval: "weekly" + commit-message: + prefix: "chore(deps)" groups: minor-and-patch: update-types: diff --git a/.github/workflows/callable-pr-policy.yaml b/.github/workflows/callable-pr-policy.yaml new file mode 100644 index 0000000..cd7e0ea --- /dev/null +++ b/.github/workflows/callable-pr-policy.yaml @@ -0,0 +1,697 @@ +name: PR Policy + +# Reusable PR metadata gate for all Sea-Haven-Industries repos. +# +# Validates pull-request metadata — title convention, branch naming, body +# structure, commit subjects, Jira existence, AI attribution footers, and +# workflow file pin compliance — without executing any PR code or checking +# out the repository. All checks run through the GitHub API only. +# +# Callers trigger this on `pull_request` (NOT pull_request_target) with event +# types: opened, reopened, synchronize, edited, labeled, unlabeled, +# ready_for_review. The check-run name is ` / pr`; the +# canonical caller job id is `policy`, producing the context `policy / pr`. +# +# Secrets are optional at the declaration level. For human PRs that include a +# Jira key, all three must be configured or the check fails closed (POLICY-INFRA). +# Dependabot skips Jira/branch/body checks but still runs commit-subject and +# workflow supply-chain checks. +# +# Emergency-revert exemption: when the title type is `revert`, the PR has no +# Jira key in the title, and the `emergency-revert` label is present on the PR, +# a candidate exemption is computed before title validation so the Jira key is +# not required in the title. The exemption is confirmed by verifying that the +# label was applied by a collaborator with maintain or admin permission. Any +# pagination truncation of the event timeline is POLICY-INFRA — partial history +# is never trusted. Unauthorized/null-actor/bot results add a violation. +# Branch, body, and commit checks remain regardless. +# +# Known platform limitation: metadata edits (labels, title changes) made via +# GITHUB_TOKEN do not reliably emit a new pull_request event. Org automation +# that applies labels must use a GitHub App token or a PAT so the policy gate +# re-runs automatically after the label is applied. +# +# Caller example: +# jobs: +# policy: +# uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@ # vX.Y.Z +# secrets: +# JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} +# JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} +# JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + +on: + workflow_call: + secrets: + JIRA_CLOUD_ID: + required: false + JIRA_SERVICE_ACCOUNT_EMAIL: + required: false + JIRA_API_TOKEN: + required: false + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + pr: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Validate PR + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} + JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + with: + script: | + // ── Pure validation functions ──────────────────────────────────────────── + // Extracted and exercised by test/pr-policy.test.mjs via PR_POLICY_TEST. + // These functions have no side effects and make no API calls. + + const CONV_TYPES = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release']; + const REQUIRED_H2 = ['Summary','Validation','Tests','Notes']; + + // AI-attribution footer patterns — case-insensitive, multiline. + // Matches Co-authored-by: trailers naming known AI tools and "Generated by/with" + // phrases. Does NOT flag generic prose like "uses AI" or "AI-powered". + // GPT variants: gpt-3, gpt-4, gpt-4o, gpt-5, gpt-o, etc. covered by gpt-[a-z0-9]+. + const AI_FOOTER_RE = /^(?:co-authored-by:\s+(?:claude|chatgpt|gpt-[a-z0-9]+|copilot|github\s+copilot|gemini|cursor(?:\s*ai)?|codeium|anthropic|openai|codex)\b|generated\s+(?:with|by)\s+(?:claude(?:\s+code)?|github\s+copilot|chatgpt|codex|gpt-[a-z0-9]+|gemini|codeium|cursor(?:\s*ai)?|anthropic|openai)|🤖\s+generated\b)/im; + + function validateTitle(title, isDependabot, jiraMaybeExempt) { + const errs = []; + if (title.length > 72) errs.push('Title is ' + title.length + ' chars — max 72'); + const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC)-\d+\))?$/); + if (!m) { + errs.push('Title must match: type(scope): description (KEY-NNN). Allowed types: ' + CONV_TYPES.join(' ')); + return errs; + } + const desc = m[4]; + const jiraSuffix = m[5]; + if (desc.endsWith('.')) errs.push('Description must not end with a period'); + if (!/^[a-z]/.test(desc)) errs.push('Description must start with a lowercase letter'); + if (!isDependabot && !jiraSuffix && !jiraMaybeExempt) { + errs.push('Missing Jira key — expected (DEV-NNN), (PLAT-NNN), or (SEC-NNN) at end of title'); + } + return errs; + } + + function getJiraKey(title) { + const m = title.match(/\((DEV|PLAT|SEC)-(\d+)\)$/); + return m ? m[1] + '-' + m[2] : null; + } + + function validateBranch(branch, isDependabot) { + if (isDependabot) return []; + const errs = []; + // Segment must be proper kebab-case: no consecutive hyphens, no trailing hyphen. + const m = branch.match(/^(feature|fix|hotfix|chore|docs|refactor|release)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/); + if (!m) { + errs.push('Branch "' + branch + '" must match prefix/kebab-case (no consecutive/trailing hyphens, no uppercase, one segment). Prefixes: feature fix hotfix chore docs refactor release'); + return errs; + } + if (/(?:DEV|PLAT|SEC|INFRA)-\d+/i.test(m[2])) errs.push('Branch segment must not contain a Jira key'); + return errs; + } + + function validateBody(rawBody, isDependabot) { + if (isDependabot) return []; + if (!rawBody || !rawBody.trim()) return ['PR body is empty']; + const errs = []; + // Strip fenced code blocks line-by-line before scanning headings. + // Fence markers: backtick (0x60) or tilde. Up to 3 leading spaces allowed + // (CommonMark spec). Use charCode to avoid literal backtick in source + // (which confuses actionlint's expression scanner). + const TICK = String.fromCharCode(0x60); + const bodyLines = rawBody.split('\n'); + const stripped = []; + let inFence = false; + let fenceChar = ''; + let fenceLen = 0; + const fenceRe = new RegExp('^ {0,3}(' + TICK + '{3,}|~{3,})'); + for (const line of bodyLines) { + if (!inFence) { + const fm = fenceRe.exec(line); + if (fm) { + inFence = true; + fenceChar = fm[1][0]; + fenceLen = fm[1].length; + stripped.push(''); + } else { + stripped.push(line); + } + } else { + const fm = fenceRe.exec(line); + if (fm && fm[1][0] === fenceChar && fm[1].length >= fenceLen && line.trim() === fm[1]) { + inFence = false; + stripped.push(''); + } else { + stripped.push(''); + } + } + } + const cleaned = stripped.join('\n').replace(//g, ''); + const h2s = Array.from(cleaned.matchAll(/^## (.+)$/gm)).map(function(x) { return x[1].trim(); }); + if (h2s.length !== 4) { + errs.push('Body must have exactly 4 ## headings (Summary/Validation/Tests/Notes), found ' + h2s.length + (h2s.length ? ': ' + h2s.join(', ') : '')); + return errs; + } + for (let i = 0; i < 4; i++) { + if (h2s[i] !== REQUIRED_H2[i]) errs.push('Heading ' + (i + 1) + ': expected "## ' + REQUIRED_H2[i] + '", got "## ' + h2s[i] + '"'); + } + const sectionParts = cleaned.split(/^(?=## )/m).filter(function(p) { return p.startsWith('## '); }); + for (let i = 0; i < Math.min(sectionParts.length, 4); i++) { + const content = sectionParts[i].replace(/^## [^\n]*\n?/, '').trim(); + if (!content) errs.push('## ' + REQUIRED_H2[i] + ' section is empty'); + } + return errs; + } + + function validateCommitSubject(subject) { + const errs = []; + if (subject.length > 72) errs.push('Commit subject is ' + subject.length + ' chars — max 72'); + const m = subject.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+)$/); + if (!m) { + errs.push('Not conventional: "' + subject.slice(0, 60) + (subject.length > 60 ? '\u2026' : '') + '"'); + return errs; + } + const desc = m[4]; + if (!/^[a-z]/.test(desc)) errs.push('Commit description must start with a lowercase letter'); + if (desc.endsWith('.')) errs.push('Commit description must not end with a period'); + if (/\s+\((DEV|PLAT|SEC)-\d+\)$/i.test(subject)) errs.push('Commit subject must not carry a Jira key suffix — only the PR title does'); + return errs; + } + + function detectAiFooter(text) { + return AI_FOOTER_RE.test(text); + } + + function isWorkflowFilename(filename) { + return /^\.github\/workflows\/[^/]+\.ya?ml$/.test(filename) || + /^workflow-templates\/[^/]+\.ya?ml$/.test(filename); + } + + // Deterministic line scanner for workflow YAML content. + // + // Block-scalar tracking: any YAML key whose value begins with | or > + // (including explicit indent/chomp forms |2, |2-, |-2, >+2, etc.) + // starts a block scalar. Lines inside ANY block scalar are not parsed + // as structural YAML keys — they are content. For run: block scalars, + // the content is still scanned for expression injection (expressions + // must flow through env:). For non-run block scalars (e.g. script:, + // name:), the content is skipped entirely — no uses: or run: detection. + // + // Quoted keys: "uses", 'uses', "run", 'run', "permissions" are all + // recognized in addition to their unquoted forms. + // + // Quoted action refs: `uses: "owner/repo@sha" # vX.Y.Z` correctly + // parses the comment outside the closing quote as the version annotation. + // + // Fails closed on YAML forms the line scanner cannot safely resolve: + // - Escaped/encoded keys in double-quoted strings ("u\u0073es") + // - Flow-style sequence steps (- { uses: ... }, - { run: ... }) + // - YAML aliases/anchors on run:, uses:, or permissions: values + // + // All-zero SHAs and v0.0.0 placeholder pins are rejected. + function validateWorkflowContent(content, filename) { + const errs = []; + const EXPR_OPEN = '$' + '{{'; + + // 1. Top-level permissions key required at column 0 (may be quoted). + if (!/^(?:"permissions"|'permissions'|permissions):/m.test(content)) { + errs.push(filename + ': missing top-level "permissions:" key'); + } + + // 1b. Top-level permissions alias check. + if (/^(?:"permissions"|'permissions'|permissions):[ \t]+\*/m.test(content)) { + errs.push(filename + ': YAML alias for top-level "permissions:" value is not supported — inline the permissions map'); + } + + // 2. Line-by-line scan. + // inBlock: currently inside a block scalar + // blockIndent: indent of the key that opened the block scalar + // blockIsRun: the block belongs to a run: key (check expressions) + const lines = content.split('\n'); + let inBlock = false; + let blockIndent = -1; + let blockIsRun = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const rawIndent = (line.match(/^([ \t]*)/) || ['', ''])[1].length; + + // ── Inside a block scalar ──────────────────────────────────────── + if (inBlock) { + if (line.trim() === '') continue; + if (rawIndent > blockIndent) { + // Content of block scalar. + // Only flag expression injection for run: block scalars. + if (blockIsRun && line.includes(EXPR_OPEN)) { + errs.push(filename + ':' + (i + 1) + ': run: block contains ' + EXPR_OPEN + ' }} — expressions must go through env:'); + } + continue; + } + // Indent at or below the block key — exit block scalar. + inBlock = false; + blockIndent = -1; + blockIsRun = false; + // Fall through to process this line as structural YAML. + } + + // ── Escaped/encoded double-quoted key — fail closed ─────────────── + // A double-quoted key containing \ cannot be reliably resolved by + // the line scanner (e.g. "u\u0073es" parses as "uses" in YAML). + // Reject any such key at the structural position. + if (/^[ \t]*(?:-[ \t]+)?"[^"]*\\[^"]*":/.test(line)) { + errs.push(filename + ':' + (i + 1) + ': escaped key in double-quoted string is not supported — use literal key names (run:, uses:, permissions:)'); + continue; + } + + // ── Flow-style sequence step — fail closed only for structural keys ─ + // `- { ... }` form cannot be safely resolved when it contains a + // structural run: or uses: key (including quoted or escaped forms). + // Non-step data objects like `- { os: ubuntu, node: 24 }` are + // allowed — they cannot contain action refs or run scripts. + // `permissions: {}` is a mapping value (not a sequence item), + // so it is unaffected by this check entirely. + if (/^[ \t]*-[ \t]+\{[^}]/.test(line)) { + const braceIdx = line.indexOf('{'); + const flowContent = line.slice(braceIdx); + if (/[{,]\s*(?:"uses"|'uses'|uses|"run"|'run'|run|"[^"]*\\[^"]*")\s*:/.test(flowContent)) { + errs.push(filename + ':' + (i + 1) + ': flow-style step mapping with structural "run:" or "uses:" key is not supported — use block mapping style'); + } + continue; + } + + // ── Any block scalar key detection (| or >) ────────────────────── + // Matches quoted ("key", 'key') and unquoted (key) key names, + // with optional sequence-item prefix (- ), followed by a block + // indicator (| or > with optional explicit-indent/chomp modifiers). + // YAML block scalar header forms: | |2 |- |+ |2- |2+ |-2 |+2 + // and equivalents with > (folded). Both digit-first and chomp-first + // orderings are recognized per the YAML 1.2 spec. + // Groups: [1]=indent [2]=full-key [3]=dq-content [4]=sq-content [5]=unquoted [6]=indicator + const blockM = line.match(/^([ \t]*)(?:-[ \t]+)?("([^"]*)"|'([^']*)'|([\w-]+)):[ \t]*([|>](?:[1-9][-+]?|[-+][1-9]?)?)[ \t]*(?:#.*)?$/); + if (blockM) { + const keyName = blockM[3] !== undefined ? blockM[3] : (blockM[4] !== undefined ? blockM[4] : (blockM[5] || '')); + inBlock = true; + blockIndent = blockM[1].length; + blockIsRun = (keyName === 'run'); + continue; + } + + // ── Inline run: value (no block indicator) ─────────────────────── + // Handles mapping form and sequence-item form; quoted and unquoted key. + // A run: &anchor | line (anchor before block indicator) falls here + // because blockM cannot match it; the & causes the alias check below. + const inlineRunM = line.match(/^[ \t]*(?:-[ \t]+)?(?:"run"|'run'|run):[ \t]+(.*)$/); + if (inlineRunM) { + const runVal = inlineRunM[1].trimStart(); + // A YAML alias is *name; an anchor is &name (non-whitespace after &). + // Ordinary shell & like 'echo "R&D build"' does not start with * or &word. + if (runVal[0] === '*' || /^&\S/.test(runVal)) { + errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "run:" value is not supported — inline the run script'); + continue; + } + if (inlineRunM[1].includes(EXPR_OPEN)) { + errs.push(filename + ':' + (i + 1) + ': run: value contains ' + EXPR_OPEN + ' }} — expressions must go through env:'); + } + continue; + } + + // ── uses: key detection ────────────────────────────────────────── + // Handles mapping form and sequence-item form; quoted and unquoted key. + const usesM = line.match(/^[ \t]+(?:-[ \t]+)?(?:"uses"|'uses'|uses):[ \t]+(.+)$/); + if (!usesM) continue; + + // Parse the action ref — handle quoted scalar with comment outside quotes. + const rawVal = usesM[1].trim(); + + // Reject YAML alias/anchor in uses: value. + // An alias is *name; an anchor is &name (non-whitespace after &). + if (rawVal[0] === '*' || /^&\S/.test(rawVal)) { + errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "uses:" value is not supported — inline the action ref'); + continue; + } + + let ref; + let extComment = ''; + + if (rawVal[0] === '"' || rawVal[0] === "'") { + const q = rawVal[0]; + const closeIdx = rawVal.indexOf(q, 1); + if (closeIdx !== -1) { + ref = rawVal.slice(1, closeIdx); + const rest = rawVal.slice(closeIdx + 1).trimStart(); + if (rest[0] === '#') extComment = rest; + } else { + ref = rawVal; // malformed quote — treat as unquoted + } + } else { + ref = rawVal; + } + + // Local and docker refs are exempt from SHA pinning. + if (ref.startsWith('./') || ref.startsWith('docker://')) continue; + + // Validate SHA + version comment. + // For quoted refs, combine the unquoted value with any external comment. + const forShaCheck = extComment ? ref + ' ' + extComment : ref; + const shaMatch = forShaCheck.match(/@([0-9a-f]{40})[ \t]+#[ \t]+v(\d+)\.(\d+)\.(\d+)$/i); + if (!shaMatch) { + const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" must be pinned to a 40-char SHA with "# vX.Y.Z" comment'); + continue; + } + + // Reject all-zero placeholder SHA. + if (/^0{40}$/.test(shaMatch[1])) { + const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" uses a placeholder all-zero SHA — replace with the actual release SHA'); + } + + // Reject v0.0.0 placeholder version. + if (shaMatch[2] === '0' && shaMatch[3] === '0' && shaMatch[4] === '0') { + const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" uses placeholder version v0.0.0 — update to the actual release version'); + } + } + + return errs; + } + + // Retry-After header parser — supports integer seconds and HTTP-date. + // Returns milliseconds to wait, capped at 60000. Returns 0 for invalid + // or non-positive values so the caller uses exponential fallback instead. + // nowMs is injectable for testing; defaults to Date.now(). + function parseRetryAfterMs(header, nowMs) { + if (!header) return 0; + const secs = parseInt(header, 10); + if (!isNaN(secs) && secs > 0) return Math.min(secs * 1000, 60000); + const date = new Date(header); + if (!isNaN(date.getTime())) { + const ms = date.getTime() - (nowMs !== undefined ? nowMs : Date.now()); + return ms > 0 ? Math.min(ms, 60000) : 0; + } + return 0; + } + + // Pure helpers for commit and file count limit checks. + function checkCommitLimit(prCommits) { + if (prCommits > 250) { + return 'POLICY-INFRA: PR has ' + prCommits + ' commits — GitHub REST API caps listCommits at 250; not all commit subjects can be validated'; + } + return null; + } + + function checkFilesLimit(prChangedFiles) { + if (prChangedFiles > 3000) { + return 'POLICY-INFRA: PR has ' + prChangedFiles + ' changed files — GitHub REST API caps listFiles at 3000; not all workflow files can be validated'; + } + return null; + } + + // ── Test escape ────────────────────────────────────────────────────────── + // Set PR_POLICY_TEST=1 to extract pure functions without hitting any API. + if (process.env.PR_POLICY_TEST === '1') { + return { + validateTitle, + getJiraKey, + validateBranch, + validateBody, + validateCommitSubject, + detectAiFooter, + isWorkflowFilename, + validateWorkflowContent, + parseRetryAfterMs, + checkCommitLimit, + checkFilesLimit, + }; + } + + // ── Jira API helper ────────────────────────────────────────────────────── + // Retries on 429/5xx up to 3 times with Retry-After header support. + // On the final attempt (attempt === 3), 429/5xx falls through to the + // status-specific throw. Never logs secrets or response bodies. + async function jiraGetIssue(cloudId, issueKey, email, token) { + const https = require('https'); + const apiPath = '/ex/jira/' + cloudId + '/rest/api/3/issue/' + issueKey + '?fields=key'; + const authHeader = 'Basic ' + Buffer.from(email + ':' + token).toString('base64'); + for (let attempt = 0; attempt <= 3; attempt++) { + const result = await new Promise(function(resolve, reject) { + const req = https.request({ + hostname: 'api.atlassian.com', + path: apiPath, + method: 'GET', + headers: { 'Authorization': authHeader, 'Accept': 'application/json' }, + }, function(res) { + const chunks = []; + res.on('data', function(c) { chunks.push(c); }); + res.on('end', function() { + resolve({ status: res.statusCode, retryAfter: res.headers['retry-after'], body: Buffer.concat(chunks).toString('utf8') }); + }); + }); + req.on('error', reject); + req.end(); + }); + if (result.status === 200) { + let parsed; + try { parsed = JSON.parse(result.body); } catch (_) { + const e = new Error('Jira API returned non-JSON'); e.isInfra = true; throw e; + } + if (parsed.key !== issueKey) throw new Error('Jira returned key "' + parsed.key + '" but expected "' + issueKey + '"'); + return parsed; + } + if (result.status === 404) throw new Error('Jira issue ' + issueKey + ' not found'); + if (result.status === 401 || result.status === 403) { + const e = new Error('Jira auth rejected (HTTP ' + result.status + ')'); e.isInfra = true; throw e; + } + if ((result.status === 429 || result.status >= 500) && attempt < 3) { + const headerMs = parseRetryAfterMs(result.retryAfter); + const delayMs = headerMs > 0 ? headerMs : Math.min(2000 * (attempt + 1), 30000); + await new Promise(function(r) { setTimeout(r, delayMs); }); + continue; + } + const e = new Error('Jira API returned HTTP ' + result.status); e.isInfra = true; throw e; + } + } + + // ── Main ───────────────────────────────────────────────────────────────── + const violations = []; + const infraCodes = []; + let infraFailed = false; + const MAX_ANNOTATIONS = 50; + + function addViolation(msg) { violations.push(msg); } + function addInfra(msg) { infraCodes.push(msg); infraFailed = true; } + + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + const pr = context.payload.pull_request; + const prNum = pr.number; + const isDep = pr.user.login === 'dependabot[bot]'; + const titleTypeMatch = pr.title.match(/^([a-z]+)/); + const titleType = titleTypeMatch ? titleTypeMatch[1] : ''; + + // Pre-compute emergency-revert candidate before title validation. + // Only a revert title that LACKS a Jira suffix triggers emergency + // authorization; a revert title that already carries a Jira key does not. + const hasEmergencyLabel = pr.labels.some(function(l) { return l.name === 'emergency-revert'; }); + const titleHasJira = !!getJiraKey(pr.title); + const isEmergencyCandidate = !isDep && titleType === 'revert' && hasEmergencyLabel && !titleHasJira; + + // 1 — title convention + for (const e of validateTitle(pr.title, isDep, isEmergencyCandidate)) addViolation('Title: ' + e); + + // 2 — branch naming (Dependabot exempt) + for (const e of validateBranch(pr.head.ref, isDep)) addViolation('Branch: ' + e); + + // 3 — body structure (Dependabot exempt) + for (const e of validateBody(pr.body, isDep)) addViolation('Body: ' + e); + + // 4 — AI attribution footer in title/body + if (detectAiFooter((pr.title || '') + '\n' + (pr.body || ''))) { + addViolation('AI attribution footer detected in PR title or body'); + } + + // 5 — commits: subject convention + AI footer + // GitHub REST API caps listCommits at 250 total. Fail infra immediately + // when pr.commits exceeds that limit; compare fetched count to detect + // API truncation. + { + const commitLimitErr = checkCommitLimit(pr.commits); + if (commitLimitErr) addInfra(commitLimitErr); + + let commitPage = 1; + let commitMore = true; + let totalFetched = 0; + while (commitMore) { + let resp; + try { + resp = await github.rest.pulls.listCommits({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: commitPage }); + } catch (err) { + addInfra('POLICY-INFRA: Failed to fetch commits (page ' + commitPage + '): ' + err.message); + break; + } + const commits = resp.data; + const link = (resp.headers && resp.headers.link) ? resp.headers.link : ''; + totalFetched += commits.length; + if (!link.includes('rel="next"') || commits.length === 0) commitMore = false; + for (const c of commits) { + const subject = c.commit.message.split('\n')[0]; + for (const e of validateCommitSubject(subject)) addViolation('Commit ' + c.sha.slice(0, 8) + ': ' + e); + if (detectAiFooter(c.commit.message)) addViolation('Commit ' + c.sha.slice(0, 8) + ': AI attribution footer detected'); + } + commitPage++; + } + if (pr.commits <= 250 && totalFetched > 0 && totalFetched !== pr.commits) { + addInfra('POLICY-INFRA: Fetched ' + totalFetched + ' commits but PR reports ' + pr.commits + ' — API truncation suspected'); + } + } + + // 6 — emergency-revert authorisation + // Event timeline truncation is always POLICY-INFRA regardless of whether + // an earlier label event was found — partial history is never trusted. + let jiraExempt = isDep; + let emergencyAuthFailed = false; + if (isEmergencyCandidate) { + try { + let evPage = 1; + let evMore = true; + let latestLabelEvent = null; + let evTruncated = false; + while (evMore) { + const evResp = await github.rest.issues.listEvents({ owner: repoOwner, repo: repoName, issue_number: prNum, per_page: 100, page: evPage }); + const evLink = (evResp.headers && evResp.headers.link) ? evResp.headers.link : ''; + for (const ev of evResp.data) { + if (ev.event === 'labeled' && ev.label && ev.label.name === 'emergency-revert') latestLabelEvent = ev; + } + if (!evLink.includes('rel="next"') || evResp.data.length === 0) { + evMore = false; + } else if (evPage >= 20) { + evMore = false; + evTruncated = true; + } + evPage++; + } + if (evTruncated) { + // Partial history cannot verify the most-recent label event. + // An earlier maintainer event might have been superseded. + addInfra('POLICY-INFRA: Event timeline truncated at pagination limit — cannot verify the most-recent emergency-revert label actor; Jira key required'); + emergencyAuthFailed = true; + } else if (!latestLabelEvent) { + addViolation('emergency-revert: label present but no label event found in timeline — Jira key required'); + } else if (!latestLabelEvent.actor) { + addViolation('emergency-revert: label event actor is null — Jira key required'); + } else if (latestLabelEvent.actor.type === 'Bot') { + addViolation('emergency-revert: label applied by a bot — Jira key required'); + } else { + const permResp = await github.rest.repos.getCollaboratorPermissionLevel({ owner: repoOwner, repo: repoName, username: latestLabelEvent.actor.login }); + if (permResp.data.permission === 'maintain' || permResp.data.permission === 'admin') { + jiraExempt = true; + } else { + addViolation('emergency-revert: label applied by user without maintain/admin permission — Jira key required'); + } + } + } catch (err) { + addInfra('POLICY-INFRA: Emergency-revert authorisation check failed: ' + err.message); + emergencyAuthFailed = true; + } + } + + // 7 — Jira existence (Dependabot exempt; emergency-revert may be exempt) + // Skip entirely when emergency auth already produced an infra error to + // avoid a redundant credential error on a PR that has no Jira key. + const jiraKey = isDep ? null : getJiraKey(pr.title); + if (!jiraExempt && jiraKey && !emergencyAuthFailed) { + const cloudId = process.env.JIRA_CLOUD_ID || ''; + const jiraEmail = process.env.JIRA_SERVICE_ACCOUNT_EMAIL || ''; + const jiraToken = process.env.JIRA_API_TOKEN || ''; + if (!cloudId || !jiraEmail || !jiraToken) { + addInfra('POLICY-INFRA: Jira credentials missing — JIRA_CLOUD_ID, JIRA_SERVICE_ACCOUNT_EMAIL, and JIRA_API_TOKEN must all be set for human PRs'); + } else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)) { + addInfra('POLICY-INFRA: JIRA_CLOUD_ID is not a valid UUID'); + } else { + try { + await jiraGetIssue(cloudId, jiraKey, jiraEmail, jiraToken); + } catch (err) { + if (err.isInfra) addInfra('POLICY-INFRA: ' + err.message); + else addViolation('Jira: ' + err.message); + } + } + } + + // 8 — workflow file supply-chain checks + // GitHub REST API caps listFiles at 3000. Fail infra immediately when + // pr.changed_files exceeds that limit; compare fetched count to detect + // truncation at lower file counts. + try { + const filesLimitErr = checkFilesLimit(pr.changed_files); + if (filesLimitErr) addInfra(filesLimitErr); + + let filesPage = 1; + let filesMore = true; + let totalFilesFetched = 0; + while (filesMore) { + const filesResp = await github.rest.pulls.listFiles({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: filesPage }); + const filesLink = (filesResp.headers && filesResp.headers.link) ? filesResp.headers.link : ''; + totalFilesFetched += filesResp.data.length; + if (!filesLink.includes('rel="next"') || filesResp.data.length === 0) filesMore = false; + for (const file of filesResp.data) { + if (!isWorkflowFilename(file.filename)) continue; + if (file.status !== 'added' && file.status !== 'modified' && file.status !== 'renamed') continue; + try { + const blobResp = await github.rest.git.getBlob({ owner: repoOwner, repo: repoName, file_sha: file.sha }); + const raw = blobResp.data; + const encoding = raw.encoding === 'base64' ? 'base64' : 'utf8'; + const fileContent = Buffer.from(raw.content, encoding).toString('utf8'); + for (const e of validateWorkflowContent(fileContent, file.filename)) addViolation(e); + } catch (blobErr) { + addInfra('POLICY-INFRA: Cannot fetch blob for ' + file.filename + ': ' + blobErr.message); + } + } + filesPage++; + } + if (pr.changed_files <= 3000 && totalFilesFetched > 0 && totalFilesFetched !== pr.changed_files) { + addInfra('POLICY-INFRA: Fetched ' + totalFilesFetched + ' changed files but PR reports ' + pr.changed_files + ' — API truncation suspected'); + } + } catch (err) { + addInfra('POLICY-INFRA: Failed to list PR files: ' + err.message); + } + + // 9 — emit annotations + step summary, then fail once + // Both annotations and summary entries are capped at MAX_ANNOTATIONS + // to prevent oversized outputs on PRs with many violations. + const annotated = violations.slice(0, MAX_ANNOTATIONS); + for (const msg of annotated) core.error(msg); + for (const msg of infraCodes.slice(0, MAX_ANNOTATIONS)) core.error(msg); + if (violations.length > MAX_ANNOTATIONS) { + core.warning((violations.length - MAX_ANNOTATIONS) + ' additional violation(s) suppressed (max ' + MAX_ANNOTATIONS + ' annotations)'); + } + + const totalCount = violations.length + infraCodes.length; + const summaryParts = [totalCount === 0 ? '## PR Policy: All checks passed \u2713' : '## PR Policy: ' + totalCount + ' issue(s) found']; + if (violations.length > 0) { + summaryParts.push('', '### Policy violations'); + const shownV = violations.slice(0, MAX_ANNOTATIONS); + for (const msg of shownV) summaryParts.push('- ' + msg); + if (violations.length > MAX_ANNOTATIONS) { + summaryParts.push('- _...and ' + (violations.length - MAX_ANNOTATIONS) + ' more violation(s) not shown_'); + } + } + if (infraCodes.length > 0) { + summaryParts.push('', '### Infrastructure failures'); + const shownI = infraCodes.slice(0, MAX_ANNOTATIONS); + for (const msg of shownI) summaryParts.push('- ' + msg); + if (infraCodes.length > MAX_ANNOTATIONS) { + summaryParts.push('- _...and ' + (infraCodes.length - MAX_ANNOTATIONS) + ' more infra error(s) not shown_'); + } + } + await core.summary.addRaw(summaryParts.join('\n')).write(); + + if (violations.length > 0 || infraFailed) { + core.setFailed('PR policy: ' + violations.length + ' violation(s), ' + infraCodes.length + ' infrastructure error(s)'); + } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80e2e63..9aa2224 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,7 +50,11 @@ jobs: name: ci / ci runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run policy unit tests + run: node --test test/pr-policy.test.mjs + shell: bash - name: Install actionlint env: diff --git a/.github/workflows/policy.yaml b/.github/workflows/policy.yaml new file mode 100644 index 0000000..9a43e96 --- /dev/null +++ b/.github/workflows/policy.yaml @@ -0,0 +1,36 @@ +name: policy + +# Self-caller: runs the org-wide PR policy gate on THIS repo's own pull requests. +# +# This workflow is new and will begin enforcing policy on PRs opened AFTER it +# merges to main. PRs that are already open at merge time are not retroactively +# re-evaluated until one of the trigger events fires again (e.g. a new commit). +# +# The check-run name this emits is `policy / pr`, matching the org standard +# documented in callable-pr-policy.yaml. Do not rename the job below — the +# job id (`policy`) is the first segment of that context. +# +# Uses a local path reference because this repo IS the source of the reusable; +# pinning to a SHA of itself would lag by one merge every time either file +# changes. Local `./` refs are exempt from the SHA-pin policy. + +on: + pull_request: + types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review] + +concurrency: + group: policy-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + policy: + uses: ./.github/workflows/callable-pr-policy.yaml + secrets: + JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} + JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} diff --git a/.github/workflows/release-on-merge.yaml b/.github/workflows/release-on-merge.yaml index 7460afe..4cd7292 100644 --- a/.github/workflows/release-on-merge.yaml +++ b/.github/workflows/release-on-merge.yaml @@ -30,6 +30,7 @@ on: - ".github/workflows/**" - "!.github/workflows/ci.yaml" - "!.github/workflows/labeler.yaml" + - "!.github/workflows/policy.yaml" - "!.github/workflows/release-on-merge.yaml" workflow_dispatch: inputs: diff --git a/README.md b/README.md index ea77995..c68a371 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and **`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping. +**`.github/workflows/callable-pr-policy.yaml`** — Reusable PR metadata gate. Validates PR title convention (type/scope/Jira key), branch naming, four-section body, commit subjects, AI attribution footers, and workflow file pin compliance — all via GitHub API, no checkout. Emits `policy / pr` when the caller job is named `policy`. Optional secrets `JIRA_CLOUD_ID`, `JIRA_SERVICE_ACCOUNT_EMAIL`, and `JIRA_API_TOKEN` must all be set for human PRs; Dependabot skips Jira/branch/body but still runs commit and workflow supply-chain checks. Emergency `revert` PRs may skip Jira with the `emergency-revert` label applied by a human collaborator with `maintain` or `admin` permission. + +> **Workflow file constraints enforced by the supply-chain scanner.** Changed workflow files scanned by this policy must use block-style structural keys and inline `run:`/`uses:` values. The scanner fails closed on YAML forms it cannot safely resolve: flow-style step mappings (`- { uses: ... }`, `- { run: ... }`), escaped or Unicode-encoded structural keys in double-quoted strings (`"u\u0073es"`, `"r\u0075n"`), and YAML aliases or anchors on `run:`, `uses:`, or `permissions:` values (`run: *cmd`, `uses: &anchor ...`). Use the literal unquoted key forms and inline values in all workflow steps. + **`.github/workflows/callable-labeler.yaml`** — Org-wide PR auto-labeler. Label rules live inline here (single source of truth) — consumer repos need only a thin caller with `contents: read`, `pull-requests: write`, and `issues: write`; no per-repo labeler.yml. **`.github/workflows/callable-dependency-review.yaml`** — Dependency review on PRs, failing on high severity. Requires Dependency Graph. @@ -58,6 +62,8 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and **`.github/workflows/release-on-merge.yaml`** — Repo automation (not callable): cuts a tag and GitHub Release for **this** repo whenever a merge to `main` changes a reusable workflow, so Dependabot has a release to advance consumer SHA pins to (see the pinning policy below). +**`.github/workflows/policy.yaml`** — This repo's own thin caller of `callable-pr-policy.yaml`, so PR policy runs on `.github`'s own PRs. Uses a local path reference (`./.github/workflows/callable-pr-policy.yaml`); begins enforcing on PRs opened after its merge to main. + **`.github/workflows/labeler.yaml`** — This repo's own thin caller of `callable-labeler.yaml`, so the labeler runs on `.github`'s own PRs. **`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted. @@ -66,6 +72,8 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `labeler`, `mobile-ios-deploy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one. +A `pr-policy` starter template can be added to `workflow-templates/` only after the PR that introduces `callable-pr-policy.yaml` merges and `release-on-merge.yaml` cuts the first release containing it. Until then, consumer repos must add the caller workflow manually (see §3). + ### Ref pinning policy All workflow refs across the org are pinned to full commit SHAs: @@ -157,6 +165,14 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each The CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §3). +Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo: + +| Secret | Value | Consumed by | +|--------|-------|-------------| +| `JIRA_CLOUD_ID` | Atlassian Cloud ID UUID (find in **Jira Settings → Products → Jira Software**) | `callable-pr-policy.yaml` | +| `JIRA_SERVICE_ACCOUNT_EMAIL` | Email of the service account with read access to DEV/PLAT/SEC projects | `callable-pr-policy.yaml` | +| `JIRA_API_TOKEN` | API token for that account (generated at **id.atlassian.com/manage-profile/security/api-tokens**) | `callable-pr-policy.yaml` | + ### 2. Add CI to a repo Create `.github/workflows/ci.yaml` in the target repo. Examples: @@ -222,7 +238,45 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 ``` -### 3. Add CD to a repo +### 3. Add PR policy to a repo + +Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1). + +```yaml +name: PR Policy +on: + pull_request: + types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review] + +concurrency: + group: policy-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + policy: + uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@ # vX.Y.Z + secrets: + JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} + JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} +``` + +Replace `` with the SHA of the release that contains `callable-pr-policy.yaml`: + +```bash +gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha +``` + +The check-run name is `policy / pr`. If your branch-protection ruleset requires this context, add it after the first PR passes. + +> **Known platform limitation — GITHUB_TOKEN label and metadata events.** When the `policy` workflow re-runs on `labeled` or `edited` events, the metadata edits themselves (label adds, title edits) must be performed by a GitHub App or a PAT that owns its own event stream. Edits made through `GITHUB_TOKEN` do not reliably emit a new `pull_request` event to trigger re-evaluation; the check stays in its prior state until the next push or manual re-run. Org automation that applies labels (such as the `emergency-revert` label) must therefore use a GitHub App token or a PAT — not `GITHUB_TOKEN` — or the policy gate will not re-run automatically after the label is applied. This is a GitHub platform constraint, not a deficiency that can be solved at the workflow level. + +### 4. Add CD to a repo Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret. diff --git a/test/pr-policy.test.mjs b/test/pr-policy.test.mjs new file mode 100644 index 0000000..9ced827 --- /dev/null +++ b/test/pr-policy.test.mjs @@ -0,0 +1,1357 @@ +/** + * pr-policy.test.mjs + * + * Extracts the exact `script: |` block from callable-pr-policy.yaml and + * exercises the pure validation functions via the PR_POLICY_TEST=1 escape. + * No npm dependencies — uses only Node.js built-ins. + * + * Run: node --test test/pr-policy.test.mjs + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ── Script extraction ──────────────────────────────────────────────────────── +// Reads the YAML file and extracts the literal block scalar under `script: |`. +// The strip amount is determined from the indentation of the first non-empty +// line after the `script: |` marker. + +function extractScriptBlock(yamlText) { + const lines = yamlText.split('\n'); + let state = 'looking'; + let strip = 0; + const scriptLines = []; + + for (let i = 0; i < lines.length; i++) { + if (state === 'looking') { + if (/^\s+script:\s*\|/.test(lines[i])) { + state = 'content'; + } + } else { + const line = lines[i]; + if (line.trim() === '') { + scriptLines.push(''); + continue; + } + const indent = (line.match(/^(\s*)/) || ['', ''])[1].length; + if (strip === 0) { + strip = indent; + } + if (indent >= strip) { + scriptLines.push(line.slice(strip)); + } else { + break; + } + } + } + + while (scriptLines.length && !scriptLines[scriptLines.length - 1].trim()) { + scriptLines.pop(); + } + return scriptLines.join('\n'); +} + +// ── Pure-function loader ───────────────────────────────────────────────────── +// Runs the extracted script with PR_POLICY_TEST=1, which causes the script to +// return the pure validator functions before making any API calls. + +let v; // shared validator object + +async function loadValidators() { + const yamlPath = join(__dirname, '../.github/workflows/callable-pr-policy.yaml'); + const yamlText = readFileSync(yamlPath, 'utf8'); + const scriptCode = extractScriptBlock(yamlText); + + assert.ok(scriptCode.length > 100, 'script block must be non-trivially long'); + assert.ok(scriptCode.includes('PR_POLICY_TEST'), 'script block must contain PR_POLICY_TEST escape'); + + process.env.PR_POLICY_TEST = '1'; + try { + // Wrap in an async IIFE matching how actions/github-script executes it. + // Pure functions do not call github/context/core, so empty mocks suffice. + const asyncFn = new Function( + 'github', 'context', 'core', 'process', + 'return (async function() {\n' + scriptCode + '\n})()' + ); + const mockCore = { + error: () => {}, + setFailed: () => {}, + warning: () => {}, + summary: { addRaw: () => ({ write: async () => {} }) }, + }; + v = await asyncFn({}, {}, mockCore, process); + } finally { + delete process.env.PR_POLICY_TEST; + } + + assert.ok(v && typeof v === 'object', 'PR_POLICY_TEST escape must return an object'); + for (const fn of [ + 'validateTitle', 'getJiraKey', 'validateBranch', 'validateBody', + 'validateCommitSubject', 'detectAiFooter', 'isWorkflowFilename', + 'validateWorkflowContent', 'parseRetryAfterMs', 'checkCommitLimit', + 'checkFilesLimit', + ]) { + assert.equal(typeof v[fn], 'function', fn + ' must be exported'); + } +} + +// ── Test suite ─────────────────────────────────────────────────────────────── + +before(async () => { await loadValidators(); }); + +// ── validateTitle ──────────────────────────────────────────────────────────── + +describe('validateTitle', () => { + it('accepts a valid non-Dependabot title', () => { + assert.deepEqual(v.validateTitle('feat(auth): add login endpoint (DEV-123)', false), []); + }); + + it('accepts a valid title without scope', () => { + assert.deepEqual(v.validateTitle('fix: resolve null pointer (PLAT-42)', false), []); + }); + + it('accepts a valid Dependabot title without Jira key', () => { + assert.deepEqual(v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21', true), []); + }); + + it('rejects missing Jira key for non-Dependabot', () => { + const errs = v.validateTitle('fix: resolve null pointer', false); + assert.ok(errs.length > 0, 'should have errors'); + assert.ok(errs.some(e => e.includes('Jira')), 'should mention Jira: ' + errs.join('; ')); + }); + + it('rejects unknown type', () => { + const errs = v.validateTitle('update: something (DEV-1)', false); + assert.ok(errs.length > 0, 'should have errors for unknown type'); + assert.ok(errs.some(e => e.includes('type') || e.includes('match')), 'should mention type'); + }); + + it('rejects title over 72 chars', () => { + const long = 'feat: ' + 'a'.repeat(60) + ' (DEV-1)'; + const errs = v.validateTitle(long, false); + assert.ok(errs.some(e => e.includes('72')), 'should mention 72 char limit'); + }); + + it('rejects title ending with a period (Dependabot, no Jira required)', () => { + // Use a Dependabot title so only the period error fires. + const errs = v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21.', true); + assert.ok(errs.some(e => e.includes('period')), 'should mention period: ' + errs.join('; ')); + }); + + it('rejects description ending with period before Jira suffix', () => { + const errs = v.validateTitle('fix: resolve issue. (DEV-1)', false); + assert.ok(errs.some(e => e.includes('period')), 'desc period before Jira: ' + errs.join('; ')); + }); + + it('rejects description starting with uppercase', () => { + const errs = v.validateTitle('fix: Resolve issue (DEV-1)', false); + assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); + }); + + it('accepts PLAT and SEC Jira keys', () => { + assert.deepEqual(v.validateTitle('chore: update deps (PLAT-99)', false), []); + assert.deepEqual(v.validateTitle('docs: add runbook (SEC-7)', false), []); + }); + + it('rejects inactive Jira projects (INFRA)', () => { + const errs = v.validateTitle('fix: patch (INFRA-1)', false); + assert.ok(errs.length > 0, 'INFRA is inactive and should fail'); + }); + + it('accepts all valid types', () => { + const types = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release']; + for (const t of types) { + const errs = v.validateTitle(t + ': do something (DEV-1)', false); + assert.deepEqual(errs, [], t + ' should be a valid type'); + } + }); + + // Emergency-revert candidate: jiraMaybeExempt skips the Jira key requirement. + it('accepts revert title without Jira when jiraMaybeExempt is true', () => { + assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, true), []); + }); + + it('rejects revert title without Jira when jiraMaybeExempt is false', () => { + const errs = v.validateTitle('revert: emergency rollback of payment service', false, false); + assert.ok(errs.some(e => e.includes('Jira')), 'missing Jira should fail without exemption: ' + errs.join('; ')); + }); + + it('rejects revert title without Jira when jiraMaybeExempt is omitted (default false)', () => { + const errs = v.validateTitle('revert: emergency rollback of payment service', false); + assert.ok(errs.some(e => e.includes('Jira')), 'default should behave like false: ' + errs.join('; ')); + }); +}); + +// ── getJiraKey ─────────────────────────────────────────────────────────────── + +describe('getJiraKey', () => { + it('extracts DEV key', () => assert.equal(v.getJiraKey('fix: thing (DEV-42)'), 'DEV-42')); + it('extracts PLAT key', () => assert.equal(v.getJiraKey('chore: thing (PLAT-1)'), 'PLAT-1')); + it('extracts SEC key', () => assert.equal(v.getJiraKey('docs: thing (SEC-999)'), 'SEC-999')); + it('returns null when no key', () => assert.equal(v.getJiraKey('chore: thing'), null)); + it('returns null for mid-title key', () => assert.equal(v.getJiraKey('fix (DEV-1): something'), null)); +}); + +// ── validateBranch ─────────────────────────────────────────────────────────── + +describe('validateBranch', () => { + it('accepts valid feature branch', () => { + assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []); + }); + + it('accepts all valid prefixes', () => { + const prefixes = ['feature','fix','hotfix','chore','docs','refactor','release']; + for (const p of prefixes) { + assert.deepEqual(v.validateBranch(p + '/my-thing', false), [], p + '/ should be valid'); + } + }); + + it('skips validation for Dependabot', () => { + assert.deepEqual(v.validateBranch('dependabot/npm_and_yarn/lodash-4.17.21', true), []); + }); + + it('rejects unknown prefix', () => { + const errs = v.validateBranch('bugfix/my-thing', false); + assert.ok(errs.length > 0, 'bugfix/ is not a valid prefix'); + }); + + it('rejects nested path (two slashes)', () => { + const errs = v.validateBranch('feature/team/my-thing', false); + assert.ok(errs.length > 0, 'nested paths should be rejected'); + }); + + it('rejects uppercase in segment', () => { + const errs = v.validateBranch('feature/myThing', false); + assert.ok(errs.length > 0, 'uppercase in segment should be rejected'); + }); + + it('rejects Jira key in segment (case-insensitive)', () => { + const errs = v.validateBranch('fix/dev-123', false); + assert.ok(errs.length > 0, 'Jira-key pattern in segment should be rejected'); + }); + + it('rejects uppercase Jira key in segment', () => { + const errs = v.validateBranch('fix/DEV-123', false); + assert.ok(errs.length > 0, 'uppercase Jira key should be rejected'); + }); + + it('rejects branch with no segment after prefix', () => { + const errs = v.validateBranch('feature/', false); + assert.ok(errs.length > 0, 'empty segment should be rejected'); + }); +}); + +// ── validateBody ───────────────────────────────────────────────────────────── + +describe('validateBody', () => { + const goodBody = '## Summary\nSomething changed.\n\n## Validation\nRan tests.\n\n## Tests\nUnit tests pass.\n\n## Notes\nNone.'; + + it('accepts a valid body', () => { + assert.deepEqual(v.validateBody(goodBody, false), []); + }); + + it('accepts None. under Notes', () => { + assert.deepEqual(v.validateBody(goodBody, false), []); + }); + + it('skips validation for Dependabot', () => { + assert.deepEqual(v.validateBody('', true), []); + }); + + it('rejects empty body', () => { + const errs = v.validateBody('', false); + assert.ok(errs.length > 0, 'empty body should fail'); + }); + + it('rejects wrong heading order', () => { + const body = '## Validation\nOK\n\n## Summary\nOK\n\n## Tests\nOK\n\n## Notes\nNone.'; + const errs = v.validateBody(body, false); + assert.ok(errs.some(e => e.includes('Validation') || e.includes('Summary')), 'wrong order: ' + errs.join('; ')); + }); + + it('rejects fifth H2 heading', () => { + const body = goodBody + '\n\n## Extra\nwhoops'; + const errs = v.validateBody(body, false); + assert.ok(errs.some(e => e.includes('5') || e.includes('4')), 'fifth heading: ' + errs.join('; ')); + }); + + it('rejects empty section', () => { + const body = '## Summary\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; + const errs = v.validateBody(body, false); + assert.ok(errs.some(e => e.includes('Summary') && e.includes('empty')), 'empty summary: ' + errs.join('; ')); + }); + + it('strips HTML comments before heading scan', () => { + const body = '\n## Summary\nreal.\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; + assert.deepEqual(v.validateBody(body, false), [], 'HTML comment heading should not count'); + }); + + it('strips fenced code blocks before heading scan', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n${TICK.repeat(3)}\n## Fake heading inside fence\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + assert.deepEqual(v.validateBody(body, false), [], 'fenced heading should not count'); + }); + + it('handles tilde fences', () => { + const body = '## Summary\nSee below.\n\n~~~\n## Fake inside tilde fence\n~~~\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; + assert.deepEqual(v.validateBody(body, false), [], 'tilde-fenced heading should not count'); + }); + + it('handles fences with 1 leading space', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + assert.deepEqual(v.validateBody(body, false), [], '1-space indented fence should strip fake heading'); + }); + + it('handles fences with 3 leading spaces', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + assert.deepEqual(v.validateBody(body, false), [], '3-space indented fence should strip fake heading'); + }); + + it('does not treat 4-space-indented fence as a code fence', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Extra Heading\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + // 4-space indent exceeds the 0-3 space fence rule; heading is not stripped → error. + const errs = v.validateBody(body, false); + assert.ok(errs.length > 0, '4-space fence should not strip heading (counted as extra heading)'); + }); + + it('rejects missing Notes heading', () => { + const body = '## Summary\nOK.\n\n## Validation\nOK.\n\n## Tests\nOK.'; + const errs = v.validateBody(body, false); + assert.ok(errs.length > 0, 'missing Notes should fail'); + }); +}); + +// ── validateCommitSubject ───────────────────────────────────────────────────── + +describe('validateCommitSubject', () => { + it('accepts a valid commit subject', () => { + assert.deepEqual(v.validateCommitSubject('feat(auth): add login endpoint'), []); + }); + + it('accepts subject without scope', () => { + assert.deepEqual(v.validateCommitSubject('fix: resolve null pointer'), []); + }); + + it('accepts breaking change marker', () => { + assert.deepEqual(v.validateCommitSubject('feat!: remove deprecated api'), []); + }); + + it('rejects subject with Jira key suffix', () => { + const errs = v.validateCommitSubject('fix: patch (DEV-123)'); + assert.ok(errs.some(e => e.includes('Jira')), 'should reject Jira suffix: ' + errs.join('; ')); + }); + + it('rejects subject with lowercase Jira key suffix (case-insensitive)', () => { + const errs = v.validateCommitSubject('fix: patch (dev-123)'); + assert.ok(errs.some(e => e.includes('Jira')), 'lowercase Jira suffix should also be rejected: ' + errs.join('; ')); + }); + + it('rejects subject with plat Jira key suffix', () => { + const errs = v.validateCommitSubject('chore: update config (plat-5)'); + assert.ok(errs.some(e => e.includes('Jira')), 'plat Jira suffix should be rejected: ' + errs.join('; ')); + }); + + it('rejects non-conventional subject', () => { + const errs = v.validateCommitSubject('Update readme'); + assert.ok(errs.length > 0, 'should reject non-conventional subject'); + }); + + it('rejects uppercase description start', () => { + const errs = v.validateCommitSubject('fix: Resolve issue'); + assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); + }); + + it('rejects subject over 72 chars', () => { + const long = 'feat: ' + 'a'.repeat(70); + const errs = v.validateCommitSubject(long); + assert.ok(errs.some(e => e.includes('72')), 'should mention 72: ' + errs.join('; ')); + }); + + it('rejects description ending with a period', () => { + const errs = v.validateCommitSubject('fix: resolve issue.'); + assert.ok(errs.some(e => e.includes('period')), 'trailing period in description: ' + errs.join('; ')); + }); +}); + +// ── detectAiFooter ──────────────────────────────────────────────────────────── + +describe('detectAiFooter', () => { + it('detects Claude Co-authored-by', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Claude ')); + }); + + it('detects ChatGPT Co-authored-by', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: ChatGPT ')); + }); + + 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 ')); + }); + + it('detects AI footer in PR body', () => { + assert.ok(v.detectAiFooter('## Summary\nDone.\n\nCo-authored-by: Gemini ')); + }); + + it('detects Co-authored-by case-insensitively (all-caps header)', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCO-AUTHORED-BY: Claude '), '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 '), 'all-caps identity should match'); + }); + + it('detects Cursor identity', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Cursor '), '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 '), 'Anthropic co-authored-by'); + }); + + it('detects Codeium identity', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codeium '), '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 '), 'OpenAI co-authored-by'); + }); + + it('does not flag generic AI discussion in prose', () => { + assert.ok(!v.detectAiFooter('fix: thing\n\nThis uses AI to improve performance.'), 'generic AI mention'); + assert.ok(!v.detectAiFooter('fix: thing\n\nUpdated AI model configuration.'), 'AI model config mention'); + assert.ok(!v.detectAiFooter('feat: add AI-powered search (DEV-1)\n\n## Summary\nAI search.'), 'AI in title/body prose'); + }); +}); + +// ── isWorkflowFilename ──────────────────────────────────────────────────────── + +describe('isWorkflowFilename', () => { + it('matches .github/workflows/*.yaml', () => { + assert.ok(v.isWorkflowFilename('.github/workflows/ci.yaml')); + }); + + it('matches .github/workflows/*.yml', () => { + assert.ok(v.isWorkflowFilename('.github/workflows/deploy.yml')); + }); + + it('matches workflow-templates/*.yml', () => { + assert.ok(v.isWorkflowFilename('workflow-templates/ci-node.yml')); + }); + + it('rejects nested path in workflows', () => { + assert.ok(!v.isWorkflowFilename('.github/workflows/subdir/ci.yaml')); + }); + + it('rejects non-YAML files', () => { + assert.ok(!v.isWorkflowFilename('.github/workflows/ci.json')); + }); + + it('rejects unrelated files', () => { + assert.ok(!v.isWorkflowFilename('src/foo.yaml')); + }); +}); + +// ── validateWorkflowContent ─────────────────────────────────────────────────── + +describe('validateWorkflowContent', () => { + const BASE = '.github/workflows/test.yaml'; + const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; + const ZERO_SHA = '0'.repeat(40); + + function wf(uses, extra = '') { + return [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - ' + uses, + extra, + ].join('\n'); + } + + it('accepts a fully pinned remote action', () => { + const content = wf(`uses: actions/checkout@${VALID_SHA} # v9.0.0`); + assert.deepEqual(v.validateWorkflowContent(content, BASE), []); + }); + + it('accepts local ./ ref without SHA requirement', () => { + const content = wf('uses: ./.github/workflows/sub.yaml'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), []); + }); + + it('accepts docker:// ref without SHA requirement', () => { + const content = wf('uses: docker://alpine:3.19'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), []); + }); + + it('rejects floating tag ref (v1, v2)', () => { + const content = wf('uses: actions/checkout@v4'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('40-char SHA')), 'floating tag should fail: ' + errs.join('; ')); + }); + + it('rejects SHA without version comment', () => { + const content = wf(`uses: actions/checkout@${VALID_SHA}`); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'no comment should fail: ' + errs.join('; ')); + }); + + it('rejects SHA with wrong comment format', () => { + const content = wf(`uses: actions/checkout@${VALID_SHA} # latest`); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'wrong comment should fail'); + }); + + it('rejects all-zero placeholder SHA', () => { + const content = wf(`uses: actions/checkout@${ZERO_SHA} # v1.0.0`); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('placeholder') || e.includes('zero') || e.includes('all-zero')), 'all-zero SHA should fail: ' + errs.join('; ')); + }); + + it('rejects v0.0.0 placeholder version', () => { + const content = wf(`uses: actions/checkout@${VALID_SHA} # v0.0.0`); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('v0.0.0') || e.includes('placeholder')), 'v0.0.0 should fail: ' + errs.join('; ')); + }); + + it('rejects both all-zero SHA and v0.0.0', () => { + const content = wf(`uses: actions/checkout@${ZERO_SHA} # v0.0.0`); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.length >= 2, 'should report two errors (zero SHA + v0.0.0): ' + errs.join('; ')); + }); + + it('rejects missing top-level permissions', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: ./.github/workflows/sub.yaml', + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions should fail: ' + errs.join('; ')); + }); + + it('accepts top-level permissions: {} (explicit empty)', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions: {}', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: ./.github/workflows/sub.yaml', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), []); + }); + + it('accepts quoted uses: value with valid SHA', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ` - uses: "${VALID_SHA} # v9.0.0"`, + ].join('\n'); + // The quoted value doesn't have an owner/repo@ prefix — just validate that + // the quote-stripping doesn't break the parser. A quoted SHA without an owner + // won't parse as a remote action at all (no @ before sha). Confirm no crash. + // Use a proper quoted action ref: + const content2 = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ` - uses: "actions/checkout@${VALID_SHA} # v9.0.0"`, + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content2, BASE), [], 'double-quoted valid ref should pass'); + }); + + it('accepts single-quoted uses: value with valid SHA', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ` - uses: 'actions/checkout@${VALID_SHA} # v9.0.0'`, + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted valid ref should pass'); + }); + + it('does not treat uses: inside a run: block as an action reference', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: shell-step', + ' run: |', + ' echo "uses: actions/checkout@v4"', + ' uses: some-other@v1', + ' echo done', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block must not be flagged'); + }); + + it('rejects ${{ }} in run: inline value', () => { + const EXPR = '$' + '{{ github.token }}'; + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: bad', + ' run: echo ' + EXPR, + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('env:')), 'inline ${{ }} should fail: ' + errs.join('; ')); + }); + + it('rejects ${{ }} in run: block scalar', () => { + const EXPR = '$' + '{{ secrets.OTHER }}'; + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: bad', + ' env:', + ' TOKEN: $' + '{{ secrets.TOKEN }}', + ' run: |', + ' echo ' + EXPR, + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('env:')), 'block ${{ }} should fail: ' + errs.join('; ')); + }); + + it('does not flag ${{ }} in env: above run:', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: ok', + ' env:', + ' MY_VAR: $' + '{{ secrets.TOKEN }}', + ' run: |', + ' echo "$MY_VAR"', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), []); + }); + + it('accumulates multiple violations', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: actions/checkout@v4', + ' - uses: actions/setup-node@v4', + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.length >= 3, 'should have at least 3 errors (no perms + 2 bad pins): ' + errs.join('; ')); + }); +}); + +// ── checkCommitLimit ────────────────────────────────────────────────────────── + +describe('checkCommitLimit', () => { + it('returns null for exactly 250 commits', () => { + assert.equal(v.checkCommitLimit(250), null); + }); + + it('returns null for fewer than 250 commits', () => { + assert.equal(v.checkCommitLimit(1), null); + assert.equal(v.checkCommitLimit(100), null); + }); + + it('returns an infra error string for 251 commits', () => { + const result = v.checkCommitLimit(251); + assert.ok(result !== null, 'should return error for >250'); + assert.ok(result.includes('250'), 'error should mention the limit: ' + result); + }); + + it('returns an infra error string for large commit count', () => { + const result = v.checkCommitLimit(1000); + assert.ok(result !== null, 'should return error for large count'); + assert.ok(result.includes('1000'), 'error should include the actual count: ' + result); + }); +}); + +// ── checkFilesLimit ─────────────────────────────────────────────────────────── + +describe('checkFilesLimit', () => { + it('returns null for exactly 3000 files', () => { + assert.equal(v.checkFilesLimit(3000), null); + }); + + it('returns null for fewer than 3000 files', () => { + assert.equal(v.checkFilesLimit(1), null); + assert.equal(v.checkFilesLimit(500), null); + }); + + it('returns an infra error string for 3001 files', () => { + const result = v.checkFilesLimit(3001); + assert.ok(result !== null, 'should return error for >3000'); + assert.ok(result.includes('3000'), 'error should mention the limit: ' + result); + }); + + it('returns an infra error string for large file count', () => { + const result = v.checkFilesLimit(5000); + assert.ok(result !== null, 'should return error for large count'); + assert.ok(result.includes('5000'), 'error should include the actual count: ' + result); + }); +}); + +// ── parseRetryAfterMs ───────────────────────────────────────────────────────── + +describe('parseRetryAfterMs', () => { + it('parses integer seconds', () => { + assert.equal(v.parseRetryAfterMs('30'), 30000); + assert.equal(v.parseRetryAfterMs('1'), 1000); + }); + + it('returns 0 for zero seconds', () => { + assert.equal(v.parseRetryAfterMs('0'), 0); + }); + + it('caps at 60000ms for large integer values', () => { + assert.equal(v.parseRetryAfterMs('999'), 60000); + assert.equal(v.parseRetryAfterMs('61'), 60000); + }); + + it('parses HTTP-date format and returns positive ms', () => { + const nowMs = Date.now(); + const futureDate = new Date(nowMs + 10000).toUTCString(); + const result = v.parseRetryAfterMs(futureDate, nowMs); + assert.ok(result > 9000 && result <= 10000, 'HTTP-date ~10s in future should produce ~10000ms, got ' + result); + }); + + it('returns 0 for past HTTP-date', () => { + const nowMs = Date.now(); + const pastDate = new Date(nowMs - 5000).toUTCString(); + assert.equal(v.parseRetryAfterMs(pastDate, nowMs), 0); + }); + + it('returns 0 for invalid header string', () => { + assert.equal(v.parseRetryAfterMs('not-a-number'), 0); + assert.equal(v.parseRetryAfterMs('banana'), 0); + }); + + it('returns 0 for empty string', () => { + assert.equal(v.parseRetryAfterMs(''), 0); + }); + + it('returns 0 for missing header (falsy)', () => { + assert.equal(v.parseRetryAfterMs(undefined), 0); + assert.equal(v.parseRetryAfterMs(null), 0); + }); + + it('caps HTTP-date result at 60000ms', () => { + const nowMs = Date.now(); + const farFutureDate = new Date(nowMs + 120000).toUTCString(); + assert.equal(v.parseRetryAfterMs(farFutureDate, nowMs), 60000); + }); +}); + +// ── Additional branch-segment hygiene tests (fix 7) ────────────────────────── + +describe('validateBranch — consecutive and trailing hyphens', () => { + it('rejects consecutive hyphens in segment', () => { + const errs = v.validateBranch('feature/my--feature', false); + assert.ok(errs.length > 0, 'consecutive hyphens should be rejected'); + assert.ok(errs.some(e => e.includes('feature/my--feature')), 'error should name the bad branch: ' + errs.join('; ')); + }); + + it('rejects trailing hyphen in segment', () => { + const errs = v.validateBranch('feature/my-feature-', false); + assert.ok(errs.length > 0, 'trailing hyphen should be rejected'); + }); + + it('rejects segment that is just a hyphen', () => { + const errs = v.validateBranch('feature/-', false); + assert.ok(errs.length > 0, 'bare hyphen segment should be rejected'); + }); + + it('accepts proper kebab-case with multiple words', () => { + assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []); + assert.deepEqual(v.validateBranch('fix/patch-null-check', false), []); + assert.deepEqual(v.validateBranch('chore/update-deps', false), []); + }); + + it('accepts single-word segment', () => { + assert.deepEqual(v.validateBranch('feature/auth', false), []); + assert.deepEqual(v.validateBranch('fix/login', false), []); + }); +}); + +// ── AI-footer extended patterns (fix 6) ────────────────────────────────────── + +describe('detectAiFooter — extended AI identities', () => { + it('detects Codex in Co-authored-by', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codex '), 'Codex Co-authored-by'); + assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: codex '), '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 '), 'GPT-5 Co-authored-by'); + assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: gpt-5 '), 'lowercase gpt-5'); + }); + + it('detects GPT-4o Co-authored-by', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-4o '), '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 '), 'GPT-3'); + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-4 '), 'GPT-4'); + }); + + it('does not flag "GPT" without version as generic prose', () => { + // "GPT" alone as a word in prose should not be flagged — there must be a dash+version. + assert.ok(!v.detectAiFooter('fix: thing\n\nUpdated GPT configuration.'), 'bare GPT in prose is not a trailer'); + }); +}); + +// ── validateWorkflowContent — quoted keys, block-scalar improvements (fixes 1-3) ── + +describe('validateWorkflowContent — quoted keys and block-scalar tracking', () => { + const BASE = '.github/workflows/test.yaml'; + const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; + + function wfHeader() { + return [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ].join('\n'); + } + + it('recognises double-quoted "uses" key and validates pin', () => { + // "uses": floating-tag should still be rejected + const content = wfHeader() + '\n - "uses": actions/checkout@v4'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('40-char SHA')), 'quoted "uses" floating tag must fail: ' + errs.join('; ')); + }); + + it('accepts double-quoted "uses" key with valid pinned SHA', () => { + const content = wfHeader() + `\n - "uses": actions/checkout@${VALID_SHA} # v9.0.0`; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'quoted "uses" with valid SHA should pass'); + }); + + it('recognises single-quoted uses key and validates pin', () => { + const content = wfHeader() + "\n - 'uses': actions/checkout@v4"; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('40-char SHA')), "single-quoted 'uses' floating tag must fail: " + errs.join('; ')); + }); + + it('accepts single-quoted uses key with valid pinned SHA', () => { + const content = wfHeader() + `\n - 'uses': actions/checkout@${VALID_SHA} # v9.0.0`; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted 'uses' with valid SHA should pass"); + }); + + it('recognises sequence-form "- run: |" and does not flag uses: inside as action ref', () => { + // The sequence item `- run: |` opens a run block scalar. + // uses: lines inside must not be treated as action references. + const content = [ + ...wfHeader().split('\n'), + ' - name: build', + ' run: |', + ' echo "uses: actions/checkout@v4"', + ' uses: some/action@v1', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block (sequence step) must not be flagged'); + }); + + it('recognises sequence-form "- run: echo hi" inline and does not flag uses: on next step', () => { + const content = [ + ...wfHeader().split('\n'), + ` - run: echo hello`, + ` - uses: actions/checkout@${VALID_SHA} # v9.0.0`, + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'run: inline does not swallow uses: on next step'); + }); + + it('does not flag uses: inside a non-run block scalar (e.g. script: |)', () => { + // script: | is a block scalar that is NOT a run block. + // uses: lines inside must not be treated as action references. + // Expressions inside script: | must not be flagged as run: injection. + const EXPR = '$' + '{{ github.token }}'; + const content = [ + ...wfHeader().split('\n'), + ' - name: js-step', + ' uses: actions/github-script@' + VALID_SHA + ' # v9.0.0', + ' with:', + ' script: |', + ' // uses: actions/checkout@v4 (this is JS comment, not YAML)', + ' uses: some/action@v1', + ' const tok = ' + EXPR + ';', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: and expressions inside script: block must not be flagged'); + }); + + it('accepts quoted action ref with version comment OUTSIDE the quotes', () => { + // uses: "owner/repo@sha" # vX.Y.Z — comment is a YAML comment, not inside quotes. + const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}" # v9.0.0`; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'quoted ref with external comment should pass'); + }); + + it('accepts single-quoted action ref with version comment OUTSIDE the quotes', () => { + const content = wfHeader() + `\n - uses: 'actions/checkout@${VALID_SHA}' # v9.0.0`; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted ref with external comment should pass'); + }); + + it('rejects quoted action ref with no comment at all', () => { + const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}"`; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'quoted ref with no comment must fail: ' + errs.join('; ')); + }); + + it('recognises quoted "permissions" key at column 0 for top-level check', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + '"permissions":', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: ./.github/workflows/sub.yaml', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'double-quoted "permissions": at col 0 should count'); + }); +}); + +// ── validateTitle — Jira-backed revert is not an emergency candidate (fix 5) ── + +describe('validateTitle — Jira-backed revert semantics', () => { + it('accepts revert title WITH Jira key regardless of jiraMaybeExempt', () => { + // A revert that already carries a Jira key needs no emergency exemption. + assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, false), []); + assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, true), []); + }); + + it('getJiraKey extracts key from Jira-backed revert title', () => { + // Confirms that getJiraKey correctly identifies a revert title with Jira key, + // which is what the main logic uses to decide isEmergencyCandidate = false. + assert.equal(v.getJiraKey('revert: rollback payment service (DEV-42)'), 'DEV-42'); + }); + + it('getJiraKey returns null for revert title without Jira key', () => { + // Null result means isEmergencyCandidate COULD be true (if label is also present). + assert.equal(v.getJiraKey('revert: emergency rollback of payment service'), null); + }); +}); + +// ── Scanner fail-closed cases (fixes: |2, flow, escaped keys, aliases) ─────── + +describe('validateWorkflowContent — scanner fail-closed cases', () => { + const BASE = '.github/workflows/test.yaml'; + const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; + + function wfHeader() { + return [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ].join('\n'); + } + + // ── Fix 1: explicit block indent/chomp indicators |2, |2-, |-2, >+2 ────── + + it('enters run block on "run: |2" and detects expression injection inside', () => { + const EXPR = '$' + '{{ github.token }}'; + const content = [ + ...wfHeader().split('\n'), + ' - name: x', + ' run: |2', + ' echo hi', + ' echo ' + EXPR, + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('env:')), 'run |2 block should flag expression: ' + errs.join('; ')); + }); + + it('enters run block on "run: |2-" and detects expression injection', () => { + const EXPR = '$' + '{{ secrets.TOKEN }}'; + const content = [ + ...wfHeader().split('\n'), + ' - name: x', + ' run: |2-', + ' echo ' + EXPR, + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('env:')), 'run |2- block should flag expression: ' + errs.join('; ')); + }); + + it('enters run block on "run: |-2" and detects expression injection', () => { + const EXPR = '$' + '{{ github.ref }}'; + const content = [ + ...wfHeader().split('\n'), + ' - name: x', + ' run: |-2', + ' echo ' + EXPR, + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('env:')), 'run |-2 block should flag expression: ' + errs.join('; ')); + }); + + it('enters run block on "run: >+2" and detects expression injection', () => { + const EXPR = '$' + '{{ github.actor }}'; + const content = [ + ...wfHeader().split('\n'), + ' - name: x', + ' run: >+2', + ' echo ' + EXPR, + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('env:')), 'run >+2 block should flag expression: ' + errs.join('; ')); + }); + + it('does NOT flag uses: inside run: |2 block as an action reference', () => { + const content = [ + ...wfHeader().split('\n'), + ' - name: x', + ' run: |2', + ' uses: actions/checkout@v4', + ' echo done', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run |2 must not be flagged'); + }); + + it('sequence-form "- run: |2" correctly enters run block', () => { + const EXPR = '$' + '{{ github.sha }}'; + const content = [ + ...wfHeader().split('\n'), + ' - run: |2', + ' echo ' + EXPR, + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('env:')), 'sequence - run: |2 should detect expression: ' + errs.join('; ')); + }); + + // ── Fix 2: flow-style sequence steps ───────────────────────────────────── + + it('rejects flow-style step "- { uses: ... }"', () => { + const content = wfHeader() + '\n - { uses: actions/checkout@v4, with: { token: x } }'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('flow-style')), 'flow uses step should fail: ' + errs.join('; ')); + }); + + it('rejects flow-style step "- { run: ... }"', () => { + const content = wfHeader() + '\n - { run: echo hello }'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('flow-style')), 'flow run step should fail: ' + errs.join('; ')); + }); + + it('rejects flow-style step with any nonempty mapping', () => { + const content = wfHeader() + '\n - { name: my-step, uses: actions/checkout@v4 }'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('flow-style')), 'any nonempty flow step should fail: ' + errs.join('; ')); + }); + + it('does NOT reject permissions: {} (mapping value, not sequence item)', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions: {}', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: ./.github/workflows/sub.yaml', + ].join('\n'); + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'permissions: {} must not be rejected'); + }); + + // ── Fix 3: escaped/encoded structural keys ───────────────────────────────── + + it('rejects double-quoted key with Unicode escape "u\\u0073es" (encodes "uses")', () => { + // In the YAML source, the key is literally "u\u0073es": — the scanner sees \u + const escapedUses = '"u\\u0073es": actions/checkout@v4'; + const content = wfHeader() + '\n - ' + escapedUses; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped uses key must be rejected: ' + errs.join('; ')); + }); + + it('rejects double-quoted key with Unicode escape "r\\u0075n" (encodes "run")', () => { + const escapedRun = '"r\\u0075n": echo hello'; + const content = wfHeader() + '\n ' + escapedRun; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped run key must be rejected: ' + errs.join('; ')); + }); + + it('does NOT reject literal double-quoted "uses" key (no backslash)', () => { + const content = wfHeader() + `\n - "uses": actions/checkout@${VALID_SHA} # v9.0.0`; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "uses" key should pass'); + }); + + it('does NOT reject literal double-quoted "run" key (no backslash)', () => { + const content = wfHeader() + '\n "run": echo hello'; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "run" key should pass'); + }); + + // ── Fix 4: YAML aliases/anchors on structural values ────────────────────── + + it('rejects YAML alias in "run:" value (run: *command)', () => { + const content = wfHeader() + '\n run: *deploy_script'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must fail: ' + errs.join('; ')); + }); + + it('rejects YAML alias in "uses:" value (uses: *action_ref)', () => { + const content = wfHeader() + '\n - uses: *checkout_action'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: *alias must fail: ' + errs.join('; ')); + }); + + it('rejects YAML anchor definition in "run:" value (run: &anchor |)', () => { + // &anchor before the block indicator means the run block has an anchor definition. + // The line scanner cannot safely resolve what aliases to *anchor will execute. + const content = wfHeader() + '\n run: &deploy_script |'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must fail: ' + errs.join('; ')); + }); + + it('rejects YAML anchor definition in "uses:" value (uses: &anchor ref)', () => { + const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must fail: ' + errs.join('; ')); + }); + + it('rejects YAML alias for top-level permissions: value', () => { + const content = [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions: *read_perms', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: ./.github/workflows/sub.yaml', + ].join('\n'); + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor') || e.includes('permissions')), 'permissions: *alias must fail: ' + errs.join('; ')); + }); + + // ── Fix: flow mapping false-positive — non-step data must pass ──────────── + + it('allows flow-style sequence item without structural uses/run key', () => { + const content = wfHeader() + '\n - { os: ubuntu-latest, node: 24 }'; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'matrix data object must not be rejected'); + }); + + it('allows flow-style sequence item with only name key', () => { + const content = wfHeader() + '\n - { name: my-step, timeout: 10 }'; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'non-step flow object with name/timeout must pass'); + }); + + it('still rejects flow-style step with uses: key inside', () => { + const content = wfHeader() + '\n - { uses: actions/checkout@v4 }'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('flow-style')), '- { uses: ... } must still fail: ' + errs.join('; ')); + }); + + it('still rejects flow-style step with run: key inside', () => { + const content = wfHeader() + '\n - { run: echo hi }'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('flow-style')), '- { run: ... } must still fail: ' + errs.join('; ')); + }); + + it('still rejects flow-style step with uses: after a comma', () => { + const content = wfHeader() + '\n - { name: checkout, uses: actions/checkout@v4 }'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('flow-style')), '- { name: x, uses: ... } must still fail: ' + errs.join('; ')); + }); + + // ── Fix: anchor/alias false-positive — ordinary shell & must pass ───────── + + it('allows run: value containing & in a shell command (not a YAML anchor)', () => { + const content = wfHeader() + '\n run: echo "R&D build"'; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell & in run value must not be rejected'); + }); + + it('allows run: value with && (shell AND operator)', () => { + const content = wfHeader() + '\n run: make build && make test'; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell && must not be rejected'); + }); + + it('allows single-quoted run: value with & inside', () => { + const content = wfHeader() + "\n run: 'echo R&D'"; + assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted run with & must pass"); + }); + + it('still rejects run: *alias (YAML alias token)', () => { + const content = wfHeader() + '\n run: *deploy_script'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must still fail: ' + errs.join('; ')); + }); + + it('still rejects run: &anchor | (YAML anchor before block indicator)', () => { + const content = wfHeader() + '\n run: &deploy_script |'; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must still fail: ' + errs.join('; ')); + }); + + it('still rejects uses: &anchor ref (YAML anchor in action ref)', () => { + const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`; + const errs = v.validateWorkflowContent(content, BASE); + assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must still fail: ' + errs.join('; ')); + }); +}); + +// ── Static assertions on the YAML file ─────────────────────────────────────── + +describe('static YAML assertions', () => { + let yamlText; + before(() => { + yamlText = readFileSync( + join(__dirname, '../.github/workflows/callable-pr-policy.yaml'), + 'utf8' + ); + }); + + it('does not use pull_request_target as a trigger', () => { + // The word may appear in comments, but must never be a YAML on: trigger key. + assert.ok( + !/^\s*pull_request_target\s*:/m.test(yamlText), + 'callable-pr-policy.yaml must not declare pull_request_target as an on: trigger' + ); + }); + + it('pins github-script to the exact SHA', () => { + assert.ok( + yamlText.includes('actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'), + 'must pin github-script to 3a2844b7e9c422d3c10d287c895573f7108da1b3' + ); + }); + + it('includes the v9.0.0 version comment', () => { + assert.ok(yamlText.includes('# v9.0.0'), 'must have # v9.0.0 comment'); + }); + + it('declares job id "pr"', () => { + assert.ok(/^ pr:$/m.test(yamlText), 'job id must be "pr"'); + }); + + it('policy.yaml uses job id "policy"', () => { + const policyYaml = readFileSync(join(__dirname, '../.github/workflows/policy.yaml'), 'utf8'); + assert.ok(/^ policy:$/m.test(policyYaml), 'caller job id must be "policy"'); + }); + + it('policy.yaml does not use pull_request_target as a trigger', () => { + const policyYaml = readFileSync(join(__dirname, '../.github/workflows/policy.yaml'), 'utf8'); + assert.ok( + !/^\s*pull_request_target\s*:/m.test(policyYaml), + 'policy.yaml must not declare pull_request_target as an on: trigger' + ); + }); +});