diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ff8d21b..4906030 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,13 +3,94 @@ on: pull_request: branches: [main] +# Temporary outage smoke test (PLAT-87): inlined org ci-python-app jobs on the +# repo self-hosted runner. Aggregator job name preserves required check "ci / ci". + permissions: contents: read jobs: + lint: + runs-on: self-hosted + timeout-minutes: 10 + concurrency: + group: ci-selfhosted-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install 'ruff==0.15.22' + + - name: Ruff check + run: ruff check . + + - name: Ruff format check + run: ruff format --check . + + - name: Conventions check + run: | + errors=0 + fail() { echo "::error::$1"; errors=$((errors + 1)); } + + if [[ ! -f README.md ]]; then + fail "Missing README.md" + fi + + if [[ -f .gitignore ]]; then + if ! grep -qE '^\.env$|^\.env\b' .gitignore; then + fail ".gitignore does not include .env" + fi + else + fail "Missing .gitignore" + fi + + if [[ $errors -gt 0 ]]; then + echo "Conventions check failed with $errors error(s)." + exit 1 + fi + echo "Conventions check passed." + + test-collect: + runs-on: self-hosted + timeout-minutes: 10 + concurrency: + group: ci-selfhosted-${{ github.workflow }}-${{ github.ref }}-test-collect + cancel-in-progress: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install pytest python-dotenv + + - name: Pytest collect-only + run: pytest --collect-only -q + ci: - # Thin wrapper over the org reusable CI: ruff lint/format + conventions and a - # root `pytest --collect-only` import check. This is code hygiene for THIS repo's - # own source (run_headless.py et al.), not a security gate over other repos. The - # aggregator job (keyed `ci`) emits the org-required `ci / ci` check. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3 + name: ci / ci + needs: [lint, test-collect] + if: always() + runs-on: self-hosted + concurrency: + group: ci-selfhosted-${{ github.workflow }}-${{ github.ref }}-ci + cancel-in-progress: true + steps: + - name: Require all jobs to have succeeded + run: | + if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then + echo "A required CI job failed or was cancelled." + exit 1 + fi + echo "All CI jobs passed." diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b380113..ae9f719 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,9 +2,16 @@ name: Dependency Review on: pull_request: +# Temporary outage smoke test (PLAT-87): inlined on repo self-hosted runner. + permissions: contents: read jobs: review: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3 + runs-on: self-hosted + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 63643e1..70d47a9 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -3,15 +3,116 @@ on: pull_request: branches: [main] -# All three grants are required: reusable-workflow permissions can only be -# downgraded by the caller, so omitting one (e.g. issues: write, needed to create -# a label that does not exist yet) causes a silent startup_failure. +# Temporary outage smoke test (PLAT-87): inlined on repo self-hosted runner. permissions: contents: read pull-requests: write issues: write +concurrency: + group: labeler-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: label: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3 + runs-on: self-hosted + steps: + - name: Write central label rules + run: | + mkdir -p "${{ runner.temp }}" + cat > "${{ runner.temp }}/labeler.yml" <<'EOF' + infra: + - changed-files: + - any-glob-to-any-file: + - 'lib/**' + - 'bin/**' + - 'cdk/**' + - 'cdk.json' + - 'template.yaml' + - 'template.yml' + - '**/template.yaml' + - 'samconfig.toml' + - 'infra/**' + app: + - changed-files: + - any-glob-to-any-file: + - 'src/**' + - 'functions/**' + - 'lambdas/**' + - 'api/**' + - 'services/**' + - 'web/**' + - 'mobile/**' + - 'shared/**' + content: + - changed-files: + - any-glob-to-any-file: + - '**/*.html' + - '**/*.css' + - 'assets/**' + - 'sitemap.xml' + - 'robots.txt' + ci: + - changed-files: + - any-glob-to-any-file: + - '.github/workflows/**' + - '.github/actions/**' + docs: + - changed-files: + - any-glob-to-any-file: + - '**/*.md' + dependencies: + - changed-files: + - any-glob-to-any-file: + - '**/requirements.txt' + - '**/package.json' + - '**/package-lock.json' + - '**/*.csproj' + - '**/packages.lock.json' + - '**/Directory.Packages.props' + - '**/yarn.lock' + - '**/pnpm-lock.yaml' + - '**/Podfile' + - '**/Podfile.lock' + - '.github/dependabot.yml' + tests: + - changed-files: + - any-glob-to-any-file: + # directory conventions (covers Java src/test, Ruby test/spec, etc.) + - '**/tests/**' + - '**/test/**' + - '**/spec/**' + - '**/__tests__/**' + # JS / TS + - '**/*.test.js' + - '**/*.test.jsx' + - '**/*.test.ts' + - '**/*.test.tsx' + - '**/*.spec.js' + - '**/*.spec.jsx' + - '**/*.spec.ts' + - '**/*.spec.tsx' + # Python + - '**/*_test.py' + - '**/test_*.py' + - '**/conftest.py' + # .NET + - '**/*Tests.cs' + - '**/*Test.cs' + - '**/*.Tests/**' + # Java / JVM + - '**/*Test.java' + - '**/*Tests.java' + - '**/*IT.java' + # Go + - '**/*_test.go' + # Ruby + - '**/*_spec.rb' + - '**/*_test.rb' + EOF + - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: ${{ runner.temp }}/labeler.yml + sync-labels: false diff --git a/.github/workflows/policy.yaml b/.github/workflows/policy.yaml index eba1158..4adc1be 100644 --- a/.github/workflows/policy.yaml +++ b/.github/workflows/policy.yaml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review] +# Temporary outage smoke test (PLAT-87): inlined on repo self-hosted runner. +# Job name preserves required check "policy / pr". + concurrency: group: "policy-${{ github.event.pull_request.number }}" cancel-in-progress: true @@ -14,9 +17,853 @@ permissions: pull-requests: read jobs: - policy: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@9c1ecf942894b19aba5c71b85b41906c6c83b749 # v1.0.5 - secrets: - JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} - JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + pr: + name: policy / pr + runs-on: self-hosted + timeout-minutes: 10 + steps: + - name: Validate PR + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} + JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + with: + script: | + // ── Pure validation functions ──────────────────────────────────────────── + // Extracted and exercised by test/pr-policy.test.mjs via PR_POLICY_TEST. + // These functions have no side effects and make no API calls. + + const CONV_TYPES = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release']; + const REQUIRED_H2 = ['Summary','Validation','Tests','Notes']; + + // AI-attribution footer patterns — case-insensitive, multiline. + // Matches Co-authored-by: trailers naming known AI tools and "Generated by/with" + // phrases. Does NOT flag generic prose like "uses AI" or "AI-powered". + // GPT variants: gpt-3, gpt-4, gpt-4o, gpt-5, gpt-o, etc. covered by gpt-[a-z0-9]+. + const AI_FOOTER_RE = /^(?:co-authored-by:\s+(?:claude|chatgpt|gpt-[a-z0-9]+|copilot|github\s+copilot|gemini|cursor(?:\s*ai)?|codeium|anthropic|openai|codex)\b|generated\s+(?:with|by)\s+(?:claude(?:\s+code)?|github\s+copilot|chatgpt|codex|gpt-[a-z0-9]+|gemini|codeium|cursor(?:\s*ai)?|anthropic|openai)|🤖\s+generated\b)/im; + + function validateTitle(title, isDependabot, jiraMaybeExempt) { + const errs = []; + if (title.length > 120) errs.push('Title is ' + title.length + ' chars — max 120'); + const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC)-\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 isSyncMergeCommit(commit) { + if (!Array.isArray(commit.parents) || commit.parents.length < 2) return false; + const subject = (commit.commit && commit.commit.message ? commit.commit.message : '').split('\n')[0]; + return /^Merge (?:branch|remote-tracking branch) '[^']+' into \S.+$/.test(subject); + } + + function detectAiFooter(text) { + return AI_FOOTER_RE.test(text); + } + + function isWorkflowFilename(filename) { + return /^\.github\/workflows\/[^/]+\.ya?ml$/.test(filename) || + /^workflow-templates\/[^/]+\.ya?ml$/.test(filename); + } + + // Matches action manifests at any depth (e.g. .github/actions/**/action.yml). + function isActionManifestFilename(filename) { + return /(?:^|\/)action\.ya?ml$/.test(filename); + } + + // Combined predicate — any file that the supply-chain scanner must process. + function isPolicyFilename(filename) { + return isWorkflowFilename(filename) || isActionManifestFilename(filename); + } + + // Returns 'workflow', 'action', or null for non-policy files. + // Used by classifyFileStatus to prevent cross-kind rename diffing. + function policyFileKind(filename) { + if (isWorkflowFilename(filename)) return 'workflow'; + if (isActionManifestFilename(filename)) return 'action'; + return null; + } + + // FNV-1a 32-bit hash of a string — used to produce content fingerprints + // for line-specific violations so changing the payload changes the + // fingerprint even when the violation remains on the same line. + function fnv1a32(str) { + let h = 2166136261; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 16777619) >>> 0; + } + return h.toString(16).padStart(8, '0'); + } + + // Strip the trailing [fnv:XXXXXXXX] content-hash token from a violation + // string before emitting it to users. The hash is purely internal. + function stripHash(msg) { + return msg.replace(/ \[fnv:[0-9a-f]{8}\]$/, ''); + } + + // Deterministic line scanner for workflow YAML content. + // + // Block-scalar tracking: any YAML key whose value begins with | or > + // (including explicit indent/chomp forms |2, |2-, |-2, >+2, etc.) + // starts a block scalar. Lines inside ANY block scalar are not parsed + // as structural YAML keys — they are content. For run: block scalars, + // the content is still scanned for expression injection (expressions + // must flow through env:). uses: block scalars are rejected because an + // action ref must be an inline scalar to validate its immutable pin. + // For other non-run block scalars (e.g. script:, name:), the content + // is skipped entirely — no uses: or run: detection. + // + // Quoted keys: "uses", 'uses', "run", 'run', "permissions" are all + // recognized in addition to their unquoted forms. + // + // Quoted action refs: `uses: "owner/repo@sha" # vX.Y.Z` correctly + // parses the comment outside the closing quote as the version annotation. + // + // Fails closed on YAML forms the line scanner cannot safely resolve: + // - Escaped/encoded keys in double-quoted strings ("u\u0073es") + // - Flow-style sequence steps (- { uses: ... }, - { run: ... }) + // - YAML aliases/anchors on run:, uses:, or permissions: values + // + // All-zero SHAs and v0.0.0 placeholder pins are rejected. + // opts.requirePermissions (default true) — workflow files require a top-level + // permissions: key; action manifests do not support it and must pass false. + function validateWorkflowContent(content, filename, opts) { + const requirePermissions = !opts || opts.requirePermissions !== false; + const errs = []; + const EXPR_OPEN = '$' + '{{'; + + // 1. Top-level permissions key required (workflows only; not action manifests). + if (requirePermissions) { + if (!/^(?:"permissions"|'permissions'|permissions):/m.test(content)) { + errs.push(filename + ': missing top-level "permissions:" key'); + } + + // 1b. Top-level permissions alias check. + if (/^(?:"permissions"|'permissions'|permissions):[ \t]+\*/m.test(content)) { + errs.push(filename + ': YAML alias for top-level "permissions:" value is not supported — inline the permissions map'); + } + } + + // 2. Line-by-line scan. + // inBlock: currently inside a block scalar + // blockIndent: indent of the key that opened the block scalar + // blockIsRun: the block belongs to a run: key (check expressions) + const lines = content.split('\n'); + let inBlock = false; + let blockIndent = -1; + let blockIsRun = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const rawIndent = (line.match(/^([ \t]*)/) || ['', ''])[1].length; + + // ── Inside a block scalar ──────────────────────────────────────── + if (inBlock) { + if (line.trim() === '') continue; + if (rawIndent > blockIndent) { + // Content of block scalar. + // Only flag expression injection for run: block scalars. + if (blockIsRun && line.includes(EXPR_OPEN)) { + errs.push(filename + ':' + (i + 1) + ': run: block contains ' + EXPR_OPEN + ' }} — expressions must go through env: [fnv:' + fnv1a32(line.trim()) + ']'); + } + continue; + } + // Indent at or below the block key — exit block scalar. + inBlock = false; + blockIndent = -1; + blockIsRun = false; + // Fall through to process this line as structural YAML. + } + + // ── Escaped/encoded double-quoted key — fail closed ─────────────── + // A double-quoted key containing \ cannot be reliably resolved by + // the line scanner (e.g. "u\u0073es" parses as "uses" in YAML). + // Reject any such key at the structural position. + if (/^[ \t]*(?:-[ \t]+)?"[^"]*\\[^"]*":/.test(line)) { + errs.push(filename + ':' + (i + 1) + ': escaped key in double-quoted string is not supported — use literal key names (run:, uses:, permissions:) [fnv:' + fnv1a32(line.trim()) + ']'); + continue; + } + + // ── Structural key with whitespace before colon — fail closed ──── + // YAML permits `key : value` but the scanner matches `key:` forms + // only; a space before the colon silently bypasses all checks. + // Reject any structural key (uses, run, steps — quoted or plain, + // sequence-item or mapping) that has whitespace before the colon. + if (/^[ \t]*(?:-[ \t]+)?(?:"(?:uses|run|steps)"|'(?:uses|run|steps)'|uses|run|steps)[ \t]+:/.test(line)) { + errs.push(filename + ':' + (i + 1) + ': structural key with whitespace before ":" is not supported — remove the space before the colon [fnv:' + fnv1a32(line.trim()) + ']'); + continue; + } + + // ── Sequence-item anchor declaration — fail closed ─────────────── + // Any line of the form `- &anchor` (with or without a mapping on + // the same line) is rejected. A standalone `- &name` can be + // followed on the next line by a flow-style mapping that the + // scanner would then misread as structural YAML. The multiline + // alias form `- *name` on subsequent steps is also unreachable + // without first declaring such an anchor. Reject unconditionally. + if (/^[ \t]*-[ \t]+&\S+/.test(line)) { + errs.push(filename + ':' + (i + 1) + ': sequence-item anchor declaration (&name) is not supported — anchors on steps may introduce flow mappings the scanner cannot safely resolve [fnv:' + fnv1a32(line.trim()) + ']'); + continue; + } + + // ── Flow-style sequence step — fail closed only for structural keys ─ + // `- { ... }` form cannot be safely resolved when it contains a + // structural run: or uses: key (including quoted or escaped forms). + // Non-step data objects like `- { os: ubuntu, node: 24 }` are + // allowed — they cannot contain action refs or run scripts. + // `permissions: {}` is a mapping value (not a sequence item), + // so it is unaffected by this check entirely. + if (/^[ \t]*-[ \t]+\{[^}]/.test(line)) { + const braceIdx = line.indexOf('{'); + const flowContent = line.slice(braceIdx); + if (/[{,]\s*(?:"uses"|'uses'|uses|"run"|'run'|run|"[^"]*\\[^"]*")\s*:/.test(flowContent)) { + errs.push(filename + ':' + (i + 1) + ': flow-style step mapping with structural "run:" or "uses:" key is not supported — use block mapping style [fnv:' + fnv1a32(line.trim()) + ']'); + } + continue; + } + + // ── Flow-style steps array — fail closed ───────────────────────── + // `steps: [...]` and `steps: [` (multiline opener) cannot be + // safely resolved. Exception: `steps: []` is an empty array + // with no execution and is explicitly allowed. + // Quoted ("steps") and unquoted forms are both detected. + const stepsFlowM = line.match(/^[ \t]*(?:"steps"|'steps'|steps):[ \t]*\[(.*)$/); + if (stepsFlowM) { + const inner = stepsFlowM[1].trimStart(); + if (!/^\]\s*(#.*)?$/.test(inner)) { + errs.push(filename + ':' + (i + 1) + ': flow-style "steps" array is not supported — use block-style steps list [fnv:' + fnv1a32(line.trim()) + ']'); + } + continue; + } + + // ── Any block scalar key detection (| or >) ────────────────────── + // Matches quoted ("key", 'key') and unquoted (key) key names, + // with optional sequence-item prefix (- ), followed by a block + // indicator (| or > with optional explicit-indent/chomp modifiers). + // YAML block scalar header forms: | |2 |- |+ |2- |2+ |-2 |+2 + // and equivalents with > (folded). Both digit-first and chomp-first + // orderings are recognized per the YAML 1.2 spec. + // Groups: [1]=indent [2]=full-key [3]=dq-content [4]=sq-content [5]=unquoted [6]=indicator + const blockM = line.match(/^([ \t]*)(?:-[ \t]+)?("([^"]*)"|'([^']*)'|([\w-]+)):[ \t]*([|>](?:[1-9][-+]?|[-+][1-9]?)?)[ \t]*(?:#.*)?$/); + if (blockM) { + const keyName = blockM[3] !== undefined ? blockM[3] : (blockM[4] !== undefined ? blockM[4] : (blockM[5] || '')); + if (keyName === 'uses') { + errs.push(filename + ':' + (i + 1) + ': uses: block scalar is not supported — action refs must be inline and pinned to an immutable SHA [fnv:' + fnv1a32(line.trim()) + ']'); + continue; + } + inBlock = true; + blockIndent = blockM[1].length; + blockIsRun = (keyName === 'run'); + continue; + } + + // ── Inline run: value (no block indicator) ─────────────────────── + // Handles mapping form and sequence-item form; quoted and unquoted key. + // A run: &anchor | line (anchor before block indicator) falls here + // because blockM cannot match it; the & causes the alias check below. + const inlineRunM = line.match(/^[ \t]*(?:-[ \t]+)?(?:"run"|'run'|run):[ \t]+(.*)$/); + if (inlineRunM) { + const runVal = inlineRunM[1].trimStart(); + // A YAML alias is *name; an anchor is &name (non-whitespace after &). + // Ordinary shell & like 'echo "R&D build"' does not start with * or &word. + if (runVal[0] === '*' || /^&\S/.test(runVal)) { + errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "run:" value is not supported — inline the run script [fnv:' + fnv1a32(line.trim()) + ']'); + continue; + } + if (inlineRunM[1].includes(EXPR_OPEN)) { + errs.push(filename + ':' + (i + 1) + ': run: value contains ' + EXPR_OPEN + ' }} — expressions must go through env: [fnv:' + fnv1a32(line.trim()) + ']'); + } + continue; + } + + // ── uses: key detection ────────────────────────────────────────── + // Handles mapping form and sequence-item form; quoted and unquoted key. + const usesM = line.match(/^[ \t]+(?:-[ \t]+)?(?:"uses"|'uses'|uses):[ \t]+(.+)$/); + if (!usesM) continue; + + // Parse the action ref — handle quoted scalar with comment outside quotes. + const rawVal = usesM[1].trim(); + + // Reject YAML alias/anchor in uses: value. + // An alias is *name; an anchor is &name (non-whitespace after &). + if (rawVal[0] === '*' || /^&\S/.test(rawVal)) { + errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "uses:" value is not supported — inline the action ref [fnv:' + fnv1a32(line.trim()) + ']'); + continue; + } + + let ref; + let extComment = ''; + + if (rawVal[0] === '"' || rawVal[0] === "'") { + const q = rawVal[0]; + const closeIdx = rawVal.indexOf(q, 1); + if (closeIdx !== -1) { + ref = rawVal.slice(1, closeIdx); + const rest = rawVal.slice(closeIdx + 1).trimStart(); + if (rest[0] === '#') extComment = rest; + } else { + ref = rawVal; // malformed quote — treat as unquoted + } + } else { + ref = rawVal; + } + + // Local action references cannot be validated — the scanner + // does not recursively resolve action manifests. Inline the + // action logic or replace with an immutable remote SHA pin. + if (ref.startsWith('./')) { + const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" local action reference is not supported — inline the action or use an immutable remote SHA pin'); + continue; + } + + // Docker refs require an immutable sha256 digest pin. + // Mutable tags, :latest, and bare image names are rejected. + // No # vX.Y.Z comment is required because the digest is the + // immutable identity. + if (ref.startsWith('docker://')) { + if (!/^docker:\/\/.+@sha256:[0-9a-f]{64}$/.test(ref)) { + const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" docker:// ref must be pinned by immutable digest (docker://@sha256:<64 lowercase hex>)'); + } + continue; + } + + // Validate SHA + version comment. + // For quoted refs, combine the unquoted value with any external comment. + const forShaCheck = extComment ? ref + ' ' + extComment : ref; + const shaMatch = forShaCheck.match(/@([0-9a-f]{40})[ \t]+#[ \t]+v(\d+)\.(\d+)\.(\d+)$/i); + if (!shaMatch) { + const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" must be pinned to a 40-char SHA with "# vX.Y.Z" comment'); + continue; + } + + // Reject all-zero placeholder SHA. + if (/^0{40}$/.test(shaMatch[1])) { + const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" uses a placeholder all-zero SHA — replace with the actual release SHA'); + } + + // Reject v0.0.0 placeholder version. + if (shaMatch[2] === '0' && shaMatch[3] === '0' && shaMatch[4] === '0') { + const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref; + errs.push(filename + ': "uses: ' + short + '" uses placeholder version v0.0.0 — update to the actual release version'); + } + } + + return errs; + } + + // Retry-After header parser — supports integer seconds and HTTP-date. + // Returns milliseconds to wait, capped at 60000. Returns 0 for invalid + // or non-positive values so the caller uses exponential fallback instead. + // nowMs is injectable for testing; defaults to Date.now(). + function parseRetryAfterMs(header, nowMs) { + if (!header) return 0; + const secs = parseInt(header, 10); + if (!isNaN(secs) && secs > 0) return Math.min(secs * 1000, 60000); + const date = new Date(header); + if (!isNaN(date.getTime())) { + const ms = date.getTime() - (nowMs !== undefined ? nowMs : Date.now()); + return ms > 0 ? Math.min(ms, 60000) : 0; + } + return 0; + } + + // Pure helpers for commit and file count limit checks. + function checkCommitLimit(prCommits) { + if (prCommits > 250) { + return 'POLICY-INFRA: PR has ' + prCommits + ' commits — GitHub REST API caps listCommits at 250; not all commit subjects can be validated'; + } + return null; + } + + function checkFilesLimit(prChangedFiles) { + if (prChangedFiles > 3000) { + return 'POLICY-INFRA: PR has ' + prChangedFiles + ' changed files — GitHub REST API caps listFiles at 3000; not all workflow files can be validated'; + } + return null; + } + + // Normalize a validateWorkflowContent error string to a diff fingerprint. + // Per-line locations are intentionally preserved so moving a grandfathered + // violation to a different execution path is treated as a new violation. + // Content-identifying tokens (action ref, expression text) are preserved. + function normalizeViolationFingerprint(err) { + return err; + } + + // Diff head vs base violations using location-preserving fingerprints + // with multiplicity. For each fingerprint, up to base-count head + // violations of that fingerprint are considered pre-existing; the + // remainder are new. Violations are returned in head-file order. + function filterNewViolations(headErrs, baseErrs) { + const baseCounts = new Map(); + for (const e of baseErrs) { + const fp = normalizeViolationFingerprint(e); + baseCounts.set(fp, (baseCounts.get(fp) || 0) + 1); + } + const remaining = new Map(baseCounts); + const result = []; + for (const e of headErrs) { + const fp = normalizeViolationFingerprint(e); + const rem = remaining.get(fp) || 0; + if (rem > 0) { + remaining.set(fp, rem - 1); + } else { + result.push(e); + } + } + return result; + } + + // Classify the status of a pull-request file for workflow scanning. + // Returns one of four action objects: + // { action: 'skip' } — removed or unchanged; no validation + // { action: 'full' } — added or copied; full validation, no baseline + // { action: 'diff', basePath: string } — modified/changed or renamed-from-workflow; + // validate head, diff against base at basePath + // { action: 'infra', reason: string } — unknown status; report POLICY-INFRA + // isWfFn must be the isWorkflowFilename predicate (injectable for testing). + function classifyFileStatus(file, isWfFn) { + const s = file.status; + if (s === 'removed' || s === 'unchanged') return { action: 'skip' }; + if (s === 'added' || s === 'copied') return { action: 'full' }; + if (s === 'modified' || s === 'changed') return { action: 'diff', basePath: file.filename }; + if (s === 'renamed') { + if (file.previous_filename && + isWfFn(file.previous_filename) && + policyFileKind(file.previous_filename) === policyFileKind(file.filename)) { + return { action: 'diff', basePath: file.previous_filename }; + } + return { action: 'full' }; + } + return { action: 'infra', reason: 'unknown file status "' + s + '" for ' + file.filename }; + } + + // ── Test escape ────────────────────────────────────────────────────────── + // Set PR_POLICY_TEST=1 to extract pure functions without hitting any API. + if (process.env.PR_POLICY_TEST === '1') { + return { + validateTitle, + getJiraKey, + validateBranch, + validateBody, + validateCommitSubject, + isSyncMergeCommit, + detectAiFooter, + isWorkflowFilename, + isActionManifestFilename, + isPolicyFilename, + policyFileKind, + validateWorkflowContent, + parseRetryAfterMs, + checkCommitLimit, + checkFilesLimit, + normalizeViolationFingerprint, + filterNewViolations, + fnv1a32, + stripHash, + classifyFileStatus, + }; + } + + // ── Jira API helper ────────────────────────────────────────────────────── + // Retries on 429/5xx up to 3 times with Retry-After header support. + // On the final attempt (attempt === 3), 429/5xx falls through to the + // status-specific throw. Never logs secrets or response bodies. + async function jiraGetIssue(cloudId, issueKey, email, token) { + const https = require('https'); + const apiPath = '/ex/jira/' + cloudId + '/rest/api/3/issue/' + issueKey + '?fields=key'; + const authHeader = 'Basic ' + Buffer.from(email + ':' + token).toString('base64'); + for (let attempt = 0; attempt <= 3; attempt++) { + const result = await new Promise(function(resolve, reject) { + const req = https.request({ + hostname: 'api.atlassian.com', + path: apiPath, + method: 'GET', + headers: { 'Authorization': authHeader, 'Accept': 'application/json' }, + }, function(res) { + const chunks = []; + res.on('data', function(c) { chunks.push(c); }); + res.on('end', function() { + resolve({ status: res.statusCode, retryAfter: res.headers['retry-after'], body: Buffer.concat(chunks).toString('utf8') }); + }); + }); + req.on('error', reject); + req.end(); + }); + if (result.status === 200) { + let parsed; + try { parsed = JSON.parse(result.body); } catch (_) { + const e = new Error('Jira API returned non-JSON'); e.isInfra = true; throw e; + } + if (parsed.key !== issueKey) throw new Error('Jira returned key "' + parsed.key + '" but expected "' + issueKey + '"'); + return parsed; + } + if (result.status === 404) throw new Error('Jira issue ' + issueKey + ' not found'); + if (result.status === 401 || result.status === 403) { + const e = new Error('Jira auth rejected (HTTP ' + result.status + ')'); e.isInfra = true; throw e; + } + if ((result.status === 429 || result.status >= 500) && attempt < 3) { + const headerMs = parseRetryAfterMs(result.retryAfter); + const delayMs = headerMs > 0 ? headerMs : Math.min(2000 * (attempt + 1), 30000); + await new Promise(function(r) { setTimeout(r, delayMs); }); + continue; + } + const e = new Error('Jira API returned HTTP ' + result.status); e.isInfra = true; throw e; + } + } + + // ── Main ───────────────────────────────────────────────────────────────── + const violations = []; + const 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]; + if (!isSyncMergeCommit(c)) { + for (const e of validateCommitSubject(subject)) addViolation('Commit ' + c.sha.slice(0, 8) + ': ' + e); + } + if (detectAiFooter(c.commit.message)) addViolation('Commit ' + c.sha.slice(0, 8) + ': AI attribution footer detected'); + } + commitPage++; + } + if (pr.commits <= 250 && totalFetched > 0 && totalFetched !== pr.commits) { + addInfra('POLICY-INFRA: Fetched ' + totalFetched + ' commits but PR reports ' + pr.commits + ' — API truncation suspected'); + } + } + + // 6 — emergency-revert authorisation + // Event timeline truncation is always POLICY-INFRA regardless of whether + // an earlier label event was found — partial history is never trusted. + let jiraExempt = isDep; + let emergencyAuthFailed = false; + if (isEmergencyCandidate) { + try { + let evPage = 1; + let evMore = true; + let latestLabelEvent = null; + let evTruncated = false; + while (evMore) { + const evResp = await github.rest.issues.listEvents({ owner: repoOwner, repo: repoName, issue_number: prNum, per_page: 100, page: evPage }); + const evLink = (evResp.headers && evResp.headers.link) ? evResp.headers.link : ''; + for (const ev of evResp.data) { + if (ev.event === 'labeled' && ev.label && ev.label.name === 'emergency-revert') latestLabelEvent = ev; + } + if (!evLink.includes('rel="next"') || evResp.data.length === 0) { + evMore = false; + } else if (evPage >= 20) { + evMore = false; + evTruncated = true; + } + evPage++; + } + if (evTruncated) { + // Partial history cannot verify the most-recent label event. + // An earlier maintainer event might have been superseded. + addInfra('POLICY-INFRA: Event timeline truncated at pagination limit — cannot verify the most-recent emergency-revert label actor; Jira key required'); + emergencyAuthFailed = true; + } else if (!latestLabelEvent) { + addViolation('emergency-revert: label present but no label event found in timeline — Jira key required'); + } else if (!latestLabelEvent.actor) { + addViolation('emergency-revert: label event actor is null — Jira key required'); + } else if (latestLabelEvent.actor.type === 'Bot') { + addViolation('emergency-revert: label applied by a bot — Jira key required'); + } else { + const permResp = await github.rest.repos.getCollaboratorPermissionLevel({ owner: repoOwner, repo: repoName, username: latestLabelEvent.actor.login }); + if (permResp.data.permission === 'maintain' || permResp.data.permission === 'admin') { + jiraExempt = true; + } else { + addViolation('emergency-revert: label applied by user without maintain/admin permission — Jira key required'); + } + } + } catch (err) { + addInfra('POLICY-INFRA: Emergency-revert authorisation check failed: ' + err.message); + emergencyAuthFailed = true; + } + } + + // 7 — Jira existence (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)'); + }