Compare commits

..

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

60 changed files with 525 additions and 3080 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)

View file

@ -1,6 +0,0 @@
# actionlint 1.7.12 rejects `$/`, which GitHub accepts as a self-repository
# action reference (runner 2.336.0+). Drop this ignore when a release parses it.
paths:
.github/workflows/ci-terraform.yaml:
ignore:
- 'specifying action "\$/.github/actions/app-terraform-isolation" in invalid format because ref is missing'

View file

@ -1,64 +0,0 @@
name: App and Terraform isolation
description: Fail when a change set mixes Terraform with deployable application files.
inputs:
app-paths:
description: Newline-separated deployable paths. A trailing slash is a prefix. Any other entry is an exact file.
required: true
terraform-dir:
description: Directory containing Terraform sources.
required: true
default: terraform
event-name:
description: github.event_name from the calling workflow.
required: true
pr-base-sha:
description: pull_request base SHA. Empty outside pull_request.
required: false
default: ""
merge-group-base-sha:
description: merge_group base SHA. Empty outside merge_group.
required: false
default: ""
runs:
using: composite
steps:
- name: Classify changed paths
shell: bash
working-directory: ${{ github.workspace }}
env:
APP_PATHS: ${{ inputs.app-paths }}
TERRAFORM_DIR: ${{ inputs.terraform-dir }}
EVENT_NAME: ${{ inputs.event-name }}
PR_BASE_SHA: ${{ inputs.pr-base-sha }}
MERGE_GROUP_BASE_SHA: ${{ inputs.merge-group-base-sha }}
CHECKER: ${{ github.action_path }}/check_app_terraform_isolation.py
run: |
set -euo pipefail
classify() {
python3 "${CHECKER}"
}
case "${EVENT_NAME}" in
pull_request)
if [[ -z "${PR_BASE_SHA}" ]]; then
echo "FAIL: pull_request base SHA is empty" >&2
exit 1
fi
merge_base="$(git merge-base "${PR_BASE_SHA}" HEAD)"
git diff --name-only --diff-filter=ACMRD "${merge_base}" HEAD | classify
;;
merge_group)
if [[ -z "${MERGE_GROUP_BASE_SHA}" ]]; then
echo "FAIL: merge_group base SHA is empty" >&2
exit 1
fi
while IFS= read -r sha; do
[[ -z "${sha}" ]] && continue
git diff --name-only --diff-filter=ACMRD "${sha}^" "${sha}" | classify
done < <(git rev-list --reverse --first-parent "${MERGE_GROUP_BASE_SHA}..HEAD")
;;
*)
echo "SKIP: live isolation runs on pull_request and merge_group (event: ${EVENT_NAME})"
;;
esac

View file

@ -1,117 +0,0 @@
#!/usr/bin/env python3
"""Fail when a change set mixes Terraform with deployable application files.
APP_PATHS is newline-separated. A trailing slash is a directory prefix.
Any other entry is an exact file. Paths that are not listed are neutral, so
workflows, docs, and tests may travel with either side. An empty APP_PATHS
skips the check.
TERRAFORM_DIR is the Terraform working directory (default terraform). A path
is Terraform when it equals that directory or sits under it.
"""
from __future__ import annotations
import argparse
import os
import sys
def parse_app_rules(raw: str) -> tuple[frozenset[str], frozenset[str]]:
prefixes: set[str] = set()
exact: set[str] = set()
for line in raw.splitlines():
item = line.strip().replace("\\", "/")
if not item or item.startswith("#"):
continue
if item.endswith("/"):
prefixes.add(item)
else:
exact.add(item)
return frozenset(prefixes), frozenset(exact)
def terraform_prefix(terraform_dir: str) -> str:
prefix = terraform_dir.replace("\\", "/").strip().strip("/")
return prefix or "terraform"
def is_terraform_path(path: str, terraform_dir: str = "terraform") -> bool:
normalized = path.replace("\\", "/")
prefix = terraform_prefix(terraform_dir)
return normalized == prefix or normalized.startswith(f"{prefix}/")
def is_app_path(path: str, prefixes: frozenset[str], exact: frozenset[str]) -> bool:
normalized = path.replace("\\", "/")
if normalized in exact:
return True
for prefix in prefixes:
if normalized.startswith(prefix) or f"{normalized}/" == prefix:
return True
return False
def isolation_violation(
paths: list[str],
app_paths: str,
terraform_dir: str = "terraform",
) -> tuple[list[str], list[str]] | None:
prefixes, exact = parse_app_rules(app_paths)
if not prefixes and not exact:
return None
terraform_files = sorted(
{path for path in paths if is_terraform_path(path, terraform_dir)}
)
app_files = sorted({path for path in paths if is_app_path(path, prefixes, exact)})
if terraform_files and app_files:
return terraform_files, app_files
return None
def first_isolation_violation(
file_sets: list[list[str]],
app_paths: str,
terraform_dir: str = "terraform",
) -> tuple[list[str], list[str]] | None:
for paths in file_sets:
violation = isolation_violation(paths, app_paths, terraform_dir)
if violation is not None:
return violation
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"paths",
nargs="*",
help="Changed paths. Omit and pass newline-separated paths on stdin.",
)
args = parser.parse_args()
paths = list(args.paths)
if not paths and not sys.stdin.isatty():
paths = [line.strip() for line in sys.stdin if line.strip()]
terraform_dir = terraform_prefix(os.environ.get("TERRAFORM_DIR", "terraform"))
violation = isolation_violation(
paths, os.environ.get("APP_PATHS", ""), terraform_dir
)
if violation is None:
print("PASS: application and Terraform changes are isolated")
return 0
terraform_files, app_files = violation
print(
f"FAIL: do not mix deployable application files with {terraform_dir}/",
file=sys.stderr,
)
print("terraform:", file=sys.stderr)
for path in terraform_files:
print(f" {path}", file=sys.stderr)
print("application:", file=sys.stderr)
for path in app_files:
print(f" {path}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

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

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,86 +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"
# app-paths: |
# src/
# package.json
# package-lock.json
#
# app-paths is optional. Empty skips isolation and leaves fmt/init/validate
# unchanged. A trailing slash is a directory prefix. Any other line is an
# exact file. pull_request classifies the merge-base of the base SHA to HEAD.
# merge_group classifies each first-parent commit against its parent, so a
# Terraform-only PR stacked with an app-only PR still passes. The classified
# diff includes deletions. Terraform paths are those under working-directory.
# The checker is a composite action in this repository. `$/` resolves that
# action at this workflow's commit, so callers do not clone this private
# repository with their GITHUB_TOKEN.
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"
app-paths:
description: "Newline-separated deployable paths. A trailing slash is a prefix. Any other entry is an exact file. Empty skips isolation."
type: string
default: ""
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
fetch-depth: 0
- 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
- name: App and Terraform isolation
if: inputs.app-paths != ''
uses: $/.github/actions/app-terraform-isolation
with:
app-paths: ${{ inputs.app-paths }}
terraform-dir: ${{ inputs.working-directory }}
event-name: ${{ github.event_name }}
pr-base-sha: ${{ github.event.pull_request.base.sha }}
merge-group-base-sha: ${{ github.event.merge_group.base_sha }}

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,11 +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: Isolation checker tests
run: python3 scripts/test_check_app_terraform_isolation.py
shell: bash
- 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

198
README.md
View file

@ -2,72 +2,21 @@
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).
@ -93,7 +42,7 @@ The formatter GitHub App is not on the main-branch bypass list.
### 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: `cdk-deploy`, `ci-dotnet`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `labeler`, `mobile-ios-deploy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one.
### Ref pinning policy ### Ref pinning policy
@ -105,8 +54,8 @@ All workflow refs across the org are pinned to full commit SHAs:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha> # v1.0.3 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. 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 current tip of `main` (`gh api /repos/Sea-Haven-Industries/.github/commits/main --jq .sha`) 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. - **Third-party and first-party actions** (`actions/checkout`, `actions/setup-python`, `actions/labeler`, …) are likewise **SHA-pinned** with a trailing version comment (e.g. `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`); Dependabot keeps the SHA and comment current.
- **Binary installs are checksum-verified** (actionlint in `ci.yaml`). - **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 +110,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.
@ -183,10 +132,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 +149,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@<full-commit-sha> # <release>
``` ```
**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 +162,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@<full-commit-sha> # <release>
``` ```
**Node.js SAM repo** (e.g., payments-dashboard): **Node.js SAM repo** (e.g., payments-dashboard):
@ -228,7 +175,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@<full-commit-sha> # <release>
with: with:
run-typecheck: false run-typecheck: false
run-cdk-synth: false run-cdk-synth: false
@ -245,126 +192,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@<full-commit-sha> # <release>
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@<full-commit-sha> # <release>
``` ```
**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 +214,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@<full-commit-sha> # <release>
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 +234,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@<full-commit-sha> # <release>
secrets: secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
``` ```
@ -409,7 +249,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@<full-commit-sha> # <release>
with: with:
python-version: "3.12" python-version: "3.12"
cdk-dir: cdk cdk-dir: cdk
@ -427,7 +267,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@<full-commit-sha> # <release>
with: with:
enable-qemu: true enable-qemu: true
secrets: secrets:
@ -444,7 +284,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@<full-commit-sha> # <release>
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

@ -56,7 +56,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 +209,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
@ -1051,6 +1031,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 +1242,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,25 +1380,16 @@ 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): # Scoped runtime role for the meal-order-manager weekly-menu workflow
# weekly-menu OIDC role now lives in seahaven-prod as # (Monday scrape + order-form publish). Deliberately narrower than the
# /tf-managed/githubdeploy-meal-order-manager-weekly-menu (HCP TF). # repo's deploy role: the scheduled job reads stack outputs and app config,
# GitHub secret AWS_WEEKLY_MENU_ROLE_ARN already points at the prod role. # writes menu items and the published form, and invalidates the form's
# Mgmt role github-meal-order-manager-weekly-menu deleted with this stack update. # CloudFront path. It deploys nothing, so it gets no CloudFormation write
# actions, no PassRole, and no access outside the form bucket.
MealOrderManagerWeeklyMenuRole:
# 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 Type: AWS::IAM::Role
Properties: Properties:
RoleName: githubdeploy-seahaven-org-baseline-policy-check RoleName: github-meal-order-manager-weekly-menu
Description: Access Analyzer policy checks for seahaven-org-baseline CI. No deploy permissions.
AssumeRolePolicyDocument: AssumeRolePolicyDocument:
Version: "2012-10-17" Version: "2012-10-17"
Statement: Statement:
@ -1181,34 +1397,60 @@ Resources:
Principal: Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity 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: Condition:
# StringEquals (not the sibling roles' StringLike): no wildcard is
# intended, and job_workflow_ref pins this runtime role to the ONE
# workflow it serves — unlike the deploy roles, any main-branch
# workflow must NOT be able to mint these credentials.
StringEquals: StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com token.actions.githubusercontent.com:aud: sts.amazonaws.com
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-org-baseline:pull_request token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/meal-order-manager:ref:refs/heads/main
- Effect: Allow token.actions.githubusercontent.com:job_workflow_ref: !Sub ${GitHubOrg}/meal-order-manager/.github/workflows/weekly-menu.yml@refs/heads/main
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: Policies:
- PolicyName: access-analyzer-policy-check - PolicyName: weekly-menu-publish
PolicyDocument: PolicyDocument:
Version: "2012-10-17" Version: "2012-10-17"
Statement: Statement:
- Sid: AccessAnalyzerPolicyCheck - Effect: Allow
Effect: Allow
Action: Action:
- access-analyzer:ValidatePolicy - cloudformation:DescribeStacks
- access-analyzer:CheckNoNewAccess Resource:
Resource: "*" - !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/meal-order-manager/*
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
# Secrets Manager appends a random 6-char suffix to every secret
# ARN, so a name-based match needs a glob — but exactly six '?'
# (one char each), NOT '-*', which would also match any future
# secret extending the name (e.g. form-api-key-backup).
Resource:
- !Sub arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:meal-order-manager/form-api-key-??????
- !Sub arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:meal-order-manager/slack-bot-token-??????
- Effect: Allow
Action:
- ssm:GetParameter
Resource:
- !Sub arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/meal-order-manager/google-client-id
- !Sub arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/meal-order-manager/slack-channel-id
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
Resource:
- !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/meal-order-manager-orders
- Effect: Allow
Action:
- s3:PutObject
Resource:
- !Sub arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}/index.html
- !Sub arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}/archive/*.html
- Effect: Allow
Action:
- cloudfront:CreateInvalidation
# Distribution ID = the meal-order-manager stack's DistributionId
# output (stable for the life of the distribution).
Resource:
- !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/E314J1CJJ9ZTRA
Outputs: Outputs:
LambdaExecutionBoundaryArn: LambdaExecutionBoundaryArn:
@ -1224,20 +1466,25 @@ 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: MealOrderManagerWeeklyMenuRoleArn:
Value: !GetAtt SeahavenOrgBaselinePolicyCheckRole.Arn Value: !GetAtt MealOrderManagerWeeklyMenuRole.Arn
# MealOrderManagerWeeklyMenuRoleArn removed 2026-08-20 (PLAT-70): role deleted with resource.

View file

@ -1,239 +0,0 @@
#!/usr/bin/env python3
"""Tests for check_app_terraform_isolation."""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ACTION = ROOT / ".github" / "actions" / "app-terraform-isolation"
WORKFLOW = ROOT / ".github" / "workflows" / "ci-terraform.yaml"
sys.path.insert(0, str(ACTION))
from check_app_terraform_isolation import ( # noqa: E402
first_isolation_violation,
isolation_violation,
)
APP_PATHS = "src/\npackage.json\npackage-lock.json\n"
class IsolationTests(unittest.TestCase):
def test_terraform_only(self) -> None:
self.assertIsNone(
isolation_violation(
["terraform/lambda.tf", "terraform/README.md"],
APP_PATHS,
)
)
def test_app_only(self) -> None:
self.assertIsNone(
isolation_violation(
["src/processPaymentCsv.js", "package.json", "package-lock.json"],
APP_PATHS,
)
)
def test_docs_and_workflows_with_terraform(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/lambda.tf",
".github/workflows/deploy.yaml",
"SETUP.md",
"scripts/check_app_terraform_isolation.py",
],
APP_PATHS,
)
)
def test_mixed_app_and_terraform_fails(self) -> None:
violation = isolation_violation(
["terraform/lambda.tf", "src/processPaymentCsv.js", "package.json"],
APP_PATHS,
)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["terraform/lambda.tf"])
self.assertEqual(app_files, ["package.json", "src/processPaymentCsv.js"])
def test_empty_app_paths_skips(self) -> None:
self.assertIsNone(
isolation_violation(
["terraform/lambda.tf", "src/processPaymentCsv.js"],
"",
)
)
def test_separate_commits_pass_when_classified_alone(self) -> None:
self.assertIsNone(
first_isolation_violation(
[
["terraform/lambda.tf"],
["src/processPaymentCsv.js"],
],
APP_PATHS,
)
)
def test_union_of_separate_commits_fails(self) -> None:
violation = isolation_violation(
["terraform/lambda.tf", "src/processPaymentCsv.js"],
APP_PATHS,
)
self.assertIsNotNone(violation)
def test_non_default_terraform_dir_mixed_fails(self) -> None:
violation = isolation_violation(
["infra/main.tf", "src/app.js"],
"src/\n",
terraform_dir="infra",
)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["infra/main.tf"])
self.assertEqual(app_files, ["src/app.js"])
def test_default_dir_leaves_other_prefixes_neutral(self) -> None:
self.assertIsNone(
isolation_violation(["infra/main.tf", "src/app.js"], "src/\n")
)
def test_terraform_prefix_does_not_match_a_longer_directory(self) -> None:
self.assertIsNone(
isolation_violation(
["infrastructure/main.tf", "src/app.js"],
"src/\n",
terraform_dir="infra",
)
)
def test_terraform_dir_trailing_slash(self) -> None:
violation = isolation_violation(
["infra/main.tf", "src/app.js"],
"src/\n",
terraform_dir="infra/",
)
self.assertIsNotNone(violation)
def test_blank_terraform_dir_defaults_to_terraform(self) -> None:
violation = isolation_violation(
["terraform/lambda.tf", "src/app.js"],
"src/\n",
terraform_dir=" ",
)
self.assertIsNotNone(violation)
class WorkflowDiffTests(unittest.TestCase):
def test_classified_diffs_include_deletions(self) -> None:
self.assertEqual(_workflow_diff_filters(), ["ACMRD", "ACMRD"])
def test_workflow_passes_working_directory(self) -> None:
self.assertIn(
"terraform-dir: ${{ inputs.working-directory }}",
WORKFLOW.read_text(),
)
def test_checker_is_this_repos_action_at_the_workflow_commit(self) -> None:
text = WORKFLOW.read_text()
self.assertIn("uses: $/.github/actions/app-terraform-isolation", text)
self.assertNotIn("github.workflow_sha", text)
self.assertNotIn("repository: Sea-Haven-Industries/.github", text)
def test_deleted_app_file_is_classified(self) -> None:
diff_filter = _workflow_diff_filters()[0]
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
base = _commit_base(repo)
(repo / "terraform" / "lambda.tf").write_text("changed\n")
(repo / "src" / "processPaymentCsv.js").unlink()
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "decommission handler")
omitted = _changed_paths(repo, base, "HEAD", "ACMR")
included = _changed_paths(repo, base, "HEAD", diff_filter)
self.assertNotIn("src/processPaymentCsv.js", omitted)
self.assertIn("src/processPaymentCsv.js", included)
self.assertIn("terraform/lambda.tf", included)
self.assertIsNotNone(isolation_violation(included, APP_PATHS))
def test_deleted_terraform_file_is_classified(self) -> None:
diff_filter = _workflow_diff_filters()[0]
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
base = _commit_base(repo)
(repo / "terraform" / "lambda.tf").unlink()
(repo / "src" / "processPaymentCsv.js").write_text("changed\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "remove lambda")
omitted = _changed_paths(repo, base, "HEAD", "ACMR")
included = _changed_paths(repo, base, "HEAD", diff_filter)
self.assertNotIn("terraform/lambda.tf", omitted)
violation = isolation_violation(included, APP_PATHS)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["terraform/lambda.tf"])
self.assertEqual(app_files, ["src/processPaymentCsv.js"])
def _workflow_diff_filters() -> list[str]:
action = (ACTION / "action.yml").read_text()
return re.findall(r"--diff-filter=([A-Z]+)", action)
def _git(repo: Path, *args: str) -> str:
env = os.environ.copy()
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_NOSYSTEM"] = "1"
completed = subprocess.run(
[
"git",
"-c",
"commit.gpgsign=false",
"-c",
"user.name=test",
"-c",
"user.email=test@example.com",
*args,
],
cwd=repo,
check=True,
capture_output=True,
text=True,
env=env,
)
return completed.stdout
def _commit_base(repo: Path) -> str:
_git(repo, "init", "-b", "main")
(repo / "terraform").mkdir()
(repo / "src").mkdir()
(repo / "terraform" / "lambda.tf").write_text("resource\n")
(repo / "src" / "processPaymentCsv.js").write_text("export {}\n")
_git(repo, "add", ".")
_git(repo, "commit", "-m", "base")
return _git(repo, "rev-parse", "HEAD").strip()
def _changed_paths(repo: Path, base: str, head: str, diff_filter: str) -> list[str]:
output = _git(
repo,
"diff",
"--name-only",
f"--diff-filter={diff_filter}",
base,
head,
)
return [line for line in output.splitlines() if line]
if __name__ == "__main__":
unittest.main()

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