Compare commits

...

8 commits

Author SHA1 Message Date
Adam Moussa
ee5b843ca1
feat(ci): add HCP Lambda zip deploy reusable (PLAT-79) (#156)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
* feat(ci): add HCP Lambda zip deploy reusable (PLAT-79)

* fix(ci): pick the ancestor release in the Lambda ship-gate (PLAT-79)
2026-09-28 19:29:38 +00:00
Adam Moussa
892580d7d7
fix(iam): match org-baseline policy-check OIDC subject (PLAT-234) (#155)
Some checks are pending
ci / ci / ci (push) Waiting to run
Pull request tokens use repo:ORG/seahaven-org-baseline:pull_request.
Merge queue tokens use the gh-readonly-queue ref. The previous
refs/pull/* subject never matched either.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-28 18:38:19 +00:00
Adam Moussa
c9134742ce
chore(iam): remove soaked githubdeploy roles from the template (PLAT-232) (#154)
Some checks are pending
ci / ci / ci (push) Waiting to run
Drops the management-account deploy roles for front-integrations,
afi-backup-monitor, exec-aide, seahaven-door-unlock-api, and
apm-wo-analysis. CloudTrail showed no successful mutation for 14 days.
Deploying this stack deletes those roles. This change does not deploy it.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-28 16:55:02 +00:00
Adam Moussa
8e6b8e8665
feat(iam): add org-baseline Access Analyzer CI role (PLAT-234) (#153)
Some checks are pending
ci / ci / ci (push) Waiting to run
* feat(iam): add org-baseline Access Analyzer CI role (PLAT-234)

Adds githubdeploy-seahaven-org-baseline-policy-check with only
ValidatePolicy and CheckNoNewAccess, trusted for main and pull_request.
The deploy role stays limited to main and CDK assume.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* docs(iam): point the policy-check role at its CI job (PLAT-234)

The Access Analyzer checks live in seahaven-org-baseline pull request 160.
This role is only the principal that job assumes.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(iam): match the default pull request OIDC subject (PLAT-234)

Trust refs/pull/* so seahaven-org-baseline pull request tokens can assume
the policy-check role. The immutable subject claim is not enabled.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-28 16:23:57 +00:00
Adam Moussa
0a1010e632
feat(ci): add a static-site HCP deploy caller (PLAT-225) (#152)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
Unfingerprinted Eleventy builds need a one-day asset cache, not the SPA immutable sync.
2026-09-24 23:24:57 +00:00
Adam Moussa
6fc4ca31e1
chore(renovate): add the empty org preset (PLAT-224) (#151)
Some checks are pending
ci / ci / ci (push) Waiting to run
local>Sea-Haven-Industries/.github resolves to default.json. Policy stays in renovate-config.
2026-09-24 18:38:49 +00:00
Adam Moussa
bf14925fcf
feat(ci): derive Fargate health URL from CloudFront (#150)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
Callers that cannot add an SSM parameter to a shared permissions boundary can set health-from-distribution. The default still reads SSM api-url.
2026-09-24 16:49:49 +00:00
Adam Moussa
acaf2bfc0d
feat(ci): let HCP deploys share an SSM prefix (#149)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
Optional task-env replace, source-map upload, health attempts, and a concurrency suffix keep existing callers on the same defaults.
2026-09-24 16:15:53 +00:00
7 changed files with 888 additions and 238 deletions

View file

@ -18,6 +18,11 @@ name: CD — HCP Fargate
# 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.
@ -57,6 +62,41 @@ on:
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
@ -69,7 +109,7 @@ jobs:
timeout-minutes: 30
environment: ${{ inputs.environment }}
concurrency:
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
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
@ -186,6 +226,7 @@ jobs:
id: deploy
env:
SSM_PREFIX: ${{ inputs.ssm-prefix }}
HEALTH_FROM_DISTRIBUTION: ${{ inputs.health-from-distribution }}
run: |
set -euo pipefail
get_param() {
@ -197,7 +238,13 @@ jobs:
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}"
@ -232,6 +279,57 @@ jobs:
--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 }}
@ -241,8 +339,19 @@ jobs:
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 \
@ -268,16 +377,25 @@ jobs:
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", [])}
env["GIT_SHA"] = sha
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:
@ -298,6 +416,7 @@ jobs:
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}"
@ -306,7 +425,11 @@ jobs:
*) path="/${path}" ;;
esac
url="${API_URL%/}${path}"
for _ in 1 2 3 4 5 6; do
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

274
.github/workflows/cd-hcp-lambda.yaml vendored Normal file
View 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

View file

@ -21,6 +21,10 @@ name: CD — HCP SPA
# 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:
@ -49,6 +53,16 @@ on:
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
@ -61,7 +75,7 @@ jobs:
timeout-minutes: 45
environment: ${{ inputs.environment }}
concurrency:
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
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
@ -301,3 +315,95 @@ jobs:
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
View 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

View file

@ -39,6 +39,10 @@ The formatter GitHub App is not on the main-branch bypass list.
## 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
**`.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.
@ -57,6 +61,8 @@ The formatter GitHub App is not on the main-branch bypass list.
**`.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-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.

3
default.json Normal file
View file

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View file

@ -1051,146 +1051,6 @@ Resources:
Resource:
- !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:
Type: AWS::IAM::Role
Properties:
@ -1262,90 +1122,12 @@ Resources:
- !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:
Type: AWS::IAM::Role
Properties:
@ -1380,6 +1162,54 @@ Resources:
# 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:
LambdaExecutionBoundaryArn:
Value: !Ref LambdaExecutionBoundary
@ -1394,24 +1224,20 @@ Outputs:
Name: github-cfn-execution-role-arn
AfterhoursShiftManagerDeployRoleArn:
Value: !GetAtt AfterhoursShiftManagerDeployRole.Arn
FrontIntegrationsDeployRoleArn:
Value: !GetAtt FrontIntegrationsDeployRole.Arn
AfiBackupMonitorDeployRoleArn:
Value: !GetAtt AfiBackupMonitorDeployRole.Arn
PaymentsDashboardDeployRoleArn:
Value: !GetAtt PaymentsDashboardDeployRole.Arn
# 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
# broke every stack update. Nothing imported it (the Output had no
# 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
# mutate path; prod githubdeploy role deleted; mgmt twin already gone.
ApmWoAnalysisDeployRoleArn:
Value: !GetAtt ApmWoAnalysisDeployRole.Arn
# FrontIntegrations, AfiBackupMonitor, ExecAide, SeahavenDoorUnlockApi,
# 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:
Value: !GetAtt SeahavenAccountBaselineDeployRole.Arn
SeahavenOrgBaselinePolicyCheckRoleArn:
Value: !GetAtt SeahavenOrgBaselinePolicyCheckRole.Arn
# MealOrderManagerWeeklyMenuRoleArn removed 2026-08-20 (PLAT-70): role deleted with resource.