diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c4a2d58..34ed237 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,6 @@ PR conventions - Maximum 120 characters, including the Jira suffix. - Active Jira projects: DEV (product), PLAT (platform), SEC (security). INFRA is a closed archive. - Put the Jira key at the end of the title in parentheses. A missing key is a warning, not a failure. - - Dependabot-authored PRs skip the policy gate. Authorized emergency reverts suppress the missing-key warning. - Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ + kebab-case description. Branch names do not contain Jira keys. - Scope: one logical change per PR. If the title needs "and", split it. diff --git a/.github/workflows/callable-pr-policy.yaml b/.github/workflows/callable-pr-policy.yaml deleted file mode 100644 index 69413f5..0000000 --- a/.github/workflows/callable-pr-policy.yaml +++ /dev/null @@ -1,924 +0,0 @@ -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-authored PRs (pull_request.user.login == dependabot[bot]) exit -# successfully with no metadata or supply-chain checks. -# -# A missing Jira key on a human PR is a warning, not a failure. A present key -# is still verified against Jira and fails closed on lookup or credential errors. -# -# 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 so the missing-key warning is suppressed. -# 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 > 120) errs.push('Title is ' + title.length + ' chars — max 120'); - const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC|AP)-\d+\))?$/); - if (!m) { - errs.push('Title must match: type(scope): description (KEY-NNN). Allowed types: ' + CONV_TYPES.join(' ')); - return errs; - } - const desc = m[4]; - 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'); - return errs; - } - - function getJiraKey(title) { - const m = title.match(/\((DEV|PLAT|SEC|AP)-(\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|AP|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'); - return errs; - } - - function isSyncMergeCommit(commit) { - if (!Array.isArray(commit.parents) || commit.parents.length < 2) return false; - const subject = (commit.commit && commit.commit.message ? commit.commit.message : '').split('\n')[0]; - return /^Merge (?:branch|remote-tracking branch) '[^']+' into \S.+$/.test(subject); - } - - 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); - } - - // Matches action manifests at any depth (e.g. .github/actions/**/action.yml). - function isActionManifestFilename(filename) { - return /(?:^|\/)action\.ya?ml$/.test(filename); - } - - // Combined predicate — any file that the supply-chain scanner must process. - function isPolicyFilename(filename) { - return isWorkflowFilename(filename) || isActionManifestFilename(filename); - } - - // Returns 'workflow', 'action', or null for non-policy files. - // Used by classifyFileStatus to prevent cross-kind rename diffing. - function policyFileKind(filename) { - if (isWorkflowFilename(filename)) return 'workflow'; - if (isActionManifestFilename(filename)) return 'action'; - return null; - } - - // FNV-1a 32-bit hash of a string — used to produce content fingerprints - // for line-specific violations so changing the payload changes the - // fingerprint even when the violation remains on the same line. - function fnv1a32(str) { - let h = 2166136261; - for (let i = 0; i < str.length; i++) { - h = Math.imul(h ^ str.charCodeAt(i), 16777619) >>> 0; - } - return h.toString(16).padStart(8, '0'); - } - - // Strip the trailing [fnv:XXXXXXXX] content-hash token from a violation - // string before emitting it to users. The hash is purely internal. - function stripHash(msg) { - return msg.replace(/ \[fnv:[0-9a-f]{8}\]$/, ''); - } - - // 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:). uses: block scalars are rejected because an - // action ref must be an inline scalar to validate its immutable pin. - // For other 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. - // opts.requirePermissions (default true) — workflow files require a top-level - // permissions: key; action manifests do not support it and must pass false. - function validateWorkflowContent(content, filename, opts) { - const requirePermissions = !opts || opts.requirePermissions !== false; - const errs = []; - const EXPR_OPEN = '$' + '{{'; - - // 1. Top-level permissions key required (workflows only; not action manifests). - if (requirePermissions) { - 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: [fnv:' + fnv1a32(line.trim()) + ']'); - } - 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:) [fnv:' + fnv1a32(line.trim()) + ']'); - continue; - } - - // ── Structural key with whitespace before colon — fail closed ──── - // YAML permits `key : value` but the scanner matches `key:` forms - // only; a space before the colon silently bypasses all checks. - // Reject any structural key (uses, run, steps — quoted or plain, - // sequence-item or mapping) that has whitespace before the colon. - if (/^[ \t]*(?:-[ \t]+)?(?:"(?:uses|run|steps)"|'(?:uses|run|steps)'|uses|run|steps)[ \t]+:/.test(line)) { - errs.push(filename + ':' + (i + 1) + ': structural key with whitespace before ":" is not supported — remove the space before the colon [fnv:' + fnv1a32(line.trim()) + ']'); - continue; - } - - // ── Sequence-item anchor declaration — fail closed ─────────────── - // Any line of the form `- &anchor` (with or without a mapping on - // the same line) is rejected. A standalone `- &name` can be - // followed on the next line by a flow-style mapping that the - // scanner would then misread as structural YAML. The multiline - // alias form `- *name` on subsequent steps is also unreachable - // without first declaring such an anchor. Reject unconditionally. - if (/^[ \t]*-[ \t]+&\S+/.test(line)) { - errs.push(filename + ':' + (i + 1) + ': sequence-item anchor declaration (&name) is not supported — anchors on steps may introduce flow mappings the scanner cannot safely resolve [fnv:' + fnv1a32(line.trim()) + ']'); - 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 [fnv:' + fnv1a32(line.trim()) + ']'); - } - continue; - } - - // ── Flow-style steps array — fail closed ───────────────────────── - // `steps: [...]` and `steps: [` (multiline opener) cannot be - // safely resolved. Exception: `steps: []` is an empty array - // with no execution and is explicitly allowed. - // Quoted ("steps") and unquoted forms are both detected. - const stepsFlowM = line.match(/^[ \t]*(?:"steps"|'steps'|steps):[ \t]*\[(.*)$/); - if (stepsFlowM) { - const inner = stepsFlowM[1].trimStart(); - if (!/^\]\s*(#.*)?$/.test(inner)) { - errs.push(filename + ':' + (i + 1) + ': flow-style "steps" array is not supported — use block-style steps list [fnv:' + fnv1a32(line.trim()) + ']'); - } - 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] || '')); - if (keyName === 'uses') { - errs.push(filename + ':' + (i + 1) + ': uses: block scalar is not supported — action refs must be inline and pinned to an immutable SHA [fnv:' + fnv1a32(line.trim()) + ']'); - continue; - } - 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 [fnv:' + fnv1a32(line.trim()) + ']'); - continue; - } - if (inlineRunM[1].includes(EXPR_OPEN)) { - errs.push(filename + ':' + (i + 1) + ': run: value contains ' + EXPR_OPEN + ' }} — expressions must go through env: [fnv:' + fnv1a32(line.trim()) + ']'); - } - 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 [fnv:' + fnv1a32(line.trim()) + ']'); - 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 action references cannot be validated — the scanner - // does not recursively resolve action manifests. Inline the - // action logic or replace with an immutable remote SHA pin. - if (ref.startsWith('./')) { - const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref; - errs.push(filename + ': "uses: ' + short + '" local action reference is not supported — inline the action or use an immutable remote SHA pin'); - continue; - } - - // Docker refs require an immutable sha256 digest pin. - // Mutable tags, :latest, and bare image names are rejected. - // No # vX.Y.Z comment is required because the digest is the - // immutable identity. - if (ref.startsWith('docker://')) { - if (!/^docker:\/\/.+@sha256:[0-9a-f]{64}$/.test(ref)) { - const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref; - errs.push(filename + ': "uses: ' + short + '" docker:// ref must be pinned by immutable digest (docker://@sha256:<64 lowercase hex>)'); - } - 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; - } - - // Normalize a validateWorkflowContent error string to a diff fingerprint. - // Per-line locations are intentionally preserved so moving a grandfathered - // violation to a different execution path is treated as a new violation. - // Content-identifying tokens (action ref, expression text) are preserved. - function normalizeViolationFingerprint(err) { - return err; - } - - // Diff head vs base violations using location-preserving fingerprints - // with multiplicity. For each fingerprint, up to base-count head - // violations of that fingerprint are considered pre-existing; the - // remainder are new. Violations are returned in head-file order. - function filterNewViolations(headErrs, baseErrs) { - const baseCounts = new Map(); - for (const e of baseErrs) { - const fp = normalizeViolationFingerprint(e); - baseCounts.set(fp, (baseCounts.get(fp) || 0) + 1); - } - const remaining = new Map(baseCounts); - const result = []; - for (const e of headErrs) { - const fp = normalizeViolationFingerprint(e); - const rem = remaining.get(fp) || 0; - if (rem > 0) { - remaining.set(fp, rem - 1); - } else { - result.push(e); - } - } - return result; - } - - // Classify the status of a pull-request file for workflow scanning. - // Returns one of four action objects: - // { action: 'skip' } — removed or unchanged; no validation - // { action: 'full' } — added or copied; full validation, no baseline - // { action: 'diff', basePath: string } — modified/changed or renamed-from-workflow; - // validate head, diff against base at basePath - // { action: 'infra', reason: string } — unknown status; report POLICY-INFRA - // isWfFn must be the isWorkflowFilename predicate (injectable for testing). - function classifyFileStatus(file, isWfFn) { - const s = file.status; - if (s === 'removed' || s === 'unchanged') return { action: 'skip' }; - if (s === 'added' || s === 'copied') return { action: 'full' }; - if (s === 'modified' || s === 'changed') return { action: 'diff', basePath: file.filename }; - if (s === 'renamed') { - if (file.previous_filename && - isWfFn(file.previous_filename) && - policyFileKind(file.previous_filename) === policyFileKind(file.filename)) { - return { action: 'diff', basePath: file.previous_filename }; - } - return { action: 'full' }; - } - return { action: 'infra', reason: 'unknown file status "' + s + '" for ' + file.filename }; - } - - // ── 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, - isSyncMergeCommit, - detectAiFooter, - isWorkflowFilename, - isActionManifestFilename, - isPolicyFilename, - policyFileKind, - validateWorkflowContent, - parseRetryAfterMs, - checkCommitLimit, - checkFilesLimit, - normalizeViolationFingerprint, - filterNewViolations, - fnv1a32, - stripHash, - classifyFileStatus, - }; - } - - // ── 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 warnings = []; - const infraCodes = []; - let infraFailed = false; - const MAX_ANNOTATIONS = 50; - - function addViolation(msg) { violations.push(msg); } - function addWarning(msg) { warnings.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]'; - if (isDep) { - await core.summary.addRaw('## PR Policy: skipped (Dependabot)').write(); - return; - } - 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]; - if (!isSyncMergeCommit(c)) { - 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. A present key is verified fail-closed. A missing - // key is a warning, except authorized emergency-reverts which stay silent. - // Skip the existence lookup when emergency auth already produced an infra - // error to avoid a redundant credential error on a PR that has no key. - const jiraKey = getJiraKey(pr.title); - if (!jiraKey && !jiraExempt) { - addWarning('Missing Jira key. Expected (DEV-NNN), (PLAT-NNN), (SEC-NNN), or (AP-NNN) at end of 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 (diff-mode) - // Only NEW violations relative to the base branch are reported. - // Added files have no baseline and must be fully compliant. - // Modified/renamed files are diffed: base content is fetched at - // pr.base.sha (using previous_filename for renames). Base fetch - // failures are POLICY-INFRA — partial history is never silently - // grandfathered. Deleted files are skipped. - // GitHub REST API caps listFiles at 3000. - 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 (!isPolicyFilename(file.filename)) continue; - - const cls = classifyFileStatus(file, isPolicyFilename); - if (cls.action === 'skip') continue; - if (cls.action === 'infra') { - addInfra('POLICY-INFRA: ' + cls.reason + '; skipping workflow validation'); - continue; - } - - // Fetch HEAD content via blob SHA. - let headContent; - try { - const blobResp = await github.rest.git.getBlob({ owner: repoOwner, repo: repoName, file_sha: file.sha }); - const raw = blobResp.data; - const enc = raw.encoding === 'base64' ? 'base64' : 'utf8'; - headContent = Buffer.from(raw.content, enc).toString('utf8'); - } catch (blobErr) { - addInfra('POLICY-INFRA: Cannot fetch blob for ' + file.filename + ': ' + blobErr.message); - continue; - } - - // Action manifests do not support top-level permissions:. - const wfOpts = policyFileKind(file.filename) === 'action' ? { requirePermissions: false } : {}; - const headErrs = validateWorkflowContent(headContent, file.filename, wfOpts); - - if (cls.action === 'full') { - // No baseline — added, copied, or renamed-from-non-policy path. - for (const e of headErrs) addViolation(e); - } else { - // diff — modified, changed, or renamed-from-policy path. - // Both head and base violations use file.filename so fingerprints match. - let baseErrs = []; - try { - const baseResp = await github.rest.repos.getContent({ owner: repoOwner, repo: repoName, path: cls.basePath, ref: pr.base.sha }); - const baseRaw = baseResp.data; - const baseEnc = baseRaw.encoding === 'base64' ? 'base64' : 'utf8'; - const baseContent = Buffer.from(baseRaw.content, baseEnc).toString('utf8'); - baseErrs = validateWorkflowContent(baseContent, file.filename, wfOpts); - } catch (baseErr) { - addInfra('POLICY-INFRA: Cannot fetch base content for ' + file.filename + ' at ' + pr.base.sha + ': ' + baseErr.message); - continue; - } - for (const e of filterNewViolations(headErrs, baseErrs)) addViolation(e); - } - } - 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(stripHash(msg)); - for (const msg of infraCodes.slice(0, MAX_ANNOTATIONS)) core.error(msg); - for (const msg of warnings.slice(0, MAX_ANNOTATIONS)) core.warning(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('- ' + stripHash(msg)); - if (violations.length > MAX_ANNOTATIONS) { - summaryParts.push('- _...and ' + (violations.length - MAX_ANNOTATIONS) + ' more violation(s) not shown_'); - } - } - if (warnings.length > 0) { - summaryParts.push('', '### Warnings'); - const shownW = warnings.slice(0, MAX_ANNOTATIONS); - for (const msg of shownW) summaryParts.push('- ' + msg); - if (warnings.length > MAX_ANNOTATIONS) { - summaryParts.push('- _...and ' + (warnings.length - MAX_ANNOTATIONS) + ' more warning(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 e2d4a7b..9067c5c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,10 +53,6 @@ jobs: steps: - 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: ACTIONLINT_VERSION: 1.7.12 diff --git a/.github/workflows/release-on-merge.yaml b/.github/workflows/release-on-merge.yaml index 211885a..c865b11 100644 --- a/.github/workflows/release-on-merge.yaml +++ b/.github/workflows/release-on-merge.yaml @@ -30,7 +30,6 @@ on: - ".github/workflows/**" - "!.github/workflows/ci.yaml" - "!.github/workflows/labeler.yaml" - - "!.github/workflows/policy.yaml" - "!.github/workflows/release-on-merge.yaml" - "!.github/workflows/auto-merge.yaml" workflow_dispatch: diff --git a/README.md b/README.md index 138b2c2..e8a1ba1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Organization-level GitHub configuration for Sea Haven Industries. ### PR title -`type(scope): description (DEV-123)` — maximum 120 characters, including the Jira suffix. Put the Jira key at the end in parentheses. A missing key is a warning, not a failure. Active projects: **DEV** (product), **PLAT** (platform), **SEC** (security). INFRA is a closed archive. Dependabot-authored PRs skip the policy gate. Authorized emergency reverts suppress the missing-key warning. +`type(scope): description (DEV-123)` — maximum 120 characters, including the Jira suffix. Put the Jira key at the end in parentheses. A missing key is a warning, not a failure. Active projects: **DEV** (product), **PLAT** (platform), **SEC** (security). INFRA is a closed archive. ### PR body @@ -73,12 +73,6 @@ The formatter GitHub App is not on the main-branch bypass list. **`.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), 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 when a human PR title includes a Jira key. A missing key is a warning; a present key is verified fail-closed. Dependabot-authored PRs (`pull_request.user.login == dependabot[bot]`) exit successfully with no checks. Emergency `revert` PRs suppress the missing-key warning when the `emergency-revert` label was applied by a human collaborator with `maintain` or `admin` permission. - -The supply-chain check operates in **diff mode**: for modified or renamed workflow files, the gate fetches the base-branch version at `pr.base.sha` and reports only violations whose normalized fingerprint is absent from the base. Added files must be fully compliant. Historical drift already present in the base branch is handled by the drift audit/remediation backlog, not by this gate. A failure to fetch the base version is a `POLICY-INFRA` error and the file is not silently grandfathered. - -> **Supply-chain scanner.** Changed workflow files and action manifests (`action.yml` / `action.yaml` at any path) are scanned. Workflow files must use block-style structural keys and inline `run:`/`uses:` values. Action manifests follow the same constraints except that top-level `permissions:` is not required (action manifests do not support it). The scanner fails closed on YAML forms it cannot safely resolve: flow-style step mappings (`- { uses: ... }`, `- { run: ... }`), flow-style `steps` arrays (`steps: [...]` with any content — `steps: []` is allowed), sequence-item anchor declarations (`- &anchor { uses: ... }` and the multiline form `- &anchor`), escaped or Unicode-encoded structural keys in double-quoted strings (`"u\u0073es"`, `"r\u0075n"`), YAML aliases or anchors on `run:`, `uses:`, or `permissions:` values (`run: *cmd`, `uses: &anchor ...`), and local action references (`uses: ./...` — the scanner cannot recursively validate action manifests; inline the logic or replace with an immutable remote SHA pin). `docker://` action refs must carry an immutable sha256 digest pin (`docker://@sha256:<64 lowercase hex>`); mutable tags and bare image names are rejected. Use 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. @@ -93,9 +87,7 @@ The supply-chain check operates in **diff mode**: for modified or renamed workfl ### Workflow templates (`workflow-templates/`) -Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-hcp`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-terraform`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `hcp-fargate-deploy`, `hcp-spa-deploy`, `labeler`, `mobile-ios-deploy`, `pr-policy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone; `ci-hcp` is the converted-repo caller with autofix, frontend, terraform, and `ci-complete`). 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 is available in `workflow-templates/`. Before the template produces passing human PR checks, the three Jira org secrets must be granted to the consumer repo (see §1). +Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-hcp`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-terraform`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `hcp-fargate-deploy`, `hcp-spa-deploy`, `labeler`, `mobile-ios-deploy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone; `ci-hcp` is the converted-repo caller with autofix, frontend, terraform, and `ci-complete`). 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. ### Ref pinning policy @@ -188,15 +180,7 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each | `AUTOFMT_APP_ID` | Formatter GitHub App id | `ci-autofix.yaml` | | `AUTOFMT_APP_PRIVATE_KEY` | Formatter GitHub App private key | `ci-autofix.yaml` | -The remaining-lane 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 §4). HCP CD uses `vars.DEPLOY_ROLE_ARN` on the GitHub Environment after OIDC. Adam installs the formatter App (contents: write, metadata: read; not a main-branch ruleset bypass) and grants the two autofmt secrets before the first converted repo runs autofix. - -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` | +The remaining-lane 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). HCP CD uses `vars.DEPLOY_ROLE_ARN` on the GitHub Environment after OIDC. Adam installs the formatter App (contents: write, metadata: read; not a main-branch ruleset bypass) and grants the two autofmt secrets before the first converted repo runs autofix. ### 2. Add CI to a repo @@ -322,45 +306,7 @@ jobs: Python HCP callers pass `format-command: ruff format .` and `lint-fix-command: ruff check --fix .`. Optional `extra-command: terraform fmt -write` is available on `ci-autofix.yaml`. Do not run `eslint --fix` unless that repo's `lint` script is already fix-safe. Flip org ruleset membership in the same window as this merge: include on `CI complete`, exclude from `main branch protection`. Never require both `ci / ci` and `ci-complete`. Do not edit native GitHub merge-queue rulesets. -### 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@9c1ecf942894b19aba5c71b85b41906c6c83b749 # v1.0.5 - 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`. The current pinned SHA is `9c1ecf942894b19aba5c71b85b41906c6c83b749` (v1.0.5). To resolve the SHA for a future release: - -```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 +### 3. Add CD to a repo **HCP Fargate** (one caller job per GitHub Environment; `environment` is a `with:` input): diff --git a/test/pr-policy.test.mjs b/test/pr-policy.test.mjs deleted file mode 100644 index fb925bc..0000000 --- a/test/pr-policy.test.mjs +++ /dev/null @@ -1,2470 +0,0 @@ -/** - * pr-policy.test.mjs - * - * Extracts the exact `script: |` block from callable-pr-policy.yaml and - * exercises the pure validation functions via the PR_POLICY_TEST=1 escape. - * No npm dependencies — uses only Node.js built-ins. - * - * Run: node --test test/pr-policy.test.mjs - */ - -import { describe, it, before } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// ── Script extraction ──────────────────────────────────────────────────────── -// Reads the YAML file and extracts the literal block scalar under `script: |`. -// The strip amount is determined from the indentation of the first non-empty -// line after the `script: |` marker. - -function extractScriptBlock(yamlText) { - const lines = yamlText.split('\n'); - let state = 'looking'; - let strip = 0; - const scriptLines = []; - - for (let i = 0; i < lines.length; i++) { - if (state === 'looking') { - if (/^\s+script:\s*\|/.test(lines[i])) { - state = 'content'; - } - } else { - const line = lines[i]; - if (line.trim() === '') { - scriptLines.push(''); - continue; - } - const indent = (line.match(/^(\s*)/) || ['', ''])[1].length; - if (strip === 0) { - strip = indent; - } - if (indent >= strip) { - scriptLines.push(line.slice(strip)); - } else { - break; - } - } - } - - while (scriptLines.length && !scriptLines[scriptLines.length - 1].trim()) { - scriptLines.pop(); - } - return scriptLines.join('\n'); -} - -// ── Pure-function loader ───────────────────────────────────────────────────── -// Runs the extracted script with PR_POLICY_TEST=1, which causes the script to -// return the pure validator functions before making any API calls. - -let v; // shared validator object - -async function loadValidators() { - const yamlPath = join(__dirname, '../.github/workflows/callable-pr-policy.yaml'); - const yamlText = readFileSync(yamlPath, 'utf8'); - const scriptCode = extractScriptBlock(yamlText); - - assert.ok(scriptCode.length > 100, 'script block must be non-trivially long'); - assert.ok(scriptCode.includes('PR_POLICY_TEST'), 'script block must contain PR_POLICY_TEST escape'); - - process.env.PR_POLICY_TEST = '1'; - try { - // Wrap in an async IIFE matching how actions/github-script executes it. - // Pure functions do not call github/context/core, so empty mocks suffice. - const asyncFn = new Function( - 'github', 'context', 'core', 'process', - 'return (async function() {\n' + scriptCode + '\n})()' - ); - const mockCore = { - error: () => {}, - setFailed: () => {}, - warning: () => {}, - summary: { addRaw: () => ({ write: async () => {} }) }, - }; - v = await asyncFn({}, {}, mockCore, process); - } finally { - delete process.env.PR_POLICY_TEST; - } - - assert.ok(v && typeof v === 'object', 'PR_POLICY_TEST escape must return an object'); - for (const fn of [ - 'validateTitle', 'getJiraKey', 'validateBranch', 'validateBody', - 'validateCommitSubject', 'isSyncMergeCommit', 'detectAiFooter', 'isWorkflowFilename', - 'validateWorkflowContent', 'parseRetryAfterMs', 'checkCommitLimit', - 'checkFilesLimit', 'normalizeViolationFingerprint', 'filterNewViolations', - 'fnv1a32', 'stripHash', 'classifyFileStatus', - ]) { - assert.equal(typeof v[fn], 'function', fn + ' must be exported'); - } -} - -// ── Test suite ─────────────────────────────────────────────────────────────── - -before(async () => { await loadValidators(); }); - -// ── validateTitle ──────────────────────────────────────────────────────────── - -describe('validateTitle', () => { - it('accepts a valid non-Dependabot title', () => { - assert.deepEqual(v.validateTitle('feat(auth): add login endpoint (DEV-123)', false), []); - }); - - it('accepts a valid title without scope', () => { - assert.deepEqual(v.validateTitle('fix: resolve null pointer (PLAT-42)', false), []); - }); - - it('accepts a valid Dependabot title without Jira key', () => { - assert.deepEqual(v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21', true), []); - }); - - it('accepts a title without Jira key', () => { - assert.deepEqual(v.validateTitle('fix: resolve null pointer', false), []); - }); - - it('rejects unknown type', () => { - const errs = v.validateTitle('update: something (DEV-1)', false); - assert.ok(errs.length > 0, 'should have errors for unknown type'); - assert.ok(errs.some(e => e.includes('type') || e.includes('match')), 'should mention type'); - }); - - it('accepts a human PR title at exactly 120 chars', () => { - const long = 'feat: ' + 'a'.repeat(106) + ' (DEV-1)'; - assert.equal(long.length, 120); - assert.deepEqual(v.validateTitle(long, false), []); - }); - - it('accepts a Dependabot PR title at exactly 120 chars', () => { - const long = 'chore(deps): ' + 'a'.repeat(107); - assert.equal(long.length, 120); - assert.deepEqual(v.validateTitle(long, true), []); - }); - - it('rejects a human PR title at exactly 121 chars', () => { - const long = 'feat: ' + 'a'.repeat(107) + ' (DEV-1)'; - assert.equal(long.length, 121); - const errs = v.validateTitle(long, false); - assert.ok(errs.some(e => e.includes('120')), 'should mention 120 char limit'); - }); - - it('rejects a Dependabot PR title at exactly 121 chars', () => { - const long = 'chore(deps): ' + 'a'.repeat(108); - assert.equal(long.length, 121); - const errs = v.validateTitle(long, true); - assert.ok(errs.some(e => e.includes('120')), 'should mention 120 char limit'); - }); - - it('rejects title ending with a period (Dependabot, no Jira required)', () => { - // Use a Dependabot title so only the period error fires. - const errs = v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21.', true); - assert.ok(errs.some(e => e.includes('period')), 'should mention period: ' + errs.join('; ')); - }); - - it('rejects description ending with period before Jira suffix', () => { - const errs = v.validateTitle('fix: resolve issue. (DEV-1)', false); - assert.ok(errs.some(e => e.includes('period')), 'desc period before Jira: ' + errs.join('; ')); - }); - - it('rejects description starting with uppercase', () => { - const errs = v.validateTitle('fix: Resolve issue (DEV-1)', false); - assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); - }); - - it('accepts PLAT and SEC Jira keys', () => { - assert.deepEqual(v.validateTitle('chore: update deps (PLAT-99)', false), []); - assert.deepEqual(v.validateTitle('docs: add runbook (SEC-7)', false), []); - }); - - it('accepts AP Jira keys', () => { - assert.deepEqual(v.validateTitle('feat(frontend): scaffold vite spa and ci (AP-4)', false), []); - }); - - it('does not treat INFRA as a valid Jira suffix', () => { - // INFRA is a closed archive, so (INFRA-1) is not a recognized key. - // validateTitle no longer fails on a missing key; Main warns instead. - assert.deepEqual(v.validateTitle('fix: patch (INFRA-1)', false), []); - assert.equal(v.getJiraKey('fix: patch (INFRA-1)'), null); - }); - - it('accepts all valid types', () => { - const types = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release']; - for (const t of types) { - const errs = v.validateTitle(t + ': do something (DEV-1)', false); - assert.deepEqual(errs, [], t + ' should be a valid type'); - } - }); - - // Missing Jira is a warning in Main, not a validateTitle error. - it('accepts revert title without Jira regardless of jiraMaybeExempt', () => { - assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, true), []); - assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, false), []); - assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false), []); - }); -}); - -// ── getJiraKey ─────────────────────────────────────────────────────────────── - -describe('getJiraKey', () => { - it('extracts DEV key', () => assert.equal(v.getJiraKey('fix: thing (DEV-42)'), 'DEV-42')); - it('extracts PLAT key', () => assert.equal(v.getJiraKey('chore: thing (PLAT-1)'), 'PLAT-1')); - it('extracts SEC key', () => assert.equal(v.getJiraKey('docs: thing (SEC-999)'), 'SEC-999')); - it('extracts AP key', () => assert.equal(v.getJiraKey('feat(frontend): scaffold (AP-4)'), 'AP-4')); - it('returns null when no key', () => assert.equal(v.getJiraKey('chore: thing'), null)); - it('returns null for mid-title key', () => assert.equal(v.getJiraKey('fix (DEV-1): something'), null)); -}); - -// ── validateBranch ─────────────────────────────────────────────────────────── - -describe('validateBranch', () => { - it('accepts valid feature branch', () => { - assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []); - }); - - it('accepts all valid prefixes', () => { - const prefixes = ['feature','fix','hotfix','chore','docs','refactor','release']; - for (const p of prefixes) { - assert.deepEqual(v.validateBranch(p + '/my-thing', false), [], p + '/ should be valid'); - } - }); - - it('skips validation for Dependabot', () => { - assert.deepEqual(v.validateBranch('dependabot/npm_and_yarn/lodash-4.17.21', true), []); - }); - - it('rejects unknown prefix', () => { - const errs = v.validateBranch('bugfix/my-thing', false); - assert.ok(errs.length > 0, 'bugfix/ is not a valid prefix'); - }); - - it('rejects nested path (two slashes)', () => { - const errs = v.validateBranch('feature/team/my-thing', false); - assert.ok(errs.length > 0, 'nested paths should be rejected'); - }); - - it('rejects uppercase in segment', () => { - const errs = v.validateBranch('feature/myThing', false); - assert.ok(errs.length > 0, 'uppercase in segment should be rejected'); - }); - - it('rejects Jira key in segment (case-insensitive)', () => { - const errs = v.validateBranch('fix/dev-123', false); - assert.ok(errs.length > 0, 'Jira-key pattern in segment should be rejected'); - }); - - it('rejects uppercase Jira key in segment', () => { - const errs = v.validateBranch('fix/DEV-123', false); - assert.ok(errs.length > 0, 'uppercase Jira key should be rejected'); - }); - - it('rejects branch with no segment after prefix', () => { - const errs = v.validateBranch('feature/', false); - assert.ok(errs.length > 0, 'empty segment should be rejected'); - }); -}); - -// ── validateBody ───────────────────────────────────────────────────────────── - -describe('validateBody', () => { - const goodBody = '## Summary\nSomething changed.\n\n## Validation\nRan tests.\n\n## Tests\nUnit tests pass.\n\n## Notes\nNone.'; - - it('accepts a valid body', () => { - assert.deepEqual(v.validateBody(goodBody, false), []); - }); - - it('accepts None. under Notes', () => { - assert.deepEqual(v.validateBody(goodBody, false), []); - }); - - it('skips validation for Dependabot', () => { - assert.deepEqual(v.validateBody('', true), []); - }); - - it('rejects empty body', () => { - const errs = v.validateBody('', false); - assert.ok(errs.length > 0, 'empty body should fail'); - }); - - it('rejects wrong heading order', () => { - const body = '## Validation\nOK\n\n## Summary\nOK\n\n## Tests\nOK\n\n## Notes\nNone.'; - const errs = v.validateBody(body, false); - assert.ok(errs.some(e => e.includes('Validation') || e.includes('Summary')), 'wrong order: ' + errs.join('; ')); - }); - - it('rejects fifth H2 heading', () => { - const body = goodBody + '\n\n## Extra\nwhoops'; - const errs = v.validateBody(body, false); - assert.ok(errs.some(e => e.includes('5') || e.includes('4')), 'fifth heading: ' + errs.join('; ')); - }); - - it('rejects empty section', () => { - const body = '## Summary\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; - const errs = v.validateBody(body, false); - assert.ok(errs.some(e => e.includes('Summary') && e.includes('empty')), 'empty summary: ' + errs.join('; ')); - }); - - it('strips HTML comments before heading scan', () => { - const body = '\n## Summary\nreal.\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; - assert.deepEqual(v.validateBody(body, false), [], 'HTML comment heading should not count'); - }); - - it('strips fenced code blocks before heading scan', () => { - const TICK = String.fromCharCode(0x60); - const body = `## Summary\nSee below.\n\n${TICK.repeat(3)}\n## Fake heading inside fence\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; - assert.deepEqual(v.validateBody(body, false), [], 'fenced heading should not count'); - }); - - it('handles tilde fences', () => { - const body = '## Summary\nSee below.\n\n~~~\n## Fake inside tilde fence\n~~~\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; - assert.deepEqual(v.validateBody(body, false), [], 'tilde-fenced heading should not count'); - }); - - it('handles fences with 1 leading space', () => { - const TICK = String.fromCharCode(0x60); - const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; - assert.deepEqual(v.validateBody(body, false), [], '1-space indented fence should strip fake heading'); - }); - - it('handles fences with 3 leading spaces', () => { - const TICK = String.fromCharCode(0x60); - const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; - assert.deepEqual(v.validateBody(body, false), [], '3-space indented fence should strip fake heading'); - }); - - it('does not treat 4-space-indented fence as a code fence', () => { - const TICK = String.fromCharCode(0x60); - const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Extra Heading\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; - // 4-space indent exceeds the 0-3 space fence rule; heading is not stripped → error. - const errs = v.validateBody(body, false); - assert.ok(errs.length > 0, '4-space fence should not strip heading (counted as extra heading)'); - }); - - it('rejects missing Notes heading', () => { - const body = '## Summary\nOK.\n\n## Validation\nOK.\n\n## Tests\nOK.'; - const errs = v.validateBody(body, false); - assert.ok(errs.length > 0, 'missing Notes should fail'); - }); -}); - -// ── validateCommitSubject ───────────────────────────────────────────────────── - -describe('validateCommitSubject', () => { - it('accepts a valid commit subject', () => { - assert.deepEqual(v.validateCommitSubject('feat(auth): add login endpoint'), []); - }); - - it('accepts subject without scope', () => { - assert.deepEqual(v.validateCommitSubject('fix: resolve null pointer'), []); - }); - - it('accepts breaking change marker', () => { - assert.deepEqual(v.validateCommitSubject('feat!: remove deprecated api'), []); - }); - - it('accepts subject with DEV Jira key suffix', () => { - assert.deepEqual(v.validateCommitSubject('fix: patch (DEV-123)'), []); - }); - - it('accepts subject with AP Jira key suffix', () => { - assert.deepEqual(v.validateCommitSubject('feat(frontend): scaffold vite spa and ci (AP-4)'), []); - }); - - it('accepts subject with PLAT Jira key suffix', () => { - assert.deepEqual(v.validateCommitSubject('chore: update config (PLAT-5)'), []); - }); - - it('rejects non-conventional subject', () => { - const errs = v.validateCommitSubject('Update readme'); - assert.ok(errs.length > 0, 'should reject non-conventional subject'); - }); - - it('rejects uppercase description start', () => { - const errs = v.validateCommitSubject('fix: Resolve issue'); - assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); - }); - - it('rejects subject over 72 chars', () => { - const long = 'feat: ' + 'a'.repeat(70); - const errs = v.validateCommitSubject(long); - assert.ok(errs.some(e => e.includes('72')), 'should mention 72: ' + errs.join('; ')); - }); - - it('rejects description ending with a period', () => { - const errs = v.validateCommitSubject('fix: resolve issue.'); - assert.ok(errs.some(e => e.includes('period')), 'trailing period in description: ' + errs.join('; ')); - }); -}); - -describe('isSyncMergeCommit', () => { - it('recognizes a generated branch synchronization merge', () => { - const commit = { - parents: [{ sha: 'a' }, { sha: 'b' }], - commit: { message: "Merge branch 'main' into chore/pr-policy-rollout" }, - }; - assert.equal(v.isSyncMergeCommit(commit), true); - }); - - it('recognizes a generated remote-tracking synchronization merge', () => { - const commit = { - parents: [{ sha: 'a' }, { sha: 'b' }], - commit: { message: "Merge remote-tracking branch 'origin/main' into fix/example" }, - }; - assert.equal(v.isSyncMergeCommit(commit), true); - }); - - it('does not exempt an arbitrary multi-parent commit', () => { - const commit = { - parents: [{ sha: 'a' }, { sha: 'b' }], - commit: { message: 'Update policy files' }, - }; - assert.equal(v.isSyncMergeCommit(commit), false); - }); - - it('does not exempt a single-parent commit with a merge-shaped subject', () => { - const commit = { - parents: [{ sha: 'a' }], - commit: { message: "Merge branch 'main' into fix/example" }, - }; - assert.equal(v.isSyncMergeCommit(commit), false); - }); - - it('does not exempt commits with missing parent metadata', () => { - assert.equal(v.isSyncMergeCommit({}), false); - }); -}); - -// ── detectAiFooter ──────────────────────────────────────────────────────────── - -describe('detectAiFooter', () => { - it('detects Claude Co-authored-by', () => { - assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Claude ')); - }); - - 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('rejects local ./ ref (unsupported until recursive action-manifest validation)', () => { - const content = wf('uses: ./.github/workflows/sub.yaml'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('local action') || e.includes('not supported')), 'local ref must fail: ' + errs.join('; ')); - }); - - it('accepts docker:// ref with valid sha256 digest', () => { - const digest = 'a' .repeat(64); - const content = wf('uses: docker://alpine@sha256:' + digest); - assert.deepEqual(v.validateWorkflowContent(content, BASE), []); - }); - - it('rejects floating tag ref (v1, v2)', () => { - const content = wf('uses: actions/checkout@v4'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('40-char SHA')), 'floating tag should fail: ' + errs.join('; ')); - }); - - it('rejects SHA without version comment', () => { - const content = wf(`uses: actions/checkout@${VALID_SHA}`); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'no comment should fail: ' + errs.join('; ')); - }); - - it('rejects SHA with wrong comment format', () => { - const content = wf(`uses: actions/checkout@${VALID_SHA} # latest`); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'wrong comment should fail'); - }); - - it('rejects all-zero placeholder SHA', () => { - const content = wf(`uses: actions/checkout@${ZERO_SHA} # v1.0.0`); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('placeholder') || e.includes('zero') || e.includes('all-zero')), 'all-zero SHA should fail: ' + errs.join('; ')); - }); - - it('rejects v0.0.0 placeholder version', () => { - const content = wf(`uses: actions/checkout@${VALID_SHA} # v0.0.0`); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('v0.0.0') || e.includes('placeholder')), 'v0.0.0 should fail: ' + errs.join('; ')); - }); - - it('rejects both all-zero SHA and v0.0.0', () => { - const content = wf(`uses: actions/checkout@${ZERO_SHA} # v0.0.0`); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.length >= 2, 'should report two errors (zero SHA + v0.0.0): ' + errs.join('; ')); - }); - - it('rejects missing top-level permissions', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ./.github/workflows/sub.yaml', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions should fail: ' + errs.join('; ')); - }); - - it('accepts top-level permissions: {} (explicit empty)', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions: {}', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), []); - }); - - it('accepts quoted uses: value with valid SHA', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ` - uses: "${VALID_SHA} # v9.0.0"`, - ].join('\n'); - // The quoted value doesn't have an owner/repo@ prefix — just validate that - // the quote-stripping doesn't break the parser. A quoted SHA without an owner - // won't parse as a remote action at all (no @ before sha). Confirm no crash. - // Use a proper quoted action ref: - const content2 = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ` - uses: "actions/checkout@${VALID_SHA} # v9.0.0"`, - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content2, BASE), [], 'double-quoted valid ref should pass'); - }); - - it('accepts single-quoted uses: value with valid SHA', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ` - uses: 'actions/checkout@${VALID_SHA} # v9.0.0'`, - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted valid ref should pass'); - }); - - it('does not treat uses: inside a run: block as an action reference', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - name: shell-step', - ' run: |', - ' echo "uses: actions/checkout@v4"', - ' uses: some-other@v1', - ' echo done', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block must not be flagged'); - }); - - it('rejects ${{ }} in run: inline value', () => { - const EXPR = '$' + '{{ github.token }}'; - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - name: bad', - ' run: echo ' + EXPR, - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('env:')), 'inline ${{ }} should fail: ' + errs.join('; ')); - }); - - it('rejects ${{ }} in run: block scalar', () => { - const EXPR = '$' + '{{ secrets.OTHER }}'; - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - name: bad', - ' env:', - ' TOKEN: $' + '{{ secrets.TOKEN }}', - ' run: |', - ' echo ' + EXPR, - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('env:')), 'block ${{ }} should fail: ' + errs.join('; ')); - }); - - it('does not flag ${{ }} in env: above run:', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - name: ok', - ' env:', - ' MY_VAR: $' + '{{ secrets.TOKEN }}', - ' run: |', - ' echo "$MY_VAR"', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), []); - }); - - it('accumulates multiple violations', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@v4', - ' - uses: actions/setup-node@v4', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.length >= 3, 'should have at least 3 errors (no perms + 2 bad pins): ' + errs.join('; ')); - }); -}); - -// ── validateWorkflowContent — docker digest pinning ────────────────────────── -describe('validateWorkflowContent — docker digest pinning', () => { - const BASE = 'f.yaml'; - const GOOD_DIGEST = 'b'.repeat(64); - function wf(uses) { - return [ - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - ' + uses, - ].join('\n'); - } - - it('accepts docker:// ref with valid lowercase 64-hex sha256 digest', () => { - assert.deepEqual(v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + GOOD_DIGEST), BASE), []); - }); - - it('accepts docker:// ref for image with tag+digest', () => { - assert.deepEqual(v.validateWorkflowContent(wf('uses: docker://ubuntu:22.04@sha256:' + GOOD_DIGEST), BASE), []); - }); - - it('rejects docker:// ref with mutable tag only', () => { - const errs = v.validateWorkflowContent(wf('uses: docker://alpine:3.19'), BASE); - assert.ok(errs.some(e => e.includes('sha256')), 'mutable tag must fail: ' + errs.join('; ')); - }); - - it('rejects docker:// ref with :latest tag', () => { - const errs = v.validateWorkflowContent(wf('uses: docker://ubuntu:latest'), BASE); - assert.ok(errs.some(e => e.includes('sha256')), ':latest must fail: ' + errs.join('; ')); - }); - - it('rejects bare docker:// ref with no tag or digest', () => { - const errs = v.validateWorkflowContent(wf('uses: docker://ubuntu'), BASE); - assert.ok(errs.some(e => e.includes('sha256')), 'bare image must fail: ' + errs.join('; ')); - }); - - it('rejects docker:// ref with uppercase in sha256 digest', () => { - const errs = v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + 'A'.repeat(64)), BASE); - assert.ok(errs.some(e => e.includes('sha256')), 'uppercase hex must fail: ' + errs.join('; ')); - }); - - it('rejects docker:// ref with short (63-char) sha256 digest', () => { - const errs = v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + 'a'.repeat(63)), BASE); - assert.ok(errs.some(e => e.includes('sha256')), 'short digest must fail: ' + errs.join('; ')); - }); - - it('rejects docker:// ref with wrong digest prefix (md5:)', () => { - const errs = v.validateWorkflowContent(wf('uses: docker://alpine@md5:' + 'a'.repeat(32)), BASE); - assert.ok(errs.some(e => e.includes('sha256')), 'non-sha256 digest must fail: ' + errs.join('; ')); - }); -}); - -// ── validateWorkflowContent — anchored flow step ───────────────────────────── -describe('validateWorkflowContent — anchored flow step', () => { - const BASE = 'f.yaml'; - function wf(step) { - return 'permissions: {}\n' + step; - } - - it('rejects anchored flow-style step with uses (- &step { uses: ... })', () => { - const errs = v.validateWorkflowContent(wf(' - &step { uses: actions/checkout@v4 }'), BASE); - assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'anchored flow uses must fail: ' + errs.join('; ')); - }); - - it('rejects anchored flow-style step with run (- &step { run: ... })', () => { - const errs = v.validateWorkflowContent(wf(' - &step { run: echo hi }'), BASE); - assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'anchored flow run must fail: ' + errs.join('; ')); - }); - - it('rejects anchored flow-style even for non-structural keys', () => { - const errs = v.validateWorkflowContent(wf(' - &data { os: ubuntu, node: 24 }'), BASE); - assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'any anchored flow step must fail: ' + errs.join('; ')); - }); - - it('still rejects plain flow-style step with structural uses key', () => { - const errs = v.validateWorkflowContent(wf(' - { uses: actions/checkout@v4 }'), BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'plain flow uses must still fail: ' + errs.join('; ')); - }); - - it('rejects block-style step with standalone sequence-item anchor (- &ref)', () => { - const content = [ - 'permissions: {}', - 'steps:', - ' - &ref', - ' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'standalone - &anchor step must fail: ' + errs.join('; ')); - }); - - it('rejects multiline anchored uses: - &step / { uses: ... }', () => { - const content = [ - 'permissions: {}', - 'steps:', - ' - &step', - ' { uses: actions/checkout@v4 }', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'multiline - &step / { uses } must fail: ' + errs.join('; ')); - }); - - it('rejects multiline anchored run: - &step / { run: ... }', () => { - const content = [ - 'permissions: {}', - 'steps:', - ' - &step', - ' { run: echo hi }', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'multiline - &step / { run } must fail: ' + errs.join('; ')); - }); - - it('no regression: plain block-style step without anchor passes pin checks normally', () => { - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - const content = [ - 'permissions: {}', - 'steps:', - ' - uses: ' + PIN, - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), []); - }); - - it('non-sequence mapping-value anchor is not flagged (foo: &anchor value)', () => { - const content = [ - 'permissions: {}', - 'env:', - ' TOKEN: &tok my-value', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(!errs.some(e => e.includes('sequence-item anchor')), 'mapping-value anchor must not trigger sequence-item rule: ' + errs.join('; ')); - }); -}); - -// ── checkCommitLimit ────────────────────────────────────────────────────────── - -describe('checkCommitLimit', () => { - it('returns null for exactly 250 commits', () => { - assert.equal(v.checkCommitLimit(250), null); - }); - - it('returns null for fewer than 250 commits', () => { - assert.equal(v.checkCommitLimit(1), null); - assert.equal(v.checkCommitLimit(100), null); - }); - - it('returns an infra error string for 251 commits', () => { - const result = v.checkCommitLimit(251); - assert.ok(result !== null, 'should return error for >250'); - assert.ok(result.includes('250'), 'error should mention the limit: ' + result); - }); - - it('returns an infra error string for large commit count', () => { - const result = v.checkCommitLimit(1000); - assert.ok(result !== null, 'should return error for large count'); - assert.ok(result.includes('1000'), 'error should include the actual count: ' + result); - }); -}); - -// ── checkFilesLimit ─────────────────────────────────────────────────────────── - -describe('checkFilesLimit', () => { - it('returns null for exactly 3000 files', () => { - assert.equal(v.checkFilesLimit(3000), null); - }); - - it('returns null for fewer than 3000 files', () => { - assert.equal(v.checkFilesLimit(1), null); - assert.equal(v.checkFilesLimit(500), null); - }); - - it('returns an infra error string for 3001 files', () => { - const result = v.checkFilesLimit(3001); - assert.ok(result !== null, 'should return error for >3000'); - assert.ok(result.includes('3000'), 'error should mention the limit: ' + result); - }); - - it('returns an infra error string for large file count', () => { - const result = v.checkFilesLimit(5000); - assert.ok(result !== null, 'should return error for large count'); - assert.ok(result.includes('5000'), 'error should include the actual count: ' + result); - }); -}); - -// ── parseRetryAfterMs ───────────────────────────────────────────────────────── - -describe('parseRetryAfterMs', () => { - it('parses integer seconds', () => { - assert.equal(v.parseRetryAfterMs('30'), 30000); - assert.equal(v.parseRetryAfterMs('1'), 1000); - }); - - it('returns 0 for zero seconds', () => { - assert.equal(v.parseRetryAfterMs('0'), 0); - }); - - it('caps at 60000ms for large integer values', () => { - assert.equal(v.parseRetryAfterMs('999'), 60000); - assert.equal(v.parseRetryAfterMs('61'), 60000); - }); - - it('parses HTTP-date format and returns positive ms', () => { - const nowMs = Date.now(); - const futureDate = new Date(nowMs + 10000).toUTCString(); - const result = v.parseRetryAfterMs(futureDate, nowMs); - assert.ok(result > 9000 && result <= 10000, 'HTTP-date ~10s in future should produce ~10000ms, got ' + result); - }); - - it('returns 0 for past HTTP-date', () => { - const nowMs = Date.now(); - const pastDate = new Date(nowMs - 5000).toUTCString(); - assert.equal(v.parseRetryAfterMs(pastDate, nowMs), 0); - }); - - it('returns 0 for invalid header string', () => { - assert.equal(v.parseRetryAfterMs('not-a-number'), 0); - assert.equal(v.parseRetryAfterMs('banana'), 0); - }); - - it('returns 0 for empty string', () => { - assert.equal(v.parseRetryAfterMs(''), 0); - }); - - it('returns 0 for missing header (falsy)', () => { - assert.equal(v.parseRetryAfterMs(undefined), 0); - assert.equal(v.parseRetryAfterMs(null), 0); - }); - - it('caps HTTP-date result at 60000ms', () => { - const nowMs = Date.now(); - const farFutureDate = new Date(nowMs + 120000).toUTCString(); - assert.equal(v.parseRetryAfterMs(farFutureDate, nowMs), 60000); - }); -}); - -// ── Additional branch-segment hygiene tests (fix 7) ────────────────────────── - -describe('validateBranch — consecutive and trailing hyphens', () => { - it('rejects consecutive hyphens in segment', () => { - const errs = v.validateBranch('feature/my--feature', false); - assert.ok(errs.length > 0, 'consecutive hyphens should be rejected'); - assert.ok(errs.some(e => e.includes('feature/my--feature')), 'error should name the bad branch: ' + errs.join('; ')); - }); - - it('rejects trailing hyphen in segment', () => { - const errs = v.validateBranch('feature/my-feature-', false); - assert.ok(errs.length > 0, 'trailing hyphen should be rejected'); - }); - - it('rejects segment that is just a hyphen', () => { - const errs = v.validateBranch('feature/-', false); - assert.ok(errs.length > 0, 'bare hyphen segment should be rejected'); - }); - - it('accepts proper kebab-case with multiple words', () => { - assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []); - assert.deepEqual(v.validateBranch('fix/patch-null-check', false), []); - assert.deepEqual(v.validateBranch('chore/update-deps', false), []); - }); - - it('accepts single-word segment', () => { - assert.deepEqual(v.validateBranch('feature/auth', false), []); - assert.deepEqual(v.validateBranch('fix/login', false), []); - }); -}); - -// ── AI-footer extended patterns (fix 6) ────────────────────────────────────── - -describe('detectAiFooter — extended AI identities', () => { - it('detects Codex in Co-authored-by', () => { - assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codex '), '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('rejects uses block scalar before opaque block handling can hide it', () => { - const content = [ - ...wfHeader().split('\n'), - ' - uses: >-', - ' actions/checkout@v4', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('uses: block scalar')), 'uses: folded block scalar must fail: ' + errs.join('; ')); - }); - - it('rejects quoted uses block scalar before pin validation can be bypassed', () => { - const content = [ - ...wfHeader().split('\n'), - ' - "uses": |-', - ' actions/checkout@v4', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('uses: block scalar')), 'quoted uses: literal block scalar must fail: ' + errs.join('; ')); - }); - - it('recognises sequence-form "- run: |" and does not flag uses: inside as action ref', () => { - // The sequence item `- run: |` opens a run block scalar. - // uses: lines inside must not be treated as action references. - const content = [ - ...wfHeader().split('\n'), - ' - name: build', - ' run: |', - ' echo "uses: actions/checkout@v4"', - ' uses: some/action@v1', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block (sequence step) must not be flagged'); - }); - - it('recognises sequence-form "- run: echo hi" inline and does not flag uses: on next step', () => { - const content = [ - ...wfHeader().split('\n'), - ` - run: echo hello`, - ` - uses: actions/checkout@${VALID_SHA} # v9.0.0`, - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'run: inline does not swallow uses: on next step'); - }); - - it('does not flag uses: inside a non-run block scalar (e.g. script: |)', () => { - // script: | is a block scalar that is NOT a run block. - // uses: lines inside must not be treated as action references. - // Expressions inside script: | must not be flagged as run: injection. - const EXPR = '$' + '{{ github.token }}'; - const content = [ - ...wfHeader().split('\n'), - ' - name: js-step', - ' uses: actions/github-script@' + VALID_SHA + ' # v9.0.0', - ' with:', - ' script: |', - ' // uses: actions/checkout@v4 (this is JS comment, not YAML)', - ' uses: some/action@v1', - ' const tok = ' + EXPR + ';', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: and expressions inside script: block must not be flagged'); - }); - - it('accepts quoted action ref with version comment OUTSIDE the quotes', () => { - // uses: "owner/repo@sha" # vX.Y.Z — comment is a YAML comment, not inside quotes. - const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}" # v9.0.0`; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'quoted ref with external comment should pass'); - }); - - it('accepts single-quoted action ref with version comment OUTSIDE the quotes', () => { - const content = wfHeader() + `\n - uses: 'actions/checkout@${VALID_SHA}' # v9.0.0`; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted ref with external comment should pass'); - }); - - it('rejects quoted action ref with no comment at all', () => { - const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}"`; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'quoted ref with no comment must fail: ' + errs.join('; ')); - }); - - it('recognises quoted "permissions" key at column 0 for top-level check', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - '"permissions":', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'double-quoted "permissions": at col 0 should count'); - }); -}); - -// ── validateTitle — Jira-backed revert is not an emergency candidate (fix 5) ── - -describe('validateTitle — Jira-backed revert semantics', () => { - it('accepts revert title WITH Jira key regardless of jiraMaybeExempt', () => { - // A revert that already carries a Jira key needs no emergency exemption. - assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, false), []); - assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, true), []); - }); - - it('getJiraKey extracts key from Jira-backed revert title', () => { - // Confirms that getJiraKey correctly identifies a revert title with Jira key, - // which is what the main logic uses to decide isEmergencyCandidate = false. - assert.equal(v.getJiraKey('revert: rollback payment service (DEV-42)'), 'DEV-42'); - }); - - it('getJiraKey returns null for revert title without Jira key', () => { - // Null result means isEmergencyCandidate COULD be true (if label is also present). - assert.equal(v.getJiraKey('revert: emergency rollback of payment service'), null); - }); -}); - -// ── Scanner fail-closed cases (fixes: |2, flow, escaped keys, aliases) ─────── - -describe('validateWorkflowContent — scanner fail-closed cases', () => { - const BASE = '.github/workflows/test.yaml'; - const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; - - function wfHeader() { - return [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ].join('\n'); - } - - // ── Fix 1: explicit block indent/chomp indicators |2, |2-, |-2, >+2 ────── - - it('enters run block on "run: |2" and detects expression injection inside', () => { - const EXPR = '$' + '{{ github.token }}'; - const content = [ - ...wfHeader().split('\n'), - ' - name: x', - ' run: |2', - ' echo hi', - ' echo ' + EXPR, - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('env:')), 'run |2 block should flag expression: ' + errs.join('; ')); - }); - - it('enters run block on "run: |2-" and detects expression injection', () => { - const EXPR = '$' + '{{ secrets.TOKEN }}'; - const content = [ - ...wfHeader().split('\n'), - ' - name: x', - ' run: |2-', - ' echo ' + EXPR, - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('env:')), 'run |2- block should flag expression: ' + errs.join('; ')); - }); - - it('enters run block on "run: |-2" and detects expression injection', () => { - const EXPR = '$' + '{{ github.ref }}'; - const content = [ - ...wfHeader().split('\n'), - ' - name: x', - ' run: |-2', - ' echo ' + EXPR, - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('env:')), 'run |-2 block should flag expression: ' + errs.join('; ')); - }); - - it('enters run block on "run: >+2" and detects expression injection', () => { - const EXPR = '$' + '{{ github.actor }}'; - const content = [ - ...wfHeader().split('\n'), - ' - name: x', - ' run: >+2', - ' echo ' + EXPR, - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('env:')), 'run >+2 block should flag expression: ' + errs.join('; ')); - }); - - it('does NOT flag uses: inside run: |2 block as an action reference', () => { - const content = [ - ...wfHeader().split('\n'), - ' - name: x', - ' run: |2', - ' uses: actions/checkout@v4', - ' echo done', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run |2 must not be flagged'); - }); - - it('sequence-form "- run: |2" correctly enters run block', () => { - const EXPR = '$' + '{{ github.sha }}'; - const content = [ - ...wfHeader().split('\n'), - ' - run: |2', - ' echo ' + EXPR, - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('env:')), 'sequence - run: |2 should detect expression: ' + errs.join('; ')); - }); - - // ── Fix 2: flow-style sequence steps ───────────────────────────────────── - - it('rejects flow-style step "- { uses: ... }"', () => { - const content = wfHeader() + '\n - { uses: actions/checkout@v4, with: { token: x } }'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'flow uses step should fail: ' + errs.join('; ')); - }); - - it('rejects flow-style step "- { run: ... }"', () => { - const content = wfHeader() + '\n - { run: echo hello }'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'flow run step should fail: ' + errs.join('; ')); - }); - - it('rejects flow-style step with any nonempty mapping', () => { - const content = wfHeader() + '\n - { name: my-step, uses: actions/checkout@v4 }'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'any nonempty flow step should fail: ' + errs.join('; ')); - }); - - it('does NOT reject permissions: {} (mapping value, not sequence item)', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions: {}', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'permissions: {} must not be rejected'); - }); - - // ── Fix 3: escaped/encoded structural keys ───────────────────────────────── - - it('rejects double-quoted key with Unicode escape "u\\u0073es" (encodes "uses")', () => { - // In the YAML source, the key is literally "u\u0073es": — the scanner sees \u - const escapedUses = '"u\\u0073es": actions/checkout@v4'; - const content = wfHeader() + '\n - ' + escapedUses; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped uses key must be rejected: ' + errs.join('; ')); - }); - - it('rejects double-quoted key with Unicode escape "r\\u0075n" (encodes "run")', () => { - const escapedRun = '"r\\u0075n": echo hello'; - const content = wfHeader() + '\n ' + escapedRun; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped run key must be rejected: ' + errs.join('; ')); - }); - - it('does NOT reject literal double-quoted "uses" key (no backslash)', () => { - const content = wfHeader() + `\n - "uses": actions/checkout@${VALID_SHA} # v9.0.0`; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "uses" key should pass'); - }); - - it('does NOT reject literal double-quoted "run" key (no backslash)', () => { - const content = wfHeader() + '\n "run": echo hello'; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "run" key should pass'); - }); - - // ── Fix 4: YAML aliases/anchors on structural values ────────────────────── - - it('rejects YAML alias in "run:" value (run: *command)', () => { - const content = wfHeader() + '\n run: *deploy_script'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must fail: ' + errs.join('; ')); - }); - - it('rejects YAML alias in "uses:" value (uses: *action_ref)', () => { - const content = wfHeader() + '\n - uses: *checkout_action'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: *alias must fail: ' + errs.join('; ')); - }); - - it('rejects YAML anchor definition in "run:" value (run: &anchor |)', () => { - // &anchor before the block indicator means the run block has an anchor definition. - // The line scanner cannot safely resolve what aliases to *anchor will execute. - const content = wfHeader() + '\n run: &deploy_script |'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must fail: ' + errs.join('; ')); - }); - - it('rejects YAML anchor definition in "uses:" value (uses: &anchor ref)', () => { - const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must fail: ' + errs.join('; ')); - }); - - it('rejects YAML alias for top-level permissions: value', () => { - const content = [ - 'name: Test', - 'on:', - ' workflow_call:', - 'permissions: *read_perms', - 'jobs:', - ' job:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ./.github/workflows/sub.yaml', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor') || e.includes('permissions')), 'permissions: *alias must fail: ' + errs.join('; ')); - }); - - // ── Fix: flow mapping false-positive — non-step data must pass ──────────── - - it('allows flow-style sequence item without structural uses/run key', () => { - const content = wfHeader() + '\n - { os: ubuntu-latest, node: 24 }'; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'matrix data object must not be rejected'); - }); - - it('allows flow-style sequence item with only name key', () => { - const content = wfHeader() + '\n - { name: my-step, timeout: 10 }'; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'non-step flow object with name/timeout must pass'); - }); - - it('still rejects flow-style step with uses: key inside', () => { - const content = wfHeader() + '\n - { uses: actions/checkout@v4 }'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('flow-style')), '- { uses: ... } must still fail: ' + errs.join('; ')); - }); - - it('still rejects flow-style step with run: key inside', () => { - const content = wfHeader() + '\n - { run: echo hi }'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('flow-style')), '- { run: ... } must still fail: ' + errs.join('; ')); - }); - - it('still rejects flow-style step with uses: after a comma', () => { - const content = wfHeader() + '\n - { name: checkout, uses: actions/checkout@v4 }'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('flow-style')), '- { name: x, uses: ... } must still fail: ' + errs.join('; ')); - }); - - // ── Fix: anchor/alias false-positive — ordinary shell & must pass ───────── - - it('allows run: value containing & in a shell command (not a YAML anchor)', () => { - const content = wfHeader() + '\n run: echo "R&D build"'; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell & in run value must not be rejected'); - }); - - it('allows run: value with && (shell AND operator)', () => { - const content = wfHeader() + '\n run: make build && make test'; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell && must not be rejected'); - }); - - it('allows single-quoted run: value with & inside', () => { - const content = wfHeader() + "\n run: 'echo R&D'"; - assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted run with & must pass"); - }); - - it('still rejects run: *alias (YAML alias token)', () => { - const content = wfHeader() + '\n run: *deploy_script'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must still fail: ' + errs.join('; ')); - }); - - it('still rejects run: &anchor | (YAML anchor before block indicator)', () => { - const content = wfHeader() + '\n run: &deploy_script |'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must still fail: ' + errs.join('; ')); - }); - - it('still rejects uses: &anchor ref (YAML anchor in action ref)', () => { - const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must still fail: ' + errs.join('; ')); - }); -}); - -// ── Static assertions on the YAML file ─────────────────────────────────────── - -describe('static YAML assertions', () => { - let yamlText; - before(() => { - yamlText = readFileSync( - join(__dirname, '../.github/workflows/callable-pr-policy.yaml'), - 'utf8' - ); - }); - - it('does not use pull_request_target as a trigger', () => { - // The word may appear in comments, but must never be a YAML on: trigger key. - assert.ok( - !/^\s*pull_request_target\s*:/m.test(yamlText), - 'callable-pr-policy.yaml must not declare pull_request_target as an on: trigger' - ); - }); - - it('pins github-script to the exact SHA', () => { - assert.ok( - yamlText.includes('actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'), - 'must pin github-script to 3a2844b7e9c422d3c10d287c895573f7108da1b3' - ); - }); - - it('includes the v9.0.0 version comment', () => { - assert.ok(yamlText.includes('# v9.0.0'), 'must have # v9.0.0 comment'); - }); - - it('declares job id "pr"', () => { - assert.ok(/^ pr:$/m.test(yamlText), 'job id must be "pr"'); - }); - -}); - -// ── normalizeViolationFingerprint ──────────────────────────────────────────── -describe('normalizeViolationFingerprint', () => { - it('preserves :LINE: in per-line errors', () => { - const err = 'f.yaml:42: run: block contains expression — expressions must go through env:'; - assert.equal(v.normalizeViolationFingerprint(err), err); - }); - - it('leaves uses: violations unchanged (no line number in error)', () => { - const err = 'f.yaml: "uses: actions/checkout@v4" must be pinned to a 40-char SHA with "# vX.Y.Z" comment'; - assert.equal(v.normalizeViolationFingerprint(err), err); - }); - - it('leaves missing-permissions unchanged (no line number)', () => { - const err = 'f.yaml: missing top-level "permissions:" key'; - assert.equal(v.normalizeViolationFingerprint(err), err); - }); - - it('does not rewrite line-like message content', () => { - const err = 'f.yaml:10: escaped key: something'; - assert.equal(v.normalizeViolationFingerprint(err), err); - }); - - it('line 10 and line 200 remain distinct fingerprints', () => { - const a = v.normalizeViolationFingerprint('f.yaml:10: run: block contains expression'); - const b = v.normalizeViolationFingerprint('f.yaml:200: run: block contains expression'); - assert.notEqual(a, b); - }); -}); - -// ── filterNewViolations ─────────────────────────────────────────────────────── -describe('filterNewViolations', () => { - const FP_UNPIN = (f, ref) => `${f}: "uses: ${ref}" must be pinned to a 40-char SHA with "# vX.Y.Z" comment`; - const FP_PERM = (f) => `${f}: missing top-level "permissions:" key`; - const FP_EXPR = (f, ln) => `${f}:${ln}: run: block contains expression — expressions must go through env:`; - - it('unchanged floating action is ignored', () => { - const err = FP_UNPIN('w.yaml', 'actions/checkout@v4'); - assert.deepEqual(v.filterNewViolations([err], [err]), []); - }); - - it('changed floating ref is treated as new', () => { - const head = FP_UNPIN('w.yaml', 'actions/setup-node@v4'); - const base = FP_UNPIN('w.yaml', 'actions/checkout@v4'); - assert.deepEqual(v.filterNewViolations([head], [base]), [head]); - }); - - it('new floating action (no base violation) is blocked', () => { - const err = FP_UNPIN('w.yaml', 'actions/checkout@v4'); - assert.deepEqual(v.filterNewViolations([err], []), [err]); - }); - - it('unchanged missing permissions is ignored', () => { - const err = FP_PERM('w.yaml'); - assert.deepEqual(v.filterNewViolations([err], [err]), []); - }); - - it('added file missing permissions is blocked (empty base)', () => { - const err = FP_PERM('w.yaml'); - assert.deepEqual(v.filterNewViolations([err], []), [err]); - }); - - it('unchanged run expression at same line is ignored', () => { - const err = FP_EXPR('w.yaml', 10); - assert.deepEqual(v.filterNewViolations([err], [err]), []); - }); - - it('line shift is blocked when the same run expression moves', () => { - const head = FP_EXPR('w.yaml', 20); - const base = FP_EXPR('w.yaml', 10); - assert.deepEqual(v.filterNewViolations([head], [base]), [head]); - }); - - it('newly added run expression is blocked', () => { - const e1 = FP_EXPR('w.yaml', 10); - const e2 = FP_EXPR('w.yaml', 20); - const result = v.filterNewViolations([e1, e2], [e1]); - assert.equal(result.length, 1); - }); - - it('unchanged run expression ignored; a second new one blocked', () => { - const base = FP_EXPR('w.yaml', 10); - const head1 = FP_EXPR('w.yaml', 10); - const head2 = FP_EXPR('w.yaml', 50); - const result = v.filterNewViolations([head1, head2], [base]); - assert.equal(result.length, 1); - assert.equal(result[0], head2); - }); - - it('multiple violation types: unchanged ones are ignored', () => { - const perm = FP_PERM('w.yaml'); - const unpin = FP_UNPIN('w.yaml', 'actions/checkout@v4'); - const newUnpin = FP_UNPIN('w.yaml', 'actions/upload-artifact@v4'); - const result = v.filterNewViolations([perm, unpin, newUnpin], [perm, unpin]); - assert.deepEqual(result, [newUnpin]); - }); - - it('renamed file: fingerprints use head filename for both sides', () => { - const headV = FP_PERM('new-name.yaml'); - const baseV = FP_PERM('new-name.yaml'); - assert.deepEqual(v.filterNewViolations([headV], [baseV]), []); - }); - - it('returns empty array when head has no violations', () => { - const base = FP_UNPIN('w.yaml', 'actions/checkout@v4'); - assert.deepEqual(v.filterNewViolations([], [base]), []); - }); - - it('returns all head violations when base is empty (added file)', () => { - const v1 = FP_PERM('w.yaml'); - const v2 = FP_UNPIN('w.yaml', 'actions/checkout@v4'); - assert.deepEqual(v.filterNewViolations([v1, v2], []), [v1, v2]); - }); -}); - -// ── fnv1a32 ─────────────────────────────────────────────────────────────────── -describe('fnv1a32', () => { - it('returns 8 hex chars', () => { - assert.match(v.fnv1a32('hello'), /^[0-9a-f]{8}$/); - }); - - it('is deterministic', () => { - assert.equal(v.fnv1a32('run: echo hi'), v.fnv1a32('run: echo hi')); - }); - - it('different strings produce different hashes', () => { - assert.notEqual(v.fnv1a32('run: echo ${{ github.ref }}'), v.fnv1a32('run: echo ${{ github.event.pull_request.title }}')); - }); - - it('empty string returns known value', () => { - assert.equal(v.fnv1a32(''), '811c9dc5'); - }); -}); - -// ── stripHash ───────────────────────────────────────────────────────────────── -describe('stripHash', () => { - it('strips [fnv:XXXXXXXX] at end of message', () => { - assert.equal( - v.stripHash('f.yaml:42: run: block contains ${{ }} [fnv:a1b2c3d4]'), - 'f.yaml:42: run: block contains ${{ }}' - ); - }); - - it('is a no-op when no hash suffix present', () => { - const msg = 'f.yaml: missing top-level "permissions:" key'; - assert.equal(v.stripHash(msg), msg); - }); - - it('does not strip [fnv:...] appearing in the middle of a message', () => { - const msg = 'f.yaml: some [fnv:a1b2c3d4] middle text'; - assert.equal(v.stripHash(msg), msg); - }); -}); - -// ── Content fingerprint integration (Finding 1) ─────────────────────────────── -describe('content fingerprint: changed payload is new', () => { - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - const BASE_WF = (runLine) => [ - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ' - run: ' + runLine, - ].join('\n'); - - it('changed run expression is treated as new (block scalar)', () => { - const wfRef = [ - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ' - run: |', - ' echo ${{ github.ref }}', - ].join('\n'); - const wfTitle = wfRef.replace('echo ${{ github.ref }}', 'echo ${{ github.event.pull_request.title }}'); - const headErrs = v.validateWorkflowContent(wfTitle, 'f.yaml'); - const baseErrs = v.validateWorkflowContent(wfRef, 'f.yaml'); - assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed expression must be reported as new'); - }); - - it('unchanged run expression at a shifted line is blocked', () => { - const wfA = BASE_WF('echo ${{ github.ref }}'); - // wfB inserts a blank step before the run step, shifting its line number - const wfB = [ - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ' - name: placeholder', - ' run: echo noop', - ' - run: echo ${{ github.ref }}', - ].join('\n'); - const headErrs = v.validateWorkflowContent(wfB, 'f.yaml'); - const baseErrs = v.validateWorkflowContent(wfA, 'f.yaml'); - assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'same expression on a different line must be reported as new'); - }); - - it('flow-style run changed to flow-style uses is new', () => { - const wfRun = 'permissions: {}\n - { run: echo hi }'; - const wfUses = 'permissions: {}\n - { uses: actions/checkout@v4 }'; - const headErrs = v.validateWorkflowContent(wfUses, 'f.yaml'); - const baseErrs = v.validateWorkflowContent(wfRun, 'f.yaml'); - assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed flow mapping must be reported as new'); - }); -}); - -// ── Renamed from non-workflow path (Finding 2) ─────────────────────────────── -describe('isWorkflowFilename: rename routing', () => { - it('non-workflow source path is not a workflow filename', () => { - assert.equal(v.isWorkflowFilename('scripts/deploy.yaml'), false); - assert.equal(v.isWorkflowFilename('infra/template.yml'), false); - assert.equal(v.isWorkflowFilename('.github/deploy.yaml'), false); - }); - - it('workflow target paths are workflow filenames', () => { - assert.equal(v.isWorkflowFilename('.github/workflows/deploy.yaml'), true); - assert.equal(v.isWorkflowFilename('workflow-templates/ci.yml'), true); - }); - - it('rename from non-workflow treated as added: filterNewViolations with empty base captures all', () => { - // When previous_filename is not a workflow file, the runtime uses [] as baseErrs. - // This test verifies that all head violations are surfaced (same as added file). - const noncompliantWf = [ - 'on:', - ' workflow_call:', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@v4', - ].join('\n'); - const headErrs = v.validateWorkflowContent(noncompliantWf, '.github/workflows/new.yaml'); - assert.ok(headErrs.length > 0, 'noncompliant file must have violations'); - // With empty base (simulating rename from non-workflow), all violations are new - assert.deepEqual(v.filterNewViolations(headErrs, []), headErrs); - }); -}); - -// ── classifyFileStatus ──────────────────────────────────────────────────────── -describe('classifyFileStatus', () => { - function isWf(f) { return v.isWorkflowFilename(f); } - const wfFile = '.github/workflows/deploy.yaml'; - const nonWfFile = 'scripts/setup.yaml'; - - function file(status, filename, previous_filename) { - return { status, filename: filename || wfFile, previous_filename }; - } - - it('removed → skip', () => { - assert.deepEqual(v.classifyFileStatus(file('removed'), isWf), { action: 'skip' }); - }); - - it('unchanged → skip', () => { - assert.deepEqual(v.classifyFileStatus(file('unchanged'), isWf), { action: 'skip' }); - }); - - it('added → full', () => { - assert.deepEqual(v.classifyFileStatus(file('added'), isWf), { action: 'full' }); - }); - - it('copied → full (even with previous_filename)', () => { - assert.deepEqual(v.classifyFileStatus(file('copied', wfFile, wfFile), isWf), { action: 'full' }); - }); - - it('modified → diff with same filename as basePath', () => { - assert.deepEqual(v.classifyFileStatus(file('modified'), isWf), { action: 'diff', basePath: wfFile }); - }); - - it('changed → diff with same filename as basePath', () => { - assert.deepEqual(v.classifyFileStatus(file('changed'), isWf), { action: 'diff', basePath: wfFile }); - }); - - it('renamed from workflow path → diff with previous_filename as basePath', () => { - const prev = '.github/workflows/old.yaml'; - assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, prev), isWf), { action: 'diff', basePath: prev }); - }); - - it('renamed from non-workflow path → full (treat as added)', () => { - assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, nonWfFile), isWf), { action: 'full' }); - }); - - it('renamed with no previous_filename → full', () => { - assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, undefined), isWf), { action: 'full' }); - }); - - it('unknown status → infra with reason string', () => { - const result = v.classifyFileStatus(file('bogus'), isWf); - assert.equal(result.action, 'infra'); - assert.ok(result.reason.includes('bogus'), 'reason must name the unknown status: ' + result.reason); - assert.ok(result.reason.includes(wfFile), 'reason must include filename: ' + result.reason); - }); - - it('copied noncompliant workflow gets full validation (no baseline)', () => { - // Simulate: copied file has violations in head, previous_filename also exists. - // classifyFileStatus returns full, so filterNewViolations is called with empty base. - const noncompliantWf = [ - 'on:', - ' workflow_call:', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@v4', - ].join('\n'); - const cls = v.classifyFileStatus({ status: 'copied', filename: wfFile, previous_filename: wfFile }, isWf); - assert.equal(cls.action, 'full', 'copied must be full'); - const headErrs = v.validateWorkflowContent(noncompliantWf, wfFile); - assert.ok(headErrs.length > 0, 'noncompliant file must have violations'); - assert.deepEqual(v.filterNewViolations(headErrs, []), headErrs, 'all violations reported with empty base'); - }); - - it('copied compliant workflow produces no violations', () => { - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - const compliantWf = [ - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - const cls = v.classifyFileStatus({ status: 'copied', filename: wfFile }, isWf); - assert.equal(cls.action, 'full'); - assert.deepEqual(v.validateWorkflowContent(compliantWf, wfFile), []); - }); - - it('unknown status result reason is usable as POLICY-INFRA message', () => { - const result = v.classifyFileStatus(file('merge'), isWf); - assert.equal(result.action, 'infra'); - const infra = 'POLICY-INFRA: ' + result.reason + '; skipping workflow validation'; - assert.ok(infra.includes('POLICY-INFRA'), 'infra message must have prefix: ' + infra); - }); -}); - -// ── validateWorkflowContent — local action refs ─────────────────────────────── -describe('validateWorkflowContent — local action refs', () => { - const BASE = 'f.yaml'; - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - function wf(uses) { - return [ - 'on:', - ' workflow_call:', - 'permissions:', - ' contents: read', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + uses, - ].join('\n'); - } - - it('new local ref is blocked', () => { - const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); - assert.ok(errs.some(e => e.includes('local action')), 'local ref must fail: ' + errs.join('; ')); - }); - - it('local ref error includes the path for content fingerprinting', () => { - const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); - assert.ok(errs.some(e => e.includes('./.github/actions/my-action')), 'path must appear in error: ' + errs.join('; ')); - }); - - it('changed local path fingerprints differently from original', () => { - const headErrs = v.validateWorkflowContent(wf('./.github/actions/new-action'), BASE); - const baseErrs = v.validateWorkflowContent(wf('./.github/actions/old-action'), BASE); - // Different paths → different fingerprints → changed path is new - assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed local path must be reported as new'); - }); - - it('unchanged local ref is grandfathered by diff mode', () => { - const headErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); - const baseErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); - assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'same local ref must be grandfathered'); - }); - - it('remote pinned ref is still accepted', () => { - assert.deepEqual(v.validateWorkflowContent(wf(PIN), BASE), []); - }); -}); - -// ── validateWorkflowContent — flow steps array ──────────────────────────────── -describe('validateWorkflowContent — flow steps array', () => { - const BASE = 'f.yaml'; - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - function wfSteps(stepsLine) { - return [ - 'permissions: {}', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' ' + stepsLine, - ].join('\n'); - } - - it('rejects steps: [{ uses: unpinned }] flow array', () => { - const errs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'inline uses flow steps must fail: ' + errs.join('; ')); - }); - - it('rejects steps: [{ run: echo }] flow array with run', () => { - const errs = v.validateWorkflowContent(wfSteps('steps: [{ run: echo hi }]'), BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'inline run flow steps must fail: ' + errs.join('; ')); - }); - - it('rejects "steps": [...] with double-quoted key', () => { - const errs = v.validateWorkflowContent(wfSteps('"steps": [{ uses: actions/checkout@v4 }]'), BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'double-quoted steps flow array must fail: ' + errs.join('; ')); - }); - - it('rejects single-quoted \'steps\': [...] key', () => { - const errs = v.validateWorkflowContent(wfSteps("'steps': [{ uses: actions/checkout@v4 }]"), BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'single-quoted steps flow array must fail: ' + errs.join('; ')); - }); - - it('rejects multiline flow opener steps: [', () => { - const content = [ - 'permissions: {}', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps: [', - ' { uses: actions/checkout@v4 }', - ' ]', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('flow-style')), 'multiline flow steps opener must fail: ' + errs.join('; ')); - }); - - it('allows steps: [] (empty array, no execution)', () => { - const errs = v.validateWorkflowContent(wfSteps('steps: []'), BASE); - assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'empty steps: [] must pass: ' + errs.join('; ')); - }); - - it('allows steps: [] with trailing comment', () => { - const errs = v.validateWorkflowContent(wfSteps('steps: [] # placeholder'), BASE); - assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'steps: [] with comment must pass: ' + errs.join('; ')); - }); - - it('block-style steps list is not affected', () => { - const content = [ - 'permissions: {}', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), []); - }); - - it('changed flow steps payload fingerprints differently (content hash)', () => { - // Two different flow steps lines produce different FNV hashes → different fingerprints - const headErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE); - const baseErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/setup-node@v4 }]'), BASE); - assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed flow payload must be new'); - }); -}); - -// ── isActionManifestFilename ────────────────────────────────────────────────── -describe('isActionManifestFilename', () => { - it('matches action.yml at repo root', () => { - assert.ok(v.isActionManifestFilename('action.yml')); - }); - - it('matches action.yaml at repo root', () => { - assert.ok(v.isActionManifestFilename('action.yaml')); - }); - - it('matches .github/actions/my-action/action.yml', () => { - assert.ok(v.isActionManifestFilename('.github/actions/my-action/action.yml')); - }); - - it('matches nested .github/actions/sub/deep/action.yaml', () => { - assert.ok(v.isActionManifestFilename('.github/actions/sub/deep/action.yaml')); - }); - - it('rejects workflow files', () => { - assert.ok(!v.isActionManifestFilename('.github/workflows/ci.yaml')); - }); - - it('rejects action-like filenames that are not action.yml/yaml', () => { - assert.ok(!v.isActionManifestFilename('.github/actions/my-action/entrypoint.js')); - assert.ok(!v.isActionManifestFilename('actions.yml')); - }); -}); - -// ── isPolicyFilename ────────────────────────────────────────────────────────── -describe('isPolicyFilename', () => { - it('accepts workflow files', () => { - assert.ok(v.isPolicyFilename('.github/workflows/ci.yaml')); - assert.ok(v.isPolicyFilename('workflow-templates/pr-policy.yml')); - }); - - it('accepts action manifests', () => { - assert.ok(v.isPolicyFilename('action.yml')); - assert.ok(v.isPolicyFilename('.github/actions/setup/action.yaml')); - }); - - it('rejects unrelated files', () => { - assert.ok(!v.isPolicyFilename('src/index.js')); - assert.ok(!v.isPolicyFilename('.github/actions/setup/entrypoint.js')); - }); -}); - -// ── validateWorkflowContent — action manifests ──────────────────────────────── -describe('validateWorkflowContent — action manifests', () => { - const BASE = '.github/actions/my-action/action.yml'; - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - - function manifest(usesLine) { - return [ - 'name: My Action', - 'description: Does something.', - 'runs:', - ' using: composite', - ' steps:', - ' - uses: ' + usesLine, - ].join('\n'); - } - - it('does NOT require top-level permissions: in action manifests', () => { - const content = manifest(PIN); - assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []); - }); - - it('still requires permissions: in workflow files (default)', () => { - const content = [ - 'on: workflow_call', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - const errs = v.validateWorkflowContent(content, '.github/workflows/test.yaml'); - assert.ok(errs.some(e => e.includes('permissions')), 'workflow must require permissions: ' + errs.join('; ')); - }); - - it('rejects unpinned remote uses in action manifest', () => { - const content = manifest('actions/checkout@v4'); - const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); - assert.ok(errs.some(e => e.includes('40-char SHA') || e.includes('pinned')), 'unpinned must fail: ' + errs.join('; ')); - }); - - it('accepts fully pinned remote uses in action manifest', () => { - const content = manifest(PIN); - assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []); - }); - - it('rejects unsafe run expression in action manifest', () => { - const content = [ - 'name: My Action', - 'description: Does something.', - 'runs:', - ' using: composite', - ' steps:', - ' - run: echo ${{ github.event.pull_request.title }}', - ].join('\n'); - const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); - assert.ok(errs.some(e => e.includes('expression')), 'run expression must fail: ' + errs.join('; ')); - }); - - it('rejects local action ref in action manifest (unsupported)', () => { - const content = manifest('./.github/actions/nested'); - const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); - assert.ok(errs.some(e => e.includes('local action')), 'local ref in manifest must fail: ' + errs.join('; ')); - }); - - it('diff mode: modified manifest adding unpinned ref is blocked', () => { - const headContent = manifest('actions/checkout@v4'); - const baseContent = manifest(PIN); - const headErrs = v.validateWorkflowContent(headContent, BASE, { requirePermissions: false }); - const baseErrs = v.validateWorkflowContent(baseContent, BASE, { requirePermissions: false }); - assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'new unpinned ref must block'); - }); - - it('diff mode: unchanged unpinned ref in manifest is grandfathered', () => { - const content = manifest('actions/checkout@v4'); - const headErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); - const baseErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); - assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged violation must be grandfathered'); - }); -}); - -// ── classifyFileStatus — action manifests ───────────────────────────────────── -describe('classifyFileStatus — action manifests', () => { - function isWf(f) { return v.isPolicyFilename(f); } - - it('added action manifest → full', () => { - const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'added' }, isWf); - assert.equal(result.action, 'full'); - }); - - it('copied action manifest → full', () => { - const result = v.classifyFileStatus({ filename: '.github/actions/new/action.yml', status: 'copied', previous_filename: '.github/actions/old/action.yml' }, isWf); - assert.equal(result.action, 'full'); - }); - - it('modified action manifest → diff with same path', () => { - const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'modified' }, isWf); - assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/setup/action.yml' }); - }); - - it('renamed from action manifest path → diff with previous_filename', () => { - const result = v.classifyFileStatus({ - filename: '.github/actions/new-name/action.yml', - status: 'renamed', - previous_filename: '.github/actions/old-name/action.yml', - }, isWf); - assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old-name/action.yml' }); - }); - - it('renamed from non-policy path → full (treat as added)', () => { - const result = v.classifyFileStatus({ - filename: '.github/actions/setup/action.yml', - status: 'renamed', - previous_filename: 'docs/action-template.yml', - }, isWf); - assert.equal(result.action, 'full'); - }); -}); - -// ── Exact bypass scenario ───────────────────────────────────────────────────── -// A modified composite action.yml that adds an unpinned uses: must block even -// when the referencing workflow file and its local uses: ./... line are unchanged. -describe('bypass scenario: modified action manifest adds unpinned ref', () => { - const ACTION_FILE = '.github/actions/setup/action.yml'; - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - - const baseManifest = [ - 'name: Setup', - 'description: Sets up the environment.', - 'runs:', - ' using: composite', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - - const headManifest = [ - 'name: Setup', - 'description: Sets up the environment.', - 'runs:', - ' using: composite', - ' steps:', - ' - uses: ' + PIN, - ' - uses: actions/setup-node@v4', - ].join('\n'); - - it('base manifest (pinned only) has no violations', () => { - assert.deepEqual(v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false }), []); - }); - - it('head manifest (adds unpinned step) has a violation', () => { - const errs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false }); - assert.ok(errs.length > 0, 'head must have violations: ' + errs.join('; ')); - }); - - it('diff mode reports the new unpinned ref as a new violation', () => { - const headErrs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false }); - const baseErrs = v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false }); - const newViolations = v.filterNewViolations(headErrs, baseErrs); - assert.equal(newViolations.length, 1, 'exactly one new violation expected: ' + newViolations.join('; ')); - assert.ok(newViolations[0].includes('setup-node'), 'violation must name the offending ref: ' + newViolations[0]); - }); - - it('new local ref inside composite action also fails closed', () => { - const manifest = [ - 'name: Setup', - 'description: Sets up the environment.', - 'runs:', - ' using: composite', - ' steps:', - ' - uses: ./.github/actions/nested', - ].join('\n'); - const errs = v.validateWorkflowContent(manifest, ACTION_FILE, { requirePermissions: false }); - assert.ok(errs.some(e => e.includes('local action')), 'local ref in composite must fail: ' + errs.join('; ')); - }); -}); - -// ── policyFileKind ──────────────────────────────────────────────────────────── -describe('policyFileKind', () => { - it('returns "workflow" for workflow files', () => { - assert.equal(v.policyFileKind('.github/workflows/ci.yaml'), 'workflow'); - assert.equal(v.policyFileKind('workflow-templates/pr.yml'), 'workflow'); - }); - - it('returns "action" for action manifests', () => { - assert.equal(v.policyFileKind('action.yml'), 'action'); - assert.equal(v.policyFileKind('.github/actions/setup/action.yaml'), 'action'); - }); - - it('returns null for non-policy files', () => { - assert.equal(v.policyFileKind('src/index.js'), null); - assert.equal(v.policyFileKind('.github/actions/setup/entrypoint.js'), null); - }); -}); - -// ── classifyFileStatus — cross-type rename grandfathering ───────────────────── -describe('classifyFileStatus — cross-type rename grandfathering', () => { - function isWf(f) { return v.isPolicyFilename(f); } - - it('action -> workflow rename → full (cross-kind, not grandfathered)', () => { - const result = v.classifyFileStatus({ - filename: '.github/workflows/imported.yml', - status: 'renamed', - previous_filename: 'action.yml', - }, isWf); - assert.equal(result.action, 'full', 'action→workflow must be full, got: ' + JSON.stringify(result)); - }); - - it('workflow -> action rename → full (cross-kind, not grandfathered)', () => { - const result = v.classifyFileStatus({ - filename: '.github/actions/setup/action.yml', - status: 'renamed', - previous_filename: '.github/workflows/ci.yml', - }, isWf); - assert.equal(result.action, 'full', 'workflow→action must be full, got: ' + JSON.stringify(result)); - }); - - it('workflow -> workflow rename → diff (same kind)', () => { - const result = v.classifyFileStatus({ - filename: '.github/workflows/new.yml', - status: 'renamed', - previous_filename: '.github/workflows/old.yml', - }, isWf); - assert.deepEqual(result, { action: 'diff', basePath: '.github/workflows/old.yml' }); - }); - - it('action -> action rename → diff (same kind)', () => { - const result = v.classifyFileStatus({ - filename: '.github/actions/new/action.yml', - status: 'renamed', - previous_filename: '.github/actions/old/action.yml', - }, isWf); - assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old/action.yml' }); - }); - - it('non-policy -> workflow rename → full', () => { - const result = v.classifyFileStatus({ - filename: '.github/workflows/imported.yml', - status: 'renamed', - previous_filename: 'docs/template.yml', - }, isWf); - assert.equal(result.action, 'full'); - }); -}); - -// ── Exact bypass reproduction: action.yml renamed to workflow ───────────────── -describe('bypass reproduction: action renamed to workflow reports missing permissions', () => { - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - - // Simulates: base is action.yml (no permissions, valid for action), head is - // .github/workflows/imported.yml (same content, but now a workflow file). - // Full validation must run because the policy kind changed (action → workflow). - it('action.yml content re-validated as workflow reports missing permissions', () => { - const actionContent = [ - 'name: Setup', - 'description: Sets up the environment.', - 'runs:', - ' using: composite', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - - // classifyFileStatus returns full → no baseline. - const isWf = f => v.isPolicyFilename(f); - const cls = v.classifyFileStatus({ - filename: '.github/workflows/imported.yml', - status: 'renamed', - previous_filename: 'action.yml', - }, isWf); - assert.equal(cls.action, 'full', 'cross-kind rename must be full'); - - // Full validation as a workflow file — no opts.requirePermissions: false. - const errs = v.validateWorkflowContent(actionContent, '.github/workflows/imported.yml'); - assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions must be reported: ' + errs.join('; ')); - }); - - it('workflow.yml renamed to workflow.yml stays in diff mode and grandfathers unchanged violations', () => { - const content = [ - 'permissions: {}', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - const isWf = f => v.isPolicyFilename(f); - const cls = v.classifyFileStatus({ - filename: '.github/workflows/new.yml', - status: 'renamed', - previous_filename: '.github/workflows/old.yml', - }, isWf); - assert.deepEqual(cls, { action: 'diff', basePath: '.github/workflows/old.yml' }, 'same-kind rename must be diff'); - // Use file.filename for both head/base so fingerprints match. - const headErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml'); - const baseErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml'); - assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged content must be grandfathered'); - }); -}); - -// ── Whitespace-before-colon bypass ─────────────────────────────────────────── -describe('validateWorkflowContent — whitespace before colon', () => { - const BASE = '.github/workflows/test.yaml'; - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - - function wfHeader() { - return 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps:'; - } - - it('rejects "uses : actions/checkout@v4" (space before colon)', () => { - const content = wfHeader() + '\n - uses : actions/checkout@v4'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('whitespace before')), 'must reject uses with space: ' + errs.join('; ')); - }); - - it('rejects sequence-form "- uses : ..." (space before colon)', () => { - const content = wfHeader() + '\n - uses : actions/checkout@v4'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'sequence uses space must fail: ' + errs.join('; ')); - }); - - it('rejects "run : echo ${{ github.token }}" (space before colon with expression)', () => { - const content = wfHeader() + '\n - run : echo ${{ github.token }}'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'run with space must fail: ' + errs.join('; ')); - }); - - it('rejects "steps : [{ uses : actions/checkout@v4 }]" (space before colon on steps)', () => { - const content = 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps : [{ uses : actions/checkout@v4 }]'; - const errs = v.validateWorkflowContent(content, BASE); - assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'steps with space must fail: ' + errs.join('; ')); - }); - - it('normal keys without space still work correctly', () => { - const content = [ - 'permissions: {}', - 'jobs:', - ' j:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, BASE), []); - }); - - it('changed whitespace-before-colon payload fingerprints differently', () => { - const h = wfHeader() + '\n - uses : actions/checkout@v4'; - const b = wfHeader() + '\n - uses : actions/setup-node@v4'; - const headErrs = v.validateWorkflowContent(h, BASE); - const baseErrs = v.validateWorkflowContent(b, BASE); - assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed payload must be new'); - }); -}); - -// ── Workflow/action overlap: .github/workflows/action.yml ───────────────────── -describe('policyFileKind: workflow takes precedence over action-manifest pattern', () => { - it('policyFileKind returns "workflow" for .github/workflows/action.yml', () => { - assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow'); - }); - - it('.github/workflows/action.yml requires permissions (workflow semantics)', () => { - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - const content = [ - 'name: Inline Action', - 'description: Does something.', - 'runs:', - ' using: composite', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - // Default opts (requirePermissions: true) because policyFileKind === 'workflow' - const errs = v.validateWorkflowContent(content, '.github/workflows/action.yml'); - assert.ok(errs.some(e => e.includes('permissions')), 'workflow-path action.yml must require permissions: ' + errs.join('; ')); - }); - - it('.github/actions/foo/action.yml does NOT require permissions (action semantics)', () => { - const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; - const content = [ - 'name: Inline Action', - 'description: Does something.', - 'runs:', - ' using: composite', - ' steps:', - ' - uses: ' + PIN, - ].join('\n'); - assert.deepEqual(v.validateWorkflowContent(content, '.github/actions/foo/action.yml', { requirePermissions: false }), []); - }); - - it('isActionManifestFilename still matches .github/workflows/action.yml (pattern overlap acknowledged)', () => { - assert.ok(v.isActionManifestFilename('.github/workflows/action.yml'), 'pattern overlap exists'); - assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow', 'but kind is workflow'); - }); -});