feat(ci): add HCP reusable workflows and drop Mergify (#145)

* feat(ci): add HCP reusable workflows and drop Mergify

Callers can pin org Fargate/SPA CD and parallel CI instead of copying per-repo deploy jobs.

* 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:
Adam Moussa 2026-09-22 19:22:23 +00:00 • committed by GitHub
parent 22c47f924f
commit 216604ad67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1453 additions and 3497 deletions

View file

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

View file

@ -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)');
}

318
.github/workflows/cd-hcp-fargate.yaml vendored Normal file
View file

@ -0,0 +1,318 @@
name: CD — HCP Fargate
# Reusable Fargate image CD for HCP app repos. The caller owns triggers and
# passes `environment` as a `with:` input. This job owns `environment:`,
# concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. GitHub rejects `environment:`
# beside `uses:`.
#
# Caller example (one job per GitHub Environment):
# jobs:
# deploy-prod:
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@<sha> # vX.Y.Z
# permissions: { contents: read, id-token: write }
# secrets: inherit
# with:
# environment: prod
# ref: ${{ github.event.release.tag_name || inputs.ref }}
# ssm-prefix: /meal-order-manager/deploy
# docker-platform: linux/amd64
# ship-gate: true
#
# Nothing here creates an HCP run. Terraform owns the cluster, service, ALB,
# and ignores container_definitions / task_definition.
on:
workflow_call:
inputs:
environment:
description: "GitHub Environment to deploy to (dev, staging, prod)"
type: string
required: true
ref:
description: "Git ref to build. Empty means github.sha."
type: string
required: false
default: ""
ssm-prefix:
description: "SSM prefix for deploy parameters (e.g. /meal-order-manager/deploy)"
type: string
required: true
docker-platform:
description: "docker build --platform value"
type: string
required: false
default: "linux/amd64"
health-path:
description: "Health endpoint path appended to SSM api-url"
type: string
required: false
default: "/api/health"
ship-gate:
description: "Require the ref to be on main or a legal hotfix/release tag"
type: boolean
required: false
default: false
extra-task-env:
description: "JSON object of extra container environment keys to merge (e.g. {\"SENTRY_DSN_PARAM\":\"/app/sentry-dsn\"})"
type: string
required: false
default: "{}"
permissions:
contents: read
id-token: write
jobs:
deploy:
name: Deploy Fargate to ${{ inputs.environment }}
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ inputs.environment }}
concurrency:
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
cancel-in-progress: false
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref != '' && inputs.ref || github.sha }}
persist-credentials: false
fetch-tags: true
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Ship-gate
if: ${{ inputs.ship-gate }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
INPUT_REF: ${{ inputs.ref != '' && inputs.ref || github.sha }}
ENVIRONMENT: ${{ inputs.environment }}
HEAD_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
status="$(gh api "repos/${REPO}/compare/main...${INPUT_REF}" --jq .status)"
if [ "${status}" = "behind" ] || [ "${status}" = "identical" ]; then
echo "ship-gate: ${INPUT_REF} is ${status} relative to main"
exit 0
fi
echo "ship-gate: ${INPUT_REF} is not on main (compare status: ${status}); checking hotfix/release path"
TAG="${INPUT_REF}"
if [[ ! "${TAG}" =~ ^v ]]; then
TAG="$(git describe --tags --exact-match "${HEAD_SHA}" 2>/dev/null || true)"
fi
if [ "${ENVIRONMENT}" = "staging" ]; then
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-staging$'
else
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
fi
if [[ -z "${TAG}" ]] || [[ ! "${TAG}" =~ ${PATTERN} ]]; then
echo "ship-gate: ref ${INPUT_REF} (tag ${TAG:-none}) does not match ${PATTERN} for environment ${ENVIRONMENT} and is not on main" >&2
exit 1
fi
export PATTERN TAG
PREV="$(
gh api "repos/${REPO}/releases" --paginate --jq '.[].tag_name' | python3 -c '
import os, re, sys
pattern = re.compile(os.environ["PATTERN"])
current = os.environ["TAG"]
tags = [
line.strip()
for line in sys.stdin
if pattern.fullmatch(line.strip()) and line.strip() != current
]
def key(tag):
body = tag[1:]
core = body.split("-", 1)[0]
return tuple(int(part) for part in core.split("."))
tags.sort(key=key)
print(tags[-1] if tags else "")
'
)"
if [ -z "${PREV}" ]; then
echo "ship-gate: no previous matching release tag; hotfix path requires a prior ${PATTERN} release" >&2
exit 1
fi
ff_status="$(gh api "repos/${REPO}/compare/${PREV}...${TAG}" --jq .status)"
if [ "${ff_status}" != "ahead" ]; then
echo "ship-gate: ${TAG} is not a fast-forward of ${PREV} (status: ${ff_status})" >&2
exit 1
fi
from_train=false
TARGET_COMMITISH="$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .target_commitish 2>/dev/null || true)"
if [[ "${TARGET_COMMITISH}" == hotfix/* || "${TARGET_COMMITISH}" == release/* ]]; then
from_train=true
fi
if [ "${from_train}" = false ]; then
git fetch origin '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*' '+refs/heads/release/*:refs/remotes/origin/release/*' || true
if git branch -r --contains "${HEAD_SHA}" | grep -Eq 'origin/(hotfix|release)/'; then
from_train=true
fi
fi
if [ "${from_train}" = false ]; then
echo "ship-gate: ${TAG} was not created from hotfix/* or release/* (target_commitish=${TARGET_COMMITISH:-none})" >&2
exit 1
fi
echo "ship-gate: hotfix/release path accepted (${PREV} -> ${TAG} from ${TARGET_COMMITISH:-branch})"
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
env:
SSM_PREFIX: ${{ inputs.ssm-prefix }}
run: |
set -euo pipefail
get_param() {
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
}
prefix="${SSM_PREFIX%/}"
CLUSTER=$(get_param "${prefix}/cluster")
SERVICE=$(get_param "${prefix}/service")
FAMILY=$(get_param "${prefix}/task-family")
ECR=$(get_param "${prefix}/ecr-repository")
CONTAINER=$(get_param "${prefix}/container-name")
API_URL=$(get_param "${prefix}/api-url")
{
echo "cluster=${CLUSTER}"
echo "service=${SERVICE}"
echo "family=${FAMILY}"
echo "ecr=${ECR}"
echo "container=${CONTAINER}"
echo "api_url=${API_URL}"
} >> "${GITHUB_OUTPUT}"
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- name: Build and push image
env:
ECR: ${{ steps.deploy.outputs.ecr }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
ENVIRONMENT: ${{ inputs.environment }}
DOCKER_PLATFORM: ${{ inputs.docker-platform }}
run: |
set -euo pipefail
docker buildx build \
--platform "${DOCKER_PLATFORM}" \
--build-arg "GIT_SHA=${GIT_SHA}" \
-t "${ECR}:${GIT_SHA}" \
-t "${ECR}:${ENVIRONMENT}" \
--push \
.
- name: Register task definition and update service
env:
CLUSTER: ${{ steps.deploy.outputs.cluster }}
SERVICE: ${{ steps.deploy.outputs.service }}
FAMILY: ${{ steps.deploy.outputs.family }}
CONTAINER: ${{ steps.deploy.outputs.container }}
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
EXTRA_TASK_ENV: ${{ inputs.extra-task-env }}
run: |
set -euo pipefail
aws ecs describe-task-definition \
--task-definition "${FAMILY}" \
--query taskDefinition \
--output json \
| python3 -c '
import json, os, sys
td = json.load(sys.stdin)
for key in (
"taskDefinitionArn",
"revision",
"status",
"requiresAttributes",
"compatibilities",
"registeredAt",
"registeredBy",
"deregisteredAt",
):
td.pop(key, None)
image = os.environ["IMAGE"]
sha = os.environ["GIT_SHA"]
name = os.environ["CONTAINER"]
extra_raw = os.environ.get("EXTRA_TASK_ENV") or "{}"
extra_env = json.loads(extra_raw)
if not isinstance(extra_env, dict):
sys.exit("extra-task-env must be a JSON object")
found = False
for container in td["containerDefinitions"]:
if container["name"] != name:
continue
found = True
container["image"] = image
env = {item["name"]: item["value"] for item in container.get("environment", [])}
env["GIT_SHA"] = sha
for key, value in extra_env.items():
env[str(key)] = str(value)
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
container.pop("command", None)
if not found:
sys.exit(f"container {name} not in task definition")
json.dump(td, sys.stdout)
' > /tmp/task-def.json
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
aws ecs update-service \
--cluster "${CLUSTER}" \
--service "${SERVICE}" \
--task-definition "${FAMILY}:${REV}" \
--force-new-deployment \
>/dev/null
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
- name: Verify health SHA
env:
API_URL: ${{ steps.deploy.outputs.api_url }}
HEALTH_PATH: ${{ inputs.health-path }}
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
path="${HEALTH_PATH}"
case "${path}" in
/*) ;;
*) path="/${path}" ;;
esac
url="${API_URL%/}${path}"
for _ in 1 2 3 4 5 6; do
BODY="$(curl -fsS "${url}" || true)"
echo "${BODY}"
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
exit 0
fi
sleep 10
done
echo "health SHA did not match ${EXPECTED_SHA}" >&2
exit 1

303
.github/workflows/cd-hcp-spa.yaml vendored Normal file
View file

@ -0,0 +1,303 @@
name: CD — HCP SPA
# Reusable CloudFront/S3 SPA CD for HCP app repos. The caller owns triggers
# and passes `environment` as a `with:` input. This job owns `environment:`,
# concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. GitHub rejects `environment:`
# beside `uses:`.
#
# Build env comes from the GitHub Environment: every `vars.VITE_*` value plus
# `secrets.SENTRY_AUTH_TOKEN`. Pass `required-vite-vars` for keys that must
# be set before `npm run build`.
#
# Caller example (one job per GitHub Environment):
# jobs:
# deploy-prod:
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@<sha> # vX.Y.Z
# permissions: { contents: read, id-token: write }
# secrets: inherit
# with:
# environment: prod
# ref: ${{ github.event.release.tag_name || inputs.ref }}
# ssm-prefix: /internal-portal/deploy
# ship-gate: true
#
# Nothing here creates an HCP run. Terraform owns the bucket and distribution.
on:
workflow_call:
inputs:
environment:
description: "GitHub Environment to deploy to (dev, staging, prod)"
type: string
required: true
ref:
description: "Git ref to build. Empty means github.sha."
type: string
required: false
default: ""
ssm-prefix:
description: "SSM prefix for deploy parameters (e.g. /internal-portal/deploy)"
type: string
required: true
ship-gate:
description: "Require the ref to be on main or a legal hotfix/release tag"
type: boolean
required: false
default: false
required-vite-vars:
description: "Comma-separated VITE_* GitHub Environment variable names that must be set"
type: string
required: false
default: ""
permissions:
contents: read
id-token: write
jobs:
deploy:
name: Deploy SPA to ${{ inputs.environment }}
runs-on: ubuntu-latest
timeout-minutes: 45
environment: ${{ inputs.environment }}
concurrency:
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
cancel-in-progress: false
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref != '' && inputs.ref || github.sha }}
persist-credentials: false
fetch-tags: true
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Ship-gate
if: ${{ inputs.ship-gate }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
INPUT_REF: ${{ inputs.ref != '' && inputs.ref || github.sha }}
ENVIRONMENT: ${{ inputs.environment }}
HEAD_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
status="$(gh api "repos/${REPO}/compare/main...${INPUT_REF}" --jq .status)"
if [ "${status}" = "behind" ] || [ "${status}" = "identical" ]; then
echo "ship-gate: ${INPUT_REF} is ${status} relative to main"
exit 0
fi
echo "ship-gate: ${INPUT_REF} is not on main (compare status: ${status}); checking hotfix/release path"
TAG="${INPUT_REF}"
if [[ ! "${TAG}" =~ ^v ]]; then
TAG="$(git describe --tags --exact-match "${HEAD_SHA}" 2>/dev/null || true)"
fi
if [ "${ENVIRONMENT}" = "staging" ]; then
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-staging$'
else
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
fi
if [[ -z "${TAG}" ]] || [[ ! "${TAG}" =~ ${PATTERN} ]]; then
echo "ship-gate: ref ${INPUT_REF} (tag ${TAG:-none}) does not match ${PATTERN} for environment ${ENVIRONMENT} and is not on main" >&2
exit 1
fi
export PATTERN TAG
PREV="$(
gh api "repos/${REPO}/releases" --paginate --jq '.[].tag_name' | python3 -c '
import os, re, sys
pattern = re.compile(os.environ["PATTERN"])
current = os.environ["TAG"]
tags = [
line.strip()
for line in sys.stdin
if pattern.fullmatch(line.strip()) and line.strip() != current
]
def key(tag):
body = tag[1:]
core = body.split("-", 1)[0]
return tuple(int(part) for part in core.split("."))
tags.sort(key=key)
print(tags[-1] if tags else "")
'
)"
if [ -z "${PREV}" ]; then
echo "ship-gate: no previous matching release tag; hotfix path requires a prior ${PATTERN} release" >&2
exit 1
fi
ff_status="$(gh api "repos/${REPO}/compare/${PREV}...${TAG}" --jq .status)"
if [ "${ff_status}" != "ahead" ]; then
echo "ship-gate: ${TAG} is not a fast-forward of ${PREV} (status: ${ff_status})" >&2
exit 1
fi
from_train=false
TARGET_COMMITISH="$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .target_commitish 2>/dev/null || true)"
if [[ "${TARGET_COMMITISH}" == hotfix/* || "${TARGET_COMMITISH}" == release/* ]]; then
from_train=true
fi
if [ "${from_train}" = false ]; then
git fetch origin '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*' '+refs/heads/release/*:refs/remotes/origin/release/*' || true
if git branch -r --contains "${HEAD_SHA}" | grep -Eq 'origin/(hotfix|release)/'; then
from_train=true
fi
fi
if [ "${from_train}" = false ]; then
echo "ship-gate: ${TAG} was not created from hotfix/* or release/* (target_commitish=${TARGET_COMMITISH:-none})" >&2
exit 1
fi
echo "ship-gate: hotfix/release path accepted (${PREV} -> ${TAG} from ${TARGET_COMMITISH:-branch})"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Build SPA
env:
VARS_JSON: ${{ toJSON(vars) }}
REQUIRED_VITE_VARS: ${{ inputs.required-vite-vars }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
TARGET_ENVIRONMENT: ${{ inputs.environment }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
python3 -c '
import json, os, shlex, sys
required = [s.strip() for s in os.environ.get("REQUIRED_VITE_VARS", "").split(",") if s.strip()]
vars_obj = json.loads(os.environ["VARS_JSON"])
missing = [key for key in required if not vars_obj.get(key)]
if missing:
print("Missing required GitHub Environment vars: " + ", ".join(missing), file=sys.stderr)
sys.exit(1)
with open("/tmp/vite.env", "w", encoding="utf-8") as fh:
for key, value in vars_obj.items():
if key.startswith("VITE_") and value:
fh.write(f"export {key}={shlex.quote(str(value))}\n")
'
# shellcheck source=/dev/null
source /tmp/vite.env
export VITE_SENTRY_ENVIRONMENT="${TARGET_ENVIRONMENT}"
export VITE_SENTRY_RELEASE="${GIT_SHA}"
npm ci
npm run build
test -f dist/index.html
find dist -name '*.map' -delete
if find dist -name '*.map' | grep -q .; then
echo "SPA source maps must not ship in dist/" >&2
exit 1
fi
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
echo "dist/index.html sha256=${index_sha}"
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
env:
SSM_PREFIX: ${{ inputs.ssm-prefix }}
run: |
set -euo pipefail
prefix="${SSM_PREFIX%/}"
BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text)
DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text)
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \
--query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')"
if [ -n "${origin_paths}" ]; then
echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2
echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2
exit 1
fi
{
echo "bucket=${BUCKET}"
echo "distribution_id=${DIST_ID}"
echo "site_url=https://${DOMAIN}"
} >> "${GITHUB_OUTPUT}"
- name: Sync dist/ to the bucket root
env:
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
run: |
set -euo pipefail
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--delete \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html
- name: Invalidate CloudFront
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
run: |
set -euo pipefail
invalidation_id="$(aws cloudfront create-invalidation \
--distribution-id "${DISTRIBUTION_ID}" \
--paths "/*" \
--query Invalidation.Id --output text)"
echo "Invalidation ${invalidation_id} created; waiting"
aws cloudfront wait invalidation-completed \
--distribution-id "${DISTRIBUTION_ID}" \
--id "${invalidation_id}"
- name: Verify served release
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
SITE_URL: ${{ steps.deploy.outputs.site_url }}
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
run: |
set -euo pipefail
SITE_URL="${SITE_URL%/}"
sha256_of() {
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
}
last_status="Unknown"
last_hash="Unknown"
for attempt in $(seq 1 40); do
last_status="$(aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --query Distribution.Status --output text)"
if last_hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [ -n "${last_hash}" ]; then
:
else
last_hash="unreachable"
fi
echo "poll ${attempt}/40: status=${last_status} served_sha256=${last_hash}"
if [ "${last_status}" = "Deployed" ] && [ "${last_hash}" = "${EXPECTED_INDEX_SHA256}" ]; then
exit 0
fi
sleep 15
done
echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2
exit 1

181
.github/workflows/ci-autofix.yaml vendored Normal file
View file

@ -0,0 +1,181 @@
name: CI — Autofix
# Convenience formatter on pull_request. Keeps format:check / lint in the
# parallel portions as the fail-closed gate. GITHUB_TOKEN commits do not
# retrigger workflows, so this mints a GitHub App token.
#
# Skip forks, merge_group, push, and when the actor is the App (no loop).
# If the tree is dirty, commit `style: apply formatter` and push to the PR
# head, then set output committed=true so the caller skips portions on SHA_old.
# Do not --no-verify. Do not push to main.
#
# Caller example:
# jobs:
# autofix:
# if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@<sha> # vX.Y.Z
# permissions: { contents: write }
# secrets: inherit
# with:
# format-command: npm run format
# lint-fix-command: npm run lint -- --fix
#
# Org secrets (names only): AUTOFMT_APP_ID, AUTOFMT_APP_PRIVATE_KEY.
on:
workflow_call:
inputs:
format-command:
description: "Write formatter command (e.g. npm run format, ruff format .)"
type: string
required: true
lint-fix-command:
description: "Optional write lint-fix command (e.g. ruff check --fix .)"
type: string
required: false
default: ""
extra-command:
description: "Optional extra write command (e.g. terraform fmt -write)"
type: string
required: false
default: ""
node-version:
description: "Node.js version when package-lock.json is present"
type: string
required: false
default: "24"
terraform-version:
description: "Terraform version when extra-command mentions terraform"
type: string
required: false
default: "1.16.0"
outputs:
committed:
description: "true when this job pushed a formatter commit"
value: ${{ jobs.autofix.outputs.committed }}
secrets:
AUTOFMT_APP_ID:
description: "GitHub App id for the formatter"
required: true
AUTOFMT_APP_PRIVATE_KEY:
description: "GitHub App private key for the formatter"
required: true
permissions:
contents: write
jobs:
autofix:
name: autofix
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-autofix-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
outputs:
committed: ${{ steps.result.outputs.committed }}
steps:
- name: Mint GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.AUTOFMT_APP_ID }}
private-key: ${{ secrets.AUTOFMT_APP_PRIVATE_KEY }}
- name: Skip App-authored synchronize
id: skip-bot
env:
ACTOR: ${{ github.actor }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
set -euo pipefail
expected="${APP_SLUG}[bot]"
if [ "${ACTOR}" = "${expected}" ]; then
echo "skip=true" >> "${GITHUB_OUTPUT}"
echo "Actor is ${expected}; not reformatting an App push."
else
echo "skip=false" >> "${GITHUB_OUTPUT}"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: ${{ steps.skip-bot.outputs.skip != 'true' }}
with:
token: ${{ steps.app-token.outputs.token }}
ref: ${{ github.head_ref }}
persist-credentials: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }}
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install npm dependencies
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }}
run: npm ci
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }}
with:
python-version: "3.12"
- name: Install ruff
if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }}
run: pip install 'ruff==0.15.22'
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
if: ${{ steps.skip-bot.outputs.skip != 'true' && contains(inputs.extra-command, 'terraform') }}
with:
terraform_version: ${{ inputs.terraform-version }}
terraform_wrapper: false
- name: Apply formatter
if: ${{ steps.skip-bot.outputs.skip != 'true' }}
env:
FORMAT_COMMAND: ${{ inputs.format-command }}
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
EXTRA_COMMAND: ${{ inputs.extra-command }}
run: |
set -euo pipefail
bash -euo pipefail -c "${FORMAT_COMMAND}"
if [ -n "${LINT_FIX_COMMAND}" ]; then
bash -euo pipefail -c "${LINT_FIX_COMMAND}"
fi
if [ -n "${EXTRA_COMMAND}" ]; then
bash -euo pipefail -c "${EXTRA_COMMAND}"
fi
- name: Commit and push if dirty
id: result
env:
SKIP_BOT: ${{ steps.skip-bot.outputs.skip }}
HEAD_REF: ${{ github.head_ref }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
APP_ID: ${{ secrets.AUTOFMT_APP_ID }}
run: |
set -euo pipefail
if [ "${SKIP_BOT}" = "true" ]; then
echo "committed=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
if [ -z "${HEAD_REF}" ] || [ "${HEAD_REF}" = "main" ]; then
echo "Refusing to push formatter commits to ${HEAD_REF:-empty}" >&2
exit 1
fi
git config user.name "${APP_SLUG}[bot]"
git config user.email "${APP_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
if [ -z "$(git status --porcelain)" ]; then
echo "Tree is clean; no formatter commit."
echo "committed=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
git add -A
git commit -m "style: apply formatter"
git push origin "HEAD:refs/heads/${HEAD_REF}"
echo "committed=true" >> "${GITHUB_OUTPUT}"

262
.github/workflows/ci-frontend.yaml vendored Normal file
View file

@ -0,0 +1,262 @@
name: CI — Frontend
# Parallel CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
# with vitest + Playwright). Jobs: guard, static, build, unit (optional shards),
# browser-smoke. The caller owns the `ci-complete` aggregator and ruleset check.
# Do not put these portion names in an org ruleset.
#
# Remaining-lane repos that still need the sequential `ci / ci` context should
# keep calling ci-typescript-frontend.yaml until they migrate.
#
# Caller example:
# jobs:
# frontend:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@<sha> # vX.Y.Z
# with:
# node-version: "24"
# unit-shards: 4
# run-e2e: true
on:
workflow_call:
inputs:
node-version:
description: "Node.js version to use"
type: string
default: "24"
unit-shards:
description: "Vitest shard count (1-8). PR UI shows unit (1) .. unit (N)."
type: number
default: 1
run-e2e:
description: "Run the test:e2e script (Playwright browser smoke)"
type: boolean
default: true
required-scripts:
description: "Comma-separated npm scripts that must exist in package.json"
type: string
default: "format:check,lint,build,test,test:e2e"
working-directory:
description: "Directory to run npm/build/test commands from"
type: string
default: "."
permissions:
contents: read
jobs:
guard:
name: guard
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-guard
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Verify unit-shards
env:
UNIT_SHARDS: ${{ inputs.unit-shards }}
run: |
set -euo pipefail
if [ "${UNIT_SHARDS}" -lt 1 ] || [ "${UNIT_SHARDS}" -gt 8 ]; then
echo "unit-shards must be between 1 and 8 (got ${UNIT_SHARDS})" >&2
exit 1
fi
- name: Verify required npm scripts
env:
REQUIRED_SCRIPTS: ${{ inputs.required-scripts }}
RUN_E2E: ${{ inputs.run-e2e }}
run: |
node <<'NODE'
const { readFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const required = (process.env.REQUIRED_SCRIPTS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.filter((script) => process.env.RUN_E2E !== "false" || script !== "test:e2e");
const missing = required.filter((script) => !pkg.scripts?.[script]);
if (missing.length > 0) {
console.error(`Missing required scripts: ${missing.join(", ")}`);
process.exit(1);
}
console.log(`All required scripts present: ${required.join(", ")}`);
NODE
- name: Guard changed lines
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: |
set -euo pipefail
if [ "${EVENT_NAME}" = "pull_request" ]; then
BASE_REF="${PR_BASE_SHA}"
elif [ "${EVENT_NAME}" = "merge_group" ]; then
BASE_REF="${MERGE_GROUP_BASE_SHA}"
else
BASE_REF="${PUSH_BEFORE}"
fi
if [ -z "${BASE_REF}" ] || [ "${BASE_REF}" = "0000000000000000000000000000000000000000" ]; then
BASE_REF="$(git rev-parse HEAD~1 2>/dev/null || true)"
fi
if [ -z "${BASE_REF}" ]; then
echo "No base ref available; skipping changed-line guard."
exit 0
fi
ADDED_LINES="$(git diff --unified=0 "${BASE_REF}" "${GITHUB_SHA}" | grep -E '^\+' | grep -vE '^\+\+\+' || true)"
if printf '%s\n' "${ADDED_LINES}" | grep -E 'Generated with (Claude [C]ode|[C]odex|Chat[G]PT)|--no-[v]erify|HUSKY[=]0'; then
echo "Found generated-tool footer or hook bypass wording in added lines."
exit 1
fi
if printf '%s\n' "${ADDED_LINES}" | grep -E 'A[K]IA[0-9A-Z]{16}|A[S]IA[0-9A-Z]{16}|AWS[_]SECRET[_]ACCESS[_]KEY|aws[_-]?secret[_-]?access[_-]?key|J[W]TAuthenticationHIGHsecuredPassword|P[a]ssword=|S[e]ndGrid.*A[p]iKey'; then
echo "Found a likely secret in added lines. Move sensitive values to the environment or secret manager."
exit 1
fi
echo "Changed-line guard passed."
- name: Conventions check
working-directory: ${{ github.workspace }}
run: |
errors=0
fail() { echo "::error::$1"; errors=$((errors + 1)); }
[[ -f README.md ]] || fail "Missing README.md"
if [[ -f .gitignore ]]; then
grep -qE '^\.env$|^\.env\b' .gitignore || fail ".gitignore does not include .env"
else
fail "Missing .gitignore"
fi
if [[ $errors -gt 0 ]]; then
echo "Conventions check failed with $errors error(s)."
exit 1
fi
echo "Conventions check passed."
static:
name: static
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-static
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- run: npm run format:check
- run: npm run lint
build:
name: build
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-build
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- run: npm run build
unit:
name: unit (${{ matrix.shard }})
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-unit-${{ matrix.shard }}
cancel-in-progress: true
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(format('[{0}]', inputs.unit-shards == 1 && '1' || inputs.unit-shards == 2 && '1,2' || inputs.unit-shards == 3 && '1,2,3' || inputs.unit-shards == 4 && '1,2,3,4' || inputs.unit-shards == 5 && '1,2,3,4,5' || inputs.unit-shards == 6 && '1,2,3,4,5,6' || inputs.unit-shards == 7 && '1,2,3,4,5,6,7' || '1,2,3,4,5,6,7,8')) }}
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- name: Unit tests
env:
SHARD: ${{ matrix.shard }}
SHARDS: ${{ inputs.unit-shards }}
run: npm test -- --shard="${SHARD}/${SHARDS}"
browser-smoke:
name: browser-smoke
if: ${{ inputs.run-e2e }}
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-browser-smoke
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- name: Browser smoke
env:
CI: "true"
run: |
npx playwright install --with-deps chromium
npm run test:e2e

57
.github/workflows/ci-terraform.yaml vendored Normal file
View file

@ -0,0 +1,57 @@
name: CI — Terraform
# Reusable Terraform fmt/init/validate for HCP app repos. Init uses
# `-backend=false` so CI does not need remote state credentials. The caller
# owns the `ci-complete` aggregator.
#
# Caller example:
# jobs:
# terraform:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@<sha> # vX.Y.Z
# with:
# terraform-version: "1.16.0"
on:
workflow_call:
inputs:
terraform-version:
description: "Terraform version to install"
type: string
default: "1.16.0"
working-directory:
description: "Directory containing Terraform sources"
type: string
default: "terraform"
permissions:
contents: read
jobs:
terraform:
name: terraform
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-terraform-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: ${{ inputs.terraform-version }}
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate

View file

@ -1,9 +1,11 @@
name: CI — TypeScript Frontend
# Reusable CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
# with vitest + Playwright). Emits the single `ci / ci` status context required
# by the org branch-protection rulesets — keep the caller job id `ci` so the
# context resolves to `ci / ci`.
# Sequential reusable CI for remaining-lane TypeScript front-end apps that
# still emit `ci / ci`. HCP app repos should call ci-frontend.yaml (parallel
# portions) plus a caller-owned `ci-complete` aggregator instead.
#
# Emits the single `ci / ci` status context required by the unconverted-repo
# ruleset — keep the caller job id `ci` so the context resolves to `ci / ci`.
#
# Runs, in order: a Sea Haven standards gate (required npm scripts present, no
# AI-tool footers / hook bypasses / hardcoded secrets in the added lines),

View file

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

View file

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

View file

@ -1,44 +0,0 @@
merge_queue:
mode: serial
max_parallel_checks: 5
queue_controls_comment: false
merge_protections_settings:
auto_merge_conditions:
- base = main
- -draft
- github-review-decision = APPROVED
- check-success = "ci / ci"
merge_protections:
- name: require-review-and-ci
if:
- base = main
- -draft
success_conditions:
- github-review-decision = APPROVED
- check-success = "ci / ci"
queue_rules:
- name: default
batch_size: 3
batch_max_wait_time: 30 seconds
checks_timeout: 10 min
queue_conditions:
- base = main
- -draft
- github-review-decision = APPROVED
- check-success = "ci / ci"
merge_method: squash
commit_message_format:
title: inherit
body: empty
branch_protection_injection_mode: queue
pull_request_rules:
- name: queue on queue ready label
conditions:
- label = "queue ready"
actions:
queue:
name: default

171
README.md
View file

@ -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
@ -26,7 +26,16 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and
### Merge queue
Consumer repos extend `.mergify.yml` via `extends: .github`. Mergify auto-queues when it is awake. The default queue tests up to three PRs together on a draft branch so it does not push onto the original PR. Each PR still squash-merges on its own. Merge protections skip those drafts. Pending queue checks time out after 10 minutes. To kick a stuck PR, apply the `queue ready` label. That does not bypass `ci / ci` or `APPROVED`. Do not use `queued`; Mergify applies that while a PR is in the queue.
CI callers keep a `merge_group` trigger so native GitHub merge queues still run portions. Mergify YAML is not used. Do not put portion job names (`frontend / static`, `unit (1)`, …) in a ruleset.
### Required checks
Two org rulesets. A repo is on exactly one of them:
- **main branch protection** requires `ci / ci` for unconverted remaining-lane repos.
- **CI complete** requires `ci-complete` for converted HCP callers. It targets no repos until a cutover includes the repo and excludes it from the old ruleset in the same window.
The formatter GitHub App is not on the main-branch bypass list.
## What's in here
@ -36,7 +45,17 @@ Consumer repos extend `.mergify.yml` via `extends: .github`. Mergify auto-queues
**`.github/workflows/ci-typescript-cdk.yaml`** — Reusable CI workflow for TypeScript / CDK repos. Runs `npm ci` + optional `tsc --noEmit`, optional ESLint, optional Jest, and optional `cdk synth`. Also supports Node.js SAM repos via an optional `sam validate` step.
**`.github/workflows/ci-typescript-frontend.yaml`** — Reusable CI workflow for bundled TypeScript front-end apps (Vite / React / Vue SPAs). Runs a Sea Haven standards gate (required npm scripts present, no AI-tool footers / hook bypasses / hardcoded secrets in added lines), then `format:check`, `lint`, `build`, vitest unit tests, and an optional Playwright browser smoke. Emits the single `ci / ci` status context — keep the caller job id `ci`.
**`.github/workflows/ci-typescript-frontend.yaml`** — Sequential reusable CI for remaining-lane bundled TypeScript front-end apps that still emit `ci / ci`. HCP app repos should call `ci-frontend.yaml` plus a caller-owned `ci-complete` aggregator instead.
**`.github/workflows/ci-frontend.yaml`** — Parallel HCP frontend CI: `guard`, `static`, `build`, `unit` (optional shards), `browser-smoke`. The caller owns `ci-complete`. Do not put those portion names in a ruleset.
**`.github/workflows/ci-terraform.yaml`** — Terraform `fmt -check`, `init -backend=false`, and `validate`. Default version `1.16.0`.
**`.github/workflows/ci-autofix.yaml`** — Pull-request-only formatter. Mints a GitHub App token (`AUTOFMT_APP_ID`, `AUTOFMT_APP_PRIVATE_KEY`), runs the caller's write commands, and pushes `style: apply formatter` only when the tree is dirty. Output `committed` lets the caller skip portions on SHA_old. Does not `--no-verify` and does not push to `main`.
**`.github/workflows/cd-hcp-fargate.yaml`** — HCP Fargate image CD. Checkout at `ref` (empty means `github.sha`), OIDC, SSM cluster/service/family/ecr/container/api-url, docker build+push tagged `$sha` and `$environment`, patch `GIT_SHA`, RegisterTaskDefinition + UpdateService + services-stable, poll health SHA. `environment` is a `with:` input. The reusable job owns `environment:`, concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`.
**`.github/workflows/cd-hcp-spa.yaml`** — HCP SPA CD. Checkout at `ref`, Node 24, `npm ci` + `npm run build`, origin-path guard, hashed s3 sync then `index.html` last then prune, invalidate, verify. Build env comes from the GitHub Environment (`vars.VITE_*`, `secrets.SENTRY_AUTH_TOKEN`).
**`.github/workflows/cd-sam.yaml`** — Reusable CD workflow for SAM repos. Runs `sam build` + `sam deploy` with OIDC credentials and a CloudFormation execution role. Triggers via `workflow_call` from per-repo `deploy.yaml` on push to `main`.
@ -54,12 +73,6 @@ Consumer repos extend `.mergify.yml` via `extends: .github`. Mergify auto-queues
**`.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.
@ -74,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-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-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). 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
@ -166,16 +177,10 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each
| Secret | Value | Consumed by |
|--------|-------|-------------|
| `ANTHROPIC_API_KEY` | Anthropic API key | `reviewer-eval.yml` in `open-swe` |
| `AUTOFMT_APP_ID` | Formatter GitHub App id | `ci-autofix.yaml` |
| `AUTOFMT_APP_PRIVATE_KEY` | Formatter GitHub App private key | `ci-autofix.yaml` |
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` |
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
@ -242,49 +247,119 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
```
### 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).
**HCP app repo** (converted callers; required check is `ci-complete`):
```yaml
name: PR Policy
name: CI
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
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
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 }}
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@<full-commit-sha> # vX.Y.Z
permissions: { contents: write }
secrets: inherit
with:
format-command: npm run format
lint-fix-command: npm run lint -- --fix
frontend:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@<full-commit-sha> # vX.Y.Z
with:
node-version: "24"
unit-shards: 4
run-e2e: true
terraform:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@<full-commit-sha> # vX.Y.Z
with:
terraform-version: "1.16.0"
ci-complete:
name: ci-complete
needs: [autofix, frontend, terraform]
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require portions
env:
FRONTEND: ${{ needs.frontend.result }}
TERRAFORM: ${{ needs.terraform.result }}
run: |
set -euo pipefail
test "${FRONTEND}" = success
test "${TERRAFORM}" = success
```
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:
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.
```bash
gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha
### 3. Add CD to a repo
**HCP Fargate** (one caller job per GitHub Environment; `environment` is a `with:` input):
```yaml
name: Deploy API
on:
push:
branches: [main]
paths-ignore: [terraform/**, docs/**, "*.md"]
release:
types: [published]
workflow_dispatch:
inputs:
environment: { type: choice, options: [dev, prod] }
ref: { type: string, default: "" }
permissions:
contents: read
jobs:
deploy-dev:
name: Deploy API to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@<full-commit-sha> # vX.Y.Z
permissions: { contents: read, id-token: write }
secrets: inherit
with:
environment: dev
ref: ${{ inputs.ref }}
ssm-prefix: /meal-order-manager/deploy
docker-platform: linux/amd64
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'
deploy-prod:
name: Deploy API to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@<full-commit-sha> # vX.Y.Z
permissions: { contents: read, id-token: write }
secrets: inherit
with:
environment: prod
ref: ${{ github.event.release.tag_name || inputs.ref }}
ssm-prefix: /meal-order-manager/deploy
docker-platform: linux/amd64
ship-gate: true
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'
```
The check-run name is `policy / pr`. If your branch-protection ruleset requires this context, add it after the first PR passes.
SPA callers use `cd-hcp-spa.yaml` the same way. Pass `required-vite-vars` for Environment `VITE_*` keys that must be set before `npm run build`. Add `deploy-staging` only where that Environment exists. `DEPLOY_ROLE_ARN` is a GitHub Environment variable, not a repo secret. SHA-pinned org reusables change `job_workflow_ref` to `Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@<sha>` (and spa). Keep `workflow_ref` on the thin caller at `refs/heads/main` and `refs/tags/v*`. `sub` stays `repo:.../<app>:environment:<env>`. Adding a reusable is a cross-family IAM change.
> **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.
Create `.github/workflows/deploy.yaml` in remaining SAM/CDK repos. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
### 4. Add CD to a repo
Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
**SAM repo** (e.g., afterhours-shift-manager):
**SAM repo** (e.g., remaining SAM stacks):
```yaml
name: Deploy

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
{
"name": "Sea Haven — CI (HCP)",
"description": "Parallel frontend + Terraform CI with autofix and a ci-complete aggregator for converted HCP app repos. Remaining-lane SPAs should keep the sequential TypeScript frontend template.",
"iconName": "octicon-checklist",
"categories": ["TypeScript", "JavaScript", "Continuous integration"],
"filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "terraform/.*\\.tf$"]
}

View file

@ -0,0 +1,53 @@
name: CI
on:
pull_request:
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
permissions:
contents: read
jobs:
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@REPLACE-ME # vX.Y.Z
permissions:
contents: write
secrets: inherit
with:
format-command: npm run format
lint-fix-command: "npm run lint -- --fix"
frontend:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@REPLACE-ME # vX.Y.Z
with:
node-version: "24"
unit-shards: 4
run-e2e: true
terraform:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@REPLACE-ME # vX.Y.Z
with:
terraform-version: "1.16.0"
ci-complete:
name: ci-complete
needs: [autofix, frontend, terraform]
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require portions
env:
FRONTEND: ${{ needs.frontend.result }}
TERRAFORM: ${{ needs.terraform.result }}
run: |
set -euo pipefail
test "${FRONTEND}" = success
test "${TERRAFORM}" = success

View file

@ -0,0 +1,7 @@
{
"name": "Sea Haven — CI (Terraform)",
"description": "Runs terraform fmt -check, init -backend=false, and validate via the org reusable ci-terraform workflow. Prefer the HCP CI template when the repo also has a frontend.",
"iconName": "octicon-checklist",
"categories": ["Continuous integration"],
"filePatterns": ["terraform/.*\\.tf$"]
}

View file

@ -0,0 +1,16 @@
name: Terraform CI
on:
pull_request:
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
permissions:
contents: read
jobs:
terraform:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@REPLACE-ME # vX.Y.Z
with:
terraform-version: "1.16.0"

View file

@ -1,6 +1,6 @@
{
"name": "Sea Haven — CI (TypeScript / frontend)",
"description": "Runs the Sea Haven standards gate, format:check, lint, build, unit tests, and a Playwright browser smoke for bundled Vite/React/Vue apps via the org reusable workflow.",
"description": "Sequential remaining-lane CI that emits ci / ci. Converted HCP SPAs should use the HCP CI template (ci-frontend plus ci-complete) instead.",
"iconName": "octicon-checklist",
"categories": ["TypeScript", "JavaScript", "Continuous integration"],
"filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "playwright\\.config\\.[jt]s$"]

View file

@ -0,0 +1,7 @@
{
"name": "Sea Haven — Deploy (HCP Fargate)",
"description": "Deploys a Fargate image via the org reusable cd-hcp-fargate workflow. One caller job per GitHub Environment. Push to main deploys dev; a published Release deploys prod behind ship-gate.",
"iconName": "octicon-rocket",
"categories": ["Deployment", "Docker", "Continuous integration"],
"filePatterns": ["Dockerfile$", "terraform/.*\\.tf$"]
}

View file

@ -0,0 +1,54 @@
name: Deploy API
on:
push:
branches: [main]
paths-ignore: [terraform/**, docs/**, "*.md"]
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: "Target Environment"
required: true
type: choice
options: [dev, prod]
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
contents: read
jobs:
deploy-dev:
name: Deploy API to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@REPLACE-ME # vX.Y.Z
permissions:
contents: read
id-token: write
secrets: inherit
with:
environment: dev
ref: ${{ inputs.ref }}
ssm-prefix: /REPLACE-ME-repo/deploy
docker-platform: linux/amd64
# extra-task-env: '{"SENTRY_DSN_PARAM":"/REPLACE-ME-repo/sentry-dsn"}'
deploy-prod:
name: Deploy API to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@REPLACE-ME # vX.Y.Z
permissions:
contents: read
id-token: write
secrets: inherit
with:
environment: prod
ref: ${{ github.event.release.tag_name || inputs.ref }}
ssm-prefix: /REPLACE-ME-repo/deploy
docker-platform: linux/amd64
ship-gate: true
# extra-task-env: '{"SENTRY_DSN_PARAM":"/REPLACE-ME-repo/sentry-dsn"}'

View file

@ -0,0 +1,7 @@
{
"name": "Sea Haven — Deploy (HCP SPA)",
"description": "Deploys a Vite SPA to S3/CloudFront via the org reusable cd-hcp-spa workflow. One caller job per GitHub Environment. Add a deploy-staging job only when that Environment exists.",
"iconName": "octicon-rocket",
"categories": ["Deployment", "TypeScript", "JavaScript"],
"filePatterns": ["vite\\.config\\.[jt]s$", "package\\.json$"]
}

View file

@ -0,0 +1,51 @@
name: Deploy Web
on:
push:
branches: [main]
paths-ignore: [terraform/**, docs/**, "*.md"]
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: "Target Environment"
required: true
type: choice
options: [dev, prod]
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
contents: read
jobs:
deploy-dev:
name: Deploy SPA to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@REPLACE-ME # vX.Y.Z
permissions:
contents: read
id-token: write
secrets: inherit
with:
environment: dev
ref: ${{ inputs.ref }}
ssm-prefix: /REPLACE-ME-repo/deploy
deploy-prod:
name: Deploy SPA to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@REPLACE-ME # vX.Y.Z
permissions:
contents: read
id-token: write
secrets: inherit
with:
environment: prod
ref: ${{ github.event.release.tag_name || inputs.ref }}
ssm-prefix: /REPLACE-ME-repo/deploy
ship-gate: true
# required-vite-vars: "VITE_SHIFTS_API_BASE,VITE_SENTRY_DSN"