Compare commits

..

No commits in common. "main" and "v1.0.3" have entirely different histories.
main ... v1.0.3

56 changed files with 497 additions and 2685 deletions

View file

@ -1,5 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Jira — DEV / PLAT / SEC - name: Internal IT support
url: https://seahaven.atlassian.net/jira url: https://seahaven.atlassian.net/jira/software/projects/INFRA
about: File all org work in Jira (DEV, PLAT, or SEC). INFRA is a closed archive. GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe. about: For operational issues, file an INFRA Jira ticket instead.

View file

@ -15,6 +15,7 @@ assignees: amoussa1229
## AWS / integration impact ## AWS / integration impact
- New or changed AWS resources (Lambda, DynamoDB, S3, API Gateway): - New or changed AWS resources (Lambda, DynamoDB, S3, API Gateway):
- Slack app(s) involved: - Slack app(s) involved:
- Confluence Architecture Map update needed: yes / no
## Alternatives considered ## Alternatives considered
<!-- Other approaches and why they were rejected. --> <!-- Other approaches and why they were rejected. -->

View file

@ -21,4 +21,6 @@ assignees: amoussa1229
<!-- Exact steps to revert: prior stack version, DeletionPolicy considerations, data restore. --> <!-- Exact steps to revert: prior stack version, DeletionPolicy considerations, data restore. -->
## Documentation ## Documentation
- [ ] Confluence Architecture Map (id 1540098) update queued
- [ ] README updated in same PR - [ ] README updated in same PR
- [ ] Project memory entry queued

View file

@ -1,14 +1,8 @@
<!-- <!--
PR conventions PR conventions — see engineering-handbook/pull-requests.md
- Title format: type(scope): description (DEV-123) - Title: imperative mood, under 70 chars, describe the change not the ticket (e.g. "Add receipt parser Lambda", not "PROJ-123" or "Bug fix").
- type ∈ feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release - Scope: one logical change per PR. If the title needs an "and", split it.
- Maximum 120 characters, including the Jira suffix. - Jira: put the issue key in the branch name or this PR title (e.g. [PROJ-123]) to link the PR into the Jira issue's development panel. Omit if the work has no ticket.
- 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.
- Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ + kebab-case description.
Branch names do not contain Jira keys.
- Scope: one logical change per PR. If the title needs "and", split it.
- Body: state verifiable facts about the change and validation. Do not cite the handbook or add AI-attribution footers.
--> -->
## Summary ## Summary
@ -21,4 +15,13 @@ PR conventions
<!-- What tests were added, updated, or run. If no automated tests, explain the manual testing. --> <!-- What tests were added, updated, or run. If no automated tests, explain the manual testing. -->
## Notes ## Notes
<!-- Anything reviewers should know: migration steps, deploy order, follow-ups, breaking changes. Use None. if empty. --> <!-- Anything reviewers should know: migration steps, deploy order, follow-ups, breaking changes. Delete this section if empty. -->
## Sea Haven checklist
- [ ] CDK diff / SAM changeset reviewed (if infra change)
- [ ] Secrets added to Parameter Store / Secrets Manager (not hardcoded)
- [ ] DynamoDB PITR verified on new tables
- [ ] Slack notification tested in staging
- [ ] Confluence Architecture Map updated
- [ ] Memory update queued (if new repo/stack)
- [ ] Cross-review requested (if IAM or Lambda handler signature change)

11
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

View file

@ -16,8 +16,8 @@ jobs:
dependency-review: dependency-review:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 - uses: actions/dependency-review-action@v5
with: with:
fail-on-severity: high fail-on-severity: high
allow-ghsas: ${{ inputs.allow-ghsas }} allow-ghsas: ${{ inputs.allow-ghsas }}

View file

@ -53,12 +53,6 @@ jobs:
- '**/template.yaml' - '**/template.yaml'
- 'samconfig.toml' - 'samconfig.toml'
- 'infra/**' - 'infra/**'
- 'Dockerfile'
- '**/Dockerfile'
- '.ebextensions/**'
- '**/.ebextensions/**'
- '.platform/**'
- '**/.platform/**'
app: app:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
@ -70,9 +64,6 @@ jobs:
- 'web/**' - 'web/**'
- 'mobile/**' - 'mobile/**'
- 'shared/**' - 'shared/**'
- '**/*.cs'
- '**/*.cshtml'
- '**/*.razor'
content: content:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
@ -107,12 +98,12 @@ jobs:
tests: tests:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
# directory conventions (covers Java src/test, Ruby test/spec, etc.)
- '**/tests/**' - '**/tests/**'
- '**/test/**' - '**/test/**'
- '**/spec/**' - '**/spec/**'
- '**/__tests__/**' - '**/__tests__/**'
- 'e2e/**' # JS / TS
- '**/e2e/**'
- '**/*.test.js' - '**/*.test.js'
- '**/*.test.jsx' - '**/*.test.jsx'
- '**/*.test.ts' - '**/*.test.ts'
@ -121,16 +112,21 @@ jobs:
- '**/*.spec.jsx' - '**/*.spec.jsx'
- '**/*.spec.ts' - '**/*.spec.ts'
- '**/*.spec.tsx' - '**/*.spec.tsx'
# Python
- '**/*_test.py' - '**/*_test.py'
- '**/test_*.py' - '**/test_*.py'
- '**/conftest.py' - '**/conftest.py'
# .NET
- '**/*Tests.cs' - '**/*Tests.cs'
- '**/*Test.cs' - '**/*Test.cs'
- '**/*.Tests/**' - '**/*.Tests/**'
# Java / JVM
- '**/*Test.java' - '**/*Test.java'
- '**/*Tests.java' - '**/*Tests.java'
- '**/*IT.java' - '**/*IT.java'
# Go
- '**/*_test.go' - '**/*_test.go'
# Ruby
- '**/*_spec.rb' - '**/*_spec.rb'
- '**/*_test.rb' - '**/*_test.rb'
EOF EOF

View file

@ -70,12 +70,12 @@ jobs:
group: cd-cdk-${{ inputs.region }}-${{ inputs.stacks }}-${{ inputs.stack-name }} group: cd-cdk-${{ inputs.region }}-${{ inputs.stacks }}-${{ inputs.stack-name }}
cancel-in-progress: false cancel-in-progress: false
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
if: ${{ inputs.enable-qemu }} if: ${{ inputs.enable-qemu }}
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - uses: actions/setup-dotnet@v6
if: ${{ inputs.dotnet-version != '' }} if: ${{ inputs.dotnet-version != '' }}
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}
@ -95,11 +95,11 @@ jobs:
--self-contained false \ --self-contained false \
--output "$(dirname "$DOTNET_PUBLISH_PROJECT")/bin/Release/net8.0/linux-arm64/publish" --output "$(dirname "$DOTNET_PUBLISH_PROJECT")/bin/Release/net8.0/linux-arm64/publish"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@v7
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@v7
if: ${{ inputs.python-version != '' }} if: ${{ inputs.python-version != '' }}
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
@ -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@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}

View file

@ -4,7 +4,7 @@ name: CD — .NET Elastic Beanstalk
# #
# jobs: # jobs:
# deploy: # deploy:
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # v1.0.4 # uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # main
# with: # with:
# project: "Api.Example/Api.Example.csproj" # project: "Api.Example/Api.Example.csproj"
# eb-application: "example-api" # eb-application: "example-api"
@ -82,9 +82,9 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - uses: actions/setup-dotnet@v6
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}
@ -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@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}

View file

@ -1,441 +0,0 @@
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

View file

@ -1,274 +0,0 @@
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

@ -1,409 +0,0 @@
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."

View file

@ -1,312 +0,0 @@
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

@ -69,20 +69,20 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@v7
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }} cache-dependency-path: ${{ inputs.cache-dependency-path }}
- uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0 - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with: with:
ruby-version: ${{ inputs.ruby-version }} ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true bundler-cache: true

View file

@ -49,15 +49,15 @@ jobs:
group: cd-sam-${{ inputs.region }}-${{ inputs.stack-name }} group: cd-sam-${{ inputs.region }}-${{ inputs.stack-name }}
cancel-in-progress: false cancel-in-progress: false
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@v7
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
- uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0 - uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}
@ -69,7 +69,7 @@ jobs:
--stack-name "${{ inputs.stack-name }}" \ --stack-name "${{ inputs.stack-name }}" \
--query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo "NOT_FOUND") --query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo "NOT_FOUND")
case "$STATUS" in case "$STATUS" in
ROLLBACK_COMPLETE|*FAILED) *ROLLBACK_COMPLETE|*FAILED)
echo "::error::Stack ${{ inputs.stack-name }} is in $STATUS — manual intervention required." echo "::error::Stack ${{ inputs.stack-name }} is in $STATUS — manual intervention required."
exit 1 exit 1
;; ;;

View file

@ -1,295 +0,0 @@
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}"

View file

@ -34,9 +34,9 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - uses: actions/setup-dotnet@v6
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}

View file

@ -1,262 +0,0 @@
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

View file

@ -35,7 +35,7 @@ name: CI — Mobile iOS
# Caller example: # Caller example:
# jobs: # jobs:
# ci: # ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # v1.0.4 # uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # main
# with: # with:
# working-directory: mobile # working-directory: mobile
# cache-dependency-path: mobile/package-lock.json # cache-dependency-path: mobile/package-lock.json
@ -137,9 +137,9 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@v7
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
@ -206,15 +206,15 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@v7
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }} cache-dependency-path: ${{ inputs.cache-dependency-path }}
- uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0 - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with: with:
ruby-version: ${{ inputs.ruby-version }} ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true bundler-cache: true

View file

@ -1,14 +1,19 @@
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). # SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those). Beyond
# Runs ruff check + format, plus an optional conventions audit. # lint + format it adds two things such repos commonly need:
# * 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 lint, # job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on every
# so the single required check fails if lint fails. # other job, so the single required check fails if any sub-job fails.
on: on:
workflow_call: workflow_call:
@ -21,6 +26,18 @@ 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
@ -35,15 +52,17 @@ 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 sibling jobs would # expression evaluates to the CALLER's job id, so all four jobs here 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
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@v7
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
@ -82,19 +101,68 @@ 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@v7
- uses: actions/setup-python@v7
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@v7
- uses: actions/setup-python@v7
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] needs: [lint, test-collect, subproject-tests]
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 lint to have succeeded - name: Require all jobs to have succeeded
run: | run: |
if [ "${{ needs.lint.result }}" != "success" ]; then if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
echo "lint failed or was cancelled." echo "A required CI job failed or was cancelled."
exit 1 exit 1
fi fi
echo "All CI jobs passed." echo "All CI jobs passed."

View file

@ -55,9 +55,9 @@ jobs:
group: ci-python-sam-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }} group: ci-python-sam-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@v7
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
@ -89,13 +89,13 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
if: ${{ inputs.run-cdk-synth }} if: ${{ inputs.run-cdk-synth }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@v7
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
- 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@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: CDK synth - name: CDK synth
if: ${{ inputs.run-cdk-synth }} if: ${{ inputs.run-cdk-synth }}
@ -153,7 +153,7 @@ jobs:
- name: Setup SAM CLI - name: Setup SAM CLI
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}
uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0 uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3
- name: SAM validate - name: SAM validate
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}

View file

@ -15,7 +15,7 @@ name: CI — Static Site
# Caller example (build mode): # Caller example (build mode):
# jobs: # jobs:
# ci: # ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@<full-commit-sha> # v1.0.4 # uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@<full-commit-sha> # main
# with: # with:
# build-command: "npx @11ty/eleventy" # build-command: "npx @11ty/eleventy"
# check-dir: "_site" # check-dir: "_site"
@ -70,9 +70,9 @@ jobs:
CHECK_DIR: ${{ inputs.check-dir }} CHECK_DIR: ${{ inputs.check-dir }}
BUILD_COMMAND: ${{ inputs.build-command }} BUILD_COMMAND: ${{ inputs.build-command }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@v7
if: ${{ inputs.run-htmlhint || inputs.build-command != '' }} if: ${{ inputs.run-htmlhint || inputs.build-command != '' }}
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}

View file

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

View file

@ -67,12 +67,12 @@ jobs:
group: ci-typescript-cdk-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }} group: ci-typescript-cdk-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
if: ${{ inputs.enable-qemu }} if: ${{ inputs.enable-qemu }}
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - uses: actions/setup-dotnet@v6
if: ${{ inputs.dotnet-version != '' }} if: ${{ inputs.dotnet-version != '' }}
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}
@ -81,7 +81,7 @@ jobs:
if: ${{ inputs.dotnet-publish-project != '' }} if: ${{ inputs.dotnet-publish-project != '' }}
run: dotnet publish ${{ inputs.dotnet-publish-project }} --configuration Release --runtime linux-arm64 --self-contained run: dotnet publish ${{ inputs.dotnet-publish-project }} --configuration Release --runtime linux-arm64 --self-contained
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@v7
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
@ -159,7 +159,7 @@ jobs:
- name: Setup SAM CLI - name: Setup SAM CLI
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}
uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0 uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3
- name: SAM validate - name: SAM validate
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}

View file

@ -1,11 +1,9 @@
name: CI — TypeScript Frontend name: CI — TypeScript Frontend
# Sequential reusable CI for remaining-lane TypeScript front-end apps that # Reusable CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
# still emit `ci / ci`. HCP app repos should call ci-frontend.yaml (parallel # with vitest + Playwright). Emits the single `ci / ci` status context required
# portions) plus a caller-owned `ci-complete` aggregator instead. # by the org branch-protection rulesets — keep the caller job id `ci` so the
# # 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),
@ -15,7 +13,7 @@ name: CI — TypeScript Frontend
# Caller example: # Caller example:
# jobs: # jobs:
# ci: # ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@<full-commit-sha> # v1.0.4 # uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@<full-commit-sha> # main
# with: # with:
# node-version: "24" # node-version: "24"
@ -89,11 +87,11 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@v7
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm

View file

@ -41,7 +41,6 @@ on:
pull_request: pull_request:
push: push:
branches: [main] branches: [main]
merge_group:
permissions: permissions:
contents: read contents: read
@ -51,7 +50,7 @@ jobs:
name: ci / ci name: ci / ci
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Install actionlint - name: Install actionlint
env: env:

View file

@ -31,7 +31,6 @@ on:
- "!.github/workflows/ci.yaml" - "!.github/workflows/ci.yaml"
- "!.github/workflows/labeler.yaml" - "!.github/workflows/labeler.yaml"
- "!.github/workflows/release-on-merge.yaml" - "!.github/workflows/release-on-merge.yaml"
- "!.github/workflows/auto-merge.yaml"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@ -58,7 +57,7 @@ jobs:
outputs: outputs:
version: ${{ steps.next.outputs.version }} version: ${{ steps.next.outputs.version }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
with: with:
# Tags are the input to the version calculation, so they must be # Tags are the input to the version calculation, so they must be
# fetched; a shallow checkout without them would restart at 1.0.0. # fetched; a shallow checkout without them would restart at 1.0.0.

View file

@ -32,7 +32,7 @@ name: Release — Tag and GitHub Release
# Caller example: # Caller example:
# jobs: # jobs:
# release: # release:
# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # v1.0.4 # uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # main
# with: # with:
# version: ${{ inputs.version }} # version: ${{ inputs.version }}
# #
@ -118,7 +118,7 @@ jobs:
released: ${{ steps.publish.outputs.released || 'false' }} released: ${{ steps.publish.outputs.released || 'false' }}
url: ${{ steps.publish.outputs.url }} url: ${{ steps.publish.outputs.url }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
with: with:
# Full history + tags: the existing-tag guard reads local refs. # Full history + tags: the existing-tag guard reads local refs.
fetch-depth: 0 fetch-depth: 0

220
README.md
View file

@ -2,79 +2,26 @@
Organization-level GitHub configuration for Sea Haven Industries. Organization-level GitHub configuration for Sea Haven Industries.
## Git and PR conventions
### Branch naming
`feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/` + kebab-case description. Branch names do not contain Jira keys.
### Commit format
`type(scope): description` — lowercase, imperative, no trailing period, header ≤ 72 chars. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`, `release`. Breaking change: `feat!:` + `BREAKING CHANGE:` footer.
### 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.
### PR body
Exactly four headings in order: `## Summary`, `## Validation`, `## Tests`, `## Notes`. Use `None.` under Notes if empty.
### Deploy path
The two sanctioned deploy paths are merge to `main` triggering the pipeline and `workflow_dispatch` on that same pipeline. No manual workstation deploys to production.
### Merge 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`** — 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-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-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 and conventions; no pytest, no SAM validate). Pytest stays a caller-owned job. **`.github/workflows/ci-python-app.yaml`** — Reusable CI for non-SAM Python apps (ruff check + format, optional pytest; no SAM validate).
**`.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).
**`.github/workflows/ci-static.yaml`** — Reusable CI for static sites (e.g. Eleventy builds for seahaven-site). **`.github/workflows/ci-static.yaml`** — Reusable CI for static sites (e.g. Eleventy builds for seahaven-site).
**`.github/workflows/ci-mobile-ios.yaml`** — Reusable CI for React Native iOS apps: dependency install, typecheck, optional lint and unit tests, and an unsigned compile (nothing uploaded). Emits the aggregated `ci / ci` status context.
**`.github/workflows/cd-mobile-ios.yaml`** — Reusable CD for iOS apps via Fastlane to TestFlight (Node + Ruby setup inputs). **`.github/workflows/cd-mobile-ios.yaml`** — Reusable CD for iOS apps via Fastlane to TestFlight (Node + Ruby setup inputs).
**`.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.
@ -83,31 +30,19 @@ The formatter GitHub App is not on the main-branch bypass list.
**`.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.
**`.github/workflows/release.yaml`** — Reusable release workflow: creates an annotated git tag at a commit and publishes a GitHub Release pointing at it. The version is an input (not read from a manifest).
**`.github/workflows/release-on-merge.yaml`** — Repo automation (not callable): cuts a tag and GitHub Release for **this** repo whenever a merge to `main` changes a reusable workflow, so Dependabot has a release to advance consumer SHA pins to (see the pinning policy below).
**`.github/workflows/labeler.yaml`** — This repo's own thin caller of `callable-labeler.yaml`, so the labeler runs on `.github`'s own PRs.
**`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted. **`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted.
### Workflow templates (`workflow-templates/`) ### Workflow templates (`workflow-templates/`)
Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-hcp`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-terraform`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `hcp-fargate-deploy`, `hcp-spa-deploy`, `labeler`, `mobile-ios-deploy`, `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. Starter workflows offered on the org's **Actions → New workflow** page: `ci-python`, `ci-node`, `cdk-deploy`, `sam-deploy`, `dotnet-eb-deploy`, `dependency-review`, `labeler`, `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.
### Ref pinning policy ### Action pinning policy
All workflow refs across the org are pinned to full commit SHAs: Third-party action refs across the org follow a tiered policy:
- **Org reusable workflows** are referenced at a **full commit SHA** of this repo with a trailing comment naming the ref or release the pin tracks: - **High-trust / high-blast-radius third-party actions are SHA-pinned** with a trailing version comment (e.g. `actions/labeler` in `callable-labeler.yaml`), and binary installs are checksum-verified (actionlint in `ci.yaml`). Dependabot keeps the SHA current via its trailing-comment mechanism.
- **Common first-party actions** (`actions/checkout`, `actions/dependency-review-action`, `actions/github-script`) are pinned to a **major tag** (`@v7`, `@v5`, …) and kept current by Dependabot version updates gated by CI.
```yaml - **Org reusable workflows** are referenced at **`@main`** (`uses: Sea-Haven-Industries/.github/.github/workflows/…@main`). This is deliberate: caller and callable share one trust domain, and pinning callers to a SHA would freeze every consumer against central fixes. Templates in `workflow-templates/` follow the same `@main` convention.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha> # v1.0.3
```
Branch refs are mutable: a bad commit on this repo would flow instantly into every consumer's CI and deploy path, while a SHA pin turns the same change into a reviewable Dependabot PR. Two prerequisites keep pins advancing instead of freezing: every consumer repo's `dependabot.yml` must include the `github-actions` ecosystem (weekly), and Dependabot must be granted access to this repo at the org level (Org Settings → Advanced Security → Global settings → "Grant Dependabot access to repositories"); without the grant, update jobs fail with `git_dependencies_not_reachable` and pins freeze silently. `release-on-merge.yaml` tags this repo on every reusable-workflow change so Dependabot has releases to diff against. When adding a caller by hand, pin to the latest release commit (`gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha`), annotate it with `# vX.Y.Z`, and let Dependabot advance it from there.
- **Third-party and first-party actions** (`actions/checkout`, `actions/setup-python`, `actions/labeler`, …) — a subset are already SHA-pinned (e.g. `actions/labeler`, `aws-actions/*`, `docker/setup-qemu-action`, `ruby/setup-ruby`); the remainder (`actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-dotnet`, `actions/dependency-review-action`) currently use floating major-version tags. Full SHA pinning for this group is deferred (PLAT backlog); Dependabot will keep SHA and comment current once pins are set.
- **Binary installs are checksum-verified** (actionlint in `ci.yaml`).
### AWS deploy roles & IAM (`oidc-deploy-roles.yaml`) ### AWS deploy roles & IAM (`oidc-deploy-roles.yaml`)
@ -161,7 +96,7 @@ A function's effective permissions are the **intersection** of its own role poli
2. Redeploy the SAM stacks so their roles pick it up (while the exec role still permits it). 2. Redeploy the SAM stacks so their roles pick it up (while the exec role still permits it).
3. *Then* tighten the exec role. 3. *Then* tighten the exec role.
Wrong order breaks every SAM deploy. CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role. Wrong order breaks every SAM deploy. (History: INFRA-103 established the boundary, INFRA-97 scoped the role.) CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role.
This ordering rule is about changing the **boundary** or the conditions that gate it. It does not apply to changes that only add permissions to the exec role. This ordering rule is about changing the **boundary** or the conditions that gate it. It does not apply to changes that only add permissions to the exec role.
@ -172,7 +107,7 @@ This ordering rule is about changing the **boundary** or the conditions that gat
- **Removing `PermissionsBoundary` from an existing role fails by design.** CloudFormation issues `DeleteRolePermissionsBoundary` for that edit, gets `AccessDenied`, and the stack update rolls back. Removing the boundary from a SAM function is a security regression, so failing loudly is intended. - **Removing `PermissionsBoundary` from an existing role fails by design.** CloudFormation issues `DeleteRolePermissionsBoundary` for that edit, gets `AccessDenied`, and the stack update rolls back. Removing the boundary from a SAM function is a security regression, so failing loudly is intended.
- **Rollback of an update that *adds* a boundary to an existing role would also fail**, landing the stack in `UPDATE_ROLLBACK_FAILED`. This is currently unreachable — all 26 IAM roles across the five SAM stacks already carry the boundary (verified 2026-07-27), so no update can add one. It becomes reachable again only if a role is created without the boundary and given one later. - **Rollback of an update that *adds* a boundary to an existing role would also fail**, landing the stack in `UPDATE_ROLLBACK_FAILED`. This is currently unreachable — all 26 IAM roles across the five SAM stacks already carry the boundary (verified 2026-07-27), so no update can add one. It becomes reachable again only if a role is created without the boundary and given one later.
Recovery from `UPDATE_ROLLBACK_FAILED` is an administrator action, not a pipeline retry: clear the wedged stack with `aws cloudformation continue-update-rollback --stack-name <stack> --resources-to-skip <RoleLogicalId>`, or replace the role by renaming its logical id. A completed update rollback lands in `UPDATE_ROLLBACK_COMPLETE`, which is stable and can accept a corrective update; `cd-sam` blocks only first-create `ROLLBACK_COMPLETE` and failed or in-progress states. Recovery in either case is an administrator action, not a pipeline retry: clear the wedged stack with `aws cloudformation continue-update-rollback --stack-name <stack> --resources-to-skip <RoleLogicalId>`, or replace the role by renaming its logical id. Note `cd-sam`'s pre-flight hard-fails on `*ROLLBACK_COMPLETE`, so that repo's deploys stay blocked until it is cleared.
## Setup ## Setup
@ -183,10 +118,8 @@ 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 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. 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).
### 2. Add CI to a repo ### 2. Add CI to a repo
@ -202,7 +135,7 @@ on:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
``` ```
**TypeScript CDK repo** (e.g., seahaven-door-unlock-api, seahaven-slack-bot): **TypeScript CDK repo** (e.g., seahaven-door-unlock-api, seahaven-slack-bot):
@ -215,7 +148,7 @@ on:
jobs: jobs:
ci: ci:
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@main
``` ```
**Node.js SAM repo** (e.g., payments-dashboard): **Node.js SAM repo** (e.g., payments-dashboard):
@ -228,7 +161,7 @@ on:
jobs: jobs:
ci: ci:
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@main
with: with:
run-typecheck: false run-typecheck: false
run-cdk-synth: false run-cdk-synth: false
@ -245,126 +178,19 @@ on:
jobs: jobs:
python: python:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
with: with:
source-dirs: "src" source-dirs: "src"
run-sam-validate: false run-sam-validate: false
typescript: typescript:
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@main
``` ```
**HCP app repo** (converted callers; required check is `ci-complete`):
```yaml
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@<full-commit-sha> # 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@<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
```
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.
### 3. Add CD to a repo ### 3. Add CD to a repo
**HCP Fargate** (one caller job per GitHub Environment; `environment` is a `with:` input): Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
```yaml **SAM repo** (e.g., afterhours-shift-manager):
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"}'
```
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.
Create `.github/workflows/deploy.yaml` in remaining SAM/CDK repos. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
**SAM repo** (e.g., remaining SAM stacks):
```yaml ```yaml
name: Deploy name: Deploy
@ -374,7 +200,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@main
with: with:
stack-name: afterhours-shift-manager stack-name: afterhours-shift-manager
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
@ -394,7 +220,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
secrets: secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
``` ```
@ -409,7 +235,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
with: with:
python-version: "3.12" python-version: "3.12"
cdk-dir: cdk cdk-dir: cdk
@ -427,7 +253,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
with: with:
enable-qemu: true enable-qemu: true
secrets: secrets:
@ -444,7 +270,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@main
with: with:
project: Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj project: Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj
eb-application: shoc-backend eb-application: shoc-backend

View file

@ -4,8 +4,7 @@ Sea-Haven-Industries repositories are private and intended for internal Sea Have
## Where to go ## Where to go
- **Bugs and feature requests** — file a ticket in Jira (**DEV**, **PLAT**, or **SEC** depending on scope). GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe (contractor/fork intake). - **Bugs, feature requests, infrastructure work** — file a ticket in Jira (**INFRA** project) or open an issue on the relevant repository.
- **Infrastructure and platform work** — use the **PLAT** project. Security issues go in **SEC**.
- **Operational or urgent issues** — contact Adam Moussa (adam@seahavenind.com). - **Operational or urgent issues** — contact Adam Moussa (adam@seahavenind.com).
- **Engineering conventions and standards** — see the [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook). - **Engineering conventions and standards** — see the [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook).
- **Security vulnerabilities** — follow [SECURITY.md](SECURITY.md) (do not open a public issue). - **Security vulnerabilities** — follow [SECURITY.md](SECURITY.md) (do not open a public issue).

View file

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

View file

@ -7,10 +7,6 @@ Parameters:
GitHubOrg: GitHubOrg:
Type: String Type: String
Default: Sea-Haven-Industries Default: Sea-Haven-Industries
# No glob metacharacters: this value is interpolated into StringLike trust
# conditions, where a '*' override would silently open every role's trust
# to any GitHub org with a same-named repo.
AllowedPattern: "^[A-Za-z0-9-]+$"
CreateOIDCProvider: CreateOIDCProvider:
Type: String Type: String
Default: "false" Default: "false"
@ -56,7 +52,6 @@ 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
@ -210,25 +205,6 @@ 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
@ -358,17 +334,30 @@ Resources:
StringEquals: StringEquals:
"iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary"
# Boundary management is SET-only. Granting delete would let this # Boundary management — SET the boundary only. DELETE is NOT
# role create a boundary-gated role, strip the boundary, then pass an # granted: for a delete, the iam:PermissionsBoundary condition key
# unconstrained role to Lambda. SAM creation and teardown need only # reflects the boundary CURRENTLY attached to the target role, so
# PutRolePermissionsBoundary and DeleteRole. # a StringEquals condition on the boundary ARN MATCHES exactly the
# roles the gate protects. Granting delete under that condition
# lets this role create a boundary-gated role with an inline *:*
# policy, strip the boundary, and pass the now-unbounded role to
# Lambda — defeating the primary escalation control. Verified live
# against the mgmt copy 2026-07-27 (simulate-principal-policy:
# iam:DeleteRolePermissionsBoundary = allowed).
# #
# Removing a boundary therefore fails by design. A failed rollback # OPERATIONAL CONSEQUENCE — read before debugging a stuck stack.
# reaches UPDATE_ROLLBACK_FAILED and needs admin recovery by skipping # SAM does not need the delete for the common paths: it SETS the
# or replacing the role. A successful rollback reaches the stable # boundary on roles it creates, and stack teardown calls DeleteRole.
# UPDATE_ROLLBACK_COMPLETE state and can accept a corrective update. # But there IS one path that now fails by design: updating an
# Removing a SAM function boundary is a security regression, so # existing AWS::IAM::Role to REMOVE its PermissionsBoundary property
# failing loudly is intentional. # makes CloudFormation call DeleteRolePermissionsBoundary, which is
# denied. The stack update fails and rolls back, and because cd-sam's
# pre-flight hard-fails on *ROLLBACK_COMPLETE, that repo's deploys
# stay blocked until it is cleared. Recovery is an out-of-band admin
# action (remove the boundary directly, or replace the role by
# renaming its logical id) — not a pipeline retry. Removing the
# boundary from a SAM function is a security regression anyway, so
# failing loudly here is the intent.
- Sid: IAMPutPermissionsBoundary - Sid: IAMPutPermissionsBoundary
Effect: Allow Effect: Allow
Action: Action:
@ -486,19 +475,6 @@ Resources:
StringEquals: StringEquals:
"iam:PassedToService": "lambda.amazonaws.com" "iam:PassedToService": "lambda.amazonaws.com"
# API Gateway assumes SAM authorizer invocation roles. Keep this
# separate from Lambda PassRole so each target service and role
# pattern remains independently constrained.
- Sid: IAMPassAuthorizerRole
Effect: Allow
Action:
- iam:PassRole
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/cfn-managed/*AuthorizerInvokeRole-*"
Condition:
StringEquals:
"iam:PassedToService": "apigateway.amazonaws.com"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shared CloudFormation execution role (SAM stacks) — INFRA-97 scoped # Shared CloudFormation execution role (SAM stacks) — INFRA-97 scoped
# #
@ -1051,6 +1027,146 @@ 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:
@ -1122,12 +1238,117 @@ Resources:
- !GetAtt SamCfnExecutionRole.Arn - !GetAtt SamCfnExecutionRole.Arn
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CDK deploy role for seahaven-org-baseline. # CDK deploy roles (4 repos)
# 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-*
ProcurementIngestDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-procurement-ingest
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}/procurement-ingest: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:
@ -1155,61 +1376,6 @@ Resources:
Resource: Resource:
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-* - !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
# MealOrderManagerWeeklyMenuRole removed 2026-08-20 (PLAT-70):
# weekly-menu OIDC role now lives in seahaven-prod as
# /tf-managed/githubdeploy-meal-order-manager-weekly-menu (HCP TF).
# GitHub secret AWS_WEEKLY_MENU_ROLE_ARN already points at the prod role.
# 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
@ -1224,20 +1390,23 @@ 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).
# ProcurementIngestDeployRoleArn removed 2026-08-07 (PLAT-88): HCP sole ExecAideDeployRoleArn:
# mutate path; prod githubdeploy role deleted; mgmt twin already gone. Value: !GetAtt ExecAideDeployRole.Arn
# FrontIntegrations, AfiBackupMonitor, ExecAide, SeahavenDoorUnlockApi, SeahavenDoorUnlockApiDeployRoleArn:
# and ApmWoAnalysis deploy role outputs removed 2026-09-28 (PLAT-232). Value: !GetAtt SeahavenDoorUnlockApiDeployRole.Arn
# CloudTrail showed no successful mutation for 14 days. The roles are ProcurementIngestDeployRoleArn:
# deleted only when this stack is deployed. That deploy is not this change. Value: !GetAtt ProcurementIngestDeployRole.Arn
ApmWoAnalysisDeployRoleArn:
Value: !GetAtt ApmWoAnalysisDeployRole.Arn
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.

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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.

View file

@ -2,7 +2,6 @@ name: CI (.NET)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
@ -12,4 +11,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@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@555d07c3a240689a81668026787eba089df4c975 # main

View file

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

View file

@ -1,52 +0,0 @@
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

View file

@ -2,13 +2,12 @@ name: CI (Mobile / iOS)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: 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@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main
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.

View file

@ -2,11 +2,10 @@ name: CI (Node / TypeScript)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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.

View file

@ -1,6 +1,6 @@
{ {
"name": "Sea Haven — CI (Python / app)", "name": "Sea Haven — CI (Python / app)",
"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.", "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.",
"iconName": "octicon-checklist", "iconName": "octicon-checklist",
"categories": ["Python", "Continuous integration"], "categories": ["Python", "Continuous integration"],
"filePatterns": ["requirements.*\\.txt$", "pyproject\\.toml$"] "filePatterns": ["requirements.*\\.txt$", "pyproject\\.toml$"]

View file

@ -2,12 +2,13 @@ name: CI (Python / app)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: 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`.
# #
# Every input is optional. Common override: `source-dirs` (ruff targets). # Every input is optional. Common overrides: `source-dirs` (ruff targets),
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 # `requirements` (non-default requirements file), `subproject-dir` (a
# self-contained suite that must run in its own working directory).
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@555d07c3a240689a81668026787eba089df4c975 # main

View file

@ -2,11 +2,10 @@ name: CI (Python / SAM)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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

View file

@ -2,13 +2,12 @@ name: CI (Static Site)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: 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@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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.

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "Sea Haven — CI (TypeScript / frontend)", "name": "Sea Haven — CI (TypeScript / frontend)",
"description": "Sequential remaining-lane CI that emits ci / ci. Converted HCP SPAs should use the HCP CI template (ci-frontend plus ci-complete) instead.", "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.",
"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$"]

View file

@ -2,13 +2,12 @@ name: CI (TypeScript / frontend)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: 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@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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.

View file

@ -8,4 +8,4 @@ permissions:
jobs: jobs:
dependency-review: dependency-review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@555d07c3a240689a81668026787eba089df4c975 # main

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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

View file

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

View file

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

View file

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

View file

@ -1,51 +0,0 @@
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"

View file

@ -13,4 +13,4 @@ permissions:
jobs: jobs:
label: label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@555d07c3a240689a81668026787eba089df4c975 # main

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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.

View file

@ -13,7 +13,7 @@ permissions:
jobs: jobs:
release: release:
uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main
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

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main
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