ci(workflows): inline self-hosted smoke for Actions outage

This commit is contained in:
Adam Moussa 2026-08-06 18:14:05 -04:00
parent 51f7905cc7
commit 260f5f83e8
No known key found for this signature in database
4 changed files with 1052 additions and 16 deletions

View file

@ -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."

View file

@ -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

View file

@ -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

View file

@ -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(/<!--[\s\S]*?-->/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://<image>@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)');
}