diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 692c38d..7e183d1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Internal IT support - url: https://seahaven.atlassian.net/jira/software/projects/INFRA - about: For operational issues, file an INFRA Jira ticket instead. + - name: Jira — DEV / PLAT / SEC + url: https://seahaven.atlassian.net/jira + about: File all org work in Jira (DEV, PLAT, or SEC). INFRA is a closed archive. GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6444918..c83f223 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -15,7 +15,6 @@ assignees: amoussa1229 ## AWS / integration impact - New or changed AWS resources (Lambda, DynamoDB, S3, API Gateway): - Slack app(s) involved: -- Confluence Architecture Map update needed: yes / no ## Alternatives considered diff --git a/.github/ISSUE_TEMPLATE/infra-change.md b/.github/ISSUE_TEMPLATE/infra-change.md index 74141a3..90d0bf3 100644 --- a/.github/ISSUE_TEMPLATE/infra-change.md +++ b/.github/ISSUE_TEMPLATE/infra-change.md @@ -21,6 +21,4 @@ assignees: amoussa1229 ## Documentation -- [ ] Confluence Architecture Map (id 1540098) update queued - [ ] README updated in same PR -- [ ] Project memory entry queued diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bf5c9df..4a8f12b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,14 @@ ## Summary @@ -15,13 +21,4 @@ PR conventions — see engineering-handbook/pull-requests.md ## Notes - - -## Sea Haven checklist -- [ ] CDK diff / SAM changeset reviewed (if infra change) -- [ ] Secrets added to Parameter Store / Secrets Manager (not hardcoded) -- [ ] DynamoDB PITR verified on new tables -- [ ] Slack notification tested in staging -- [ ] Confluence Architecture Map updated -- [ ] Memory update queued (if new repo/stack) -- [ ] Cross-review requested (if IAM or Lambda handler signature change) + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1210f19..b7393d5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: "/" schedule: interval: "weekly" + commit-message: + prefix: "chore(deps)" groups: minor-and-patch: update-types: diff --git a/.github/workflows/callable-pr-policy.yaml b/.github/workflows/callable-pr-policy.yaml new file mode 100644 index 0000000..d7689d7 --- /dev/null +++ b/.github/workflows/callable-pr-policy.yaml @@ -0,0 +1,898 @@ +name: PR Policy + +# Reusable PR metadata gate for all Sea-Haven-Industries repos. +# +# Validates pull-request metadata — title convention, branch naming, body +# structure, commit subjects, Jira existence, AI attribution footers, and +# workflow file pin compliance — without executing any PR code or checking +# out the repository. All checks run through the GitHub API only. +# +# Callers trigger this on `pull_request` (NOT pull_request_target) with event +# types: opened, reopened, synchronize, edited, labeled, unlabeled, +# ready_for_review. The check-run name is ` / pr`; the +# canonical caller job id is `policy`, producing the context `policy / pr`. +# +# Secrets are optional at the declaration level. For human PRs that include a +# Jira key, all three must be configured or the check fails closed (POLICY-INFRA). +# Dependabot skips Jira/branch/body checks but still runs commit-subject and +# workflow supply-chain checks. +# +# Emergency-revert exemption: when the title type is `revert`, the PR has no +# Jira key in the title, and the `emergency-revert` label is present on the PR, +# a candidate exemption is computed before title validation so the Jira key is +# not required in the title. The exemption is confirmed by verifying that the +# label was applied by a collaborator with maintain or admin permission. Any +# pagination truncation of the event timeline is POLICY-INFRA — partial history +# is never trusted. Unauthorized/null-actor/bot results add a violation. +# Branch, body, and commit checks remain regardless. +# +# Known platform limitation: metadata edits (labels, title changes) made via +# GITHUB_TOKEN do not reliably emit a new pull_request event. Org automation +# that applies labels must use a GitHub App token or a PAT so the policy gate +# re-runs automatically after the label is applied. +# +# Caller example: +# jobs: +# policy: +# uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@ # vX.Y.Z +# secrets: +# JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} +# JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} +# JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + +on: + workflow_call: + secrets: + JIRA_CLOUD_ID: + required: false + JIRA_SERVICE_ACCOUNT_EMAIL: + required: false + JIRA_API_TOKEN: + required: false + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + pr: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Validate PR + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} + JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + with: + script: | + // ── Pure validation functions ──────────────────────────────────────────── + // Extracted and exercised by test/pr-policy.test.mjs via PR_POLICY_TEST. + // These functions have no side effects and make no API calls. + + const CONV_TYPES = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release']; + const REQUIRED_H2 = ['Summary','Validation','Tests','Notes']; + + // AI-attribution footer patterns — case-insensitive, multiline. + // Matches Co-authored-by: trailers naming known AI tools and "Generated by/with" + // phrases. Does NOT flag generic prose like "uses AI" or "AI-powered". + // GPT variants: gpt-3, gpt-4, gpt-4o, gpt-5, gpt-o, etc. covered by gpt-[a-z0-9]+. + const AI_FOOTER_RE = /^(?:co-authored-by:\s+(?:claude|chatgpt|gpt-[a-z0-9]+|copilot|github\s+copilot|gemini|cursor(?:\s*ai)?|codeium|anthropic|openai|codex)\b|generated\s+(?:with|by)\s+(?:claude(?:\s+code)?|github\s+copilot|chatgpt|codex|gpt-[a-z0-9]+|gemini|codeium|cursor(?:\s*ai)?|anthropic|openai)|🤖\s+generated\b)/im; + + function validateTitle(title, isDependabot, jiraMaybeExempt) { + const errs = []; + if (title.length > 72) errs.push('Title is ' + title.length + ' chars — max 72'); + const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC)-\d+\))?$/); + if (!m) { + errs.push('Title must match: type(scope): description (KEY-NNN). Allowed types: ' + CONV_TYPES.join(' ')); + return errs; + } + const desc = m[4]; + const jiraSuffix = m[5]; + if (desc.endsWith('.')) errs.push('Description must not end with a period'); + if (!/^[a-z]/.test(desc)) errs.push('Description must start with a lowercase letter'); + if (!isDependabot && !jiraSuffix && !jiraMaybeExempt) { + errs.push('Missing Jira key — expected (DEV-NNN), (PLAT-NNN), or (SEC-NNN) at end of title'); + } + return errs; + } + + function getJiraKey(title) { + const m = title.match(/\((DEV|PLAT|SEC)-(\d+)\)$/); + return m ? m[1] + '-' + m[2] : null; + } + + function validateBranch(branch, isDependabot) { + if (isDependabot) return []; + const errs = []; + // Segment must be proper kebab-case: no consecutive hyphens, no trailing hyphen. + const m = branch.match(/^(feature|fix|hotfix|chore|docs|refactor|release)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/); + if (!m) { + errs.push('Branch "' + branch + '" must match prefix/kebab-case (no consecutive/trailing hyphens, no uppercase, one segment). Prefixes: feature fix hotfix chore docs refactor release'); + return errs; + } + if (/(?:DEV|PLAT|SEC|INFRA)-\d+/i.test(m[2])) errs.push('Branch segment must not contain a Jira key'); + return errs; + } + + function validateBody(rawBody, isDependabot) { + if (isDependabot) return []; + if (!rawBody || !rawBody.trim()) return ['PR body is empty']; + const errs = []; + // Strip fenced code blocks line-by-line before scanning headings. + // Fence markers: backtick (0x60) or tilde. Up to 3 leading spaces allowed + // (CommonMark spec). Use charCode to avoid literal backtick in source + // (which confuses actionlint's expression scanner). + const TICK = String.fromCharCode(0x60); + const bodyLines = rawBody.split('\n'); + const stripped = []; + let inFence = false; + let fenceChar = ''; + let fenceLen = 0; + const fenceRe = new RegExp('^ {0,3}(' + TICK + '{3,}|~{3,})'); + for (const line of bodyLines) { + if (!inFence) { + const fm = fenceRe.exec(line); + if (fm) { + inFence = true; + fenceChar = fm[1][0]; + fenceLen = fm[1].length; + stripped.push(''); + } else { + stripped.push(line); + } + } else { + const fm = fenceRe.exec(line); + if (fm && fm[1][0] === fenceChar && fm[1].length >= fenceLen && line.trim() === fm[1]) { + inFence = false; + stripped.push(''); + } else { + stripped.push(''); + } + } + } + const cleaned = stripped.join('\n').replace(//g, ''); + const h2s = Array.from(cleaned.matchAll(/^## (.+)$/gm)).map(function(x) { return x[1].trim(); }); + if (h2s.length !== 4) { + errs.push('Body must have exactly 4 ## headings (Summary/Validation/Tests/Notes), found ' + h2s.length + (h2s.length ? ': ' + h2s.join(', ') : '')); + return errs; + } + for (let i = 0; i < 4; i++) { + if (h2s[i] !== REQUIRED_H2[i]) errs.push('Heading ' + (i + 1) + ': expected "## ' + REQUIRED_H2[i] + '", got "## ' + h2s[i] + '"'); + } + const sectionParts = cleaned.split(/^(?=## )/m).filter(function(p) { return p.startsWith('## '); }); + for (let i = 0; i < Math.min(sectionParts.length, 4); i++) { + const content = sectionParts[i].replace(/^## [^\n]*\n?/, '').trim(); + if (!content) errs.push('## ' + REQUIRED_H2[i] + ' section is empty'); + } + return errs; + } + + function validateCommitSubject(subject) { + const errs = []; + if (subject.length > 72) errs.push('Commit subject is ' + subject.length + ' chars — max 72'); + const m = subject.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+)$/); + if (!m) { + errs.push('Not conventional: "' + subject.slice(0, 60) + (subject.length > 60 ? '\u2026' : '') + '"'); + return errs; + } + const desc = m[4]; + if (!/^[a-z]/.test(desc)) errs.push('Commit description must start with a lowercase letter'); + if (desc.endsWith('.')) errs.push('Commit description must not end with a period'); + if (/\s+\((DEV|PLAT|SEC)-\d+\)$/i.test(subject)) errs.push('Commit subject must not carry a Jira key suffix — only the PR title does'); + return errs; + } + + function detectAiFooter(text) { + return AI_FOOTER_RE.test(text); + } + + function isWorkflowFilename(filename) { + return /^\.github\/workflows\/[^/]+\.ya?ml$/.test(filename) || + /^workflow-templates\/[^/]+\.ya?ml$/.test(filename); + } + + // 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, + 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 infraCodes = []; + let infraFailed = false; + const MAX_ANNOTATIONS = 50; + + function addViolation(msg) { violations.push(msg); } + function addInfra(msg) { infraCodes.push(msg); infraFailed = true; } + + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + const pr = context.payload.pull_request; + const prNum = pr.number; + const isDep = pr.user.login === 'dependabot[bot]'; + const titleTypeMatch = pr.title.match(/^([a-z]+)/); + const titleType = titleTypeMatch ? titleTypeMatch[1] : ''; + + // Pre-compute emergency-revert candidate before title validation. + // Only a revert title that LACKS a Jira suffix triggers emergency + // authorization; a revert title that already carries a Jira key does not. + const hasEmergencyLabel = pr.labels.some(function(l) { return l.name === 'emergency-revert'; }); + const titleHasJira = !!getJiraKey(pr.title); + const isEmergencyCandidate = !isDep && titleType === 'revert' && hasEmergencyLabel && !titleHasJira; + + // 1 — title convention + for (const e of validateTitle(pr.title, isDep, isEmergencyCandidate)) addViolation('Title: ' + e); + + // 2 — branch naming (Dependabot exempt) + for (const e of validateBranch(pr.head.ref, isDep)) addViolation('Branch: ' + e); + + // 3 — body structure (Dependabot exempt) + for (const e of validateBody(pr.body, isDep)) addViolation('Body: ' + e); + + // 4 — AI attribution footer in title/body + if (detectAiFooter((pr.title || '') + '\n' + (pr.body || ''))) { + addViolation('AI attribution footer detected in PR title or body'); + } + + // 5 — commits: subject convention + AI footer + // GitHub REST API caps listCommits at 250 total. Fail infra immediately + // when pr.commits exceeds that limit; compare fetched count to detect + // API truncation. + { + const commitLimitErr = checkCommitLimit(pr.commits); + if (commitLimitErr) addInfra(commitLimitErr); + + let commitPage = 1; + let commitMore = true; + let totalFetched = 0; + while (commitMore) { + let resp; + try { + resp = await github.rest.pulls.listCommits({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: commitPage }); + } catch (err) { + addInfra('POLICY-INFRA: Failed to fetch commits (page ' + commitPage + '): ' + err.message); + break; + } + const commits = resp.data; + const link = (resp.headers && resp.headers.link) ? resp.headers.link : ''; + totalFetched += commits.length; + if (!link.includes('rel="next"') || commits.length === 0) commitMore = false; + for (const c of commits) { + const subject = c.commit.message.split('\n')[0]; + for (const e of validateCommitSubject(subject)) addViolation('Commit ' + c.sha.slice(0, 8) + ': ' + e); + if (detectAiFooter(c.commit.message)) addViolation('Commit ' + c.sha.slice(0, 8) + ': AI attribution footer detected'); + } + commitPage++; + } + if (pr.commits <= 250 && totalFetched > 0 && totalFetched !== pr.commits) { + addInfra('POLICY-INFRA: Fetched ' + totalFetched + ' commits but PR reports ' + pr.commits + ' — API truncation suspected'); + } + } + + // 6 — emergency-revert authorisation + // Event timeline truncation is always POLICY-INFRA regardless of whether + // an earlier label event was found — partial history is never trusted. + let jiraExempt = isDep; + let emergencyAuthFailed = false; + if (isEmergencyCandidate) { + try { + let evPage = 1; + let evMore = true; + let latestLabelEvent = null; + let evTruncated = false; + while (evMore) { + const evResp = await github.rest.issues.listEvents({ owner: repoOwner, repo: repoName, issue_number: prNum, per_page: 100, page: evPage }); + const evLink = (evResp.headers && evResp.headers.link) ? evResp.headers.link : ''; + for (const ev of evResp.data) { + if (ev.event === 'labeled' && ev.label && ev.label.name === 'emergency-revert') latestLabelEvent = ev; + } + if (!evLink.includes('rel="next"') || evResp.data.length === 0) { + evMore = false; + } else if (evPage >= 20) { + evMore = false; + evTruncated = true; + } + evPage++; + } + if (evTruncated) { + // Partial history cannot verify the most-recent label event. + // An earlier maintainer event might have been superseded. + addInfra('POLICY-INFRA: Event timeline truncated at pagination limit — cannot verify the most-recent emergency-revert label actor; Jira key required'); + emergencyAuthFailed = true; + } else if (!latestLabelEvent) { + addViolation('emergency-revert: label present but no label event found in timeline — Jira key required'); + } else if (!latestLabelEvent.actor) { + addViolation('emergency-revert: label event actor is null — Jira key required'); + } else if (latestLabelEvent.actor.type === 'Bot') { + addViolation('emergency-revert: label applied by a bot — Jira key required'); + } else { + const permResp = await github.rest.repos.getCollaboratorPermissionLevel({ owner: repoOwner, repo: repoName, username: latestLabelEvent.actor.login }); + if (permResp.data.permission === 'maintain' || permResp.data.permission === 'admin') { + jiraExempt = true; + } else { + addViolation('emergency-revert: label applied by user without maintain/admin permission — Jira key required'); + } + } + } catch (err) { + addInfra('POLICY-INFRA: Emergency-revert authorisation check failed: ' + err.message); + emergencyAuthFailed = true; + } + } + + // 7 — Jira existence (Dependabot exempt; emergency-revert may be exempt) + // Skip entirely when emergency auth already produced an infra error to + // avoid a redundant credential error on a PR that has no Jira key. + const jiraKey = isDep ? null : getJiraKey(pr.title); + if (!jiraExempt && jiraKey && !emergencyAuthFailed) { + const cloudId = process.env.JIRA_CLOUD_ID || ''; + const jiraEmail = process.env.JIRA_SERVICE_ACCOUNT_EMAIL || ''; + const jiraToken = process.env.JIRA_API_TOKEN || ''; + if (!cloudId || !jiraEmail || !jiraToken) { + addInfra('POLICY-INFRA: Jira credentials missing — JIRA_CLOUD_ID, JIRA_SERVICE_ACCOUNT_EMAIL, and JIRA_API_TOKEN must all be set for human PRs'); + } else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)) { + addInfra('POLICY-INFRA: JIRA_CLOUD_ID is not a valid UUID'); + } else { + try { + await jiraGetIssue(cloudId, jiraKey, jiraEmail, jiraToken); + } catch (err) { + if (err.isInfra) addInfra('POLICY-INFRA: ' + err.message); + else addViolation('Jira: ' + err.message); + } + } + } + + // 8 — workflow file supply-chain checks (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); + 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 (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/cd-dotnet-eb.yaml b/.github/workflows/cd-dotnet-eb.yaml index 35a8435..3e9d70c 100644 --- a/.github/workflows/cd-dotnet-eb.yaml +++ b/.github/workflows/cd-dotnet-eb.yaml @@ -4,7 +4,7 @@ name: CD — .NET Elastic Beanstalk # # jobs: # deploy: -# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@ # main +# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@ # v1.0.4 # with: # project: "Api.Example/Api.Example.csproj" # eb-application: "example-api" diff --git a/.github/workflows/ci-mobile-ios.yaml b/.github/workflows/ci-mobile-ios.yaml index f29874f..8c31458 100644 --- a/.github/workflows/ci-mobile-ios.yaml +++ b/.github/workflows/ci-mobile-ios.yaml @@ -35,7 +35,7 @@ name: CI — Mobile iOS # Caller example: # jobs: # ci: -# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@ # main +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@ # v1.0.4 # with: # working-directory: mobile # cache-dependency-path: mobile/package-lock.json diff --git a/.github/workflows/ci-static.yaml b/.github/workflows/ci-static.yaml index b03d5db..e203970 100644 --- a/.github/workflows/ci-static.yaml +++ b/.github/workflows/ci-static.yaml @@ -15,7 +15,7 @@ name: CI — Static Site # Caller example (build mode): # jobs: # ci: -# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@ # main +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@ # v1.0.4 # with: # build-command: "npx @11ty/eleventy" # check-dir: "_site" diff --git a/.github/workflows/ci-typescript-frontend.yaml b/.github/workflows/ci-typescript-frontend.yaml index 18f81bf..fa72813 100644 --- a/.github/workflows/ci-typescript-frontend.yaml +++ b/.github/workflows/ci-typescript-frontend.yaml @@ -13,7 +13,7 @@ name: CI — TypeScript Frontend # Caller example: # jobs: # ci: -# uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@ # main +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@ # v1.0.4 # with: # node-version: "24" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80e2e63..9aa2224 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,7 +50,11 @@ jobs: name: ci / ci runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run policy unit tests + run: node --test test/pr-policy.test.mjs + shell: bash - name: Install actionlint env: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 43c72d0..e1eba01 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -32,7 +32,7 @@ name: Release — Tag and GitHub Release # Caller example: # jobs: # release: -# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@ # main +# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@ # v1.0.4 # with: # version: ${{ inputs.version }} # diff --git a/README.md b/README.md index 26ad712..320972d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,28 @@ Organization-level GitHub configuration for Sea Haven Industries. +## Git and PR conventions + +### Branch naming + +`feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/` + kebab-case description. Branch names do not contain Jira keys. + +### Commit format + +`type(scope): description` — lowercase, imperative, no trailing period, header ≤ 72 chars. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`, `release`. Breaking change: `feat!:` + `BREAKING CHANGE:` footer. + +### PR title + +`type(scope): description (DEV-123)` — the Jira key is required at the end in parentheses. Active projects: **DEV** (product), **PLAT** (platform), **SEC** (security). INFRA is a closed archive. Jira-exempt only: Dependabot PRs and permission-controlled emergency reverts. + +### PR body + +Exactly four headings in order: `## Summary`, `## Validation`, `## Tests`, `## Notes`. Use `None.` under Notes if empty. + +### Deploy path + +The two sanctioned deploy paths are merge to `main` triggering the pipeline and `workflow_dispatch` on that same pipeline. No manual workstation deploys to production. + ## What's in here ### Reusable Workflows @@ -28,6 +50,12 @@ Organization-level GitHub configuration for Sea Haven Industries. **`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping. +**`.github/workflows/callable-pr-policy.yaml`** — Reusable PR metadata gate. Validates PR title convention (type/scope/Jira key), branch naming, four-section body, commit subjects, AI attribution footers, and workflow file pin compliance — all via GitHub API, no checkout. Emits `policy / pr` when the caller job is named `policy`. Optional secrets `JIRA_CLOUD_ID`, `JIRA_SERVICE_ACCOUNT_EMAIL`, and `JIRA_API_TOKEN` must all be set for human PRs; Dependabot skips Jira/branch/body but still runs commit and workflow supply-chain checks. Emergency `revert` PRs may skip Jira with the `emergency-revert` label applied by a human collaborator with `maintain` or `admin` permission. + +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. @@ -36,6 +64,8 @@ Organization-level GitHub configuration for Sea Haven Industries. **`.github/workflows/release-on-merge.yaml`** — Repo automation (not callable): cuts a tag and GitHub Release for **this** repo whenever a merge to `main` changes a reusable workflow, so Dependabot has a release to advance consumer SHA pins to (see the pinning policy below). +**`.github/workflows/policy.yaml`** — Intentionally absent from this PR. The self-caller must pin `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` to a released 40-char SHA with a matching `# vX.Y.Z` comment; a mutable local `./` path reference is rejected by the supply-chain gate on modified workflow files. The follow-up PR can be opened once this PR merges and `release-on-merge.yaml` cuts the first release containing `callable-pr-policy.yaml`, then using `gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha` to obtain the pin. + **`.github/workflows/labeler.yaml`** — This repo's own thin caller of `callable-labeler.yaml`, so the labeler runs on `.github`'s own PRs. **`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted. @@ -44,6 +74,8 @@ Organization-level GitHub configuration for Sea Haven Industries. Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `labeler`, `mobile-ios-deploy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one. +A `pr-policy` starter template can be added to `workflow-templates/` only after the PR that introduces `callable-pr-policy.yaml` merges and `release-on-merge.yaml` cuts the first release containing it. Until then, consumer repos must add the caller workflow manually (see §3). + ### Ref pinning policy All workflow refs across the org are pinned to full commit SHAs: @@ -54,8 +86,8 @@ All workflow refs across the org are pinned to full commit SHAs: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@ # v1.0.3 ``` - Branch refs are mutable: a bad commit on this repo would flow instantly into every consumer's CI and deploy path, while a SHA pin turns the same change into a reviewable Dependabot PR. Two prerequisites keep pins advancing instead of freezing: every consumer repo's `dependabot.yml` must include the `github-actions` ecosystem (weekly), and Dependabot must be granted access to this repo at the org level (Org Settings → Advanced Security → Global settings → "Grant Dependabot access to repositories"); without the grant, update jobs fail with `git_dependencies_not_reachable` and pins freeze silently. `release-on-merge.yaml` tags this repo on every reusable-workflow change so Dependabot has releases to diff against. When adding a caller by hand, pin to the current tip of `main` (`gh api /repos/Sea-Haven-Industries/.github/commits/main --jq .sha`) and let Dependabot advance it from there. -- **Third-party and first-party actions** (`actions/checkout`, `actions/setup-python`, `actions/labeler`, …) are likewise **SHA-pinned** with a trailing version comment (e.g. `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`); Dependabot keeps the SHA and comment current. + Branch refs are mutable: a bad commit on this repo would flow instantly into every consumer's CI and deploy path, while a SHA pin turns the same change into a reviewable Dependabot PR. Two prerequisites keep pins advancing instead of freezing: every consumer repo's `dependabot.yml` must include the `github-actions` ecosystem (weekly), and Dependabot must be granted access to this repo at the org level (Org Settings → Advanced Security → Global settings → "Grant Dependabot access to repositories"); without the grant, update jobs fail with `git_dependencies_not_reachable` and pins freeze silently. `release-on-merge.yaml` tags this repo on every reusable-workflow change so Dependabot has releases to diff against. When adding a caller by hand, pin to the latest release commit (`gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha`), annotate it with `# vX.Y.Z`, and let Dependabot advance it from there. +- **Third-party and first-party actions** (`actions/checkout`, `actions/setup-python`, `actions/labeler`, …) — a subset are already SHA-pinned (e.g. `actions/labeler`, `aws-actions/*`, `docker/setup-qemu-action`, `ruby/setup-ruby`); the remainder (`actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-dotnet`, `actions/dependency-review-action`) currently use floating major-version tags. Full SHA pinning for this group is deferred (PLAT backlog); Dependabot will keep SHA and comment current once pins are set. - **Binary installs are checksum-verified** (actionlint in `ci.yaml`). ### AWS deploy roles & IAM (`oidc-deploy-roles.yaml`) @@ -110,7 +142,7 @@ A function's effective permissions are the **intersection** of its own role poli 2. Redeploy the SAM stacks so their roles pick it up (while the exec role still permits it). 3. *Then* tighten the exec role. -Wrong order breaks every SAM deploy. (History: INFRA-103 established the boundary, INFRA-97 scoped the role.) CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role. +Wrong order breaks every SAM deploy. CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role. This ordering rule is about changing the **boundary** or the conditions that gate it. It does not apply to changes that only add permissions to the exec role. @@ -135,6 +167,14 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each The CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §3). +Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo: + +| Secret | Value | Consumed by | +|--------|-------|-------------| +| `JIRA_CLOUD_ID` | Atlassian Cloud ID UUID (find in **Jira Settings → Products → Jira Software**) | `callable-pr-policy.yaml` | +| `JIRA_SERVICE_ACCOUNT_EMAIL` | Email of the service account with read access to DEV/PLAT/SEC projects | `callable-pr-policy.yaml` | +| `JIRA_API_TOKEN` | API token for that account (generated at **id.atlassian.com/manage-profile/security/api-tokens**) | `callable-pr-policy.yaml` | + ### 2. Add CI to a repo Create `.github/workflows/ci.yaml` in the target repo. Examples: @@ -149,7 +189,7 @@ on: jobs: ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 ``` **TypeScript CDK repo** (e.g., seahaven-door-unlock-api, seahaven-slack-bot): @@ -162,7 +202,7 @@ on: jobs: ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 ``` **Node.js SAM repo** (e.g., payments-dashboard): @@ -175,7 +215,7 @@ on: jobs: ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: run-typecheck: false run-cdk-synth: false @@ -192,15 +232,53 @@ on: jobs: python: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: source-dirs: "src" run-sam-validate: false typescript: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 ``` -### 3. Add CD to a repo +### 3. Add PR policy to a repo + +Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1). + +```yaml +name: PR Policy +on: + pull_request: + types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review] + +concurrency: + group: policy-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + policy: + uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@ # vX.Y.Z + secrets: + JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} + JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} +``` + +Replace `` with the SHA of the release that contains `callable-pr-policy.yaml`: + +```bash +gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha +``` + +The check-run name is `policy / pr`. If your branch-protection ruleset requires this context, add it after the first PR passes. + +> **Known platform limitation — GITHUB_TOKEN label and metadata events.** When the `policy` workflow re-runs on `labeled` or `edited` events, the metadata edits themselves (label adds, title edits) must be performed by a GitHub App or a PAT that owns its own event stream. Edits made through `GITHUB_TOKEN` do not reliably emit a new `pull_request` event to trigger re-evaluation; the check stays in its prior state until the next push or manual re-run. Org automation that applies labels (such as the `emergency-revert` label) must therefore use a GitHub App token or a PAT — not `GITHUB_TOKEN` — or the policy gate will not re-run automatically after the label is applied. This is a GitHub platform constraint, not a deficiency that can be solved at the workflow level. + +### 4. Add CD to a repo Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret. @@ -214,7 +292,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: stack-name: afterhours-shift-manager cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role @@ -234,7 +312,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} ``` @@ -249,7 +327,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: python-version: "3.12" cdk-dir: cdk @@ -267,7 +345,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: enable-qemu: true secrets: @@ -284,7 +362,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@ # + uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: project: Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj eb-application: shoc-backend diff --git a/SUPPORT.md b/SUPPORT.md index 6854763..2a136b6 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -4,7 +4,8 @@ Sea-Haven-Industries repositories are private and intended for internal Sea Have ## Where to go -- **Bugs, feature requests, infrastructure work** — file a ticket in Jira (**INFRA** project) or open an issue on the relevant repository. +- **Bugs and feature requests** — file a ticket in Jira (**DEV**, **PLAT**, or **SEC** depending on scope). GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe (contractor/fork intake). +- **Infrastructure and platform work** — use the **PLAT** project. Security issues go in **SEC**. - **Operational or urgent issues** — contact Adam Moussa (adam@seahavenind.com). - **Engineering conventions and standards** — see the [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook). - **Security vulnerabilities** — follow [SECURITY.md](SECURITY.md) (do not open a public issue). diff --git a/test/pr-policy.test.mjs b/test/pr-policy.test.mjs new file mode 100644 index 0000000..cad939e --- /dev/null +++ b/test/pr-policy.test.mjs @@ -0,0 +1,2418 @@ +/** + * pr-policy.test.mjs + * + * Extracts the exact `script: |` block from callable-pr-policy.yaml and + * exercises the pure validation functions via the PR_POLICY_TEST=1 escape. + * No npm dependencies — uses only Node.js built-ins. + * + * Run: node --test test/pr-policy.test.mjs + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ── Script extraction ──────────────────────────────────────────────────────── +// Reads the YAML file and extracts the literal block scalar under `script: |`. +// The strip amount is determined from the indentation of the first non-empty +// line after the `script: |` marker. + +function extractScriptBlock(yamlText) { + const lines = yamlText.split('\n'); + let state = 'looking'; + let strip = 0; + const scriptLines = []; + + for (let i = 0; i < lines.length; i++) { + if (state === 'looking') { + if (/^\s+script:\s*\|/.test(lines[i])) { + state = 'content'; + } + } else { + const line = lines[i]; + if (line.trim() === '') { + scriptLines.push(''); + continue; + } + const indent = (line.match(/^(\s*)/) || ['', ''])[1].length; + if (strip === 0) { + strip = indent; + } + if (indent >= strip) { + scriptLines.push(line.slice(strip)); + } else { + break; + } + } + } + + while (scriptLines.length && !scriptLines[scriptLines.length - 1].trim()) { + scriptLines.pop(); + } + return scriptLines.join('\n'); +} + +// ── Pure-function loader ───────────────────────────────────────────────────── +// Runs the extracted script with PR_POLICY_TEST=1, which causes the script to +// return the pure validator functions before making any API calls. + +let v; // shared validator object + +async function loadValidators() { + const yamlPath = join(__dirname, '../.github/workflows/callable-pr-policy.yaml'); + const yamlText = readFileSync(yamlPath, 'utf8'); + const scriptCode = extractScriptBlock(yamlText); + + assert.ok(scriptCode.length > 100, 'script block must be non-trivially long'); + assert.ok(scriptCode.includes('PR_POLICY_TEST'), 'script block must contain PR_POLICY_TEST escape'); + + process.env.PR_POLICY_TEST = '1'; + try { + // Wrap in an async IIFE matching how actions/github-script executes it. + // Pure functions do not call github/context/core, so empty mocks suffice. + const asyncFn = new Function( + 'github', 'context', 'core', 'process', + 'return (async function() {\n' + scriptCode + '\n})()' + ); + const mockCore = { + error: () => {}, + setFailed: () => {}, + warning: () => {}, + summary: { addRaw: () => ({ write: async () => {} }) }, + }; + v = await asyncFn({}, {}, mockCore, process); + } finally { + delete process.env.PR_POLICY_TEST; + } + + assert.ok(v && typeof v === 'object', 'PR_POLICY_TEST escape must return an object'); + for (const fn of [ + 'validateTitle', 'getJiraKey', 'validateBranch', 'validateBody', + 'validateCommitSubject', 'detectAiFooter', 'isWorkflowFilename', + 'validateWorkflowContent', 'parseRetryAfterMs', 'checkCommitLimit', + 'checkFilesLimit', 'normalizeViolationFingerprint', 'filterNewViolations', + 'fnv1a32', 'stripHash', '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('rejects missing Jira key for non-Dependabot', () => { + const errs = v.validateTitle('fix: resolve null pointer', false); + assert.ok(errs.length > 0, 'should have errors'); + assert.ok(errs.some(e => e.includes('Jira')), 'should mention Jira: ' + errs.join('; ')); + }); + + it('rejects unknown type', () => { + const errs = v.validateTitle('update: something (DEV-1)', false); + assert.ok(errs.length > 0, 'should have errors for unknown type'); + assert.ok(errs.some(e => e.includes('type') || e.includes('match')), 'should mention type'); + }); + + it('rejects title over 72 chars', () => { + const long = 'feat: ' + 'a'.repeat(60) + ' (DEV-1)'; + const errs = v.validateTitle(long, false); + assert.ok(errs.some(e => e.includes('72')), 'should mention 72 char limit'); + }); + + it('rejects title ending with a period (Dependabot, no Jira required)', () => { + // Use a Dependabot title so only the period error fires. + const errs = v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21.', true); + assert.ok(errs.some(e => e.includes('period')), 'should mention period: ' + errs.join('; ')); + }); + + it('rejects description ending with period before Jira suffix', () => { + const errs = v.validateTitle('fix: resolve issue. (DEV-1)', false); + assert.ok(errs.some(e => e.includes('period')), 'desc period before Jira: ' + errs.join('; ')); + }); + + it('rejects description starting with uppercase', () => { + const errs = v.validateTitle('fix: Resolve issue (DEV-1)', false); + assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); + }); + + it('accepts PLAT and SEC Jira keys', () => { + assert.deepEqual(v.validateTitle('chore: update deps (PLAT-99)', false), []); + assert.deepEqual(v.validateTitle('docs: add runbook (SEC-7)', false), []); + }); + + it('rejects inactive Jira projects (INFRA)', () => { + const errs = v.validateTitle('fix: patch (INFRA-1)', false); + assert.ok(errs.length > 0, 'INFRA is inactive and should fail'); + }); + + it('accepts all valid types', () => { + const types = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release']; + for (const t of types) { + const errs = v.validateTitle(t + ': do something (DEV-1)', false); + assert.deepEqual(errs, [], t + ' should be a valid type'); + } + }); + + // Emergency-revert candidate: jiraMaybeExempt skips the Jira key requirement. + it('accepts revert title without Jira when jiraMaybeExempt is true', () => { + assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, true), []); + }); + + it('rejects revert title without Jira when jiraMaybeExempt is false', () => { + const errs = v.validateTitle('revert: emergency rollback of payment service', false, false); + assert.ok(errs.some(e => e.includes('Jira')), 'missing Jira should fail without exemption: ' + errs.join('; ')); + }); + + it('rejects revert title without Jira when jiraMaybeExempt is omitted (default false)', () => { + const errs = v.validateTitle('revert: emergency rollback of payment service', false); + assert.ok(errs.some(e => e.includes('Jira')), 'default should behave like false: ' + errs.join('; ')); + }); +}); + +// ── getJiraKey ─────────────────────────────────────────────────────────────── + +describe('getJiraKey', () => { + it('extracts DEV key', () => assert.equal(v.getJiraKey('fix: thing (DEV-42)'), 'DEV-42')); + it('extracts PLAT key', () => assert.equal(v.getJiraKey('chore: thing (PLAT-1)'), 'PLAT-1')); + it('extracts SEC key', () => assert.equal(v.getJiraKey('docs: thing (SEC-999)'), 'SEC-999')); + it('returns null when no key', () => assert.equal(v.getJiraKey('chore: thing'), null)); + it('returns null for mid-title key', () => assert.equal(v.getJiraKey('fix (DEV-1): something'), null)); +}); + +// ── validateBranch ─────────────────────────────────────────────────────────── + +describe('validateBranch', () => { + it('accepts valid feature branch', () => { + assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []); + }); + + it('accepts all valid prefixes', () => { + const prefixes = ['feature','fix','hotfix','chore','docs','refactor','release']; + for (const p of prefixes) { + assert.deepEqual(v.validateBranch(p + '/my-thing', false), [], p + '/ should be valid'); + } + }); + + it('skips validation for Dependabot', () => { + assert.deepEqual(v.validateBranch('dependabot/npm_and_yarn/lodash-4.17.21', true), []); + }); + + it('rejects unknown prefix', () => { + const errs = v.validateBranch('bugfix/my-thing', false); + assert.ok(errs.length > 0, 'bugfix/ is not a valid prefix'); + }); + + it('rejects nested path (two slashes)', () => { + const errs = v.validateBranch('feature/team/my-thing', false); + assert.ok(errs.length > 0, 'nested paths should be rejected'); + }); + + it('rejects uppercase in segment', () => { + const errs = v.validateBranch('feature/myThing', false); + assert.ok(errs.length > 0, 'uppercase in segment should be rejected'); + }); + + it('rejects Jira key in segment (case-insensitive)', () => { + const errs = v.validateBranch('fix/dev-123', false); + assert.ok(errs.length > 0, 'Jira-key pattern in segment should be rejected'); + }); + + it('rejects uppercase Jira key in segment', () => { + const errs = v.validateBranch('fix/DEV-123', false); + assert.ok(errs.length > 0, 'uppercase Jira key should be rejected'); + }); + + it('rejects branch with no segment after prefix', () => { + const errs = v.validateBranch('feature/', false); + assert.ok(errs.length > 0, 'empty segment should be rejected'); + }); +}); + +// ── validateBody ───────────────────────────────────────────────────────────── + +describe('validateBody', () => { + const goodBody = '## Summary\nSomething changed.\n\n## Validation\nRan tests.\n\n## Tests\nUnit tests pass.\n\n## Notes\nNone.'; + + it('accepts a valid body', () => { + assert.deepEqual(v.validateBody(goodBody, false), []); + }); + + it('accepts None. under Notes', () => { + assert.deepEqual(v.validateBody(goodBody, false), []); + }); + + it('skips validation for Dependabot', () => { + assert.deepEqual(v.validateBody('', true), []); + }); + + it('rejects empty body', () => { + const errs = v.validateBody('', false); + assert.ok(errs.length > 0, 'empty body should fail'); + }); + + it('rejects wrong heading order', () => { + const body = '## Validation\nOK\n\n## Summary\nOK\n\n## Tests\nOK\n\n## Notes\nNone.'; + const errs = v.validateBody(body, false); + assert.ok(errs.some(e => e.includes('Validation') || e.includes('Summary')), 'wrong order: ' + errs.join('; ')); + }); + + it('rejects fifth H2 heading', () => { + const body = goodBody + '\n\n## Extra\nwhoops'; + const errs = v.validateBody(body, false); + assert.ok(errs.some(e => e.includes('5') || e.includes('4')), 'fifth heading: ' + errs.join('; ')); + }); + + it('rejects empty section', () => { + const body = '## Summary\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; + const errs = v.validateBody(body, false); + assert.ok(errs.some(e => e.includes('Summary') && e.includes('empty')), 'empty summary: ' + errs.join('; ')); + }); + + it('strips HTML comments before heading scan', () => { + const body = '\n## Summary\nreal.\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; + assert.deepEqual(v.validateBody(body, false), [], 'HTML comment heading should not count'); + }); + + it('strips fenced code blocks before heading scan', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n${TICK.repeat(3)}\n## Fake heading inside fence\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + assert.deepEqual(v.validateBody(body, false), [], 'fenced heading should not count'); + }); + + it('handles tilde fences', () => { + const body = '## Summary\nSee below.\n\n~~~\n## Fake inside tilde fence\n~~~\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; + assert.deepEqual(v.validateBody(body, false), [], 'tilde-fenced heading should not count'); + }); + + it('handles fences with 1 leading space', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + assert.deepEqual(v.validateBody(body, false), [], '1-space indented fence should strip fake heading'); + }); + + it('handles fences with 3 leading spaces', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + assert.deepEqual(v.validateBody(body, false), [], '3-space indented fence should strip fake heading'); + }); + + it('does not treat 4-space-indented fence as a code fence', () => { + const TICK = String.fromCharCode(0x60); + const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Extra Heading\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; + // 4-space indent exceeds the 0-3 space fence rule; heading is not stripped → error. + const errs = v.validateBody(body, false); + assert.ok(errs.length > 0, '4-space fence should not strip heading (counted as extra heading)'); + }); + + it('rejects missing Notes heading', () => { + const body = '## Summary\nOK.\n\n## Validation\nOK.\n\n## Tests\nOK.'; + const errs = v.validateBody(body, false); + assert.ok(errs.length > 0, 'missing Notes should fail'); + }); +}); + +// ── validateCommitSubject ───────────────────────────────────────────────────── + +describe('validateCommitSubject', () => { + it('accepts a valid commit subject', () => { + assert.deepEqual(v.validateCommitSubject('feat(auth): add login endpoint'), []); + }); + + it('accepts subject without scope', () => { + assert.deepEqual(v.validateCommitSubject('fix: resolve null pointer'), []); + }); + + it('accepts breaking change marker', () => { + assert.deepEqual(v.validateCommitSubject('feat!: remove deprecated api'), []); + }); + + it('rejects subject with Jira key suffix', () => { + const errs = v.validateCommitSubject('fix: patch (DEV-123)'); + assert.ok(errs.some(e => e.includes('Jira')), 'should reject Jira suffix: ' + errs.join('; ')); + }); + + it('rejects subject with lowercase Jira key suffix (case-insensitive)', () => { + const errs = v.validateCommitSubject('fix: patch (dev-123)'); + assert.ok(errs.some(e => e.includes('Jira')), 'lowercase Jira suffix should also be rejected: ' + errs.join('; ')); + }); + + it('rejects subject with plat Jira key suffix', () => { + const errs = v.validateCommitSubject('chore: update config (plat-5)'); + assert.ok(errs.some(e => e.includes('Jira')), 'plat Jira suffix should be rejected: ' + errs.join('; ')); + }); + + it('rejects non-conventional subject', () => { + const errs = v.validateCommitSubject('Update readme'); + assert.ok(errs.length > 0, 'should reject non-conventional subject'); + }); + + it('rejects uppercase description start', () => { + const errs = v.validateCommitSubject('fix: Resolve issue'); + assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); + }); + + it('rejects subject over 72 chars', () => { + const long = 'feat: ' + 'a'.repeat(70); + const errs = v.validateCommitSubject(long); + assert.ok(errs.some(e => e.includes('72')), 'should mention 72: ' + errs.join('; ')); + }); + + it('rejects description ending with a period', () => { + const errs = v.validateCommitSubject('fix: resolve issue.'); + assert.ok(errs.some(e => e.includes('period')), 'trailing period in description: ' + errs.join('; ')); + }); +}); + +// ── detectAiFooter ──────────────────────────────────────────────────────────── + +describe('detectAiFooter', () => { + it('detects Claude Co-authored-by', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Claude ')); + }); + + it('detects ChatGPT Co-authored-by', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: ChatGPT ')); + }); + + it('detects "Generated with Claude Code"', () => { + assert.ok(v.detectAiFooter('feat: add feature\n\nGenerated with Claude Code')); + }); + + it('detects "Generated by GitHub Copilot"', () => { + assert.ok(v.detectAiFooter('feat: add feature\n\nGenerated by GitHub Copilot')); + }); + + it('detects "Generated by Codex"', () => { + assert.ok(v.detectAiFooter('feat: add\n\nGenerated by Codex')); + }); + + it('detects emoji robot marker', () => { + assert.ok(v.detectAiFooter('fix: thing\n\n🤖 Generated by tool')); + }); + + it('returns false for clean commit', () => { + assert.ok(!v.detectAiFooter('fix: resolve null pointer\n\nThis was hand-written.')); + }); + + it('returns false for unrelated Co-authored-by', () => { + assert.ok(!v.detectAiFooter('fix: thing\n\nCo-authored-by: Alice ')); + }); + + it('detects AI footer in PR body', () => { + assert.ok(v.detectAiFooter('## Summary\nDone.\n\nCo-authored-by: Gemini ')); + }); + + it('detects Co-authored-by case-insensitively (all-caps header)', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCO-AUTHORED-BY: Claude '), 'all-caps header should match'); + }); + + it('detects Co-authored-by case-insensitively (all-caps identity)', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: CLAUDE '), 'all-caps identity should match'); + }); + + it('detects Cursor identity', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Cursor '), 'Cursor co-authored-by'); + assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Cursor'), 'Generated by Cursor'); + }); + + it('detects Anthropic identity', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Anthropic '), 'Anthropic co-authored-by'); + }); + + it('detects Codeium identity', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codeium '), 'Codeium co-authored-by'); + assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Codeium'), 'Generated by Codeium'); + }); + + it('detects OpenAI identity', () => { + assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: OpenAI '), 'OpenAI co-authored-by'); + }); + + it('does not flag generic AI discussion in prose', () => { + assert.ok(!v.detectAiFooter('fix: thing\n\nThis uses AI to improve performance.'), 'generic AI mention'); + assert.ok(!v.detectAiFooter('fix: thing\n\nUpdated AI model configuration.'), 'AI model config mention'); + assert.ok(!v.detectAiFooter('feat: add AI-powered search (DEV-1)\n\n## Summary\nAI search.'), 'AI in title/body prose'); + }); +}); + +// ── isWorkflowFilename ──────────────────────────────────────────────────────── + +describe('isWorkflowFilename', () => { + it('matches .github/workflows/*.yaml', () => { + assert.ok(v.isWorkflowFilename('.github/workflows/ci.yaml')); + }); + + it('matches .github/workflows/*.yml', () => { + assert.ok(v.isWorkflowFilename('.github/workflows/deploy.yml')); + }); + + it('matches workflow-templates/*.yml', () => { + assert.ok(v.isWorkflowFilename('workflow-templates/ci-node.yml')); + }); + + it('rejects nested path in workflows', () => { + assert.ok(!v.isWorkflowFilename('.github/workflows/subdir/ci.yaml')); + }); + + it('rejects non-YAML files', () => { + assert.ok(!v.isWorkflowFilename('.github/workflows/ci.json')); + }); + + it('rejects unrelated files', () => { + assert.ok(!v.isWorkflowFilename('src/foo.yaml')); + }); +}); + +// ── validateWorkflowContent ─────────────────────────────────────────────────── + +describe('validateWorkflowContent', () => { + const BASE = '.github/workflows/test.yaml'; + const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; + const ZERO_SHA = '0'.repeat(40); + + function wf(uses, extra = '') { + return [ + 'name: Test', + 'on:', + ' workflow_call:', + 'permissions:', + ' contents: read', + 'jobs:', + ' job:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - ' + uses, + extra, + ].join('\n'); + } + + it('accepts a fully pinned remote action', () => { + const content = wf(`uses: actions/checkout@${VALID_SHA} # v9.0.0`); + assert.deepEqual(v.validateWorkflowContent(content, BASE), []); + }); + + it('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'); + }); +}); diff --git a/workflow-templates/cdk-deploy.yml b/workflow-templates/cdk-deploy.yml index fb65cd3..0afa2ba 100644 --- a/workflow-templates/cdk-deploy.yml +++ b/workflow-templates/cdk-deploy.yml @@ -5,7 +5,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the # reusable workflow's default — passed explicitly to pin against drift. diff --git a/workflow-templates/ci-dotnet.yml b/workflow-templates/ci-dotnet.yml index 29aace2..0f0be14 100644 --- a/workflow-templates/ci-dotnet.yml +++ b/workflow-templates/ci-dotnet.yml @@ -11,4 +11,4 @@ jobs: # Every input is optional. Common overrides: `solution` (defaults to *.sln # in the working directory), `working-directory`, and `dotnet-version` # (defaults to 8.0.x). This reusable has no `node-version` input. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 diff --git a/workflow-templates/ci-mobile-ios.yml b/workflow-templates/ci-mobile-ios.yml index 01c348c..b4ca371 100644 --- a/workflow-templates/ci-mobile-ios.yml +++ b/workflow-templates/ci-mobile-ios.yml @@ -7,7 +7,7 @@ jobs: ci: # Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the # check context resolves to the required `ci / ci`. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also # the reusable workflow's default — passed explicitly to pin against drift. diff --git a/workflow-templates/ci-node.yml b/workflow-templates/ci-node.yml index c82818a..3e8e1fb 100644 --- a/workflow-templates/ci-node.yml +++ b/workflow-templates/ci-node.yml @@ -5,7 +5,7 @@ on: jobs: ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the # reusable workflow's default — passed explicitly to pin against drift. diff --git a/workflow-templates/ci-python-app.yml b/workflow-templates/ci-python-app.yml index 9e23b13..363785b 100644 --- a/workflow-templates/ci-python-app.yml +++ b/workflow-templates/ci-python-app.yml @@ -11,4 +11,4 @@ jobs: # Every input is optional. Common overrides: `source-dirs` (ruff targets), # `requirements` (non-default requirements file), `subproject-dir` (a # self-contained suite that must run in its own working directory). - uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 diff --git a/workflow-templates/ci-python.yml b/workflow-templates/ci-python.yml index a12000f..a3f09b2 100644 --- a/workflow-templates/ci-python.yml +++ b/workflow-templates/ci-python.yml @@ -5,7 +5,7 @@ on: jobs: ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: run-tests: true # ci-python-sam.yaml declares a `node-version` input (default "24") that diff --git a/workflow-templates/ci-static.yml b/workflow-templates/ci-static.yml index b7a1705..78127ea 100644 --- a/workflow-templates/ci-static.yml +++ b/workflow-templates/ci-static.yml @@ -7,7 +7,7 @@ jobs: ci: # Job id MUST stay `ci`: the reusable's job is also `ci`, so the check # context resolves to the required `ci / ci`. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Pinned explicitly (handbook): local dev is Node 24 / npm 11, which # generates lockfileVersion 3. Being explicit avoids lockfile drift. diff --git a/workflow-templates/ci-typescript-frontend.yml b/workflow-templates/ci-typescript-frontend.yml index 3b75187..14d5649 100644 --- a/workflow-templates/ci-typescript-frontend.yml +++ b/workflow-templates/ci-typescript-frontend.yml @@ -7,7 +7,7 @@ jobs: ci: # Job id MUST stay `ci`: the reusable's job is also `ci`, so the check # context resolves to the required `ci / ci`. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Pinned explicitly (handbook): local dev is Node 24 / npm 11, which # generates lockfileVersion 3. Being explicit avoids lockfile drift. diff --git a/workflow-templates/dependency-review.yml b/workflow-templates/dependency-review.yml index 36c0f4e..8c06587 100644 --- a/workflow-templates/dependency-review.yml +++ b/workflow-templates/dependency-review.yml @@ -8,4 +8,4 @@ permissions: jobs: dependency-review: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 diff --git a/workflow-templates/dotnet-eb-deploy.yml b/workflow-templates/dotnet-eb-deploy.yml index 1316bcc..b2db872 100644 --- a/workflow-templates/dotnet-eb-deploy.yml +++ b/workflow-templates/dotnet-eb-deploy.yml @@ -5,7 +5,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Required: the project to publish, relative to the repo root. project: REPLACE-ME-project-csproj diff --git a/workflow-templates/labeler.yml b/workflow-templates/labeler.yml index d07b5b8..217f89b 100644 --- a/workflow-templates/labeler.yml +++ b/workflow-templates/labeler.yml @@ -13,4 +13,4 @@ permissions: jobs: label: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 diff --git a/workflow-templates/mobile-ios-deploy.yml b/workflow-templates/mobile-ios-deploy.yml index ece8fd5..95759f4 100644 --- a/workflow-templates/mobile-ios-deploy.yml +++ b/workflow-templates/mobile-ios-deploy.yml @@ -5,7 +5,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Pinned explicitly (handbook): local dev is Node 24 / npm 11, which # generates lockfileVersion 3. Being explicit avoids lockfile drift. diff --git a/workflow-templates/release.yml b/workflow-templates/release.yml index 1dab305..b0c52ee 100644 --- a/workflow-templates/release.yml +++ b/workflow-templates/release.yml @@ -13,7 +13,7 @@ permissions: jobs: release: - uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main + uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: version: ${{ inputs.version }} # Tags in this org are v-prefixed MAJOR.MINOR.PATCH; "v" is the default diff --git a/workflow-templates/sam-deploy.yml b/workflow-templates/sam-deploy.yml index f2835a2..54a8deb 100644 --- a/workflow-templates/sam-deploy.yml +++ b/workflow-templates/sam-deploy.yml @@ -5,7 +5,7 @@ on: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main + uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 with: # Required: the CloudFormation stack name (kebab-case, matches repo name). # NOTE: this is a literal placeholder on purpose — starter-workflow variables