mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 07:03:11 +00:00
chore(ci): remove deprecated PR policy reusable
policy / pr is no longer a required check. Drop the callable, its unit tests, and the setup docs so callers stop pinning a retired gate.
This commit is contained in:
parent
9e3d0d4430
commit
f22b3132a2
6 changed files with 4 additions and 3458 deletions
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -5,7 +5,6 @@ PR conventions
|
|||
- Maximum 120 characters, including the Jira suffix.
|
||||
- Active Jira projects: DEV (product), PLAT (platform), SEC (security). INFRA is a closed archive.
|
||||
- Put the Jira key at the end of the title in parentheses. A missing key is a warning, not a failure.
|
||||
- Dependabot-authored PRs skip the policy gate. Authorized emergency reverts suppress the missing-key warning.
|
||||
- Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ + kebab-case description.
|
||||
Branch names do not contain Jira keys.
|
||||
- Scope: one logical change per PR. If the title needs "and", split it.
|
||||
|
|
|
|||
924
.github/workflows/callable-pr-policy.yaml
vendored
924
.github/workflows/callable-pr-policy.yaml
vendored
|
|
@ -1,924 +0,0 @@
|
|||
name: PR Policy
|
||||
|
||||
# Reusable PR metadata gate for all Sea-Haven-Industries repos.
|
||||
#
|
||||
# Validates pull-request metadata — title convention, branch naming, body
|
||||
# structure, commit subjects, Jira existence, AI attribution footers, and
|
||||
# workflow file pin compliance — without executing any PR code or checking
|
||||
# out the repository. All checks run through the GitHub API only.
|
||||
#
|
||||
# Callers trigger this on `pull_request` (NOT pull_request_target) with event
|
||||
# types: opened, reopened, synchronize, edited, labeled, unlabeled,
|
||||
# ready_for_review. The check-run name is `<caller-job-id> / pr`; the
|
||||
# canonical caller job id is `policy`, producing the context `policy / pr`.
|
||||
#
|
||||
# Secrets are optional at the declaration level. For human PRs that include a
|
||||
# Jira key, all three must be configured or the check fails closed (POLICY-INFRA).
|
||||
# Dependabot-authored PRs (pull_request.user.login == dependabot[bot]) exit
|
||||
# successfully with no metadata or supply-chain checks.
|
||||
#
|
||||
# A missing Jira key on a human PR is a warning, not a failure. A present key
|
||||
# is still verified against Jira and fails closed on lookup or credential errors.
|
||||
#
|
||||
# Emergency-revert exemption: when the title type is `revert`, the PR has no
|
||||
# Jira key in the title, and the `emergency-revert` label is present on the PR,
|
||||
# a candidate exemption is computed so the missing-key warning is suppressed.
|
||||
# The exemption is confirmed by verifying that the label was applied by a
|
||||
# collaborator with maintain or admin permission. Any pagination truncation of
|
||||
# the event timeline is POLICY-INFRA — partial history is never trusted.
|
||||
# Unauthorized/null-actor/bot results add a violation. Branch, body, and commit
|
||||
# checks remain regardless.
|
||||
#
|
||||
# Known platform limitation: metadata edits (labels, title changes) made via
|
||||
# GITHUB_TOKEN do not reliably emit a new pull_request event. Org automation
|
||||
# that applies labels must use a GitHub App token or a PAT so the policy gate
|
||||
# re-runs automatically after the label is applied.
|
||||
#
|
||||
# Caller example:
|
||||
# jobs:
|
||||
# policy:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@<sha> # vX.Y.Z
|
||||
# secrets:
|
||||
# JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
|
||||
# JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
|
||||
# JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
JIRA_CLOUD_ID:
|
||||
required: false
|
||||
JIRA_SERVICE_ACCOUNT_EMAIL:
|
||||
required: false
|
||||
JIRA_API_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Validate PR
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
|
||||
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
|
||||
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
||||
with:
|
||||
script: |
|
||||
// ── Pure validation functions ────────────────────────────────────────────
|
||||
// Extracted and exercised by test/pr-policy.test.mjs via PR_POLICY_TEST.
|
||||
// These functions have no side effects and make no API calls.
|
||||
|
||||
const CONV_TYPES = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release'];
|
||||
const REQUIRED_H2 = ['Summary','Validation','Tests','Notes'];
|
||||
|
||||
// AI-attribution footer patterns — case-insensitive, multiline.
|
||||
// Matches Co-authored-by: trailers naming known AI tools and "Generated by/with"
|
||||
// phrases. Does NOT flag generic prose like "uses AI" or "AI-powered".
|
||||
// GPT variants: gpt-3, gpt-4, gpt-4o, gpt-5, gpt-o, etc. covered by gpt-[a-z0-9]+.
|
||||
const AI_FOOTER_RE = /^(?:co-authored-by:\s+(?:claude|chatgpt|gpt-[a-z0-9]+|copilot|github\s+copilot|gemini|cursor(?:\s*ai)?|codeium|anthropic|openai|codex)\b|generated\s+(?:with|by)\s+(?:claude(?:\s+code)?|github\s+copilot|chatgpt|codex|gpt-[a-z0-9]+|gemini|codeium|cursor(?:\s*ai)?|anthropic|openai)|🤖\s+generated\b)/im;
|
||||
|
||||
function validateTitle(title, isDependabot, jiraMaybeExempt) {
|
||||
const errs = [];
|
||||
if (title.length > 120) errs.push('Title is ' + title.length + ' chars — max 120');
|
||||
const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC|AP)-\d+\))?$/);
|
||||
if (!m) {
|
||||
errs.push('Title must match: type(scope): description (KEY-NNN). Allowed types: ' + CONV_TYPES.join(' '));
|
||||
return errs;
|
||||
}
|
||||
const desc = m[4];
|
||||
if (desc.endsWith('.')) errs.push('Description must not end with a period');
|
||||
if (!/^[a-z]/.test(desc)) errs.push('Description must start with a lowercase letter');
|
||||
return errs;
|
||||
}
|
||||
|
||||
function getJiraKey(title) {
|
||||
const m = title.match(/\((DEV|PLAT|SEC|AP)-(\d+)\)$/);
|
||||
return m ? m[1] + '-' + m[2] : null;
|
||||
}
|
||||
|
||||
function validateBranch(branch, isDependabot) {
|
||||
if (isDependabot) return [];
|
||||
const errs = [];
|
||||
// Segment must be proper kebab-case: no consecutive hyphens, no trailing hyphen.
|
||||
const m = branch.match(/^(feature|fix|hotfix|chore|docs|refactor|release)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/);
|
||||
if (!m) {
|
||||
errs.push('Branch "' + branch + '" must match prefix/kebab-case (no consecutive/trailing hyphens, no uppercase, one segment). Prefixes: feature fix hotfix chore docs refactor release');
|
||||
return errs;
|
||||
}
|
||||
if (/(?:DEV|PLAT|SEC|AP|INFRA)-\d+/i.test(m[2])) errs.push('Branch segment must not contain a Jira key');
|
||||
return errs;
|
||||
}
|
||||
|
||||
function validateBody(rawBody, isDependabot) {
|
||||
if (isDependabot) return [];
|
||||
if (!rawBody || !rawBody.trim()) return ['PR body is empty'];
|
||||
const errs = [];
|
||||
// Strip fenced code blocks line-by-line before scanning headings.
|
||||
// Fence markers: backtick (0x60) or tilde. Up to 3 leading spaces allowed
|
||||
// (CommonMark spec). Use charCode to avoid literal backtick in source
|
||||
// (which confuses actionlint's expression scanner).
|
||||
const TICK = String.fromCharCode(0x60);
|
||||
const bodyLines = rawBody.split('\n');
|
||||
const stripped = [];
|
||||
let inFence = false;
|
||||
let fenceChar = '';
|
||||
let fenceLen = 0;
|
||||
const fenceRe = new RegExp('^ {0,3}(' + TICK + '{3,}|~{3,})');
|
||||
for (const line of bodyLines) {
|
||||
if (!inFence) {
|
||||
const fm = fenceRe.exec(line);
|
||||
if (fm) {
|
||||
inFence = true;
|
||||
fenceChar = fm[1][0];
|
||||
fenceLen = fm[1].length;
|
||||
stripped.push('');
|
||||
} else {
|
||||
stripped.push(line);
|
||||
}
|
||||
} else {
|
||||
const fm = fenceRe.exec(line);
|
||||
if (fm && fm[1][0] === fenceChar && fm[1].length >= fenceLen && line.trim() === fm[1]) {
|
||||
inFence = false;
|
||||
stripped.push('');
|
||||
} else {
|
||||
stripped.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
const cleaned = stripped.join('\n').replace(/<!--[\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');
|
||||
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 warnings = [];
|
||||
const infraCodes = [];
|
||||
let infraFailed = false;
|
||||
const MAX_ANNOTATIONS = 50;
|
||||
|
||||
function addViolation(msg) { violations.push(msg); }
|
||||
function addWarning(msg) { warnings.push(msg); }
|
||||
function addInfra(msg) { infraCodes.push(msg); infraFailed = true; }
|
||||
|
||||
const repoOwner = context.repo.owner;
|
||||
const repoName = context.repo.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNum = pr.number;
|
||||
const isDep = pr.user.login === 'dependabot[bot]';
|
||||
if (isDep) {
|
||||
await core.summary.addRaw('## PR Policy: skipped (Dependabot)').write();
|
||||
return;
|
||||
}
|
||||
const titleTypeMatch = pr.title.match(/^([a-z]+)/);
|
||||
const titleType = titleTypeMatch ? titleTypeMatch[1] : '';
|
||||
|
||||
// Pre-compute emergency-revert candidate before title validation.
|
||||
// Only a revert title that LACKS a Jira suffix triggers emergency
|
||||
// authorization; a revert title that already carries a Jira key does not.
|
||||
const hasEmergencyLabel = pr.labels.some(function(l) { return l.name === 'emergency-revert'; });
|
||||
const titleHasJira = !!getJiraKey(pr.title);
|
||||
const isEmergencyCandidate = !isDep && titleType === 'revert' && hasEmergencyLabel && !titleHasJira;
|
||||
|
||||
// 1 — title convention
|
||||
for (const e of validateTitle(pr.title, isDep, isEmergencyCandidate)) addViolation('Title: ' + e);
|
||||
|
||||
// 2 — branch naming (Dependabot exempt)
|
||||
for (const e of validateBranch(pr.head.ref, isDep)) addViolation('Branch: ' + e);
|
||||
|
||||
// 3 — body structure (Dependabot exempt)
|
||||
for (const e of validateBody(pr.body, isDep)) addViolation('Body: ' + e);
|
||||
|
||||
// 4 — AI attribution footer in title/body
|
||||
if (detectAiFooter((pr.title || '') + '\n' + (pr.body || ''))) {
|
||||
addViolation('AI attribution footer detected in PR title or body');
|
||||
}
|
||||
|
||||
// 5 — commits: subject convention + AI footer
|
||||
// GitHub REST API caps listCommits at 250 total. Fail infra immediately
|
||||
// when pr.commits exceeds that limit; compare fetched count to detect
|
||||
// API truncation.
|
||||
{
|
||||
const commitLimitErr = checkCommitLimit(pr.commits);
|
||||
if (commitLimitErr) addInfra(commitLimitErr);
|
||||
|
||||
let commitPage = 1;
|
||||
let commitMore = true;
|
||||
let totalFetched = 0;
|
||||
while (commitMore) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await github.rest.pulls.listCommits({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: commitPage });
|
||||
} catch (err) {
|
||||
addInfra('POLICY-INFRA: Failed to fetch commits (page ' + commitPage + '): ' + err.message);
|
||||
break;
|
||||
}
|
||||
const commits = resp.data;
|
||||
const link = (resp.headers && resp.headers.link) ? resp.headers.link : '';
|
||||
totalFetched += commits.length;
|
||||
if (!link.includes('rel="next"') || commits.length === 0) commitMore = false;
|
||||
for (const c of commits) {
|
||||
const subject = c.commit.message.split('\n')[0];
|
||||
if (!isSyncMergeCommit(c)) {
|
||||
for (const e of validateCommitSubject(subject)) addViolation('Commit ' + c.sha.slice(0, 8) + ': ' + e);
|
||||
}
|
||||
if (detectAiFooter(c.commit.message)) addViolation('Commit ' + c.sha.slice(0, 8) + ': AI attribution footer detected');
|
||||
}
|
||||
commitPage++;
|
||||
}
|
||||
if (pr.commits <= 250 && totalFetched > 0 && totalFetched !== pr.commits) {
|
||||
addInfra('POLICY-INFRA: Fetched ' + totalFetched + ' commits but PR reports ' + pr.commits + ' — API truncation suspected');
|
||||
}
|
||||
}
|
||||
|
||||
// 6 — emergency-revert authorisation
|
||||
// Event timeline truncation is always POLICY-INFRA regardless of whether
|
||||
// an earlier label event was found — partial history is never trusted.
|
||||
let jiraExempt = isDep;
|
||||
let emergencyAuthFailed = false;
|
||||
if (isEmergencyCandidate) {
|
||||
try {
|
||||
let evPage = 1;
|
||||
let evMore = true;
|
||||
let latestLabelEvent = null;
|
||||
let evTruncated = false;
|
||||
while (evMore) {
|
||||
const evResp = await github.rest.issues.listEvents({ owner: repoOwner, repo: repoName, issue_number: prNum, per_page: 100, page: evPage });
|
||||
const evLink = (evResp.headers && evResp.headers.link) ? evResp.headers.link : '';
|
||||
for (const ev of evResp.data) {
|
||||
if (ev.event === 'labeled' && ev.label && ev.label.name === 'emergency-revert') latestLabelEvent = ev;
|
||||
}
|
||||
if (!evLink.includes('rel="next"') || evResp.data.length === 0) {
|
||||
evMore = false;
|
||||
} else if (evPage >= 20) {
|
||||
evMore = false;
|
||||
evTruncated = true;
|
||||
}
|
||||
evPage++;
|
||||
}
|
||||
if (evTruncated) {
|
||||
// Partial history cannot verify the most-recent label event.
|
||||
// An earlier maintainer event might have been superseded.
|
||||
addInfra('POLICY-INFRA: Event timeline truncated at pagination limit — cannot verify the most-recent emergency-revert label actor; Jira key required');
|
||||
emergencyAuthFailed = true;
|
||||
} else if (!latestLabelEvent) {
|
||||
addViolation('emergency-revert: label present but no label event found in timeline — Jira key required');
|
||||
} else if (!latestLabelEvent.actor) {
|
||||
addViolation('emergency-revert: label event actor is null — Jira key required');
|
||||
} else if (latestLabelEvent.actor.type === 'Bot') {
|
||||
addViolation('emergency-revert: label applied by a bot — Jira key required');
|
||||
} else {
|
||||
const permResp = await github.rest.repos.getCollaboratorPermissionLevel({ owner: repoOwner, repo: repoName, username: latestLabelEvent.actor.login });
|
||||
if (permResp.data.permission === 'maintain' || permResp.data.permission === 'admin') {
|
||||
jiraExempt = true;
|
||||
} else {
|
||||
addViolation('emergency-revert: label applied by user without maintain/admin permission — Jira key required');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
addInfra('POLICY-INFRA: Emergency-revert authorisation check failed: ' + err.message);
|
||||
emergencyAuthFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 7 — Jira existence. A present key is verified fail-closed. A missing
|
||||
// key is a warning, except authorized emergency-reverts which stay silent.
|
||||
// Skip the existence lookup when emergency auth already produced an infra
|
||||
// error to avoid a redundant credential error on a PR that has no key.
|
||||
const jiraKey = getJiraKey(pr.title);
|
||||
if (!jiraKey && !jiraExempt) {
|
||||
addWarning('Missing Jira key. Expected (DEV-NNN), (PLAT-NNN), (SEC-NNN), or (AP-NNN) at end of title');
|
||||
}
|
||||
if (!jiraExempt && jiraKey && !emergencyAuthFailed) {
|
||||
const cloudId = process.env.JIRA_CLOUD_ID || '';
|
||||
const jiraEmail = process.env.JIRA_SERVICE_ACCOUNT_EMAIL || '';
|
||||
const jiraToken = process.env.JIRA_API_TOKEN || '';
|
||||
if (!cloudId || !jiraEmail || !jiraToken) {
|
||||
addInfra('POLICY-INFRA: Jira credentials missing — JIRA_CLOUD_ID, JIRA_SERVICE_ACCOUNT_EMAIL, and JIRA_API_TOKEN must all be set for human PRs');
|
||||
} else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)) {
|
||||
addInfra('POLICY-INFRA: JIRA_CLOUD_ID is not a valid UUID');
|
||||
} else {
|
||||
try {
|
||||
await jiraGetIssue(cloudId, jiraKey, jiraEmail, jiraToken);
|
||||
} catch (err) {
|
||||
if (err.isInfra) addInfra('POLICY-INFRA: ' + err.message);
|
||||
else addViolation('Jira: ' + err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8 — workflow file supply-chain checks (diff-mode)
|
||||
// Only NEW violations relative to the base branch are reported.
|
||||
// Added files have no baseline and must be fully compliant.
|
||||
// Modified/renamed files are diffed: base content is fetched at
|
||||
// pr.base.sha (using previous_filename for renames). Base fetch
|
||||
// failures are POLICY-INFRA — partial history is never silently
|
||||
// grandfathered. Deleted files are skipped.
|
||||
// GitHub REST API caps listFiles at 3000.
|
||||
try {
|
||||
const filesLimitErr = checkFilesLimit(pr.changed_files);
|
||||
if (filesLimitErr) addInfra(filesLimitErr);
|
||||
|
||||
let filesPage = 1;
|
||||
let filesMore = true;
|
||||
let totalFilesFetched = 0;
|
||||
while (filesMore) {
|
||||
const filesResp = await github.rest.pulls.listFiles({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: filesPage });
|
||||
const filesLink = (filesResp.headers && filesResp.headers.link) ? filesResp.headers.link : '';
|
||||
totalFilesFetched += filesResp.data.length;
|
||||
if (!filesLink.includes('rel="next"') || filesResp.data.length === 0) filesMore = false;
|
||||
for (const file of filesResp.data) {
|
||||
if (!isPolicyFilename(file.filename)) continue;
|
||||
|
||||
const cls = classifyFileStatus(file, isPolicyFilename);
|
||||
if (cls.action === 'skip') continue;
|
||||
if (cls.action === 'infra') {
|
||||
addInfra('POLICY-INFRA: ' + cls.reason + '; skipping workflow validation');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch HEAD content via blob SHA.
|
||||
let headContent;
|
||||
try {
|
||||
const blobResp = await github.rest.git.getBlob({ owner: repoOwner, repo: repoName, file_sha: file.sha });
|
||||
const raw = blobResp.data;
|
||||
const enc = raw.encoding === 'base64' ? 'base64' : 'utf8';
|
||||
headContent = Buffer.from(raw.content, enc).toString('utf8');
|
||||
} catch (blobErr) {
|
||||
addInfra('POLICY-INFRA: Cannot fetch blob for ' + file.filename + ': ' + blobErr.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Action manifests do not support top-level permissions:.
|
||||
const wfOpts = policyFileKind(file.filename) === 'action' ? { requirePermissions: false } : {};
|
||||
const headErrs = validateWorkflowContent(headContent, file.filename, wfOpts);
|
||||
|
||||
if (cls.action === 'full') {
|
||||
// No baseline — added, copied, or renamed-from-non-policy path.
|
||||
for (const e of headErrs) addViolation(e);
|
||||
} else {
|
||||
// diff — modified, changed, or renamed-from-policy path.
|
||||
// Both head and base violations use file.filename so fingerprints match.
|
||||
let baseErrs = [];
|
||||
try {
|
||||
const baseResp = await github.rest.repos.getContent({ owner: repoOwner, repo: repoName, path: cls.basePath, ref: pr.base.sha });
|
||||
const baseRaw = baseResp.data;
|
||||
const baseEnc = baseRaw.encoding === 'base64' ? 'base64' : 'utf8';
|
||||
const baseContent = Buffer.from(baseRaw.content, baseEnc).toString('utf8');
|
||||
baseErrs = validateWorkflowContent(baseContent, file.filename, wfOpts);
|
||||
} catch (baseErr) {
|
||||
addInfra('POLICY-INFRA: Cannot fetch base content for ' + file.filename + ' at ' + pr.base.sha + ': ' + baseErr.message);
|
||||
continue;
|
||||
}
|
||||
for (const e of filterNewViolations(headErrs, baseErrs)) addViolation(e);
|
||||
}
|
||||
}
|
||||
filesPage++;
|
||||
}
|
||||
if (pr.changed_files <= 3000 && totalFilesFetched > 0 && totalFilesFetched !== pr.changed_files) {
|
||||
addInfra('POLICY-INFRA: Fetched ' + totalFilesFetched + ' changed files but PR reports ' + pr.changed_files + ' — API truncation suspected');
|
||||
}
|
||||
} catch (err) {
|
||||
addInfra('POLICY-INFRA: Failed to list PR files: ' + err.message);
|
||||
}
|
||||
|
||||
// 9 — emit annotations + step summary, then fail once
|
||||
// Both annotations and summary entries are capped at MAX_ANNOTATIONS
|
||||
// to prevent oversized outputs on PRs with many violations.
|
||||
const annotated = violations.slice(0, MAX_ANNOTATIONS);
|
||||
for (const msg of annotated) core.error(stripHash(msg));
|
||||
for (const msg of infraCodes.slice(0, MAX_ANNOTATIONS)) core.error(msg);
|
||||
for (const msg of warnings.slice(0, MAX_ANNOTATIONS)) core.warning(msg);
|
||||
if (violations.length > MAX_ANNOTATIONS) {
|
||||
core.warning((violations.length - MAX_ANNOTATIONS) + ' additional violation(s) suppressed (max ' + MAX_ANNOTATIONS + ' annotations)');
|
||||
}
|
||||
|
||||
const totalCount = violations.length + infraCodes.length;
|
||||
const summaryParts = [totalCount === 0 ? '## PR Policy: All checks passed \u2713' : '## PR Policy: ' + totalCount + ' issue(s) found'];
|
||||
if (violations.length > 0) {
|
||||
summaryParts.push('', '### Policy violations');
|
||||
const shownV = violations.slice(0, MAX_ANNOTATIONS);
|
||||
for (const msg of shownV) summaryParts.push('- ' + stripHash(msg));
|
||||
if (violations.length > MAX_ANNOTATIONS) {
|
||||
summaryParts.push('- _...and ' + (violations.length - MAX_ANNOTATIONS) + ' more violation(s) not shown_');
|
||||
}
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
summaryParts.push('', '### Warnings');
|
||||
const shownW = warnings.slice(0, MAX_ANNOTATIONS);
|
||||
for (const msg of shownW) summaryParts.push('- ' + msg);
|
||||
if (warnings.length > MAX_ANNOTATIONS) {
|
||||
summaryParts.push('- _...and ' + (warnings.length - MAX_ANNOTATIONS) + ' more warning(s) not shown_');
|
||||
}
|
||||
}
|
||||
if (infraCodes.length > 0) {
|
||||
summaryParts.push('', '### Infrastructure failures');
|
||||
const shownI = infraCodes.slice(0, MAX_ANNOTATIONS);
|
||||
for (const msg of shownI) summaryParts.push('- ' + msg);
|
||||
if (infraCodes.length > MAX_ANNOTATIONS) {
|
||||
summaryParts.push('- _...and ' + (infraCodes.length - MAX_ANNOTATIONS) + ' more infra error(s) not shown_');
|
||||
}
|
||||
}
|
||||
await core.summary.addRaw(summaryParts.join('\n')).write();
|
||||
|
||||
if (violations.length > 0 || infraFailed) {
|
||||
core.setFailed('PR policy: ' + violations.length + ' violation(s), ' + infraCodes.length + ' infrastructure error(s)');
|
||||
}
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -53,10 +53,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Run policy unit tests
|
||||
run: node --test test/pr-policy.test.mjs
|
||||
shell: bash
|
||||
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
|
|
|
|||
1
.github/workflows/release-on-merge.yaml
vendored
1
.github/workflows/release-on-merge.yaml
vendored
|
|
@ -30,7 +30,6 @@ on:
|
|||
- ".github/workflows/**"
|
||||
- "!.github/workflows/ci.yaml"
|
||||
- "!.github/workflows/labeler.yaml"
|
||||
- "!.github/workflows/policy.yaml"
|
||||
- "!.github/workflows/release-on-merge.yaml"
|
||||
- "!.github/workflows/auto-merge.yaml"
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -14,7 +14,7 @@ Organization-level GitHub configuration for Sea Haven Industries.
|
|||
|
||||
### PR title
|
||||
|
||||
`type(scope): description (DEV-123)` — maximum 120 characters, including the Jira suffix. Put the Jira key at the end in parentheses. A missing key is a warning, not a failure. Active projects: **DEV** (product), **PLAT** (platform), **SEC** (security). INFRA is a closed archive. Dependabot-authored PRs skip the policy gate. Authorized emergency reverts suppress the missing-key warning.
|
||||
`type(scope): description (DEV-123)` — maximum 120 characters, including the Jira suffix. Put the Jira key at the end in parentheses. A missing key is a warning, not a failure. Active projects: **DEV** (product), **PLAT** (platform), **SEC** (security). INFRA is a closed archive.
|
||||
|
||||
### PR body
|
||||
|
||||
|
|
@ -73,12 +73,6 @@ The formatter GitHub App is not on the main-branch bypass list.
|
|||
|
||||
**`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping.
|
||||
|
||||
**`.github/workflows/callable-pr-policy.yaml`** — Reusable PR metadata gate. Validates PR title convention (type/scope), branch naming, four-section body, commit subjects, AI attribution footers, and workflow file pin compliance — all via GitHub API, no checkout. Emits `policy / pr` when the caller job is named `policy`. Optional secrets `JIRA_CLOUD_ID`, `JIRA_SERVICE_ACCOUNT_EMAIL`, and `JIRA_API_TOKEN` must all be set when a human PR title includes a Jira key. A missing key is a warning; a present key is verified fail-closed. Dependabot-authored PRs (`pull_request.user.login == dependabot[bot]`) exit successfully with no checks. Emergency `revert` PRs suppress the missing-key warning when the `emergency-revert` label was applied by a human collaborator with `maintain` or `admin` permission.
|
||||
|
||||
The supply-chain check operates in **diff mode**: for modified or renamed workflow files, the gate fetches the base-branch version at `pr.base.sha` and reports only violations whose normalized fingerprint is absent from the base. Added files must be fully compliant. Historical drift already present in the base branch is handled by the drift audit/remediation backlog, not by this gate. A failure to fetch the base version is a `POLICY-INFRA` error and the file is not silently grandfathered.
|
||||
|
||||
> **Supply-chain scanner.** Changed workflow files and action manifests (`action.yml` / `action.yaml` at any path) are scanned. Workflow files must use block-style structural keys and inline `run:`/`uses:` values. Action manifests follow the same constraints except that top-level `permissions:` is not required (action manifests do not support it). The scanner fails closed on YAML forms it cannot safely resolve: flow-style step mappings (`- { uses: ... }`, `- { run: ... }`), flow-style `steps` arrays (`steps: [...]` with any content — `steps: []` is allowed), sequence-item anchor declarations (`- &anchor { uses: ... }` and the multiline form `- &anchor`), escaped or Unicode-encoded structural keys in double-quoted strings (`"u\u0073es"`, `"r\u0075n"`), YAML aliases or anchors on `run:`, `uses:`, or `permissions:` values (`run: *cmd`, `uses: &anchor ...`), and local action references (`uses: ./...` — the scanner cannot recursively validate action manifests; inline the logic or replace with an immutable remote SHA pin). `docker://` action refs must carry an immutable sha256 digest pin (`docker://<image>@sha256:<64 lowercase hex>`); mutable tags and bare image names are rejected. Use literal unquoted key forms and inline values in all workflow steps.
|
||||
|
||||
**`.github/workflows/callable-labeler.yaml`** — Org-wide PR auto-labeler. Label rules live inline here (single source of truth) — consumer repos need only a thin caller with `contents: read`, `pull-requests: write`, and `issues: write`; no per-repo labeler.yml.
|
||||
|
||||
**`.github/workflows/callable-dependency-review.yaml`** — Dependency review on PRs, failing on high severity. Requires Dependency Graph.
|
||||
|
|
@ -93,9 +87,7 @@ The supply-chain check operates in **diff mode**: for modified or renamed workfl
|
|||
|
||||
### Workflow templates (`workflow-templates/`)
|
||||
|
||||
Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-hcp`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-terraform`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `hcp-fargate-deploy`, `hcp-spa-deploy`, `labeler`, `mobile-ios-deploy`, `pr-policy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone; `ci-hcp` is the converted-repo caller with autofix, frontend, terraform, and `ci-complete`). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one.
|
||||
|
||||
A `pr-policy` starter template is available in `workflow-templates/`. Before the template produces passing human PR checks, the three Jira org secrets must be granted to the consumer repo (see §1).
|
||||
Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-hcp`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-terraform`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `hcp-fargate-deploy`, `hcp-spa-deploy`, `labeler`, `mobile-ios-deploy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone; `ci-hcp` is the converted-repo caller with autofix, frontend, terraform, and `ci-complete`). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one.
|
||||
|
||||
### Ref pinning policy
|
||||
|
||||
|
|
@ -188,15 +180,7 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each
|
|||
| `AUTOFMT_APP_ID` | Formatter GitHub App id | `ci-autofix.yaml` |
|
||||
| `AUTOFMT_APP_PRIVATE_KEY` | Formatter GitHub App private key | `ci-autofix.yaml` |
|
||||
|
||||
The remaining-lane CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §4). HCP CD uses `vars.DEPLOY_ROLE_ARN` on the GitHub Environment after OIDC. Adam installs the formatter App (contents: write, metadata: read; not a main-branch ruleset bypass) and grants the two autofmt secrets before the first converted repo runs autofix.
|
||||
|
||||
Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo:
|
||||
|
||||
| Secret | Value | Consumed by |
|
||||
|--------|-------|-------------|
|
||||
| `JIRA_CLOUD_ID` | Atlassian Cloud ID UUID (find in **Jira Settings → Products → Jira Software**) | `callable-pr-policy.yaml` |
|
||||
| `JIRA_SERVICE_ACCOUNT_EMAIL` | Email of the service account with read access to DEV/PLAT/SEC projects | `callable-pr-policy.yaml` |
|
||||
| `JIRA_API_TOKEN` | API token for that account (generated at **id.atlassian.com/manage-profile/security/api-tokens**) | `callable-pr-policy.yaml` |
|
||||
The remaining-lane CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §3). HCP CD uses `vars.DEPLOY_ROLE_ARN` on the GitHub Environment after OIDC. Adam installs the formatter App (contents: write, metadata: read; not a main-branch ruleset bypass) and grants the two autofmt secrets before the first converted repo runs autofix.
|
||||
|
||||
### 2. Add CI to a repo
|
||||
|
||||
|
|
@ -322,45 +306,7 @@ jobs:
|
|||
|
||||
Python HCP callers pass `format-command: ruff format .` and `lint-fix-command: ruff check --fix .`. Optional `extra-command: terraform fmt -write` is available on `ci-autofix.yaml`. Do not run `eslint --fix` unless that repo's `lint` script is already fix-safe. Flip org ruleset membership in the same window as this merge: include on `CI complete`, exclude from `main branch protection`. Never require both `ci / ci` and `ci-complete`. Do not edit native GitHub merge-queue rulesets.
|
||||
|
||||
### 3. Add PR policy to a repo
|
||||
|
||||
Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1).
|
||||
|
||||
```yaml
|
||||
name: PR Policy
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: policy-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
policy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@9c1ecf942894b19aba5c71b85b41906c6c83b749 # v1.0.5
|
||||
secrets:
|
||||
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
|
||||
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
|
||||
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
||||
```
|
||||
|
||||
Replace `<full-commit-sha>` with the SHA of the release that contains `callable-pr-policy.yaml`. The current pinned SHA is `9c1ecf942894b19aba5c71b85b41906c6c83b749` (v1.0.5). To resolve the SHA for a future release:
|
||||
|
||||
```bash
|
||||
gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha
|
||||
```
|
||||
|
||||
The check-run name is `policy / pr`. If your branch-protection ruleset requires this context, add it after the first PR passes.
|
||||
|
||||
> **Known platform limitation — GITHUB_TOKEN label and metadata events.** When the `policy` workflow re-runs on `labeled` or `edited` events, the metadata edits themselves (label adds, title edits) must be performed by a GitHub App or a PAT that owns its own event stream. Edits made through `GITHUB_TOKEN` do not reliably emit a new `pull_request` event to trigger re-evaluation; the check stays in its prior state until the next push or manual re-run. Org automation that applies labels (such as the `emergency-revert` label) must therefore use a GitHub App token or a PAT — not `GITHUB_TOKEN` — or the policy gate will not re-run automatically after the label is applied. This is a GitHub platform constraint, not a deficiency that can be solved at the workflow level.
|
||||
|
||||
### 4. Add CD to a repo
|
||||
### 3. Add CD to a repo
|
||||
|
||||
**HCP Fargate** (one caller job per GitHub Environment; `environment` is a `with:` input):
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue