mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 05:53:12 +00:00
ci: add deterministic PR policy gate
Refs: PLAT-62
This commit is contained in:
parent
d9a8c7fd8f
commit
5954557ef0
7 changed files with 2153 additions and 2 deletions
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
|
|
@ -4,6 +4,8 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
|
|
|||
697
.github/workflows/callable-pr-policy.yaml
vendored
Normal file
697
.github/workflows/callable-pr-policy.yaml
vendored
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
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 skips Jira/branch/body checks but still runs commit-subject and
|
||||
# workflow supply-chain checks.
|
||||
#
|
||||
# Emergency-revert exemption: when the title type is `revert`, the PR has no
|
||||
# Jira key in the title, and the `emergency-revert` label is present on the PR,
|
||||
# a candidate exemption is computed before title validation so the Jira key is
|
||||
# not required in the title. The exemption is confirmed by verifying that the
|
||||
# label was applied by a collaborator with maintain or admin permission. Any
|
||||
# pagination truncation of the event timeline is POLICY-INFRA — partial history
|
||||
# is never trusted. Unauthorized/null-actor/bot results add a violation.
|
||||
# Branch, body, and commit checks remain regardless.
|
||||
#
|
||||
# Known platform limitation: metadata edits (labels, title changes) made via
|
||||
# GITHUB_TOKEN do not reliably emit a new pull_request event. Org automation
|
||||
# that applies labels must use a GitHub App token or a PAT so the policy gate
|
||||
# re-runs automatically after the label is applied.
|
||||
#
|
||||
# Caller example:
|
||||
# jobs:
|
||||
# policy:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@<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 > 72) errs.push('Title is ' + title.length + ' chars — max 72');
|
||||
const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC)-\d+\))?$/);
|
||||
if (!m) {
|
||||
errs.push('Title must match: type(scope): description (KEY-NNN). Allowed types: ' + CONV_TYPES.join(' '));
|
||||
return errs;
|
||||
}
|
||||
const desc = m[4];
|
||||
const jiraSuffix = m[5];
|
||||
if (desc.endsWith('.')) errs.push('Description must not end with a period');
|
||||
if (!/^[a-z]/.test(desc)) errs.push('Description must start with a lowercase letter');
|
||||
if (!isDependabot && !jiraSuffix && !jiraMaybeExempt) {
|
||||
errs.push('Missing Jira key — expected (DEV-NNN), (PLAT-NNN), or (SEC-NNN) at end of title');
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function getJiraKey(title) {
|
||||
const m = title.match(/\((DEV|PLAT|SEC)-(\d+)\)$/);
|
||||
return m ? m[1] + '-' + m[2] : null;
|
||||
}
|
||||
|
||||
function validateBranch(branch, isDependabot) {
|
||||
if (isDependabot) return [];
|
||||
const errs = [];
|
||||
// Segment must be proper kebab-case: no consecutive hyphens, no trailing hyphen.
|
||||
const m = branch.match(/^(feature|fix|hotfix|chore|docs|refactor|release)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/);
|
||||
if (!m) {
|
||||
errs.push('Branch "' + branch + '" must match prefix/kebab-case (no consecutive/trailing hyphens, no uppercase, one segment). Prefixes: feature fix hotfix chore docs refactor release');
|
||||
return errs;
|
||||
}
|
||||
if (/(?:DEV|PLAT|SEC|INFRA)-\d+/i.test(m[2])) errs.push('Branch segment must not contain a Jira key');
|
||||
return errs;
|
||||
}
|
||||
|
||||
function validateBody(rawBody, isDependabot) {
|
||||
if (isDependabot) return [];
|
||||
if (!rawBody || !rawBody.trim()) return ['PR body is empty'];
|
||||
const errs = [];
|
||||
// Strip fenced code blocks line-by-line before scanning headings.
|
||||
// Fence markers: backtick (0x60) or tilde. Up to 3 leading spaces allowed
|
||||
// (CommonMark spec). Use charCode to avoid literal backtick in source
|
||||
// (which confuses actionlint's expression scanner).
|
||||
const TICK = String.fromCharCode(0x60);
|
||||
const bodyLines = rawBody.split('\n');
|
||||
const stripped = [];
|
||||
let inFence = false;
|
||||
let fenceChar = '';
|
||||
let fenceLen = 0;
|
||||
const fenceRe = new RegExp('^ {0,3}(' + TICK + '{3,}|~{3,})');
|
||||
for (const line of bodyLines) {
|
||||
if (!inFence) {
|
||||
const fm = fenceRe.exec(line);
|
||||
if (fm) {
|
||||
inFence = true;
|
||||
fenceChar = fm[1][0];
|
||||
fenceLen = fm[1].length;
|
||||
stripped.push('');
|
||||
} else {
|
||||
stripped.push(line);
|
||||
}
|
||||
} else {
|
||||
const fm = fenceRe.exec(line);
|
||||
if (fm && fm[1][0] === fenceChar && fm[1].length >= fenceLen && line.trim() === fm[1]) {
|
||||
inFence = false;
|
||||
stripped.push('');
|
||||
} else {
|
||||
stripped.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
const cleaned = stripped.join('\n').replace(/<!--[\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 detectAiFooter(text) {
|
||||
return AI_FOOTER_RE.test(text);
|
||||
}
|
||||
|
||||
function isWorkflowFilename(filename) {
|
||||
return /^\.github\/workflows\/[^/]+\.ya?ml$/.test(filename) ||
|
||||
/^workflow-templates\/[^/]+\.ya?ml$/.test(filename);
|
||||
}
|
||||
|
||||
// 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:). For 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.
|
||||
function validateWorkflowContent(content, filename) {
|
||||
const errs = [];
|
||||
const EXPR_OPEN = '$' + '{{';
|
||||
|
||||
// 1. Top-level permissions key required at column 0 (may be quoted).
|
||||
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:');
|
||||
}
|
||||
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:)');
|
||||
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');
|
||||
}
|
||||
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] || ''));
|
||||
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');
|
||||
continue;
|
||||
}
|
||||
if (inlineRunM[1].includes(EXPR_OPEN)) {
|
||||
errs.push(filename + ':' + (i + 1) + ': run: value contains ' + EXPR_OPEN + ' }} — expressions must go through env:');
|
||||
}
|
||||
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');
|
||||
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 and docker refs are exempt from SHA pinning.
|
||||
if (ref.startsWith('./') || ref.startsWith('docker://')) 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;
|
||||
}
|
||||
|
||||
// ── Test escape ──────────────────────────────────────────────────────────
|
||||
// Set PR_POLICY_TEST=1 to extract pure functions without hitting any API.
|
||||
if (process.env.PR_POLICY_TEST === '1') {
|
||||
return {
|
||||
validateTitle,
|
||||
getJiraKey,
|
||||
validateBranch,
|
||||
validateBody,
|
||||
validateCommitSubject,
|
||||
detectAiFooter,
|
||||
isWorkflowFilename,
|
||||
validateWorkflowContent,
|
||||
parseRetryAfterMs,
|
||||
checkCommitLimit,
|
||||
checkFilesLimit,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Jira API helper ──────────────────────────────────────────────────────
|
||||
// Retries on 429/5xx up to 3 times with Retry-After header support.
|
||||
// On the final attempt (attempt === 3), 429/5xx falls through to the
|
||||
// status-specific throw. Never logs secrets or response bodies.
|
||||
async function jiraGetIssue(cloudId, issueKey, email, token) {
|
||||
const https = require('https');
|
||||
const apiPath = '/ex/jira/' + cloudId + '/rest/api/3/issue/' + issueKey + '?fields=key';
|
||||
const authHeader = 'Basic ' + Buffer.from(email + ':' + token).toString('base64');
|
||||
for (let attempt = 0; attempt <= 3; attempt++) {
|
||||
const result = await new Promise(function(resolve, reject) {
|
||||
const req = https.request({
|
||||
hostname: 'api.atlassian.com',
|
||||
path: apiPath,
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': authHeader, 'Accept': 'application/json' },
|
||||
}, function(res) {
|
||||
const chunks = [];
|
||||
res.on('data', function(c) { chunks.push(c); });
|
||||
res.on('end', function() {
|
||||
resolve({ status: res.statusCode, retryAfter: res.headers['retry-after'], body: Buffer.concat(chunks).toString('utf8') });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
if (result.status === 200) {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(result.body); } catch (_) {
|
||||
const e = new Error('Jira API returned non-JSON'); e.isInfra = true; throw e;
|
||||
}
|
||||
if (parsed.key !== issueKey) throw new Error('Jira returned key "' + parsed.key + '" but expected "' + issueKey + '"');
|
||||
return parsed;
|
||||
}
|
||||
if (result.status === 404) throw new Error('Jira issue ' + issueKey + ' not found');
|
||||
if (result.status === 401 || result.status === 403) {
|
||||
const e = new Error('Jira auth rejected (HTTP ' + result.status + ')'); e.isInfra = true; throw e;
|
||||
}
|
||||
if ((result.status === 429 || result.status >= 500) && attempt < 3) {
|
||||
const headerMs = parseRetryAfterMs(result.retryAfter);
|
||||
const delayMs = headerMs > 0 ? headerMs : Math.min(2000 * (attempt + 1), 30000);
|
||||
await new Promise(function(r) { setTimeout(r, delayMs); });
|
||||
continue;
|
||||
}
|
||||
const e = new Error('Jira API returned HTTP ' + result.status); e.isInfra = true; throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────
|
||||
const violations = [];
|
||||
const infraCodes = [];
|
||||
let infraFailed = false;
|
||||
const MAX_ANNOTATIONS = 50;
|
||||
|
||||
function addViolation(msg) { violations.push(msg); }
|
||||
function addInfra(msg) { infraCodes.push(msg); infraFailed = true; }
|
||||
|
||||
const repoOwner = context.repo.owner;
|
||||
const repoName = context.repo.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNum = pr.number;
|
||||
const isDep = pr.user.login === 'dependabot[bot]';
|
||||
const titleTypeMatch = pr.title.match(/^([a-z]+)/);
|
||||
const titleType = titleTypeMatch ? titleTypeMatch[1] : '';
|
||||
|
||||
// Pre-compute emergency-revert candidate before title validation.
|
||||
// Only a revert title that LACKS a Jira suffix triggers emergency
|
||||
// authorization; a revert title that already carries a Jira key does not.
|
||||
const hasEmergencyLabel = pr.labels.some(function(l) { return l.name === 'emergency-revert'; });
|
||||
const titleHasJira = !!getJiraKey(pr.title);
|
||||
const isEmergencyCandidate = !isDep && titleType === 'revert' && hasEmergencyLabel && !titleHasJira;
|
||||
|
||||
// 1 — title convention
|
||||
for (const e of validateTitle(pr.title, isDep, isEmergencyCandidate)) addViolation('Title: ' + e);
|
||||
|
||||
// 2 — branch naming (Dependabot exempt)
|
||||
for (const e of validateBranch(pr.head.ref, isDep)) addViolation('Branch: ' + e);
|
||||
|
||||
// 3 — body structure (Dependabot exempt)
|
||||
for (const e of validateBody(pr.body, isDep)) addViolation('Body: ' + e);
|
||||
|
||||
// 4 — AI attribution footer in title/body
|
||||
if (detectAiFooter((pr.title || '') + '\n' + (pr.body || ''))) {
|
||||
addViolation('AI attribution footer detected in PR title or body');
|
||||
}
|
||||
|
||||
// 5 — commits: subject convention + AI footer
|
||||
// GitHub REST API caps listCommits at 250 total. Fail infra immediately
|
||||
// when pr.commits exceeds that limit; compare fetched count to detect
|
||||
// API truncation.
|
||||
{
|
||||
const commitLimitErr = checkCommitLimit(pr.commits);
|
||||
if (commitLimitErr) addInfra(commitLimitErr);
|
||||
|
||||
let commitPage = 1;
|
||||
let commitMore = true;
|
||||
let totalFetched = 0;
|
||||
while (commitMore) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await github.rest.pulls.listCommits({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: commitPage });
|
||||
} catch (err) {
|
||||
addInfra('POLICY-INFRA: Failed to fetch commits (page ' + commitPage + '): ' + err.message);
|
||||
break;
|
||||
}
|
||||
const commits = resp.data;
|
||||
const link = (resp.headers && resp.headers.link) ? resp.headers.link : '';
|
||||
totalFetched += commits.length;
|
||||
if (!link.includes('rel="next"') || commits.length === 0) commitMore = false;
|
||||
for (const c of commits) {
|
||||
const subject = c.commit.message.split('\n')[0];
|
||||
for (const e of validateCommitSubject(subject)) addViolation('Commit ' + c.sha.slice(0, 8) + ': ' + e);
|
||||
if (detectAiFooter(c.commit.message)) addViolation('Commit ' + c.sha.slice(0, 8) + ': AI attribution footer detected');
|
||||
}
|
||||
commitPage++;
|
||||
}
|
||||
if (pr.commits <= 250 && totalFetched > 0 && totalFetched !== pr.commits) {
|
||||
addInfra('POLICY-INFRA: Fetched ' + totalFetched + ' commits but PR reports ' + pr.commits + ' — API truncation suspected');
|
||||
}
|
||||
}
|
||||
|
||||
// 6 — emergency-revert authorisation
|
||||
// Event timeline truncation is always POLICY-INFRA regardless of whether
|
||||
// an earlier label event was found — partial history is never trusted.
|
||||
let jiraExempt = isDep;
|
||||
let emergencyAuthFailed = false;
|
||||
if (isEmergencyCandidate) {
|
||||
try {
|
||||
let evPage = 1;
|
||||
let evMore = true;
|
||||
let latestLabelEvent = null;
|
||||
let evTruncated = false;
|
||||
while (evMore) {
|
||||
const evResp = await github.rest.issues.listEvents({ owner: repoOwner, repo: repoName, issue_number: prNum, per_page: 100, page: evPage });
|
||||
const evLink = (evResp.headers && evResp.headers.link) ? evResp.headers.link : '';
|
||||
for (const ev of evResp.data) {
|
||||
if (ev.event === 'labeled' && ev.label && ev.label.name === 'emergency-revert') latestLabelEvent = ev;
|
||||
}
|
||||
if (!evLink.includes('rel="next"') || evResp.data.length === 0) {
|
||||
evMore = false;
|
||||
} else if (evPage >= 20) {
|
||||
evMore = false;
|
||||
evTruncated = true;
|
||||
}
|
||||
evPage++;
|
||||
}
|
||||
if (evTruncated) {
|
||||
// Partial history cannot verify the most-recent label event.
|
||||
// An earlier maintainer event might have been superseded.
|
||||
addInfra('POLICY-INFRA: Event timeline truncated at pagination limit — cannot verify the most-recent emergency-revert label actor; Jira key required');
|
||||
emergencyAuthFailed = true;
|
||||
} else if (!latestLabelEvent) {
|
||||
addViolation('emergency-revert: label present but no label event found in timeline — Jira key required');
|
||||
} else if (!latestLabelEvent.actor) {
|
||||
addViolation('emergency-revert: label event actor is null — Jira key required');
|
||||
} else if (latestLabelEvent.actor.type === 'Bot') {
|
||||
addViolation('emergency-revert: label applied by a bot — Jira key required');
|
||||
} else {
|
||||
const permResp = await github.rest.repos.getCollaboratorPermissionLevel({ owner: repoOwner, repo: repoName, username: latestLabelEvent.actor.login });
|
||||
if (permResp.data.permission === 'maintain' || permResp.data.permission === 'admin') {
|
||||
jiraExempt = true;
|
||||
} else {
|
||||
addViolation('emergency-revert: label applied by user without maintain/admin permission — Jira key required');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
addInfra('POLICY-INFRA: Emergency-revert authorisation check failed: ' + err.message);
|
||||
emergencyAuthFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 7 — Jira existence (Dependabot exempt; emergency-revert may be exempt)
|
||||
// Skip entirely when emergency auth already produced an infra error to
|
||||
// avoid a redundant credential error on a PR that has no Jira key.
|
||||
const jiraKey = isDep ? null : getJiraKey(pr.title);
|
||||
if (!jiraExempt && jiraKey && !emergencyAuthFailed) {
|
||||
const cloudId = process.env.JIRA_CLOUD_ID || '';
|
||||
const jiraEmail = process.env.JIRA_SERVICE_ACCOUNT_EMAIL || '';
|
||||
const jiraToken = process.env.JIRA_API_TOKEN || '';
|
||||
if (!cloudId || !jiraEmail || !jiraToken) {
|
||||
addInfra('POLICY-INFRA: Jira credentials missing — JIRA_CLOUD_ID, JIRA_SERVICE_ACCOUNT_EMAIL, and JIRA_API_TOKEN must all be set for human PRs');
|
||||
} else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)) {
|
||||
addInfra('POLICY-INFRA: JIRA_CLOUD_ID is not a valid UUID');
|
||||
} else {
|
||||
try {
|
||||
await jiraGetIssue(cloudId, jiraKey, jiraEmail, jiraToken);
|
||||
} catch (err) {
|
||||
if (err.isInfra) addInfra('POLICY-INFRA: ' + err.message);
|
||||
else addViolation('Jira: ' + err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8 — workflow file supply-chain checks
|
||||
// GitHub REST API caps listFiles at 3000. Fail infra immediately when
|
||||
// pr.changed_files exceeds that limit; compare fetched count to detect
|
||||
// truncation at lower file counts.
|
||||
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 (!isWorkflowFilename(file.filename)) continue;
|
||||
if (file.status !== 'added' && file.status !== 'modified' && file.status !== 'renamed') continue;
|
||||
try {
|
||||
const blobResp = await github.rest.git.getBlob({ owner: repoOwner, repo: repoName, file_sha: file.sha });
|
||||
const raw = blobResp.data;
|
||||
const encoding = raw.encoding === 'base64' ? 'base64' : 'utf8';
|
||||
const fileContent = Buffer.from(raw.content, encoding).toString('utf8');
|
||||
for (const e of validateWorkflowContent(fileContent, file.filename)) addViolation(e);
|
||||
} catch (blobErr) {
|
||||
addInfra('POLICY-INFRA: Cannot fetch blob for ' + file.filename + ': ' + blobErr.message);
|
||||
}
|
||||
}
|
||||
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(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('- ' + 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)');
|
||||
}
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
|
|
@ -50,7 +50,11 @@ jobs:
|
|||
name: ci / ci
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Run policy unit tests
|
||||
run: node --test test/pr-policy.test.mjs
|
||||
shell: bash
|
||||
|
||||
- name: Install actionlint
|
||||
env:
|
||||
|
|
|
|||
36
.github/workflows/policy.yaml
vendored
Normal file
36
.github/workflows/policy.yaml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: policy
|
||||
|
||||
# Self-caller: runs the org-wide PR policy gate on THIS repo's own pull requests.
|
||||
#
|
||||
# This workflow is new and will begin enforcing policy on PRs opened AFTER it
|
||||
# merges to main. PRs that are already open at merge time are not retroactively
|
||||
# re-evaluated until one of the trigger events fires again (e.g. a new commit).
|
||||
#
|
||||
# The check-run name this emits is `policy / pr`, matching the org standard
|
||||
# documented in callable-pr-policy.yaml. Do not rename the job below — the
|
||||
# job id (`policy`) is the first segment of that context.
|
||||
#
|
||||
# Uses a local path reference because this repo IS the source of the reusable;
|
||||
# pinning to a SHA of itself would lag by one merge every time either file
|
||||
# changes. Local `./` refs are exempt from the SHA-pin 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: ./.github/workflows/callable-pr-policy.yaml
|
||||
secrets:
|
||||
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
|
||||
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
|
||||
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
||||
1
.github/workflows/release-on-merge.yaml
vendored
1
.github/workflows/release-on-merge.yaml
vendored
|
|
@ -30,6 +30,7 @@ on:
|
|||
- ".github/workflows/**"
|
||||
- "!.github/workflows/ci.yaml"
|
||||
- "!.github/workflows/labeler.yaml"
|
||||
- "!.github/workflows/policy.yaml"
|
||||
- "!.github/workflows/release-on-merge.yaml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -50,6 +50,10 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and
|
|||
|
||||
**`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping.
|
||||
|
||||
**`.github/workflows/callable-pr-policy.yaml`** — Reusable PR metadata gate. Validates PR title convention (type/scope/Jira key), branch naming, four-section body, commit subjects, AI attribution footers, and workflow file pin compliance — all via GitHub API, no checkout. Emits `policy / pr` when the caller job is named `policy`. Optional secrets `JIRA_CLOUD_ID`, `JIRA_SERVICE_ACCOUNT_EMAIL`, and `JIRA_API_TOKEN` must all be set for human PRs; Dependabot skips Jira/branch/body but still runs commit and workflow supply-chain checks. Emergency `revert` PRs may skip Jira with the `emergency-revert` label applied by a human collaborator with `maintain` or `admin` permission.
|
||||
|
||||
> **Workflow file constraints enforced by the supply-chain scanner.** Changed workflow files scanned by this policy must use block-style structural keys and inline `run:`/`uses:` values. The scanner fails closed on YAML forms it cannot safely resolve: flow-style step mappings (`- { uses: ... }`, `- { run: ... }`), escaped or Unicode-encoded structural keys in double-quoted strings (`"u\u0073es"`, `"r\u0075n"`), and YAML aliases or anchors on `run:`, `uses:`, or `permissions:` values (`run: *cmd`, `uses: &anchor ...`). Use the 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.
|
||||
|
|
@ -58,6 +62,8 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and
|
|||
|
||||
**`.github/workflows/release-on-merge.yaml`** — Repo automation (not callable): cuts a tag and GitHub Release for **this** repo whenever a merge to `main` changes a reusable workflow, so Dependabot has a release to advance consumer SHA pins to (see the pinning policy below).
|
||||
|
||||
**`.github/workflows/policy.yaml`** — This repo's own thin caller of `callable-pr-policy.yaml`, so PR policy runs on `.github`'s own PRs. Uses a local path reference (`./.github/workflows/callable-pr-policy.yaml`); begins enforcing on PRs opened after its merge to main.
|
||||
|
||||
**`.github/workflows/labeler.yaml`** — This repo's own thin caller of `callable-labeler.yaml`, so the labeler runs on `.github`'s own PRs.
|
||||
|
||||
**`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted.
|
||||
|
|
@ -66,6 +72,8 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and
|
|||
|
||||
Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `labeler`, `mobile-ios-deploy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one.
|
||||
|
||||
A `pr-policy` starter template can be added to `workflow-templates/` only after the PR that introduces `callable-pr-policy.yaml` merges and `release-on-merge.yaml` cuts the first release containing it. Until then, consumer repos must add the caller workflow manually (see §3).
|
||||
|
||||
### Ref pinning policy
|
||||
|
||||
All workflow refs across the org are pinned to full commit SHAs:
|
||||
|
|
@ -157,6 +165,14 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each
|
|||
|
||||
The CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §3).
|
||||
|
||||
Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo:
|
||||
|
||||
| Secret | Value | Consumed by |
|
||||
|--------|-------|-------------|
|
||||
| `JIRA_CLOUD_ID` | Atlassian Cloud ID UUID (find in **Jira Settings → Products → Jira Software**) | `callable-pr-policy.yaml` |
|
||||
| `JIRA_SERVICE_ACCOUNT_EMAIL` | Email of the service account with read access to DEV/PLAT/SEC projects | `callable-pr-policy.yaml` |
|
||||
| `JIRA_API_TOKEN` | API token for that account (generated at **id.atlassian.com/manage-profile/security/api-tokens**) | `callable-pr-policy.yaml` |
|
||||
|
||||
### 2. Add CI to a repo
|
||||
|
||||
Create `.github/workflows/ci.yaml` in the target repo. Examples:
|
||||
|
|
@ -222,7 +238,45 @@ jobs:
|
|||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
|
||||
```
|
||||
|
||||
### 3. Add CD to a repo
|
||||
### 3. Add PR policy to a repo
|
||||
|
||||
Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1).
|
||||
|
||||
```yaml
|
||||
name: PR Policy
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: policy-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
policy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@<full-commit-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 }}
|
||||
```
|
||||
|
||||
Replace `<full-commit-sha>` with the SHA of the release that contains `callable-pr-policy.yaml`:
|
||||
|
||||
```bash
|
||||
gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha
|
||||
```
|
||||
|
||||
The check-run name is `policy / pr`. If your branch-protection ruleset requires this context, add it after the first PR passes.
|
||||
|
||||
> **Known platform limitation — GITHUB_TOKEN label and metadata events.** When the `policy` workflow re-runs on `labeled` or `edited` events, the metadata edits themselves (label adds, title edits) must be performed by a GitHub App or a PAT that owns its own event stream. Edits made through `GITHUB_TOKEN` do not reliably emit a new `pull_request` event to trigger re-evaluation; the check stays in its prior state until the next push or manual re-run. Org automation that applies labels (such as the `emergency-revert` label) must therefore use a GitHub App token or a PAT — not `GITHUB_TOKEN` — or the policy gate will not re-run automatically after the label is applied. This is a GitHub platform constraint, not a deficiency that can be solved at the workflow level.
|
||||
|
||||
### 4. Add CD to a repo
|
||||
|
||||
Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
|
||||
|
||||
|
|
|
|||
1357
test/pr-policy.test.mjs
Normal file
1357
test/pr-policy.test.mjs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue