mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 08:13:12 +00:00
Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5b843ca1 | ||
|
|
892580d7d7 | ||
|
|
c9134742ce | ||
|
|
8e6b8e8665 | ||
|
|
0a1010e632 | ||
|
|
6fc4ca31e1 | ||
|
|
bf14925fcf | ||
|
|
acaf2bfc0d | ||
|
|
2e2b3a282f | ||
|
|
61f15d78b5 | ||
|
|
fd41132410 | ||
|
|
216604ad67 | ||
|
|
22c47f924f | ||
|
|
08d8ca31a9 | ||
|
|
514552df92 | ||
|
|
9781774f04 | ||
|
|
9b7b464d5c |
49 changed files with 2502 additions and 3834 deletions
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -5,7 +5,6 @@ PR conventions
|
||||||
- Maximum 120 characters, including the Jira suffix.
|
- Maximum 120 characters, including the Jira suffix.
|
||||||
- Active Jira projects: DEV (product), PLAT (platform), SEC (security). INFRA is a closed archive.
|
- 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.
|
- 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: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ + kebab-case description.
|
||||||
Branch names do not contain Jira keys.
|
Branch names do not contain Jira keys.
|
||||||
- Scope: one logical change per PR. If the title needs "and", split it.
|
- Scope: one logical change per PR. If the title needs "and", split it.
|
||||||
|
|
|
||||||
924
.github/workflows/callable-pr-policy.yaml
vendored
924
.github/workflows/callable-pr-policy.yaml
vendored
|
|
@ -1,924 +0,0 @@
|
||||||
name: PR Policy
|
|
||||||
|
|
||||||
# Reusable PR metadata gate for all Sea-Haven-Industries repos.
|
|
||||||
#
|
|
||||||
# Validates pull-request metadata — title convention, branch naming, body
|
|
||||||
# structure, commit subjects, Jira existence, AI attribution footers, and
|
|
||||||
# workflow file pin compliance — without executing any PR code or checking
|
|
||||||
# out the repository. All checks run through the GitHub API only.
|
|
||||||
#
|
|
||||||
# Callers trigger this on `pull_request` (NOT pull_request_target) with event
|
|
||||||
# types: opened, reopened, synchronize, edited, labeled, unlabeled,
|
|
||||||
# ready_for_review. The check-run name is `<caller-job-id> / pr`; the
|
|
||||||
# canonical caller job id is `policy`, producing the context `policy / pr`.
|
|
||||||
#
|
|
||||||
# Secrets are optional at the declaration level. For human PRs that include a
|
|
||||||
# Jira key, all three must be configured or the check fails closed (POLICY-INFRA).
|
|
||||||
# Dependabot-authored PRs (pull_request.user.login == dependabot[bot]) exit
|
|
||||||
# successfully with no metadata or supply-chain checks.
|
|
||||||
#
|
|
||||||
# A missing Jira key on a human PR is a warning, not a failure. A present key
|
|
||||||
# is still verified against Jira and fails closed on lookup or credential errors.
|
|
||||||
#
|
|
||||||
# Emergency-revert exemption: when the title type is `revert`, the PR has no
|
|
||||||
# Jira key in the title, and the `emergency-revert` label is present on the PR,
|
|
||||||
# a candidate exemption is computed so the missing-key warning is suppressed.
|
|
||||||
# The exemption is confirmed by verifying that the label was applied by a
|
|
||||||
# collaborator with maintain or admin permission. Any pagination truncation of
|
|
||||||
# the event timeline is POLICY-INFRA — partial history is never trusted.
|
|
||||||
# Unauthorized/null-actor/bot results add a violation. Branch, body, and commit
|
|
||||||
# checks remain regardless.
|
|
||||||
#
|
|
||||||
# Known platform limitation: metadata edits (labels, title changes) made via
|
|
||||||
# GITHUB_TOKEN do not reliably emit a new pull_request event. Org automation
|
|
||||||
# that applies labels must use a GitHub App token or a PAT so the policy gate
|
|
||||||
# re-runs automatically after the label is applied.
|
|
||||||
#
|
|
||||||
# Caller example:
|
|
||||||
# jobs:
|
|
||||||
# policy:
|
|
||||||
# uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@<sha> # vX.Y.Z
|
|
||||||
# secrets:
|
|
||||||
# JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
|
|
||||||
# JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
|
|
||||||
# JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
secrets:
|
|
||||||
JIRA_CLOUD_ID:
|
|
||||||
required: false
|
|
||||||
JIRA_SERVICE_ACCOUNT_EMAIL:
|
|
||||||
required: false
|
|
||||||
JIRA_API_TOKEN:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: read
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pr:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Validate PR
|
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
||||||
env:
|
|
||||||
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
|
|
||||||
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
|
|
||||||
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
// ── Pure validation functions ────────────────────────────────────────────
|
|
||||||
// Extracted and exercised by test/pr-policy.test.mjs via PR_POLICY_TEST.
|
|
||||||
// These functions have no side effects and make no API calls.
|
|
||||||
|
|
||||||
const CONV_TYPES = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release'];
|
|
||||||
const REQUIRED_H2 = ['Summary','Validation','Tests','Notes'];
|
|
||||||
|
|
||||||
// AI-attribution footer patterns — case-insensitive, multiline.
|
|
||||||
// Matches Co-authored-by: trailers naming known AI tools and "Generated by/with"
|
|
||||||
// phrases. Does NOT flag generic prose like "uses AI" or "AI-powered".
|
|
||||||
// GPT variants: gpt-3, gpt-4, gpt-4o, gpt-5, gpt-o, etc. covered by gpt-[a-z0-9]+.
|
|
||||||
const AI_FOOTER_RE = /^(?:co-authored-by:\s+(?:claude|chatgpt|gpt-[a-z0-9]+|copilot|github\s+copilot|gemini|cursor(?:\s*ai)?|codeium|anthropic|openai|codex)\b|generated\s+(?:with|by)\s+(?:claude(?:\s+code)?|github\s+copilot|chatgpt|codex|gpt-[a-z0-9]+|gemini|codeium|cursor(?:\s*ai)?|anthropic|openai)|🤖\s+generated\b)/im;
|
|
||||||
|
|
||||||
function validateTitle(title, isDependabot, jiraMaybeExempt) {
|
|
||||||
const errs = [];
|
|
||||||
if (title.length > 120) errs.push('Title is ' + title.length + ' chars — max 120');
|
|
||||||
const m = title.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+?)(\s+\((DEV|PLAT|SEC|AP)-\d+\))?$/);
|
|
||||||
if (!m) {
|
|
||||||
errs.push('Title must match: type(scope): description (KEY-NNN). Allowed types: ' + CONV_TYPES.join(' '));
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
const desc = m[4];
|
|
||||||
if (desc.endsWith('.')) errs.push('Description must not end with a period');
|
|
||||||
if (!/^[a-z]/.test(desc)) errs.push('Description must start with a lowercase letter');
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJiraKey(title) {
|
|
||||||
const m = title.match(/\((DEV|PLAT|SEC|AP)-(\d+)\)$/);
|
|
||||||
return m ? m[1] + '-' + m[2] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateBranch(branch, isDependabot) {
|
|
||||||
if (isDependabot) return [];
|
|
||||||
const errs = [];
|
|
||||||
// Segment must be proper kebab-case: no consecutive hyphens, no trailing hyphen.
|
|
||||||
const m = branch.match(/^(feature|fix|hotfix|chore|docs|refactor|release)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/);
|
|
||||||
if (!m) {
|
|
||||||
errs.push('Branch "' + branch + '" must match prefix/kebab-case (no consecutive/trailing hyphens, no uppercase, one segment). Prefixes: feature fix hotfix chore docs refactor release');
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
if (/(?:DEV|PLAT|SEC|AP|INFRA)-\d+/i.test(m[2])) errs.push('Branch segment must not contain a Jira key');
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateBody(rawBody, isDependabot) {
|
|
||||||
if (isDependabot) return [];
|
|
||||||
if (!rawBody || !rawBody.trim()) return ['PR body is empty'];
|
|
||||||
const errs = [];
|
|
||||||
// Strip fenced code blocks line-by-line before scanning headings.
|
|
||||||
// Fence markers: backtick (0x60) or tilde. Up to 3 leading spaces allowed
|
|
||||||
// (CommonMark spec). Use charCode to avoid literal backtick in source
|
|
||||||
// (which confuses actionlint's expression scanner).
|
|
||||||
const TICK = String.fromCharCode(0x60);
|
|
||||||
const bodyLines = rawBody.split('\n');
|
|
||||||
const stripped = [];
|
|
||||||
let inFence = false;
|
|
||||||
let fenceChar = '';
|
|
||||||
let fenceLen = 0;
|
|
||||||
const fenceRe = new RegExp('^ {0,3}(' + TICK + '{3,}|~{3,})');
|
|
||||||
for (const line of bodyLines) {
|
|
||||||
if (!inFence) {
|
|
||||||
const fm = fenceRe.exec(line);
|
|
||||||
if (fm) {
|
|
||||||
inFence = true;
|
|
||||||
fenceChar = fm[1][0];
|
|
||||||
fenceLen = fm[1].length;
|
|
||||||
stripped.push('');
|
|
||||||
} else {
|
|
||||||
stripped.push(line);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const fm = fenceRe.exec(line);
|
|
||||||
if (fm && fm[1][0] === fenceChar && fm[1].length >= fenceLen && line.trim() === fm[1]) {
|
|
||||||
inFence = false;
|
|
||||||
stripped.push('');
|
|
||||||
} else {
|
|
||||||
stripped.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cleaned = stripped.join('\n').replace(/<!--[\s\S]*?-->/g, '');
|
|
||||||
const h2s = Array.from(cleaned.matchAll(/^## (.+)$/gm)).map(function(x) { return x[1].trim(); });
|
|
||||||
if (h2s.length !== 4) {
|
|
||||||
errs.push('Body must have exactly 4 ## headings (Summary/Validation/Tests/Notes), found ' + h2s.length + (h2s.length ? ': ' + h2s.join(', ') : ''));
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
if (h2s[i] !== REQUIRED_H2[i]) errs.push('Heading ' + (i + 1) + ': expected "## ' + REQUIRED_H2[i] + '", got "## ' + h2s[i] + '"');
|
|
||||||
}
|
|
||||||
const sectionParts = cleaned.split(/^(?=## )/m).filter(function(p) { return p.startsWith('## '); });
|
|
||||||
for (let i = 0; i < Math.min(sectionParts.length, 4); i++) {
|
|
||||||
const content = sectionParts[i].replace(/^## [^\n]*\n?/, '').trim();
|
|
||||||
if (!content) errs.push('## ' + REQUIRED_H2[i] + ' section is empty');
|
|
||||||
}
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCommitSubject(subject) {
|
|
||||||
const errs = [];
|
|
||||||
if (subject.length > 72) errs.push('Commit subject is ' + subject.length + ' chars — max 72');
|
|
||||||
const m = subject.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|release)(\([^)]+\))?(!)?: (.+)$/);
|
|
||||||
if (!m) {
|
|
||||||
errs.push('Not conventional: "' + subject.slice(0, 60) + (subject.length > 60 ? '\u2026' : '') + '"');
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
const desc = m[4];
|
|
||||||
if (!/^[a-z]/.test(desc)) errs.push('Commit description must start with a lowercase letter');
|
|
||||||
if (desc.endsWith('.')) errs.push('Commit description must not end with a period');
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSyncMergeCommit(commit) {
|
|
||||||
if (!Array.isArray(commit.parents) || commit.parents.length < 2) return false;
|
|
||||||
const subject = (commit.commit && commit.commit.message ? commit.commit.message : '').split('\n')[0];
|
|
||||||
return /^Merge (?:branch|remote-tracking branch) '[^']+' into \S.+$/.test(subject);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectAiFooter(text) {
|
|
||||||
return AI_FOOTER_RE.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWorkflowFilename(filename) {
|
|
||||||
return /^\.github\/workflows\/[^/]+\.ya?ml$/.test(filename) ||
|
|
||||||
/^workflow-templates\/[^/]+\.ya?ml$/.test(filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches action manifests at any depth (e.g. .github/actions/**/action.yml).
|
|
||||||
function isActionManifestFilename(filename) {
|
|
||||||
return /(?:^|\/)action\.ya?ml$/.test(filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combined predicate — any file that the supply-chain scanner must process.
|
|
||||||
function isPolicyFilename(filename) {
|
|
||||||
return isWorkflowFilename(filename) || isActionManifestFilename(filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns 'workflow', 'action', or null for non-policy files.
|
|
||||||
// Used by classifyFileStatus to prevent cross-kind rename diffing.
|
|
||||||
function policyFileKind(filename) {
|
|
||||||
if (isWorkflowFilename(filename)) return 'workflow';
|
|
||||||
if (isActionManifestFilename(filename)) return 'action';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FNV-1a 32-bit hash of a string — used to produce content fingerprints
|
|
||||||
// for line-specific violations so changing the payload changes the
|
|
||||||
// fingerprint even when the violation remains on the same line.
|
|
||||||
function fnv1a32(str) {
|
|
||||||
let h = 2166136261;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
h = Math.imul(h ^ str.charCodeAt(i), 16777619) >>> 0;
|
|
||||||
}
|
|
||||||
return h.toString(16).padStart(8, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip the trailing [fnv:XXXXXXXX] content-hash token from a violation
|
|
||||||
// string before emitting it to users. The hash is purely internal.
|
|
||||||
function stripHash(msg) {
|
|
||||||
return msg.replace(/ \[fnv:[0-9a-f]{8}\]$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic line scanner for workflow YAML content.
|
|
||||||
//
|
|
||||||
// Block-scalar tracking: any YAML key whose value begins with | or >
|
|
||||||
// (including explicit indent/chomp forms |2, |2-, |-2, >+2, etc.)
|
|
||||||
// starts a block scalar. Lines inside ANY block scalar are not parsed
|
|
||||||
// as structural YAML keys — they are content. For run: block scalars,
|
|
||||||
// the content is still scanned for expression injection (expressions
|
|
||||||
// must flow through env:). uses: block scalars are rejected because an
|
|
||||||
// action ref must be an inline scalar to validate its immutable pin.
|
|
||||||
// For other non-run block scalars (e.g. script:, name:), the content
|
|
||||||
// is skipped entirely — no uses: or run: detection.
|
|
||||||
//
|
|
||||||
// Quoted keys: "uses", 'uses', "run", 'run', "permissions" are all
|
|
||||||
// recognized in addition to their unquoted forms.
|
|
||||||
//
|
|
||||||
// Quoted action refs: `uses: "owner/repo@sha" # vX.Y.Z` correctly
|
|
||||||
// parses the comment outside the closing quote as the version annotation.
|
|
||||||
//
|
|
||||||
// Fails closed on YAML forms the line scanner cannot safely resolve:
|
|
||||||
// - Escaped/encoded keys in double-quoted strings ("u\u0073es")
|
|
||||||
// - Flow-style sequence steps (- { uses: ... }, - { run: ... })
|
|
||||||
// - YAML aliases/anchors on run:, uses:, or permissions: values
|
|
||||||
//
|
|
||||||
// All-zero SHAs and v0.0.0 placeholder pins are rejected.
|
|
||||||
// opts.requirePermissions (default true) — workflow files require a top-level
|
|
||||||
// permissions: key; action manifests do not support it and must pass false.
|
|
||||||
function validateWorkflowContent(content, filename, opts) {
|
|
||||||
const requirePermissions = !opts || opts.requirePermissions !== false;
|
|
||||||
const errs = [];
|
|
||||||
const EXPR_OPEN = '$' + '{{';
|
|
||||||
|
|
||||||
// 1. Top-level permissions key required (workflows only; not action manifests).
|
|
||||||
if (requirePermissions) {
|
|
||||||
if (!/^(?:"permissions"|'permissions'|permissions):/m.test(content)) {
|
|
||||||
errs.push(filename + ': missing top-level "permissions:" key');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1b. Top-level permissions alias check.
|
|
||||||
if (/^(?:"permissions"|'permissions'|permissions):[ \t]+\*/m.test(content)) {
|
|
||||||
errs.push(filename + ': YAML alias for top-level "permissions:" value is not supported — inline the permissions map');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Line-by-line scan.
|
|
||||||
// inBlock: currently inside a block scalar
|
|
||||||
// blockIndent: indent of the key that opened the block scalar
|
|
||||||
// blockIsRun: the block belongs to a run: key (check expressions)
|
|
||||||
const lines = content.split('\n');
|
|
||||||
let inBlock = false;
|
|
||||||
let blockIndent = -1;
|
|
||||||
let blockIsRun = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
const rawIndent = (line.match(/^([ \t]*)/) || ['', ''])[1].length;
|
|
||||||
|
|
||||||
// ── Inside a block scalar ────────────────────────────────────────
|
|
||||||
if (inBlock) {
|
|
||||||
if (line.trim() === '') continue;
|
|
||||||
if (rawIndent > blockIndent) {
|
|
||||||
// Content of block scalar.
|
|
||||||
// Only flag expression injection for run: block scalars.
|
|
||||||
if (blockIsRun && line.includes(EXPR_OPEN)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': run: block contains ' + EXPR_OPEN + ' }} — expressions must go through env: [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Indent at or below the block key — exit block scalar.
|
|
||||||
inBlock = false;
|
|
||||||
blockIndent = -1;
|
|
||||||
blockIsRun = false;
|
|
||||||
// Fall through to process this line as structural YAML.
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Escaped/encoded double-quoted key — fail closed ───────────────
|
|
||||||
// A double-quoted key containing \ cannot be reliably resolved by
|
|
||||||
// the line scanner (e.g. "u\u0073es" parses as "uses" in YAML).
|
|
||||||
// Reject any such key at the structural position.
|
|
||||||
if (/^[ \t]*(?:-[ \t]+)?"[^"]*\\[^"]*":/.test(line)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': escaped key in double-quoted string is not supported — use literal key names (run:, uses:, permissions:) [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Structural key with whitespace before colon — fail closed ────
|
|
||||||
// YAML permits `key : value` but the scanner matches `key:` forms
|
|
||||||
// only; a space before the colon silently bypasses all checks.
|
|
||||||
// Reject any structural key (uses, run, steps — quoted or plain,
|
|
||||||
// sequence-item or mapping) that has whitespace before the colon.
|
|
||||||
if (/^[ \t]*(?:-[ \t]+)?(?:"(?:uses|run|steps)"|'(?:uses|run|steps)'|uses|run|steps)[ \t]+:/.test(line)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': structural key with whitespace before ":" is not supported — remove the space before the colon [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sequence-item anchor declaration — fail closed ───────────────
|
|
||||||
// Any line of the form `- &anchor` (with or without a mapping on
|
|
||||||
// the same line) is rejected. A standalone `- &name` can be
|
|
||||||
// followed on the next line by a flow-style mapping that the
|
|
||||||
// scanner would then misread as structural YAML. The multiline
|
|
||||||
// alias form `- *name` on subsequent steps is also unreachable
|
|
||||||
// without first declaring such an anchor. Reject unconditionally.
|
|
||||||
if (/^[ \t]*-[ \t]+&\S+/.test(line)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': sequence-item anchor declaration (&name) is not supported — anchors on steps may introduce flow mappings the scanner cannot safely resolve [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Flow-style sequence step — fail closed only for structural keys ─
|
|
||||||
// `- { ... }` form cannot be safely resolved when it contains a
|
|
||||||
// structural run: or uses: key (including quoted or escaped forms).
|
|
||||||
// Non-step data objects like `- { os: ubuntu, node: 24 }` are
|
|
||||||
// allowed — they cannot contain action refs or run scripts.
|
|
||||||
// `permissions: {}` is a mapping value (not a sequence item),
|
|
||||||
// so it is unaffected by this check entirely.
|
|
||||||
if (/^[ \t]*-[ \t]+\{[^}]/.test(line)) {
|
|
||||||
const braceIdx = line.indexOf('{');
|
|
||||||
const flowContent = line.slice(braceIdx);
|
|
||||||
if (/[{,]\s*(?:"uses"|'uses'|uses|"run"|'run'|run|"[^"]*\\[^"]*")\s*:/.test(flowContent)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': flow-style step mapping with structural "run:" or "uses:" key is not supported — use block mapping style [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Flow-style steps array — fail closed ─────────────────────────
|
|
||||||
// `steps: [...]` and `steps: [` (multiline opener) cannot be
|
|
||||||
// safely resolved. Exception: `steps: []` is an empty array
|
|
||||||
// with no execution and is explicitly allowed.
|
|
||||||
// Quoted ("steps") and unquoted forms are both detected.
|
|
||||||
const stepsFlowM = line.match(/^[ \t]*(?:"steps"|'steps'|steps):[ \t]*\[(.*)$/);
|
|
||||||
if (stepsFlowM) {
|
|
||||||
const inner = stepsFlowM[1].trimStart();
|
|
||||||
if (!/^\]\s*(#.*)?$/.test(inner)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': flow-style "steps" array is not supported — use block-style steps list [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Any block scalar key detection (| or >) ──────────────────────
|
|
||||||
// Matches quoted ("key", 'key') and unquoted (key) key names,
|
|
||||||
// with optional sequence-item prefix (- ), followed by a block
|
|
||||||
// indicator (| or > with optional explicit-indent/chomp modifiers).
|
|
||||||
// YAML block scalar header forms: | |2 |- |+ |2- |2+ |-2 |+2
|
|
||||||
// and equivalents with > (folded). Both digit-first and chomp-first
|
|
||||||
// orderings are recognized per the YAML 1.2 spec.
|
|
||||||
// Groups: [1]=indent [2]=full-key [3]=dq-content [4]=sq-content [5]=unquoted [6]=indicator
|
|
||||||
const blockM = line.match(/^([ \t]*)(?:-[ \t]+)?("([^"]*)"|'([^']*)'|([\w-]+)):[ \t]*([|>](?:[1-9][-+]?|[-+][1-9]?)?)[ \t]*(?:#.*)?$/);
|
|
||||||
if (blockM) {
|
|
||||||
const keyName = blockM[3] !== undefined ? blockM[3] : (blockM[4] !== undefined ? blockM[4] : (blockM[5] || ''));
|
|
||||||
if (keyName === 'uses') {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': uses: block scalar is not supported — action refs must be inline and pinned to an immutable SHA [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
inBlock = true;
|
|
||||||
blockIndent = blockM[1].length;
|
|
||||||
blockIsRun = (keyName === 'run');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inline run: value (no block indicator) ───────────────────────
|
|
||||||
// Handles mapping form and sequence-item form; quoted and unquoted key.
|
|
||||||
// A run: &anchor | line (anchor before block indicator) falls here
|
|
||||||
// because blockM cannot match it; the & causes the alias check below.
|
|
||||||
const inlineRunM = line.match(/^[ \t]*(?:-[ \t]+)?(?:"run"|'run'|run):[ \t]+(.*)$/);
|
|
||||||
if (inlineRunM) {
|
|
||||||
const runVal = inlineRunM[1].trimStart();
|
|
||||||
// A YAML alias is *name; an anchor is &name (non-whitespace after &).
|
|
||||||
// Ordinary shell & like 'echo "R&D build"' does not start with * or &word.
|
|
||||||
if (runVal[0] === '*' || /^&\S/.test(runVal)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "run:" value is not supported — inline the run script [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (inlineRunM[1].includes(EXPR_OPEN)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': run: value contains ' + EXPR_OPEN + ' }} — expressions must go through env: [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── uses: key detection ──────────────────────────────────────────
|
|
||||||
// Handles mapping form and sequence-item form; quoted and unquoted key.
|
|
||||||
const usesM = line.match(/^[ \t]+(?:-[ \t]+)?(?:"uses"|'uses'|uses):[ \t]+(.+)$/);
|
|
||||||
if (!usesM) continue;
|
|
||||||
|
|
||||||
// Parse the action ref — handle quoted scalar with comment outside quotes.
|
|
||||||
const rawVal = usesM[1].trim();
|
|
||||||
|
|
||||||
// Reject YAML alias/anchor in uses: value.
|
|
||||||
// An alias is *name; an anchor is &name (non-whitespace after &).
|
|
||||||
if (rawVal[0] === '*' || /^&\S/.test(rawVal)) {
|
|
||||||
errs.push(filename + ':' + (i + 1) + ': YAML alias/anchor in "uses:" value is not supported — inline the action ref [fnv:' + fnv1a32(line.trim()) + ']');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ref;
|
|
||||||
let extComment = '';
|
|
||||||
|
|
||||||
if (rawVal[0] === '"' || rawVal[0] === "'") {
|
|
||||||
const q = rawVal[0];
|
|
||||||
const closeIdx = rawVal.indexOf(q, 1);
|
|
||||||
if (closeIdx !== -1) {
|
|
||||||
ref = rawVal.slice(1, closeIdx);
|
|
||||||
const rest = rawVal.slice(closeIdx + 1).trimStart();
|
|
||||||
if (rest[0] === '#') extComment = rest;
|
|
||||||
} else {
|
|
||||||
ref = rawVal; // malformed quote — treat as unquoted
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ref = rawVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local action references cannot be validated — the scanner
|
|
||||||
// does not recursively resolve action manifests. Inline the
|
|
||||||
// action logic or replace with an immutable remote SHA pin.
|
|
||||||
if (ref.startsWith('./')) {
|
|
||||||
const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref;
|
|
||||||
errs.push(filename + ': "uses: ' + short + '" local action reference is not supported — inline the action or use an immutable remote SHA pin');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker refs require an immutable sha256 digest pin.
|
|
||||||
// Mutable tags, :latest, and bare image names are rejected.
|
|
||||||
// No # vX.Y.Z comment is required because the digest is the
|
|
||||||
// immutable identity.
|
|
||||||
if (ref.startsWith('docker://')) {
|
|
||||||
if (!/^docker:\/\/.+@sha256:[0-9a-f]{64}$/.test(ref)) {
|
|
||||||
const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref;
|
|
||||||
errs.push(filename + ': "uses: ' + short + '" docker:// ref must be pinned by immutable digest (docker://<image>@sha256:<64 lowercase hex>)');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate SHA + version comment.
|
|
||||||
// For quoted refs, combine the unquoted value with any external comment.
|
|
||||||
const forShaCheck = extComment ? ref + ' ' + extComment : ref;
|
|
||||||
const shaMatch = forShaCheck.match(/@([0-9a-f]{40})[ \t]+#[ \t]+v(\d+)\.(\d+)\.(\d+)$/i);
|
|
||||||
if (!shaMatch) {
|
|
||||||
const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref;
|
|
||||||
errs.push(filename + ': "uses: ' + short + '" must be pinned to a 40-char SHA with "# vX.Y.Z" comment');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject all-zero placeholder SHA.
|
|
||||||
if (/^0{40}$/.test(shaMatch[1])) {
|
|
||||||
const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref;
|
|
||||||
errs.push(filename + ': "uses: ' + short + '" uses a placeholder all-zero SHA — replace with the actual release SHA');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject v0.0.0 placeholder version.
|
|
||||||
if (shaMatch[2] === '0' && shaMatch[3] === '0' && shaMatch[4] === '0') {
|
|
||||||
const short = ref.length > 60 ? ref.slice(0, 57) + '\u2026' : ref;
|
|
||||||
errs.push(filename + ': "uses: ' + short + '" uses placeholder version v0.0.0 — update to the actual release version');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry-After header parser — supports integer seconds and HTTP-date.
|
|
||||||
// Returns milliseconds to wait, capped at 60000. Returns 0 for invalid
|
|
||||||
// or non-positive values so the caller uses exponential fallback instead.
|
|
||||||
// nowMs is injectable for testing; defaults to Date.now().
|
|
||||||
function parseRetryAfterMs(header, nowMs) {
|
|
||||||
if (!header) return 0;
|
|
||||||
const secs = parseInt(header, 10);
|
|
||||||
if (!isNaN(secs) && secs > 0) return Math.min(secs * 1000, 60000);
|
|
||||||
const date = new Date(header);
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
const ms = date.getTime() - (nowMs !== undefined ? nowMs : Date.now());
|
|
||||||
return ms > 0 ? Math.min(ms, 60000) : 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pure helpers for commit and file count limit checks.
|
|
||||||
function checkCommitLimit(prCommits) {
|
|
||||||
if (prCommits > 250) {
|
|
||||||
return 'POLICY-INFRA: PR has ' + prCommits + ' commits — GitHub REST API caps listCommits at 250; not all commit subjects can be validated';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkFilesLimit(prChangedFiles) {
|
|
||||||
if (prChangedFiles > 3000) {
|
|
||||||
return 'POLICY-INFRA: PR has ' + prChangedFiles + ' changed files — GitHub REST API caps listFiles at 3000; not all workflow files can be validated';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize a validateWorkflowContent error string to a diff fingerprint.
|
|
||||||
// Per-line locations are intentionally preserved so moving a grandfathered
|
|
||||||
// violation to a different execution path is treated as a new violation.
|
|
||||||
// Content-identifying tokens (action ref, expression text) are preserved.
|
|
||||||
function normalizeViolationFingerprint(err) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diff head vs base violations using location-preserving fingerprints
|
|
||||||
// with multiplicity. For each fingerprint, up to base-count head
|
|
||||||
// violations of that fingerprint are considered pre-existing; the
|
|
||||||
// remainder are new. Violations are returned in head-file order.
|
|
||||||
function filterNewViolations(headErrs, baseErrs) {
|
|
||||||
const baseCounts = new Map();
|
|
||||||
for (const e of baseErrs) {
|
|
||||||
const fp = normalizeViolationFingerprint(e);
|
|
||||||
baseCounts.set(fp, (baseCounts.get(fp) || 0) + 1);
|
|
||||||
}
|
|
||||||
const remaining = new Map(baseCounts);
|
|
||||||
const result = [];
|
|
||||||
for (const e of headErrs) {
|
|
||||||
const fp = normalizeViolationFingerprint(e);
|
|
||||||
const rem = remaining.get(fp) || 0;
|
|
||||||
if (rem > 0) {
|
|
||||||
remaining.set(fp, rem - 1);
|
|
||||||
} else {
|
|
||||||
result.push(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classify the status of a pull-request file for workflow scanning.
|
|
||||||
// Returns one of four action objects:
|
|
||||||
// { action: 'skip' } — removed or unchanged; no validation
|
|
||||||
// { action: 'full' } — added or copied; full validation, no baseline
|
|
||||||
// { action: 'diff', basePath: string } — modified/changed or renamed-from-workflow;
|
|
||||||
// validate head, diff against base at basePath
|
|
||||||
// { action: 'infra', reason: string } — unknown status; report POLICY-INFRA
|
|
||||||
// isWfFn must be the isWorkflowFilename predicate (injectable for testing).
|
|
||||||
function classifyFileStatus(file, isWfFn) {
|
|
||||||
const s = file.status;
|
|
||||||
if (s === 'removed' || s === 'unchanged') return { action: 'skip' };
|
|
||||||
if (s === 'added' || s === 'copied') return { action: 'full' };
|
|
||||||
if (s === 'modified' || s === 'changed') return { action: 'diff', basePath: file.filename };
|
|
||||||
if (s === 'renamed') {
|
|
||||||
if (file.previous_filename &&
|
|
||||||
isWfFn(file.previous_filename) &&
|
|
||||||
policyFileKind(file.previous_filename) === policyFileKind(file.filename)) {
|
|
||||||
return { action: 'diff', basePath: file.previous_filename };
|
|
||||||
}
|
|
||||||
return { action: 'full' };
|
|
||||||
}
|
|
||||||
return { action: 'infra', reason: 'unknown file status "' + s + '" for ' + file.filename };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Test escape ──────────────────────────────────────────────────────────
|
|
||||||
// Set PR_POLICY_TEST=1 to extract pure functions without hitting any API.
|
|
||||||
if (process.env.PR_POLICY_TEST === '1') {
|
|
||||||
return {
|
|
||||||
validateTitle,
|
|
||||||
getJiraKey,
|
|
||||||
validateBranch,
|
|
||||||
validateBody,
|
|
||||||
validateCommitSubject,
|
|
||||||
isSyncMergeCommit,
|
|
||||||
detectAiFooter,
|
|
||||||
isWorkflowFilename,
|
|
||||||
isActionManifestFilename,
|
|
||||||
isPolicyFilename,
|
|
||||||
policyFileKind,
|
|
||||||
validateWorkflowContent,
|
|
||||||
parseRetryAfterMs,
|
|
||||||
checkCommitLimit,
|
|
||||||
checkFilesLimit,
|
|
||||||
normalizeViolationFingerprint,
|
|
||||||
filterNewViolations,
|
|
||||||
fnv1a32,
|
|
||||||
stripHash,
|
|
||||||
classifyFileStatus,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Jira API helper ──────────────────────────────────────────────────────
|
|
||||||
// Retries on 429/5xx up to 3 times with Retry-After header support.
|
|
||||||
// On the final attempt (attempt === 3), 429/5xx falls through to the
|
|
||||||
// status-specific throw. Never logs secrets or response bodies.
|
|
||||||
async function jiraGetIssue(cloudId, issueKey, email, token) {
|
|
||||||
const https = require('https');
|
|
||||||
const apiPath = '/ex/jira/' + cloudId + '/rest/api/3/issue/' + issueKey + '?fields=key';
|
|
||||||
const authHeader = 'Basic ' + Buffer.from(email + ':' + token).toString('base64');
|
|
||||||
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
||||||
const result = await new Promise(function(resolve, reject) {
|
|
||||||
const req = https.request({
|
|
||||||
hostname: 'api.atlassian.com',
|
|
||||||
path: apiPath,
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': authHeader, 'Accept': 'application/json' },
|
|
||||||
}, function(res) {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', function(c) { chunks.push(c); });
|
|
||||||
res.on('end', function() {
|
|
||||||
resolve({ status: res.statusCode, retryAfter: res.headers['retry-after'], body: Buffer.concat(chunks).toString('utf8') });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
if (result.status === 200) {
|
|
||||||
let parsed;
|
|
||||||
try { parsed = JSON.parse(result.body); } catch (_) {
|
|
||||||
const e = new Error('Jira API returned non-JSON'); e.isInfra = true; throw e;
|
|
||||||
}
|
|
||||||
if (parsed.key !== issueKey) throw new Error('Jira returned key "' + parsed.key + '" but expected "' + issueKey + '"');
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
if (result.status === 404) throw new Error('Jira issue ' + issueKey + ' not found');
|
|
||||||
if (result.status === 401 || result.status === 403) {
|
|
||||||
const e = new Error('Jira auth rejected (HTTP ' + result.status + ')'); e.isInfra = true; throw e;
|
|
||||||
}
|
|
||||||
if ((result.status === 429 || result.status >= 500) && attempt < 3) {
|
|
||||||
const headerMs = parseRetryAfterMs(result.retryAfter);
|
|
||||||
const delayMs = headerMs > 0 ? headerMs : Math.min(2000 * (attempt + 1), 30000);
|
|
||||||
await new Promise(function(r) { setTimeout(r, delayMs); });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const e = new Error('Jira API returned HTTP ' + result.status); e.isInfra = true; throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ─────────────────────────────────────────────────────────────────
|
|
||||||
const violations = [];
|
|
||||||
const warnings = [];
|
|
||||||
const infraCodes = [];
|
|
||||||
let infraFailed = false;
|
|
||||||
const MAX_ANNOTATIONS = 50;
|
|
||||||
|
|
||||||
function addViolation(msg) { violations.push(msg); }
|
|
||||||
function addWarning(msg) { warnings.push(msg); }
|
|
||||||
function addInfra(msg) { infraCodes.push(msg); infraFailed = true; }
|
|
||||||
|
|
||||||
const repoOwner = context.repo.owner;
|
|
||||||
const repoName = context.repo.repo;
|
|
||||||
const pr = context.payload.pull_request;
|
|
||||||
const prNum = pr.number;
|
|
||||||
const isDep = pr.user.login === 'dependabot[bot]';
|
|
||||||
if (isDep) {
|
|
||||||
await core.summary.addRaw('## PR Policy: skipped (Dependabot)').write();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const titleTypeMatch = pr.title.match(/^([a-z]+)/);
|
|
||||||
const titleType = titleTypeMatch ? titleTypeMatch[1] : '';
|
|
||||||
|
|
||||||
// Pre-compute emergency-revert candidate before title validation.
|
|
||||||
// Only a revert title that LACKS a Jira suffix triggers emergency
|
|
||||||
// authorization; a revert title that already carries a Jira key does not.
|
|
||||||
const hasEmergencyLabel = pr.labels.some(function(l) { return l.name === 'emergency-revert'; });
|
|
||||||
const titleHasJira = !!getJiraKey(pr.title);
|
|
||||||
const isEmergencyCandidate = !isDep && titleType === 'revert' && hasEmergencyLabel && !titleHasJira;
|
|
||||||
|
|
||||||
// 1 — title convention
|
|
||||||
for (const e of validateTitle(pr.title, isDep, isEmergencyCandidate)) addViolation('Title: ' + e);
|
|
||||||
|
|
||||||
// 2 — branch naming (Dependabot exempt)
|
|
||||||
for (const e of validateBranch(pr.head.ref, isDep)) addViolation('Branch: ' + e);
|
|
||||||
|
|
||||||
// 3 — body structure (Dependabot exempt)
|
|
||||||
for (const e of validateBody(pr.body, isDep)) addViolation('Body: ' + e);
|
|
||||||
|
|
||||||
// 4 — AI attribution footer in title/body
|
|
||||||
if (detectAiFooter((pr.title || '') + '\n' + (pr.body || ''))) {
|
|
||||||
addViolation('AI attribution footer detected in PR title or body');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5 — commits: subject convention + AI footer
|
|
||||||
// GitHub REST API caps listCommits at 250 total. Fail infra immediately
|
|
||||||
// when pr.commits exceeds that limit; compare fetched count to detect
|
|
||||||
// API truncation.
|
|
||||||
{
|
|
||||||
const commitLimitErr = checkCommitLimit(pr.commits);
|
|
||||||
if (commitLimitErr) addInfra(commitLimitErr);
|
|
||||||
|
|
||||||
let commitPage = 1;
|
|
||||||
let commitMore = true;
|
|
||||||
let totalFetched = 0;
|
|
||||||
while (commitMore) {
|
|
||||||
let resp;
|
|
||||||
try {
|
|
||||||
resp = await github.rest.pulls.listCommits({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: commitPage });
|
|
||||||
} catch (err) {
|
|
||||||
addInfra('POLICY-INFRA: Failed to fetch commits (page ' + commitPage + '): ' + err.message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const commits = resp.data;
|
|
||||||
const link = (resp.headers && resp.headers.link) ? resp.headers.link : '';
|
|
||||||
totalFetched += commits.length;
|
|
||||||
if (!link.includes('rel="next"') || commits.length === 0) commitMore = false;
|
|
||||||
for (const c of commits) {
|
|
||||||
const subject = c.commit.message.split('\n')[0];
|
|
||||||
if (!isSyncMergeCommit(c)) {
|
|
||||||
for (const e of validateCommitSubject(subject)) addViolation('Commit ' + c.sha.slice(0, 8) + ': ' + e);
|
|
||||||
}
|
|
||||||
if (detectAiFooter(c.commit.message)) addViolation('Commit ' + c.sha.slice(0, 8) + ': AI attribution footer detected');
|
|
||||||
}
|
|
||||||
commitPage++;
|
|
||||||
}
|
|
||||||
if (pr.commits <= 250 && totalFetched > 0 && totalFetched !== pr.commits) {
|
|
||||||
addInfra('POLICY-INFRA: Fetched ' + totalFetched + ' commits but PR reports ' + pr.commits + ' — API truncation suspected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6 — emergency-revert authorisation
|
|
||||||
// Event timeline truncation is always POLICY-INFRA regardless of whether
|
|
||||||
// an earlier label event was found — partial history is never trusted.
|
|
||||||
let jiraExempt = isDep;
|
|
||||||
let emergencyAuthFailed = false;
|
|
||||||
if (isEmergencyCandidate) {
|
|
||||||
try {
|
|
||||||
let evPage = 1;
|
|
||||||
let evMore = true;
|
|
||||||
let latestLabelEvent = null;
|
|
||||||
let evTruncated = false;
|
|
||||||
while (evMore) {
|
|
||||||
const evResp = await github.rest.issues.listEvents({ owner: repoOwner, repo: repoName, issue_number: prNum, per_page: 100, page: evPage });
|
|
||||||
const evLink = (evResp.headers && evResp.headers.link) ? evResp.headers.link : '';
|
|
||||||
for (const ev of evResp.data) {
|
|
||||||
if (ev.event === 'labeled' && ev.label && ev.label.name === 'emergency-revert') latestLabelEvent = ev;
|
|
||||||
}
|
|
||||||
if (!evLink.includes('rel="next"') || evResp.data.length === 0) {
|
|
||||||
evMore = false;
|
|
||||||
} else if (evPage >= 20) {
|
|
||||||
evMore = false;
|
|
||||||
evTruncated = true;
|
|
||||||
}
|
|
||||||
evPage++;
|
|
||||||
}
|
|
||||||
if (evTruncated) {
|
|
||||||
// Partial history cannot verify the most-recent label event.
|
|
||||||
// An earlier maintainer event might have been superseded.
|
|
||||||
addInfra('POLICY-INFRA: Event timeline truncated at pagination limit — cannot verify the most-recent emergency-revert label actor; Jira key required');
|
|
||||||
emergencyAuthFailed = true;
|
|
||||||
} else if (!latestLabelEvent) {
|
|
||||||
addViolation('emergency-revert: label present but no label event found in timeline — Jira key required');
|
|
||||||
} else if (!latestLabelEvent.actor) {
|
|
||||||
addViolation('emergency-revert: label event actor is null — Jira key required');
|
|
||||||
} else if (latestLabelEvent.actor.type === 'Bot') {
|
|
||||||
addViolation('emergency-revert: label applied by a bot — Jira key required');
|
|
||||||
} else {
|
|
||||||
const permResp = await github.rest.repos.getCollaboratorPermissionLevel({ owner: repoOwner, repo: repoName, username: latestLabelEvent.actor.login });
|
|
||||||
if (permResp.data.permission === 'maintain' || permResp.data.permission === 'admin') {
|
|
||||||
jiraExempt = true;
|
|
||||||
} else {
|
|
||||||
addViolation('emergency-revert: label applied by user without maintain/admin permission — Jira key required');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
addInfra('POLICY-INFRA: Emergency-revert authorisation check failed: ' + err.message);
|
|
||||||
emergencyAuthFailed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7 — Jira existence. A present key is verified fail-closed. A missing
|
|
||||||
// key is a warning, except authorized emergency-reverts which stay silent.
|
|
||||||
// Skip the existence lookup when emergency auth already produced an infra
|
|
||||||
// error to avoid a redundant credential error on a PR that has no key.
|
|
||||||
const jiraKey = getJiraKey(pr.title);
|
|
||||||
if (!jiraKey && !jiraExempt) {
|
|
||||||
addWarning('Missing Jira key. Expected (DEV-NNN), (PLAT-NNN), (SEC-NNN), or (AP-NNN) at end of title');
|
|
||||||
}
|
|
||||||
if (!jiraExempt && jiraKey && !emergencyAuthFailed) {
|
|
||||||
const cloudId = process.env.JIRA_CLOUD_ID || '';
|
|
||||||
const jiraEmail = process.env.JIRA_SERVICE_ACCOUNT_EMAIL || '';
|
|
||||||
const jiraToken = process.env.JIRA_API_TOKEN || '';
|
|
||||||
if (!cloudId || !jiraEmail || !jiraToken) {
|
|
||||||
addInfra('POLICY-INFRA: Jira credentials missing — JIRA_CLOUD_ID, JIRA_SERVICE_ACCOUNT_EMAIL, and JIRA_API_TOKEN must all be set for human PRs');
|
|
||||||
} else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)) {
|
|
||||||
addInfra('POLICY-INFRA: JIRA_CLOUD_ID is not a valid UUID');
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await jiraGetIssue(cloudId, jiraKey, jiraEmail, jiraToken);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.isInfra) addInfra('POLICY-INFRA: ' + err.message);
|
|
||||||
else addViolation('Jira: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8 — workflow file supply-chain checks (diff-mode)
|
|
||||||
// Only NEW violations relative to the base branch are reported.
|
|
||||||
// Added files have no baseline and must be fully compliant.
|
|
||||||
// Modified/renamed files are diffed: base content is fetched at
|
|
||||||
// pr.base.sha (using previous_filename for renames). Base fetch
|
|
||||||
// failures are POLICY-INFRA — partial history is never silently
|
|
||||||
// grandfathered. Deleted files are skipped.
|
|
||||||
// GitHub REST API caps listFiles at 3000.
|
|
||||||
try {
|
|
||||||
const filesLimitErr = checkFilesLimit(pr.changed_files);
|
|
||||||
if (filesLimitErr) addInfra(filesLimitErr);
|
|
||||||
|
|
||||||
let filesPage = 1;
|
|
||||||
let filesMore = true;
|
|
||||||
let totalFilesFetched = 0;
|
|
||||||
while (filesMore) {
|
|
||||||
const filesResp = await github.rest.pulls.listFiles({ owner: repoOwner, repo: repoName, pull_number: prNum, per_page: 100, page: filesPage });
|
|
||||||
const filesLink = (filesResp.headers && filesResp.headers.link) ? filesResp.headers.link : '';
|
|
||||||
totalFilesFetched += filesResp.data.length;
|
|
||||||
if (!filesLink.includes('rel="next"') || filesResp.data.length === 0) filesMore = false;
|
|
||||||
for (const file of filesResp.data) {
|
|
||||||
if (!isPolicyFilename(file.filename)) continue;
|
|
||||||
|
|
||||||
const cls = classifyFileStatus(file, isPolicyFilename);
|
|
||||||
if (cls.action === 'skip') continue;
|
|
||||||
if (cls.action === 'infra') {
|
|
||||||
addInfra('POLICY-INFRA: ' + cls.reason + '; skipping workflow validation');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch HEAD content via blob SHA.
|
|
||||||
let headContent;
|
|
||||||
try {
|
|
||||||
const blobResp = await github.rest.git.getBlob({ owner: repoOwner, repo: repoName, file_sha: file.sha });
|
|
||||||
const raw = blobResp.data;
|
|
||||||
const enc = raw.encoding === 'base64' ? 'base64' : 'utf8';
|
|
||||||
headContent = Buffer.from(raw.content, enc).toString('utf8');
|
|
||||||
} catch (blobErr) {
|
|
||||||
addInfra('POLICY-INFRA: Cannot fetch blob for ' + file.filename + ': ' + blobErr.message);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action manifests do not support top-level permissions:.
|
|
||||||
const wfOpts = policyFileKind(file.filename) === 'action' ? { requirePermissions: false } : {};
|
|
||||||
const headErrs = validateWorkflowContent(headContent, file.filename, wfOpts);
|
|
||||||
|
|
||||||
if (cls.action === 'full') {
|
|
||||||
// No baseline — added, copied, or renamed-from-non-policy path.
|
|
||||||
for (const e of headErrs) addViolation(e);
|
|
||||||
} else {
|
|
||||||
// diff — modified, changed, or renamed-from-policy path.
|
|
||||||
// Both head and base violations use file.filename so fingerprints match.
|
|
||||||
let baseErrs = [];
|
|
||||||
try {
|
|
||||||
const baseResp = await github.rest.repos.getContent({ owner: repoOwner, repo: repoName, path: cls.basePath, ref: pr.base.sha });
|
|
||||||
const baseRaw = baseResp.data;
|
|
||||||
const baseEnc = baseRaw.encoding === 'base64' ? 'base64' : 'utf8';
|
|
||||||
const baseContent = Buffer.from(baseRaw.content, baseEnc).toString('utf8');
|
|
||||||
baseErrs = validateWorkflowContent(baseContent, file.filename, wfOpts);
|
|
||||||
} catch (baseErr) {
|
|
||||||
addInfra('POLICY-INFRA: Cannot fetch base content for ' + file.filename + ' at ' + pr.base.sha + ': ' + baseErr.message);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const e of filterNewViolations(headErrs, baseErrs)) addViolation(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filesPage++;
|
|
||||||
}
|
|
||||||
if (pr.changed_files <= 3000 && totalFilesFetched > 0 && totalFilesFetched !== pr.changed_files) {
|
|
||||||
addInfra('POLICY-INFRA: Fetched ' + totalFilesFetched + ' changed files but PR reports ' + pr.changed_files + ' — API truncation suspected');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
addInfra('POLICY-INFRA: Failed to list PR files: ' + err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9 — emit annotations + step summary, then fail once
|
|
||||||
// Both annotations and summary entries are capped at MAX_ANNOTATIONS
|
|
||||||
// to prevent oversized outputs on PRs with many violations.
|
|
||||||
const annotated = violations.slice(0, MAX_ANNOTATIONS);
|
|
||||||
for (const msg of annotated) core.error(stripHash(msg));
|
|
||||||
for (const msg of infraCodes.slice(0, MAX_ANNOTATIONS)) core.error(msg);
|
|
||||||
for (const msg of warnings.slice(0, MAX_ANNOTATIONS)) core.warning(msg);
|
|
||||||
if (violations.length > MAX_ANNOTATIONS) {
|
|
||||||
core.warning((violations.length - MAX_ANNOTATIONS) + ' additional violation(s) suppressed (max ' + MAX_ANNOTATIONS + ' annotations)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCount = violations.length + infraCodes.length;
|
|
||||||
const summaryParts = [totalCount === 0 ? '## PR Policy: All checks passed \u2713' : '## PR Policy: ' + totalCount + ' issue(s) found'];
|
|
||||||
if (violations.length > 0) {
|
|
||||||
summaryParts.push('', '### Policy violations');
|
|
||||||
const shownV = violations.slice(0, MAX_ANNOTATIONS);
|
|
||||||
for (const msg of shownV) summaryParts.push('- ' + stripHash(msg));
|
|
||||||
if (violations.length > MAX_ANNOTATIONS) {
|
|
||||||
summaryParts.push('- _...and ' + (violations.length - MAX_ANNOTATIONS) + ' more violation(s) not shown_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (warnings.length > 0) {
|
|
||||||
summaryParts.push('', '### Warnings');
|
|
||||||
const shownW = warnings.slice(0, MAX_ANNOTATIONS);
|
|
||||||
for (const msg of shownW) summaryParts.push('- ' + msg);
|
|
||||||
if (warnings.length > MAX_ANNOTATIONS) {
|
|
||||||
summaryParts.push('- _...and ' + (warnings.length - MAX_ANNOTATIONS) + ' more warning(s) not shown_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (infraCodes.length > 0) {
|
|
||||||
summaryParts.push('', '### Infrastructure failures');
|
|
||||||
const shownI = infraCodes.slice(0, MAX_ANNOTATIONS);
|
|
||||||
for (const msg of shownI) summaryParts.push('- ' + msg);
|
|
||||||
if (infraCodes.length > MAX_ANNOTATIONS) {
|
|
||||||
summaryParts.push('- _...and ' + (infraCodes.length - MAX_ANNOTATIONS) + ' more infra error(s) not shown_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await core.summary.addRaw(summaryParts.join('\n')).write();
|
|
||||||
|
|
||||||
if (violations.length > 0 || infraFailed) {
|
|
||||||
core.setFailed('PR policy: ' + violations.length + ' violation(s), ' + infraCodes.length + ' infrastructure error(s)');
|
|
||||||
}
|
|
||||||
4
.github/workflows/cd-cdk.yaml
vendored
4
.github/workflows/cd-cdk.yaml
vendored
|
|
@ -72,7 +72,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
if: ${{ inputs.enable-qemu }}
|
if: ${{ inputs.enable-qemu }}
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
|
@ -121,7 +121,7 @@ jobs:
|
||||||
pip install -r "$req"
|
pip install -r "$req"
|
||||||
done < <(find . -name requirements.txt -not -path '*/node_modules/*' -print0)
|
done < <(find . -name requirements.txt -not -path '*/node_modules/*' -print0)
|
||||||
|
|
||||||
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ secrets.deploy-role-arn }}
|
role-to-assume: ${{ secrets.deploy-role-arn }}
|
||||||
aws-region: ${{ inputs.region }}
|
aws-region: ${{ inputs.region }}
|
||||||
|
|
|
||||||
2
.github/workflows/cd-dotnet-eb.yaml
vendored
2
.github/workflows/cd-dotnet-eb.yaml
vendored
|
|
@ -119,7 +119,7 @@ jobs:
|
||||||
cd ..
|
cd ..
|
||||||
echo "Bundle size: $(du -h bundle.zip | cut -f1)"
|
echo "Bundle size: $(du -h bundle.zip | cut -f1)"
|
||||||
|
|
||||||
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ secrets.deploy-role-arn }}
|
role-to-assume: ${{ secrets.deploy-role-arn }}
|
||||||
aws-region: ${{ inputs.region }}
|
aws-region: ${{ inputs.region }}
|
||||||
|
|
|
||||||
441
.github/workflows/cd-hcp-fargate.yaml
vendored
Normal file
441
.github/workflows/cd-hcp-fargate.yaml
vendored
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
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
|
||||||
|
#
|
||||||
|
# apply-task-environment replaces the container env from
|
||||||
|
# ${prefix}/task-environment. sentry-project uploads image files before
|
||||||
|
# RegisterTaskDefinition. concurrency-suffix splits two deployables that
|
||||||
|
# share one SSM prefix. Empty defaults keep the previous behavior.
|
||||||
|
#
|
||||||
|
# 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: "{}"
|
||||||
|
apply-task-environment:
|
||||||
|
description: "Replace container env from SSM ${prefix}/task-environment. GIT_SHA wins."
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
sentry-org:
|
||||||
|
description: "Sentry org for BFF source map upload when sentry-project is set"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "seahaven"
|
||||||
|
sentry-project:
|
||||||
|
description: "Sentry project for BFF source map upload. Empty skips upload."
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
sentry-container-files:
|
||||||
|
description: "Comma-separated image paths to upload. Required when sentry-project is set."
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
health-attempts:
|
||||||
|
description: "Number of /api/health polls, 10 seconds apart, before failing"
|
||||||
|
type: number
|
||||||
|
required: false
|
||||||
|
default: 6
|
||||||
|
health-from-distribution:
|
||||||
|
description: "Build the health URL from SSM distribution-id and CloudFront GetDistribution. Leave false to read SSM api-url."
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
concurrency-suffix:
|
||||||
|
description: "Optional concurrency group suffix when two deployables share an SSM prefix"
|
||||||
|
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: ${{ inputs.concurrency-suffix != '' && format('deploy-{0}-{1}-{2}', inputs.ssm-prefix, inputs.concurrency-suffix, inputs.environment) || format('deploy-{0}-{1}', 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 }}
|
||||||
|
HEALTH_FROM_DISTRIBUTION: ${{ inputs.health-from-distribution }}
|
||||||
|
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")
|
||||||
|
if [ "${HEALTH_FROM_DISTRIBUTION}" = "true" ]; then
|
||||||
|
DIST_ID=$(get_param "${prefix}/distribution-id")
|
||||||
|
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
|
||||||
|
API_URL="https://${DOMAIN}"
|
||||||
|
else
|
||||||
|
API_URL=$(get_param "${prefix}/api-url")
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
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 \
|
||||||
|
.
|
||||||
|
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
if: ${{ inputs.sentry-project != '' }}
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: Upload BFF source maps
|
||||||
|
if: ${{ inputs.sentry-project != '' }}
|
||||||
|
env:
|
||||||
|
ECR: ${{ steps.deploy.outputs.ecr }}
|
||||||
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
SENTRY_URL: https://de.sentry.io
|
||||||
|
SENTRY_ORG: ${{ inputs.sentry-org }}
|
||||||
|
SENTRY_PROJECT: ${{ inputs.sentry-project }}
|
||||||
|
SENTRY_CONTAINER_FILES: ${{ inputs.sentry-container-files }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${SENTRY_AUTH_TOKEN}" ]; then
|
||||||
|
echo "SENTRY_AUTH_TOKEN is required to upload BFF source maps" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${SENTRY_CONTAINER_FILES}" ]; then
|
||||||
|
echo "sentry-container-files is required when sentry-project is set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker pull "${ECR}:${GIT_SHA}"
|
||||||
|
mkdir -p build/sentry
|
||||||
|
cid="$(docker create "${ECR}:${GIT_SHA}")"
|
||||||
|
cleanup() { docker rm "${cid}" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
IFS=',' read -r -a files <<< "${SENTRY_CONTAINER_FILES}"
|
||||||
|
for path in "${files[@]}"; do
|
||||||
|
path="${path#"${path%%[![:space:]]*}"}"
|
||||||
|
path="${path%"${path##*[![:space:]]}"}"
|
||||||
|
if [ -z "${path}" ]; then
|
||||||
|
echo "sentry-container-files contains an empty path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
base="$(basename "${path}")"
|
||||||
|
docker cp "${cid}:${path}" "build/sentry/${base}"
|
||||||
|
done
|
||||||
|
if [ -f build/sentry/server.js ]; then
|
||||||
|
grep -q "${GIT_SHA}" build/sentry/server.js
|
||||||
|
grep -q debugId build/sentry/server.js
|
||||||
|
fi
|
||||||
|
npx --yes @sentry/cli@2 sourcemaps upload \
|
||||||
|
--org "${SENTRY_ORG}" \
|
||||||
|
--project "${SENTRY_PROJECT}" \
|
||||||
|
--release "${GIT_SHA}" \
|
||||||
|
build/sentry
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
|
APPLY_TASK_ENVIRONMENT: ${{ inputs.apply-task-environment }}
|
||||||
|
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "${APPLY_TASK_ENVIRONMENT}" = "true" ]; then
|
||||||
|
prefix="${SSM_PREFIX%/}"
|
||||||
|
TASK_ENV_JSON="$(aws ssm get-parameter \
|
||||||
|
--name "${prefix}/task-environment" \
|
||||||
|
--with-decryption \
|
||||||
|
--query Parameter.Value \
|
||||||
|
--output text)"
|
||||||
|
export TASK_ENV_JSON
|
||||||
|
fi
|
||||||
|
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")
|
||||||
|
apply = os.environ.get("APPLY_TASK_ENVIRONMENT") == "true"
|
||||||
|
found = False
|
||||||
|
for container in td["containerDefinitions"]:
|
||||||
|
if container["name"] != name:
|
||||||
|
continue
|
||||||
|
found = True
|
||||||
|
container["image"] = image
|
||||||
|
if apply:
|
||||||
|
env_map = json.loads(os.environ["TASK_ENV_JSON"])
|
||||||
|
if not isinstance(env_map, dict) or not env_map:
|
||||||
|
sys.exit("task-environment must be a non-empty JSON object")
|
||||||
|
env = {str(key): str(value) for key, value in env_map.items()}
|
||||||
|
env.pop("GIT_SHA", None)
|
||||||
|
container["stopTimeout"] = 60
|
||||||
|
else:
|
||||||
|
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
||||||
|
for key, value in extra_env.items():
|
||||||
|
env[str(key)] = str(value)
|
||||||
|
env["GIT_SHA"] = sha
|
||||||
|
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 }}
|
||||||
|
HEALTH_ATTEMPTS: ${{ inputs.health-attempts }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
path="${HEALTH_PATH}"
|
||||||
|
case "${path}" in
|
||||||
|
/*) ;;
|
||||||
|
*) path="/${path}" ;;
|
||||||
|
esac
|
||||||
|
url="${API_URL%/}${path}"
|
||||||
|
if ! [[ "${HEALTH_ATTEMPTS}" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "health-attempts must be a positive integer" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for _ in $(seq 1 "${HEALTH_ATTEMPTS}"); 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
|
||||||
274
.github/workflows/cd-hcp-lambda.yaml
vendored
Normal file
274
.github/workflows/cd-hcp-lambda.yaml
vendored
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
name: CD — HCP Lambda
|
||||||
|
|
||||||
|
# Reusable Lambda zip 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-lambda.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: /payments-dashboard/deploy
|
||||||
|
# function-keys: process_csv,slack_app_home
|
||||||
|
# ship-gate: true
|
||||||
|
#
|
||||||
|
# The caller repo must provide scripts/package_lambdas.mjs, which writes
|
||||||
|
# build/packages/<key>.zip and embeds the commit in src/buildInfo.js.
|
||||||
|
# Terraform owns the functions and ignores code attributes. SSM under
|
||||||
|
# ssm-prefix supplies artifacts-bucket and <key>-function-name.
|
||||||
|
#
|
||||||
|
# Nothing here creates an HCP run.
|
||||||
|
|
||||||
|
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. /payments-dashboard/deploy)"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
function-keys:
|
||||||
|
description: "Comma-separated package keys. Each maps to SSM <prefix>/<key>-function-name."
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
node-version:
|
||||||
|
description: "Node.js version for setup-node and the packager"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "24"
|
||||||
|
ship-gate:
|
||||||
|
description: "Require the ref to be on main or a legal hotfix/release tag"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy Lambda 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
|
||||||
|
# Newest matching release that is an ancestor of TAG. The highest
|
||||||
|
# release overall is not that ancestor when a hotfix is cut from an
|
||||||
|
# older line (v2.0.0 exists, v1.2.1 is cut from v1.2.0).
|
||||||
|
CANDIDATES="$(
|
||||||
|
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, reverse=True)
|
||||||
|
print("\n".join(tags))
|
||||||
|
'
|
||||||
|
)"
|
||||||
|
|
||||||
|
PREV=""
|
||||||
|
if [ -n "${CANDIDATES}" ]; then
|
||||||
|
while IFS= read -r candidate; do
|
||||||
|
if [ -z "${candidate}" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
candidate_status="$(gh api "repos/${REPO}/compare/${candidate}...${TAG}" --jq .status)"
|
||||||
|
if [ "${candidate_status}" = "ahead" ]; then
|
||||||
|
PREV="${candidate}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done <<< "${CANDIDATES}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${PREV}" ]; then
|
||||||
|
echo "ship-gate: no prior ${PATTERN} release is an ancestor of ${TAG}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ship-gate: ${TAG} is ahead of ${PREV}"
|
||||||
|
|
||||||
|
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: ${{ inputs.node-version }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Build function zips
|
||||||
|
env:
|
||||||
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
FUNCTION_KEYS: ${{ inputs.function-keys }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${FUNCTION_KEYS}" ]; then
|
||||||
|
echo "function-keys is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
keys=()
|
||||||
|
IFS=',' read -r -a raw_keys <<< "${FUNCTION_KEYS}"
|
||||||
|
for raw in "${raw_keys[@]}"; do
|
||||||
|
key="${raw#"${raw%%[![:space:]]*}"}"
|
||||||
|
key="${key%"${key##*[![:space:]]}"}"
|
||||||
|
if [ -z "${key}" ]; then
|
||||||
|
echo "function-keys contains an empty key" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "${key}" =~ ^[A-Za-z0-9_]+$ ]]; then
|
||||||
|
echo "invalid function key: ${key}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
keys+=("${key}")
|
||||||
|
done
|
||||||
|
if [ "${#keys[@]}" -eq 0 ]; then
|
||||||
|
echo "function-keys is empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
clean="$(IFS=,; echo "${keys[*]}")"
|
||||||
|
echo "keys=${clean}" >> "${GITHUB_ENV}"
|
||||||
|
cmd=(node scripts/package_lambdas.mjs --git-sha "${GIT_SHA}" --out-dir build/packages)
|
||||||
|
for key in "${keys[@]}"; do
|
||||||
|
cmd+=(--only "${key}")
|
||||||
|
done
|
||||||
|
"${cmd[@]}"
|
||||||
|
FUNCTION_KEYS_CLEAN="${clean}" python3 - <<'PY'
|
||||||
|
import os, zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
sha = os.environ["GIT_SHA"]
|
||||||
|
keys = [part for part in os.environ["FUNCTION_KEYS_CLEAN"].split(",") if part]
|
||||||
|
for name in keys:
|
||||||
|
path = Path("build/packages") / f"{name}.zip"
|
||||||
|
if not path.is_file():
|
||||||
|
raise SystemExit(f"missing {path}")
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
info = zf.read("src/buildInfo.js").decode()
|
||||||
|
if sha not in info:
|
||||||
|
raise SystemExit(f"{path} missing GIT_SHA {sha}")
|
||||||
|
print("zips ok")
|
||||||
|
PY
|
||||||
|
|
||||||
|
- 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: Upload zips and update function code
|
||||||
|
env:
|
||||||
|
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||||
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
FUNCTION_KEYS_CLEAN: ${{ env.keys }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
prefix="${SSM_PREFIX%/}"
|
||||||
|
bucket="$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)"
|
||||||
|
IFS=',' read -r -a keys <<< "${FUNCTION_KEYS_CLEAN}"
|
||||||
|
for key in "${keys[@]}"; do
|
||||||
|
fn="$(aws ssm get-parameter --name "${prefix}/${key}-function-name" --query Parameter.Value --output text)"
|
||||||
|
s3_key="functions/${key}/${GIT_SHA}.zip"
|
||||||
|
aws s3 cp "build/packages/${key}.zip" "s3://${bucket}/${s3_key}"
|
||||||
|
aws lambda update-function-code \
|
||||||
|
--function-name "${fn}" \
|
||||||
|
--s3-bucket "${bucket}" \
|
||||||
|
--s3-key "${s3_key}" \
|
||||||
|
--query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \
|
||||||
|
--output table
|
||||||
|
aws lambda wait function-updated-v2 --function-name "${fn}"
|
||||||
|
done
|
||||||
409
.github/workflows/cd-hcp-spa.yaml
vendored
Normal file
409
.github/workflows/cd-hcp-spa.yaml
vendored
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
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
|
||||||
|
#
|
||||||
|
# concurrency-suffix splits two deployables that share one SSM prefix.
|
||||||
|
# verify-companion-api adds cache, asset, and /api/health checks after the
|
||||||
|
# index.html hash matches. Empty defaults keep the previous behavior.
|
||||||
|
#
|
||||||
|
# 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: ""
|
||||||
|
verify-companion-api:
|
||||||
|
description: "After the index hash matches, check cache headers, hashed assets, and /api/health"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
concurrency-suffix:
|
||||||
|
description: "Optional concurrency group suffix when two deployables share an SSM prefix"
|
||||||
|
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: ${{ inputs.concurrency-suffix != '' && format('deploy-{0}-{1}-{2}', inputs.ssm-prefix, inputs.concurrency-suffix, inputs.environment) || format('deploy-{0}-{1}', 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
|
||||||
|
|
||||||
|
- name: Verify companion API
|
||||||
|
if: ${{ inputs.verify-companion-api }}
|
||||||
|
env:
|
||||||
|
SITE_URL: ${{ steps.deploy.outputs.site_url }}
|
||||||
|
HEALTH_BUDGET: "20"
|
||||||
|
HEALTH_INTERVAL: "15"
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SITE_URL="${SITE_URL%/}"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${tmp}"' EXIT
|
||||||
|
|
||||||
|
curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers"
|
||||||
|
curl -fsS --max-time 30 "${SITE_URL}/signin" -o "${tmp}/signin.html"
|
||||||
|
curl -fsS --max-time 30 "${SITE_URL}/help" -o "${tmp}/route.html"
|
||||||
|
if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then
|
||||||
|
echo "FAIL: HTML Cache-Control is missing no-store." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -c '
|
||||||
|
import re, sys
|
||||||
|
html = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
seen = []
|
||||||
|
for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
|
||||||
|
if path not in seen:
|
||||||
|
seen.append(path)
|
||||||
|
print(path)
|
||||||
|
' "${tmp}/index.html" > "${tmp}/asset-paths.txt"
|
||||||
|
|
||||||
|
if [ ! -s "${tmp}/asset-paths.txt" ]; then
|
||||||
|
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
: > "${tmp}/assets.txt"
|
||||||
|
immutable_ok="no"
|
||||||
|
while IFS= read -r asset_path; do
|
||||||
|
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \
|
||||||
|
-o "${tmp}/asset-body" -D "${tmp}/asset.headers"
|
||||||
|
cat "${tmp}/asset-body" >> "${tmp}/assets.txt"
|
||||||
|
if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then
|
||||||
|
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
|
||||||
|
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
immutable_ok="yes"
|
||||||
|
fi
|
||||||
|
done < "${tmp}/asset-paths.txt"
|
||||||
|
if [ "${immutable_ok}" != "yes" ]; then
|
||||||
|
echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
|
||||||
|
if grep -Eiq 'https?://(localhost|127\.0\.0\.1):[0-9]+' "${tmp}/served.txt"; then
|
||||||
|
echo "FAIL: served assets contain forbidden URL localhost." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
health_code="000"
|
||||||
|
health_sha=""
|
||||||
|
health_attempt=0
|
||||||
|
while [ "${health_attempt}" -lt "${HEALTH_BUDGET}" ]; do
|
||||||
|
health_attempt=$((health_attempt + 1))
|
||||||
|
health_code="$(curl -sS --max-time 30 -o "${tmp}/health.json" -w '%{http_code}' "${SITE_URL}/api/health" || echo "000")"
|
||||||
|
echo "health poll ${health_attempt}/${HEALTH_BUDGET}: GET /api/health http=${health_code}"
|
||||||
|
if [ "${health_code}" = "200" ]; then
|
||||||
|
health_sha="$(python3 -c 'import json,sys
|
||||||
|
try:
|
||||||
|
print(json.load(open(sys.argv[1], encoding="utf-8")).get("sha") or "")
|
||||||
|
except Exception:
|
||||||
|
print("")
|
||||||
|
' "${tmp}/health.json")"
|
||||||
|
if [ -n "${health_sha}" ] && [ "${health_sha}" != "bootstrap" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "health poll ${health_attempt}/${HEALTH_BUDGET}: sha=${health_sha:-missing} (waiting for Deploy API)"
|
||||||
|
fi
|
||||||
|
if [ "${health_attempt}" -lt "${HEALTH_BUDGET}" ]; then
|
||||||
|
sleep "${HEALTH_INTERVAL}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "${health_code}" != "200" ]; then
|
||||||
|
echo "FAIL: GET /api/health returned HTTP ${health_code} after ${HEALTH_BUDGET} polls." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${health_sha}" ] || [ "${health_sha}" = "bootstrap" ]; then
|
||||||
|
echo "FAIL: GET /api/health is still the bootstrap stub after ${HEALTH_BUDGET} polls." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 -c 'import json,sys; body=json.load(open(sys.argv[1], encoding="utf-8")); raise SystemExit(0 if body.get("stage") and body.get("sha") else 1)' "${tmp}/health.json"
|
||||||
|
echo "PASS: companion API smoke checks passed."
|
||||||
312
.github/workflows/cd-hcp-static.yaml
vendored
Normal file
312
.github/workflows/cd-hcp-static.yaml
vendored
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
name: CD — HCP static site
|
||||||
|
|
||||||
|
# Reusable CloudFront/S3 CD for unfingerprinted static sites. 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:`.
|
||||||
|
#
|
||||||
|
# Assets are not content-hashed, so they get a one-day cache. HTML, XML, and
|
||||||
|
# text get no-cache. Do not point a hashed SPA at this workflow.
|
||||||
|
#
|
||||||
|
# Caller example:
|
||||||
|
# jobs:
|
||||||
|
# deploy-prod:
|
||||||
|
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-static.yaml@<sha> # vX.Y.Z
|
||||||
|
# permissions: { contents: read, id-token: write }
|
||||||
|
# secrets: inherit
|
||||||
|
# with:
|
||||||
|
# environment: prod
|
||||||
|
# ref: ${{ inputs.ref }}
|
||||||
|
# ssm-prefix: /seahaven-site/deploy
|
||||||
|
# required-paths: _site/index.html,_site/contact/index.html,_site/404.html
|
||||||
|
# min-file-count: 40
|
||||||
|
# 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. /seahaven-site/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
|
||||||
|
output-dir:
|
||||||
|
description: "Build output directory"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "_site"
|
||||||
|
required-paths:
|
||||||
|
description: "Comma-separated repo-relative files that must exist after the build"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
min-file-count:
|
||||||
|
description: "Minimum file count under output-dir. Zero skips the count check."
|
||||||
|
type: number
|
||||||
|
required: false
|
||||||
|
default: 1
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy static site 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 site
|
||||||
|
env:
|
||||||
|
OUTPUT_DIR: ${{ inputs.output-dir }}
|
||||||
|
REQUIRED_PATHS: ${{ inputs.required-paths }}
|
||||||
|
MIN_FILE_COUNT: ${{ inputs.min-file-count }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, sys
|
||||||
|
output_dir = os.environ["OUTPUT_DIR"]
|
||||||
|
if not os.path.isdir(output_dir):
|
||||||
|
print(f"build did not produce {output_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
missing = []
|
||||||
|
for raw in os.environ.get("REQUIRED_PATHS", "").split(","):
|
||||||
|
path = raw.strip()
|
||||||
|
if path and not os.path.isfile(path):
|
||||||
|
missing.append(path)
|
||||||
|
if missing:
|
||||||
|
print("missing required build files: " + ", ".join(missing), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
count = 0
|
||||||
|
for _root, _dirs, files in os.walk(output_dir):
|
||||||
|
count += len(files)
|
||||||
|
minimum = int(os.environ["MIN_FILE_COUNT"])
|
||||||
|
if minimum > 0 and count < minimum:
|
||||||
|
print(f"build produced only {count} files (expected >= {minimum})", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
index = os.path.join(output_dir, "index.html")
|
||||||
|
if not os.path.isfile(index):
|
||||||
|
print(f"missing {index}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Build OK: {count} files.")
|
||||||
|
PY
|
||||||
|
index_sha="$(python3 -c 'import hashlib,os,pathlib; print(hashlib.sha256(pathlib.Path(os.environ["OUTPUT_DIR"], "index.html").read_bytes()).hexdigest())')"
|
||||||
|
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
|
||||||
|
echo "${OUTPUT_DIR}/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 build output to the bucket root
|
||||||
|
env:
|
||||||
|
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
|
||||||
|
OUTPUT_DIR: ${{ inputs.output-dir }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \
|
||||||
|
--exclude "*.html" --exclude "*.xml" --exclude "*.txt" \
|
||||||
|
--cache-control "public, max-age=86400"
|
||||||
|
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \
|
||||||
|
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \
|
||||||
|
--cache-control "no-cache"
|
||||||
|
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress --delete
|
||||||
|
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
|
||||||
4
.github/workflows/cd-mobile-ios.yaml
vendored
4
.github/workflows/cd-mobile-ios.yaml
vendored
|
|
@ -71,7 +71,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ secrets.deploy-role-arn }}
|
role-to-assume: ${{ secrets.deploy-role-arn }}
|
||||||
aws-region: ${{ inputs.region }}
|
aws-region: ${{ inputs.region }}
|
||||||
|
|
@ -82,7 +82,7 @@ jobs:
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
||||||
|
|
||||||
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
- uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||||
with:
|
with:
|
||||||
ruby-version: ${{ inputs.ruby-version }}
|
ruby-version: ${{ inputs.ruby-version }}
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
|
||||||
2
.github/workflows/cd-sam.yaml
vendored
2
.github/workflows/cd-sam.yaml
vendored
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
|
|
||||||
- uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0
|
- uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0
|
||||||
|
|
||||||
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ secrets.deploy-role-arn }}
|
role-to-assume: ${{ secrets.deploy-role-arn }}
|
||||||
aws-region: ${{ inputs.region }}
|
aws-region: ${{ inputs.region }}
|
||||||
|
|
|
||||||
295
.github/workflows/ci-autofix.yaml
vendored
Normal file
295
.github/workflows/ci-autofix.yaml
vendored
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# Presets run first, then any format-command / lint-fix-command / extra-command.
|
||||||
|
# prettier npm ci + npm run format (requires package-lock.json)
|
||||||
|
# eslint npm ci + npx eslint . --fix (opt-in; do not call npm run lint)
|
||||||
|
# ruff ruff format . + ruff check --fix . (ruff 0.15.22)
|
||||||
|
# terraform terraform fmt -recursive in terraform-working-directory
|
||||||
|
#
|
||||||
|
# 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:
|
||||||
|
# presets: prettier,terraform
|
||||||
|
#
|
||||||
|
# Python callers pass presets: ruff,terraform. Add eslint only when that
|
||||||
|
# repo's CI lint step is ESLint itself and Prettier owns formatting.
|
||||||
|
# Do not pass `npm run lint -- --fix` (some apps chain Redocly into lint).
|
||||||
|
#
|
||||||
|
# Org secrets (names only): AUTOFMT_APP_ID, AUTOFMT_APP_PRIVATE_KEY.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
presets:
|
||||||
|
description: "Comma-separated presets: prettier, eslint, ruff, terraform"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
format-command:
|
||||||
|
description: "Optional write command run after presets (e.g. npm run format)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
lint-fix-command:
|
||||||
|
description: "Optional write lint-fix command run after presets"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
extra-command:
|
||||||
|
description: "Optional extra write command run after presets"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
node-version:
|
||||||
|
description: "Node.js version for the prettier or eslint preset, or an npm command"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "24"
|
||||||
|
terraform-version:
|
||||||
|
description: "Terraform version for the terraform preset or an extra-command that runs terraform"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "1.16.0"
|
||||||
|
terraform-working-directory:
|
||||||
|
description: "Directory for the terraform preset (terraform fmt -recursive)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "terraform"
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Resolve presets
|
||||||
|
id: presets
|
||||||
|
env:
|
||||||
|
SKIP_BOT: ${{ steps.skip-bot.outputs.skip }}
|
||||||
|
PRESETS: ${{ inputs.presets }}
|
||||||
|
FORMAT_COMMAND: ${{ inputs.format-command }}
|
||||||
|
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
|
||||||
|
EXTRA_COMMAND: ${{ inputs.extra-command }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
write_outputs() {
|
||||||
|
{
|
||||||
|
echo "prettier=$1"
|
||||||
|
echo "eslint=$2"
|
||||||
|
echo "ruff=$3"
|
||||||
|
echo "terraform=$4"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${SKIP_BOT}" = "true" ]; then
|
||||||
|
write_outputs false false false false
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
want_prettier=false
|
||||||
|
want_eslint=false
|
||||||
|
want_ruff=false
|
||||||
|
want_terraform=false
|
||||||
|
|
||||||
|
if [ -n "${PRESETS}" ]; then
|
||||||
|
IFS=',' read -ra parts <<< "${PRESETS}"
|
||||||
|
for raw in "${parts[@]}"; do
|
||||||
|
token=$(printf '%s' "${raw}" | tr -d '[:space:]')
|
||||||
|
case "${token}" in
|
||||||
|
"") ;;
|
||||||
|
prettier) want_prettier=true ;;
|
||||||
|
eslint) want_eslint=true ;;
|
||||||
|
ruff) want_ruff=true ;;
|
||||||
|
terraform) want_terraform=true ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown preset: ${token}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if { [ "${want_prettier}" = "true" ] || [ "${want_eslint}" = "true" ]; } && [ ! -f package-lock.json ]; then
|
||||||
|
echo "prettier and eslint presets require package-lock.json" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${want_prettier}" = "false" ] \
|
||||||
|
&& [ "${want_eslint}" = "false" ] \
|
||||||
|
&& [ "${want_ruff}" = "false" ] \
|
||||||
|
&& [ "${want_terraform}" = "false" ] \
|
||||||
|
&& [ -z "${FORMAT_COMMAND}" ] \
|
||||||
|
&& [ -z "${LINT_FIX_COMMAND}" ] \
|
||||||
|
&& [ -z "${EXTRA_COMMAND}" ]; then
|
||||||
|
echo "Set presets or a format, lint-fix, or extra command." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_outputs "${want_prettier}" "${want_eslint}" "${want_ruff}" "${want_terraform}"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' && (steps.presets.outputs.prettier == 'true' || steps.presets.outputs.eslint == 'true' || contains(inputs.format-command, 'npm') || contains(inputs.lint-fix-command, 'npm')) }}
|
||||||
|
with:
|
||||||
|
node-version: ${{ inputs.node-version }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' && (steps.presets.outputs.prettier == 'true' || steps.presets.outputs.eslint == 'true' || contains(inputs.format-command, 'npm') || contains(inputs.lint-fix-command, 'npm')) }}
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.ruff == 'true' || contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff') || contains(inputs.extra-command, 'ruff')) }}
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install ruff
|
||||||
|
if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.ruff == 'true' || contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff') || contains(inputs.extra-command, 'ruff')) }}
|
||||||
|
run: pip install 'ruff==0.15.22'
|
||||||
|
|
||||||
|
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
|
if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.terraform == '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:
|
||||||
|
PRETTIER: ${{ steps.presets.outputs.prettier }}
|
||||||
|
ESLINT: ${{ steps.presets.outputs.eslint }}
|
||||||
|
RUFF: ${{ steps.presets.outputs.ruff }}
|
||||||
|
TERRAFORM: ${{ steps.presets.outputs.terraform }}
|
||||||
|
TERRAFORM_DIR: ${{ inputs.terraform-working-directory }}
|
||||||
|
FORMAT_COMMAND: ${{ inputs.format-command }}
|
||||||
|
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
|
||||||
|
EXTRA_COMMAND: ${{ inputs.extra-command }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "${PRETTIER}" = "true" ]; then
|
||||||
|
npm run format
|
||||||
|
fi
|
||||||
|
if [ "${ESLINT}" = "true" ]; then
|
||||||
|
npx eslint . --fix
|
||||||
|
fi
|
||||||
|
if [ "${RUFF}" = "true" ]; then
|
||||||
|
ruff format .
|
||||||
|
ruff check --fix .
|
||||||
|
fi
|
||||||
|
if [ "${TERRAFORM}" = "true" ]; then
|
||||||
|
terraform -chdir="${TERRAFORM_DIR}" fmt -recursive
|
||||||
|
fi
|
||||||
|
if [ -n "${FORMAT_COMMAND}" ]; then
|
||||||
|
bash -euo pipefail -c "${FORMAT_COMMAND}"
|
||||||
|
fi
|
||||||
|
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_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
|
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
|
||||||
|
|
||||||
|
# The noreply local-part must be the bot account id, not the App id.
|
||||||
|
# An App-id prefix still pushes, but GitHub does not link the commit
|
||||||
|
# to the bot, so the app logo is not used.
|
||||||
|
bot_id=$(curl -fsSL \
|
||||||
|
-H "Authorization: Bearer ${APP_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
"https://api.github.com/users/${APP_SLUG}%5Bbot%5D" | jq -er '.id')
|
||||||
|
git config user.name "${APP_SLUG}[bot]"
|
||||||
|
git config user.email "${bot_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
262
.github/workflows/ci-frontend.yaml
vendored
Normal 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
|
||||||
2
.github/workflows/ci-mobile-ios.yaml
vendored
2
.github/workflows/ci-mobile-ios.yaml
vendored
|
|
@ -214,7 +214,7 @@ jobs:
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
||||||
|
|
||||||
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
- uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||||
with:
|
with:
|
||||||
ruby-version: ${{ inputs.ruby-version }}
|
ruby-version: ${{ inputs.ruby-version }}
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
|
||||||
86
.github/workflows/ci-python-app.yaml
vendored
86
.github/workflows/ci-python-app.yaml
vendored
|
|
@ -1,19 +1,14 @@
|
||||||
name: CI — Python (app)
|
name: CI — Python (app)
|
||||||
|
|
||||||
# Reusable CI for plain Python apps / locally-run tooling that do NOT deploy via
|
# Reusable CI for plain Python apps / locally-run tooling that do NOT deploy via
|
||||||
# SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those). Beyond
|
# SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those).
|
||||||
# lint + format it adds two things such repos commonly need:
|
# Runs ruff check + format, plus an optional conventions audit.
|
||||||
# * a collect-only import check for a root suite whose live run needs secrets
|
|
||||||
# (verifies every test module imports cleanly without running them), and
|
|
||||||
# * an isolated full pytest run for a self-contained subproject dir whose tests
|
|
||||||
# package collides with the root tests/ package (e.g. a `tests/` under a
|
|
||||||
# subdir) and so must run in its own working directory.
|
|
||||||
#
|
#
|
||||||
# Naming is load-bearing (see this repo's ci.yaml): the org ruleset matches the
|
# Naming is load-bearing (see this repo's ci.yaml): the org ruleset matches the
|
||||||
# required `ci / ci` check against the JOB check-run name. A caller job keyed `ci`
|
# required `ci / ci` check against the JOB check-run name. A caller job keyed `ci`
|
||||||
# invoking this workflow reports each job here as `ci / <job>`, so the aggregator
|
# invoking this workflow reports each job here as `ci / <job>`, so the aggregator
|
||||||
# job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on every
|
# job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on lint,
|
||||||
# other job, so the single required check fails if any sub-job fails.
|
# so the single required check fails if lint fails.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
@ -26,18 +21,6 @@ on:
|
||||||
description: "Space-separated directories for ruff (default: repo root)"
|
description: "Space-separated directories for ruff (default: repo root)"
|
||||||
type: string
|
type: string
|
||||||
default: "."
|
default: "."
|
||||||
requirements:
|
|
||||||
description: "Requirements file used for the pip cache key + install"
|
|
||||||
type: string
|
|
||||||
default: "requirements.txt"
|
|
||||||
collect-only:
|
|
||||||
description: "Run 'pytest --collect-only' at the repo root (imports resolve without secrets)"
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
subproject-dir:
|
|
||||||
description: "Optional self-contained subproject dir whose pytest suite runs in full"
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
run-conventions-check:
|
run-conventions-check:
|
||||||
description: "Run the lightweight conventions audit (README + .gitignore covers .env)"
|
description: "Run the lightweight conventions audit (README + .gitignore covers .env)"
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
@ -52,10 +35,8 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
# The trailing segment of every group in this file is the job id written
|
# The trailing segment of every group in this file is the job id written
|
||||||
# out literally, NOT `${{ github.job }}`. In a called workflow that
|
# out literally, NOT `${{ github.job }}`. In a called workflow that
|
||||||
# expression evaluates to the CALLER's job id, so all four jobs here would
|
# expression evaluates to the CALLER's job id, so sibling jobs would
|
||||||
# resolve to one group and, with cancel-in-progress on, cancel each other.
|
# resolve to one group and, with cancel-in-progress on, cancel each other.
|
||||||
# Observed live in pr-reviewer: `lint` was cancelled one second in by a
|
|
||||||
# sibling and the aggregator failed on the cancelled dependency.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-lint
|
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-lint
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
@ -101,68 +82,19 @@ jobs:
|
||||||
fi
|
fi
|
||||||
echo "Conventions check passed."
|
echo "Conventions check passed."
|
||||||
|
|
||||||
test-collect:
|
|
||||||
if: ${{ inputs.collect-only }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
concurrency:
|
|
||||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-test-collect
|
|
||||||
cancel-in-progress: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
|
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
||||||
with:
|
|
||||||
python-version: ${{ inputs.python-version }}
|
|
||||||
cache: pip
|
|
||||||
cache-dependency-path: ${{ inputs.requirements }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
pip install -r "${{ inputs.requirements }}"
|
|
||||||
pip install pytest python-dotenv
|
|
||||||
|
|
||||||
- name: Pytest collect-only
|
|
||||||
run: pytest --collect-only -q
|
|
||||||
|
|
||||||
subproject-tests:
|
|
||||||
if: ${{ inputs.subproject-dir != '' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
concurrency:
|
|
||||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-subproject-tests
|
|
||||||
cancel-in-progress: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
|
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
||||||
with:
|
|
||||||
python-version: ${{ inputs.python-version }}
|
|
||||||
cache: pip
|
|
||||||
cache-dependency-path: ${{ inputs.requirements }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
pip install -r "${{ inputs.requirements }}"
|
|
||||||
pip install pytest
|
|
||||||
|
|
||||||
- name: Run subproject suite
|
|
||||||
working-directory: ${{ inputs.subproject-dir }}
|
|
||||||
run: python -m pytest -q
|
|
||||||
|
|
||||||
ci:
|
ci:
|
||||||
# Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`.
|
# Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`.
|
||||||
needs: [lint, test-collect, subproject-tests]
|
needs: [lint]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-ci
|
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-ci
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- name: Require all jobs to have succeeded
|
- name: Require lint to have succeeded
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
|
if [ "${{ needs.lint.result }}" != "success" ]; then
|
||||||
echo "A required CI job failed or was cancelled."
|
echo "lint failed or was cancelled."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "All CI jobs passed."
|
echo "All CI jobs passed."
|
||||||
|
|
|
||||||
2
.github/workflows/ci-python-sam.yaml
vendored
2
.github/workflows/ci-python-sam.yaml
vendored
|
|
@ -95,7 +95,7 @@ jobs:
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
if: ${{ inputs.run-cdk-synth && inputs.enable-qemu }}
|
if: ${{ inputs.run-cdk-synth && inputs.enable-qemu }}
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
|
|
||||||
- name: CDK synth
|
- name: CDK synth
|
||||||
if: ${{ inputs.run-cdk-synth }}
|
if: ${{ inputs.run-cdk-synth }}
|
||||||
|
|
|
||||||
57
.github/workflows/ci-terraform.yaml
vendored
Normal file
57
.github/workflows/ci-terraform.yaml
vendored
Normal 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
|
||||||
2
.github/workflows/ci-typescript-cdk.yaml
vendored
2
.github/workflows/ci-typescript-cdk.yaml
vendored
|
|
@ -69,7 +69,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
if: ${{ inputs.enable-qemu }}
|
if: ${{ inputs.enable-qemu }}
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
|
|
||||||
10
.github/workflows/ci-typescript-frontend.yaml
vendored
10
.github/workflows/ci-typescript-frontend.yaml
vendored
|
|
@ -1,9 +1,11 @@
|
||||||
name: CI — TypeScript Frontend
|
name: CI — TypeScript Frontend
|
||||||
|
|
||||||
# Reusable CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
|
# Sequential reusable CI for remaining-lane TypeScript front-end apps that
|
||||||
# with vitest + Playwright). Emits the single `ci / ci` status context required
|
# still emit `ci / ci`. HCP app repos should call ci-frontend.yaml (parallel
|
||||||
# by the org branch-protection rulesets — keep the caller job id `ci` so the
|
# portions) plus a caller-owned `ci-complete` aggregator instead.
|
||||||
# context resolves to `ci / ci`.
|
#
|
||||||
|
# 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
|
# Runs, in order: a Sea Haven standards gate (required npm scripts present, no
|
||||||
# AI-tool footers / hook bypasses / hardcoded secrets in the added lines),
|
# AI-tool footers / hook bypasses / hardcoded secrets in the added lines),
|
||||||
|
|
|
||||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -53,10 +53,6 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- 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
|
- name: Install actionlint
|
||||||
env:
|
env:
|
||||||
ACTIONLINT_VERSION: 1.7.12
|
ACTIONLINT_VERSION: 1.7.12
|
||||||
|
|
|
||||||
1
.github/workflows/release-on-merge.yaml
vendored
1
.github/workflows/release-on-merge.yaml
vendored
|
|
@ -30,7 +30,6 @@ on:
|
||||||
- ".github/workflows/**"
|
- ".github/workflows/**"
|
||||||
- "!.github/workflows/ci.yaml"
|
- "!.github/workflows/ci.yaml"
|
||||||
- "!.github/workflows/labeler.yaml"
|
- "!.github/workflows/labeler.yaml"
|
||||||
- "!.github/workflows/policy.yaml"
|
|
||||||
- "!.github/workflows/release-on-merge.yaml"
|
- "!.github/workflows/release-on-merge.yaml"
|
||||||
- "!.github/workflows/auto-merge.yaml"
|
- "!.github/workflows/auto-merge.yaml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
|
||||||
44
.mergify.yml
44
.mergify.yml
|
|
@ -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
|
|
||||||
178
README.md
178
README.md
|
|
@ -14,7 +14,7 @@ Organization-level GitHub configuration for Sea Haven Industries.
|
||||||
|
|
||||||
### PR title
|
### 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
|
### PR body
|
||||||
|
|
||||||
|
|
@ -26,23 +26,48 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and
|
||||||
|
|
||||||
### Merge queue
|
### 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
|
## What's in here
|
||||||
|
|
||||||
|
### Renovate preset
|
||||||
|
|
||||||
|
`default.json` is the file loaded by `local>Sea-Haven-Industries/.github`. It is intentionally empty of policy. Org Renovate rules live in `Sea-Haven-Industries/renovate-config` as `org-inherited-config.json`.
|
||||||
|
|
||||||
### Reusable Workflows
|
### Reusable Workflows
|
||||||
|
|
||||||
**`.github/workflows/ci-python-sam.yaml`** — Reusable CI workflow for Python / SAM repos. Runs `ruff check` + `ruff format --check`, optional `pytest`, and optional `sam validate --lint`. Also usable for Python CDK repos by disabling SAM validate.
|
**`.github/workflows/ci-python-sam.yaml`** — Reusable CI workflow for Python / SAM repos. Runs `ruff check` + `ruff format --check`, optional `pytest`, and optional `sam validate --lint`. Also usable for Python CDK repos by disabling SAM validate.
|
||||||
|
|
||||||
**`.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-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 requested presets and any optional write commands, and pushes `style: apply formatter` only when the tree is dirty. Presets are `prettier` (`npm run format`), `eslint` (`npx eslint . --fix`, opt-in), `ruff` (`ruff format .` and `ruff check --fix .`), and `terraform` (`terraform fmt -recursive` in `terraform-working-directory`, default `terraform`). 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-hcp-static.yaml`** — HCP static-site CD for unfingerprinted builds such as Eleventy. Checkout at `ref`, Node 24, `npm ci --ignore-scripts` + `npm run build`, one-day cache on assets, `no-cache` on HTML/XML/text, prune, invalidate, verify the served index hash. Reads `/<prefix>/bucket` and `/<prefix>/distribution-id`. Do not use this for a hashed SPA.
|
||||||
|
|
||||||
**`.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`.
|
**`.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`.
|
||||||
|
|
||||||
**`.github/workflows/cd-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds.
|
**`.github/workflows/cd-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds.
|
||||||
|
|
||||||
**`.github/workflows/ci-python-app.yaml`** — Reusable CI for non-SAM Python apps (ruff check + format, optional pytest; no SAM validate).
|
**`.github/workflows/ci-python-app.yaml`** — Reusable CI for non-SAM Python apps (ruff check + format and conventions; no pytest, no SAM validate). Pytest stays a caller-owned job.
|
||||||
|
|
||||||
**`.github/workflows/ci-dotnet.yaml`** — Reusable CI for .NET solutions (`dotnet build`, optional `dotnet test`; SDK version and solution path as inputs).
|
**`.github/workflows/ci-dotnet.yaml`** — Reusable CI for .NET solutions (`dotnet build`, optional `dotnet test`; SDK version and solution path as inputs).
|
||||||
|
|
||||||
|
|
@ -54,12 +79,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/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-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.
|
**`.github/workflows/callable-dependency-review.yaml`** — Dependency review on PRs, failing on high severity. Requires Dependency Graph.
|
||||||
|
|
@ -74,9 +93,7 @@ The supply-chain check operates in **diff mode**: for modified or renamed workfl
|
||||||
|
|
||||||
### Workflow templates (`workflow-templates/`)
|
### 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.
|
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.
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
### Ref pinning policy
|
### Ref pinning policy
|
||||||
|
|
||||||
|
|
@ -166,16 +183,10 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each
|
||||||
| Secret | Value | Consumed by |
|
| Secret | Value | Consumed by |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
| `ANTHROPIC_API_KEY` | Anthropic API key | `reviewer-eval.yml` in `open-swe` |
|
| `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).
|
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.
|
||||||
|
|
||||||
Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo:
|
|
||||||
|
|
||||||
| Secret | Value | Consumed by |
|
|
||||||
|--------|-------|-------------|
|
|
||||||
| `JIRA_CLOUD_ID` | Atlassian Cloud ID UUID (find in **Jira Settings → Products → Jira Software**) | `callable-pr-policy.yaml` |
|
|
||||||
| `JIRA_SERVICE_ACCOUNT_EMAIL` | Email of the service account with read access to DEV/PLAT/SEC projects | `callable-pr-policy.yaml` |
|
|
||||||
| `JIRA_API_TOKEN` | API token for that account (generated at **id.atlassian.com/manage-profile/security/api-tokens**) | `callable-pr-policy.yaml` |
|
|
||||||
|
|
||||||
### 2. Add CI to a repo
|
### 2. Add CI to a repo
|
||||||
|
|
||||||
|
|
@ -242,49 +253,118 @@ jobs:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Add PR policy to a repo
|
**HCP app repo** (converted callers; required check is `ci-complete`):
|
||||||
|
|
||||||
Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1).
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: PR Policy
|
name: CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review]
|
branches: [main, hotfix/**, release/**]
|
||||||
|
merge_group:
|
||||||
concurrency:
|
push:
|
||||||
group: policy-${{ github.event.pull_request.number }}
|
branches: [hotfix/**, release/**]
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: read
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
policy:
|
autofix:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@9c1ecf942894b19aba5c71b85b41906c6c83b749 # v1.0.5
|
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||||
secrets:
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@<full-commit-sha> # vX.Y.Z
|
||||||
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
|
permissions: { contents: write }
|
||||||
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
|
secrets: inherit
|
||||||
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
with:
|
||||||
|
presets: prettier,terraform
|
||||||
|
|
||||||
|
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 `presets: ruff,terraform`. The `eslint` preset is opt-in and runs `npx eslint . --fix`. Do not pass `npm run lint -- --fix`: several apps chain Redocly into `lint`. Enable `eslint` only when that repo's CI lint step is ESLint itself and Prettier owns formatting. Optional `format-command`, `lint-fix-command`, and `extra-command` still run after the presets. 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
|
### 3. Add CD to a repo
|
||||||
gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha
|
|
||||||
|
**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
|
**SAM repo** (e.g., remaining SAM stacks):
|
||||||
|
|
||||||
Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
|
|
||||||
|
|
||||||
**SAM repo** (e.g., afterhours-shift-manager):
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: Deploy
|
name: Deploy
|
||||||
|
|
|
||||||
3
default.json
Normal file
3
default.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ Resources:
|
||||||
# - DynamoDB CRUD (afterhours-shifts table)
|
# - DynamoDB CRUD (afterhours-shifts table)
|
||||||
# - secretsmanager:GetSecretValue (afterhours-shift-manager/*)
|
# - secretsmanager:GetSecretValue (afterhours-shift-manager/*)
|
||||||
# - ses:SendEmail (SES identity)
|
# - ses:SendEmail (SES identity)
|
||||||
|
# - sqs:SendMessage (paychex-checkcomponents in seahaven-prod, WeeklyPost)
|
||||||
# - CloudWatch Logs (all functions)
|
# - CloudWatch Logs (all functions)
|
||||||
#
|
#
|
||||||
# payments-dashboard
|
# payments-dashboard
|
||||||
|
|
@ -209,6 +210,25 @@ Resources:
|
||||||
Resource:
|
Resource:
|
||||||
- !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:*"
|
- !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:*"
|
||||||
|
|
||||||
|
# ── SQS cross-account send (afterhours WeeklyPost -> paychex) ─────
|
||||||
|
# afterhours-shift-manager WeeklyPostFunction enqueues the weekly
|
||||||
|
# after-hours pay payload onto paychex-integrations' checkcomponents
|
||||||
|
# queue in seahaven-prod (PLAT-135). Send only. This is a ceiling,
|
||||||
|
# not a grant: the function's inline policy already allows this ARN
|
||||||
|
# and the prod queue policy admits only WeeklyPostFunctionRole-*, so
|
||||||
|
# the boundary was the one missing piece. The PrincipalArn condition
|
||||||
|
# keeps the ceiling closed for every other role on this boundary even
|
||||||
|
# if the queue policy is later loosened.
|
||||||
|
- Sid: SQSPaychexCheckcomponentsSend
|
||||||
|
Effect: Allow
|
||||||
|
Action:
|
||||||
|
- sqs:SendMessage
|
||||||
|
Resource:
|
||||||
|
- arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents
|
||||||
|
Condition:
|
||||||
|
ArnLike:
|
||||||
|
aws:PrincipalArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/afterhours-shift-manager-WeeklyPostFunctionRole-*"
|
||||||
|
|
||||||
# ── Lambda invocation (payments, meal-order inter-function calls) ──
|
# ── Lambda invocation (payments, meal-order inter-function calls) ──
|
||||||
- Sid: LambdaInvoke
|
- Sid: LambdaInvoke
|
||||||
Effect: Allow
|
Effect: Allow
|
||||||
|
|
@ -1031,146 +1051,6 @@ Resources:
|
||||||
Resource:
|
Resource:
|
||||||
- !GetAtt SamCfnExecutionRole.Arn
|
- !GetAtt SamCfnExecutionRole.Arn
|
||||||
|
|
||||||
FrontIntegrationsDeployRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
RoleName: githubdeploy-front-integrations
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
|
||||||
Action: sts:AssumeRoleWithWebIdentity
|
|
||||||
Condition:
|
|
||||||
StringEquals:
|
|
||||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
|
||||||
StringLike:
|
|
||||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/front-integrations:ref:refs/heads/main
|
|
||||||
Policies:
|
|
||||||
- PolicyName: sam-deploy
|
|
||||||
PolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- cloudformation:CreateChangeSet
|
|
||||||
- cloudformation:DeleteChangeSet
|
|
||||||
- cloudformation:DescribeChangeSet
|
|
||||||
- cloudformation:DescribeStackEvents
|
|
||||||
- cloudformation:DescribeStacks
|
|
||||||
- cloudformation:ExecuteChangeSet
|
|
||||||
- cloudformation:GetTemplate
|
|
||||||
- cloudformation:ListStackResources
|
|
||||||
- cloudformation:UpdateStack
|
|
||||||
- cloudformation:CreateStack
|
|
||||||
- cloudformation:TagResource
|
|
||||||
Resource:
|
|
||||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/front-integrations/*
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- cloudformation:GetTemplateSummary
|
|
||||||
Resource: "*"
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- cloudformation:DescribeStacks
|
|
||||||
- cloudformation:CreateChangeSet
|
|
||||||
- cloudformation:DescribeChangeSet
|
|
||||||
- cloudformation:ExecuteChangeSet
|
|
||||||
- cloudformation:CreateStack
|
|
||||||
Resource:
|
|
||||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/aws-sam-cli-managed-default/*
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- s3:PutObject
|
|
||||||
- s3:GetObject
|
|
||||||
- s3:ListBucket
|
|
||||||
- s3:GetBucketLocation
|
|
||||||
- s3:CreateBucket
|
|
||||||
- s3:PutBucketPolicy
|
|
||||||
- s3:GetBucketPolicy
|
|
||||||
- s3:PutLifecycleConfiguration
|
|
||||||
- s3:PutBucketVersioning
|
|
||||||
- s3:DeleteObject
|
|
||||||
Resource:
|
|
||||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*
|
|
||||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*/*
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- iam:PassRole
|
|
||||||
Resource:
|
|
||||||
- !GetAtt SamCfnExecutionRole.Arn
|
|
||||||
|
|
||||||
AfiBackupMonitorDeployRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
RoleName: githubdeploy-afi-backup-monitor
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
|
||||||
Action: sts:AssumeRoleWithWebIdentity
|
|
||||||
Condition:
|
|
||||||
StringEquals:
|
|
||||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
|
||||||
StringLike:
|
|
||||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/afi-backup-monitor:ref:refs/heads/main
|
|
||||||
Policies:
|
|
||||||
- PolicyName: sam-deploy
|
|
||||||
PolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- cloudformation:CreateChangeSet
|
|
||||||
- cloudformation:DeleteChangeSet
|
|
||||||
- cloudformation:DescribeChangeSet
|
|
||||||
- cloudformation:DescribeStackEvents
|
|
||||||
- cloudformation:DescribeStacks
|
|
||||||
- cloudformation:ExecuteChangeSet
|
|
||||||
- cloudformation:GetTemplate
|
|
||||||
- cloudformation:ListStackResources
|
|
||||||
- cloudformation:UpdateStack
|
|
||||||
- cloudformation:CreateStack
|
|
||||||
- cloudformation:TagResource
|
|
||||||
Resource:
|
|
||||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/afi-backup-monitor/*
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- cloudformation:GetTemplateSummary
|
|
||||||
Resource: "*"
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- cloudformation:DescribeStacks
|
|
||||||
- cloudformation:CreateChangeSet
|
|
||||||
- cloudformation:DescribeChangeSet
|
|
||||||
- cloudformation:ExecuteChangeSet
|
|
||||||
- cloudformation:CreateStack
|
|
||||||
Resource:
|
|
||||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/aws-sam-cli-managed-default/*
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- s3:PutObject
|
|
||||||
- s3:GetObject
|
|
||||||
- s3:ListBucket
|
|
||||||
- s3:GetBucketLocation
|
|
||||||
- s3:CreateBucket
|
|
||||||
- s3:PutBucketPolicy
|
|
||||||
- s3:GetBucketPolicy
|
|
||||||
- s3:PutLifecycleConfiguration
|
|
||||||
- s3:PutBucketVersioning
|
|
||||||
- s3:DeleteObject
|
|
||||||
Resource:
|
|
||||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*
|
|
||||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*/*
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- iam:PassRole
|
|
||||||
Resource:
|
|
||||||
- !GetAtt SamCfnExecutionRole.Arn
|
|
||||||
|
|
||||||
PaymentsDashboardDeployRole:
|
PaymentsDashboardDeployRole:
|
||||||
Type: AWS::IAM::Role
|
Type: AWS::IAM::Role
|
||||||
Properties:
|
Properties:
|
||||||
|
|
@ -1242,90 +1122,12 @@ Resources:
|
||||||
- !GetAtt SamCfnExecutionRole.Arn
|
- !GetAtt SamCfnExecutionRole.Arn
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CDK deploy roles (4 repos)
|
# CDK deploy role for seahaven-org-baseline.
|
||||||
|
# Soaked githubdeploy roles for front-integrations, afi-backup-monitor,
|
||||||
|
# exec-aide, seahaven-door-unlock-api, and apm-wo-analysis are removed
|
||||||
|
# here (PLAT-232). Deploying this stack deletes those roles.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
ExecAideDeployRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
RoleName: githubdeploy-exec-aide
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
|
||||||
Action: sts:AssumeRoleWithWebIdentity
|
|
||||||
Condition:
|
|
||||||
StringEquals:
|
|
||||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
|
||||||
StringLike:
|
|
||||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/exec-aide:ref:refs/heads/main
|
|
||||||
Policies:
|
|
||||||
- PolicyName: cdk-deploy
|
|
||||||
PolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- sts:AssumeRole
|
|
||||||
Resource:
|
|
||||||
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
|
|
||||||
|
|
||||||
SeahavenDoorUnlockApiDeployRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
RoleName: githubdeploy-seahaven-door-unlock-api
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
|
||||||
Action: sts:AssumeRoleWithWebIdentity
|
|
||||||
Condition:
|
|
||||||
StringEquals:
|
|
||||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
|
||||||
StringLike:
|
|
||||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-door-unlock-api:ref:refs/heads/main
|
|
||||||
Policies:
|
|
||||||
- PolicyName: cdk-deploy
|
|
||||||
PolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- sts:AssumeRole
|
|
||||||
Resource:
|
|
||||||
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
|
|
||||||
|
|
||||||
ApmWoAnalysisDeployRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
RoleName: githubdeploy-apm-wo-analysis
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
|
||||||
Action: sts:AssumeRoleWithWebIdentity
|
|
||||||
Condition:
|
|
||||||
StringEquals:
|
|
||||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
|
||||||
StringLike:
|
|
||||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/apm-wo-analysis:ref:refs/heads/main
|
|
||||||
Policies:
|
|
||||||
- PolicyName: cdk-deploy
|
|
||||||
PolicyDocument:
|
|
||||||
Version: "2012-10-17"
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- sts:AssumeRole
|
|
||||||
Resource:
|
|
||||||
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
|
|
||||||
|
|
||||||
SeahavenAccountBaselineDeployRole:
|
SeahavenAccountBaselineDeployRole:
|
||||||
Type: AWS::IAM::Role
|
Type: AWS::IAM::Role
|
||||||
Properties:
|
Properties:
|
||||||
|
|
@ -1360,6 +1162,54 @@ Resources:
|
||||||
# Mgmt role github-meal-order-manager-weekly-menu deleted with this stack update.
|
# Mgmt role github-meal-order-manager-weekly-menu deleted with this stack update.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# PLAT-234 principal only. The checks are seahaven-org-baseline pull request
|
||||||
|
# 160: .github/workflows/ci.yaml job iam-policy-check and
|
||||||
|
# scripts/check_iam_policies.py. That job assumes this role. It asserts
|
||||||
|
# StringEquals on the bootstrap trust templates, no lambda write on the
|
||||||
|
# plan template, then ValidatePolicy and CheckNoNewAccess when this role
|
||||||
|
# can be assumed.
|
||||||
|
SeahavenOrgBaselinePolicyCheckRole:
|
||||||
|
Type: AWS::IAM::Role
|
||||||
|
Properties:
|
||||||
|
RoleName: githubdeploy-seahaven-org-baseline-policy-check
|
||||||
|
Description: Access Analyzer policy checks for seahaven-org-baseline CI. No deploy permissions.
|
||||||
|
AssumeRolePolicyDocument:
|
||||||
|
Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Principal:
|
||||||
|
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||||
|
Action: sts:AssumeRoleWithWebIdentity
|
||||||
|
# pull_request jobs with no environment use sub
|
||||||
|
# repo:ORG/seahaven-org-baseline:pull_request. refs/pull/N/merge is
|
||||||
|
# the ref claim, not sub. A second statement is required: StringEquals
|
||||||
|
# and StringLike in one condition are AND.
|
||||||
|
Condition:
|
||||||
|
StringEquals:
|
||||||
|
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||||
|
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-org-baseline:pull_request
|
||||||
|
- Effect: Allow
|
||||||
|
Principal:
|
||||||
|
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||||
|
Action: sts:AssumeRoleWithWebIdentity
|
||||||
|
Condition:
|
||||||
|
StringEquals:
|
||||||
|
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||||
|
StringLike:
|
||||||
|
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-org-baseline:ref:refs/heads/gh-readonly-queue/main/*
|
||||||
|
Policies:
|
||||||
|
- PolicyName: access-analyzer-policy-check
|
||||||
|
PolicyDocument:
|
||||||
|
Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Sid: AccessAnalyzerPolicyCheck
|
||||||
|
Effect: Allow
|
||||||
|
Action:
|
||||||
|
- access-analyzer:ValidatePolicy
|
||||||
|
- access-analyzer:CheckNoNewAccess
|
||||||
|
Resource: "*"
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
LambdaExecutionBoundaryArn:
|
LambdaExecutionBoundaryArn:
|
||||||
Value: !Ref LambdaExecutionBoundary
|
Value: !Ref LambdaExecutionBoundary
|
||||||
|
|
@ -1374,24 +1224,20 @@ Outputs:
|
||||||
Name: github-cfn-execution-role-arn
|
Name: github-cfn-execution-role-arn
|
||||||
AfterhoursShiftManagerDeployRoleArn:
|
AfterhoursShiftManagerDeployRoleArn:
|
||||||
Value: !GetAtt AfterhoursShiftManagerDeployRole.Arn
|
Value: !GetAtt AfterhoursShiftManagerDeployRole.Arn
|
||||||
FrontIntegrationsDeployRoleArn:
|
|
||||||
Value: !GetAtt FrontIntegrationsDeployRole.Arn
|
|
||||||
AfiBackupMonitorDeployRoleArn:
|
|
||||||
Value: !GetAtt AfiBackupMonitorDeployRole.Arn
|
|
||||||
PaymentsDashboardDeployRoleArn:
|
PaymentsDashboardDeployRoleArn:
|
||||||
Value: !GetAtt PaymentsDashboardDeployRole.Arn
|
Value: !GetAtt PaymentsDashboardDeployRole.Arn
|
||||||
# SeahavenSlackBotDeployRoleArn removed 2026-07-27: the role was deleted
|
# SeahavenSlackBotDeployRoleArn removed 2026-07-27: the role was deleted
|
||||||
# out-of-band on 2026-07-23, so this !GetAtt failed as a live IAM read and
|
# out-of-band on 2026-07-23, so this !GetAtt failed as a live IAM read and
|
||||||
# broke every stack update. Nothing imported it (the Output had no
|
# broke every stack update. Nothing imported it (the Output had no
|
||||||
# ExportName, and no stack imports any export from this stack).
|
# ExportName, and no stack imports any export from this stack).
|
||||||
ExecAideDeployRoleArn:
|
|
||||||
Value: !GetAtt ExecAideDeployRole.Arn
|
|
||||||
SeahavenDoorUnlockApiDeployRoleArn:
|
|
||||||
Value: !GetAtt SeahavenDoorUnlockApiDeployRole.Arn
|
|
||||||
# ProcurementIngestDeployRoleArn removed 2026-08-07 (PLAT-88): HCP sole
|
# ProcurementIngestDeployRoleArn removed 2026-08-07 (PLAT-88): HCP sole
|
||||||
# mutate path; prod githubdeploy role deleted; mgmt twin already gone.
|
# mutate path; prod githubdeploy role deleted; mgmt twin already gone.
|
||||||
ApmWoAnalysisDeployRoleArn:
|
# FrontIntegrations, AfiBackupMonitor, ExecAide, SeahavenDoorUnlockApi,
|
||||||
Value: !GetAtt ApmWoAnalysisDeployRole.Arn
|
# and ApmWoAnalysis deploy role outputs removed 2026-09-28 (PLAT-232).
|
||||||
|
# CloudTrail showed no successful mutation for 14 days. The roles are
|
||||||
|
# deleted only when this stack is deployed. That deploy is not this change.
|
||||||
SeahavenAccountBaselineDeployRoleArn:
|
SeahavenAccountBaselineDeployRoleArn:
|
||||||
Value: !GetAtt SeahavenAccountBaselineDeployRole.Arn
|
Value: !GetAtt SeahavenAccountBaselineDeployRole.Arn
|
||||||
|
SeahavenOrgBaselinePolicyCheckRoleArn:
|
||||||
|
Value: !GetAtt SeahavenOrgBaselinePolicyCheckRole.Arn
|
||||||
# MealOrderManagerWeeklyMenuRoleArn removed 2026-08-20 (PLAT-70): role deleted with resource.
|
# MealOrderManagerWeeklyMenuRoleArn removed 2026-08-20 (PLAT-70): role deleted with resource.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
|
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
|
||||||
# reusable workflow's default — passed explicitly to pin against drift.
|
# reusable workflow's default — passed explicitly to pin against drift.
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,4 @@ jobs:
|
||||||
# Every input is optional. Common overrides: `solution` (defaults to *.sln
|
# Every input is optional. Common overrides: `solution` (defaults to *.sln
|
||||||
# in the working directory), `working-directory`, and `dotnet-version`
|
# in the working directory), `working-directory`, and `dotnet-version`
|
||||||
# (defaults to 8.0.x). This reusable has no `node-version` input.
|
# (defaults to 8.0.x). This reusable has no `node-version` input.
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
|
|
|
||||||
7
workflow-templates/ci-hcp.properties.json
Normal file
7
workflow-templates/ci-hcp.properties.json
Normal 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$"]
|
||||||
|
}
|
||||||
52
workflow-templates/ci-hcp.yml
Normal file
52
workflow-templates/ci-hcp.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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:
|
||||||
|
presets: prettier,terraform
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
ci:
|
ci:
|
||||||
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
|
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
|
||||||
# check context resolves to the required `ci / ci`.
|
# check context resolves to the required `ci / ci`.
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also
|
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also
|
||||||
# the reusable workflow's default — passed explicitly to pin against drift.
|
# the reusable workflow's default — passed explicitly to pin against drift.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
|
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
|
||||||
# reusable workflow's default — passed explicitly to pin against drift.
|
# reusable workflow's default — passed explicitly to pin against drift.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "Sea Haven — CI (Python / app)",
|
"name": "Sea Haven — CI (Python / app)",
|
||||||
"description": "Runs ruff check, ruff format --check, a pytest collect-only import check, and an optional subproject suite via the org reusable workflow. For Python repos that do not deploy via SAM or CDK.",
|
"description": "Runs ruff check, ruff format --check, and a conventions audit via the org reusable workflow. For Python repos that do not deploy via SAM or CDK.",
|
||||||
"iconName": "octicon-checklist",
|
"iconName": "octicon-checklist",
|
||||||
"categories": ["Python", "Continuous integration"],
|
"categories": ["Python", "Continuous integration"],
|
||||||
"filePatterns": ["requirements.*\\.txt$", "pyproject\\.toml$"]
|
"filePatterns": ["requirements.*\\.txt$", "pyproject\\.toml$"]
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,5 @@ jobs:
|
||||||
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
|
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
|
||||||
# check context resolves to the required `ci / ci`.
|
# check context resolves to the required `ci / ci`.
|
||||||
#
|
#
|
||||||
# Every input is optional. Common overrides: `source-dirs` (ruff targets),
|
# Every input is optional. Common override: `source-dirs` (ruff targets).
|
||||||
# `requirements` (non-default requirements file), `subproject-dir` (a
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
# self-contained suite that must run in its own working directory).
|
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
run-tests: true
|
run-tests: true
|
||||||
# ci-python-sam.yaml declares a `node-version` input (default "24") that
|
# ci-python-sam.yaml declares a `node-version` input (default "24") that
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
ci:
|
ci:
|
||||||
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
|
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
|
||||||
# context resolves to the required `ci / ci`.
|
# context resolves to the required `ci / ci`.
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
|
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
|
||||||
# generates lockfileVersion 3. Being explicit avoids lockfile drift.
|
# generates lockfileVersion 3. Being explicit avoids lockfile drift.
|
||||||
|
|
|
||||||
7
workflow-templates/ci-terraform.properties.json
Normal file
7
workflow-templates/ci-terraform.properties.json
Normal 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$"]
|
||||||
|
}
|
||||||
16
workflow-templates/ci-terraform.yml
Normal file
16
workflow-templates/ci-terraform.yml
Normal 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"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "Sea Haven — CI (TypeScript / frontend)",
|
"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",
|
"iconName": "octicon-checklist",
|
||||||
"categories": ["TypeScript", "JavaScript", "Continuous integration"],
|
"categories": ["TypeScript", "JavaScript", "Continuous integration"],
|
||||||
"filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "playwright\\.config\\.[jt]s$"]
|
"filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "playwright\\.config\\.[jt]s$"]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
ci:
|
ci:
|
||||||
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
|
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
|
||||||
# context resolves to the required `ci / ci`.
|
# context resolves to the required `ci / ci`.
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
|
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
|
||||||
# generates lockfileVersion 3. Being explicit avoids lockfile drift.
|
# generates lockfileVersion 3. Being explicit avoids lockfile drift.
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dependency-review:
|
dependency-review:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Required: the project to publish, relative to the repo root.
|
# Required: the project to publish, relative to the repo root.
|
||||||
project: REPLACE-ME-project-csproj
|
project: REPLACE-ME-project-csproj
|
||||||
|
|
|
||||||
7
workflow-templates/hcp-fargate-deploy.properties.json
Normal file
7
workflow-templates/hcp-fargate-deploy.properties.json
Normal 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$"]
|
||||||
|
}
|
||||||
54
workflow-templates/hcp-fargate-deploy.yml
Normal file
54
workflow-templates/hcp-fargate-deploy.yml
Normal 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"}'
|
||||||
7
workflow-templates/hcp-spa-deploy.properties.json
Normal file
7
workflow-templates/hcp-spa-deploy.properties.json
Normal 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$"]
|
||||||
|
}
|
||||||
51
workflow-templates/hcp-spa-deploy.yml
Normal file
51
workflow-templates/hcp-spa-deploy.yml
Normal 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"
|
||||||
|
|
@ -13,4 +13,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
|
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
|
||||||
# generates lockfileVersion 3. Being explicit avoids lockfile drift.
|
# generates lockfileVersion 3. Being explicit avoids lockfile drift.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
version: ${{ inputs.version }}
|
version: ${{ inputs.version }}
|
||||||
# Tags in this org are v-prefixed MAJOR.MINOR.PATCH; "v" is the default
|
# Tags in this org are v-prefixed MAJOR.MINOR.PATCH; "v" is the default
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||||
with:
|
with:
|
||||||
# Required: the CloudFormation stack name (kebab-case, matches repo name).
|
# Required: the CloudFormation stack name (kebab-case, matches repo name).
|
||||||
# NOTE: this is a literal placeholder on purpose — starter-workflow variables
|
# NOTE: this is a literal placeholder on purpose — starter-workflow variables
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue