mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 05:53:12 +00:00
Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5b843ca1 | ||
|
|
892580d7d7 | ||
|
|
c9134742ce | ||
|
|
8e6b8e8665 | ||
|
|
0a1010e632 | ||
|
|
6fc4ca31e1 | ||
|
|
bf14925fcf | ||
|
|
acaf2bfc0d | ||
|
|
2e2b3a282f | ||
|
|
61f15d78b5 | ||
|
|
fd41132410 |
12 changed files with 1035 additions and 343 deletions
129
.github/workflows/cd-hcp-fargate.yaml
vendored
129
.github/workflows/cd-hcp-fargate.yaml
vendored
|
|
@ -18,6 +18,11 @@ name: CD — HCP Fargate
|
|||
# docker-platform: linux/amd64
|
||||
# ship-gate: true
|
||||
#
|
||||
# apply-task-environment replaces the container env from
|
||||
# ${prefix}/task-environment. sentry-project uploads image files before
|
||||
# RegisterTaskDefinition. concurrency-suffix splits two deployables that
|
||||
# share one SSM prefix. Empty defaults keep the previous behavior.
|
||||
#
|
||||
# Nothing here creates an HCP run. Terraform owns the cluster, service, ALB,
|
||||
# and ignores container_definitions / task_definition.
|
||||
|
||||
|
|
@ -57,6 +62,41 @@ on:
|
|||
type: string
|
||||
required: false
|
||||
default: "{}"
|
||||
apply-task-environment:
|
||||
description: "Replace container env from SSM ${prefix}/task-environment. GIT_SHA wins."
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
sentry-org:
|
||||
description: "Sentry org for BFF source map upload when sentry-project is set"
|
||||
type: string
|
||||
required: false
|
||||
default: "seahaven"
|
||||
sentry-project:
|
||||
description: "Sentry project for BFF source map upload. Empty skips upload."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
sentry-container-files:
|
||||
description: "Comma-separated image paths to upload. Required when sentry-project is set."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
health-attempts:
|
||||
description: "Number of /api/health polls, 10 seconds apart, before failing"
|
||||
type: number
|
||||
required: false
|
||||
default: 6
|
||||
health-from-distribution:
|
||||
description: "Build the health URL from SSM distribution-id and CloudFront GetDistribution. Leave false to read SSM api-url."
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
concurrency-suffix:
|
||||
description: "Optional concurrency group suffix when two deployables share an SSM prefix"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -69,7 +109,7 @@ jobs:
|
|||
timeout-minutes: 30
|
||||
environment: ${{ inputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
|
||||
group: ${{ inputs.concurrency-suffix != '' && format('deploy-{0}-{1}-{2}', inputs.ssm-prefix, inputs.concurrency-suffix, inputs.environment) || format('deploy-{0}-{1}', inputs.ssm-prefix, inputs.environment) }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
|
|
@ -186,6 +226,7 @@ jobs:
|
|||
id: deploy
|
||||
env:
|
||||
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||
HEALTH_FROM_DISTRIBUTION: ${{ inputs.health-from-distribution }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
get_param() {
|
||||
|
|
@ -197,7 +238,13 @@ jobs:
|
|||
FAMILY=$(get_param "${prefix}/task-family")
|
||||
ECR=$(get_param "${prefix}/ecr-repository")
|
||||
CONTAINER=$(get_param "${prefix}/container-name")
|
||||
if [ "${HEALTH_FROM_DISTRIBUTION}" = "true" ]; then
|
||||
DIST_ID=$(get_param "${prefix}/distribution-id")
|
||||
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
|
||||
API_URL="https://${DOMAIN}"
|
||||
else
|
||||
API_URL=$(get_param "${prefix}/api-url")
|
||||
fi
|
||||
{
|
||||
echo "cluster=${CLUSTER}"
|
||||
echo "service=${SERVICE}"
|
||||
|
|
@ -232,6 +279,57 @@ jobs:
|
|||
--push \
|
||||
.
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
if: ${{ inputs.sentry-project != '' }}
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Upload BFF source maps
|
||||
if: ${{ inputs.sentry-project != '' }}
|
||||
env:
|
||||
ECR: ${{ steps.deploy.outputs.ecr }}
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_URL: https://de.sentry.io
|
||||
SENTRY_ORG: ${{ inputs.sentry-org }}
|
||||
SENTRY_PROJECT: ${{ inputs.sentry-project }}
|
||||
SENTRY_CONTAINER_FILES: ${{ inputs.sentry-container-files }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SENTRY_AUTH_TOKEN}" ]; then
|
||||
echo "SENTRY_AUTH_TOKEN is required to upload BFF source maps" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${SENTRY_CONTAINER_FILES}" ]; then
|
||||
echo "sentry-container-files is required when sentry-project is set" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker pull "${ECR}:${GIT_SHA}"
|
||||
mkdir -p build/sentry
|
||||
cid="$(docker create "${ECR}:${GIT_SHA}")"
|
||||
cleanup() { docker rm "${cid}" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
IFS=',' read -r -a files <<< "${SENTRY_CONTAINER_FILES}"
|
||||
for path in "${files[@]}"; do
|
||||
path="${path#"${path%%[![:space:]]*}"}"
|
||||
path="${path%"${path##*[![:space:]]}"}"
|
||||
if [ -z "${path}" ]; then
|
||||
echo "sentry-container-files contains an empty path" >&2
|
||||
exit 1
|
||||
fi
|
||||
base="$(basename "${path}")"
|
||||
docker cp "${cid}:${path}" "build/sentry/${base}"
|
||||
done
|
||||
if [ -f build/sentry/server.js ]; then
|
||||
grep -q "${GIT_SHA}" build/sentry/server.js
|
||||
grep -q debugId build/sentry/server.js
|
||||
fi
|
||||
npx --yes @sentry/cli@2 sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
--project "${SENTRY_PROJECT}" \
|
||||
--release "${GIT_SHA}" \
|
||||
build/sentry
|
||||
|
||||
- name: Register task definition and update service
|
||||
env:
|
||||
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
||||
|
|
@ -241,8 +339,19 @@ jobs:
|
|||
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
EXTRA_TASK_ENV: ${{ inputs.extra-task-env }}
|
||||
APPLY_TASK_ENVIRONMENT: ${{ inputs.apply-task-environment }}
|
||||
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${APPLY_TASK_ENVIRONMENT}" = "true" ]; then
|
||||
prefix="${SSM_PREFIX%/}"
|
||||
TASK_ENV_JSON="$(aws ssm get-parameter \
|
||||
--name "${prefix}/task-environment" \
|
||||
--with-decryption \
|
||||
--query Parameter.Value \
|
||||
--output text)"
|
||||
export TASK_ENV_JSON
|
||||
fi
|
||||
aws ecs describe-task-definition \
|
||||
--task-definition "${FAMILY}" \
|
||||
--query taskDefinition \
|
||||
|
|
@ -268,16 +377,25 @@ jobs:
|
|||
extra_env = json.loads(extra_raw)
|
||||
if not isinstance(extra_env, dict):
|
||||
sys.exit("extra-task-env must be a JSON object")
|
||||
apply = os.environ.get("APPLY_TASK_ENVIRONMENT") == "true"
|
||||
found = False
|
||||
for container in td["containerDefinitions"]:
|
||||
if container["name"] != name:
|
||||
continue
|
||||
found = True
|
||||
container["image"] = image
|
||||
if apply:
|
||||
env_map = json.loads(os.environ["TASK_ENV_JSON"])
|
||||
if not isinstance(env_map, dict) or not env_map:
|
||||
sys.exit("task-environment must be a non-empty JSON object")
|
||||
env = {str(key): str(value) for key, value in env_map.items()}
|
||||
env.pop("GIT_SHA", None)
|
||||
container["stopTimeout"] = 60
|
||||
else:
|
||||
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
||||
env["GIT_SHA"] = sha
|
||||
for key, value in extra_env.items():
|
||||
env[str(key)] = str(value)
|
||||
env["GIT_SHA"] = sha
|
||||
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
||||
container.pop("command", None)
|
||||
if not found:
|
||||
|
|
@ -298,6 +416,7 @@ jobs:
|
|||
API_URL: ${{ steps.deploy.outputs.api_url }}
|
||||
HEALTH_PATH: ${{ inputs.health-path }}
|
||||
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
||||
HEALTH_ATTEMPTS: ${{ inputs.health-attempts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
path="${HEALTH_PATH}"
|
||||
|
|
@ -306,7 +425,11 @@ jobs:
|
|||
*) path="/${path}" ;;
|
||||
esac
|
||||
url="${API_URL%/}${path}"
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
if ! [[ "${HEALTH_ATTEMPTS}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "health-attempts must be a positive integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
for _ in $(seq 1 "${HEALTH_ATTEMPTS}"); do
|
||||
BODY="$(curl -fsS "${url}" || true)"
|
||||
echo "${BODY}"
|
||||
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
|
||||
|
|
|
|||
274
.github/workflows/cd-hcp-lambda.yaml
vendored
Normal file
274
.github/workflows/cd-hcp-lambda.yaml
vendored
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
name: CD — HCP Lambda
|
||||
|
||||
# Reusable Lambda zip CD for HCP app repos. The caller owns triggers and
|
||||
# passes `environment` as a `with:` input. This job owns `environment:`,
|
||||
# concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. GitHub rejects `environment:`
|
||||
# beside `uses:`.
|
||||
#
|
||||
# Caller example (one job per GitHub Environment):
|
||||
# jobs:
|
||||
# deploy-prod:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-lambda.yaml@<sha> # vX.Y.Z
|
||||
# permissions: { contents: read, id-token: write }
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# environment: prod
|
||||
# ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||
# ssm-prefix: /payments-dashboard/deploy
|
||||
# function-keys: process_csv,slack_app_home
|
||||
# ship-gate: true
|
||||
#
|
||||
# The caller repo must provide scripts/package_lambdas.mjs, which writes
|
||||
# build/packages/<key>.zip and embeds the commit in src/buildInfo.js.
|
||||
# Terraform owns the functions and ignores code attributes. SSM under
|
||||
# ssm-prefix supplies artifacts-bucket and <key>-function-name.
|
||||
#
|
||||
# Nothing here creates an HCP run.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
description: "GitHub Environment to deploy to (dev, staging, prod)"
|
||||
type: string
|
||||
required: true
|
||||
ref:
|
||||
description: "Git ref to build. Empty means github.sha."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
ssm-prefix:
|
||||
description: "SSM prefix for deploy parameters (e.g. /payments-dashboard/deploy)"
|
||||
type: string
|
||||
required: true
|
||||
function-keys:
|
||||
description: "Comma-separated package keys. Each maps to SSM <prefix>/<key>-function-name."
|
||||
type: string
|
||||
required: true
|
||||
node-version:
|
||||
description: "Node.js version for setup-node and the packager"
|
||||
type: string
|
||||
required: false
|
||||
default: "24"
|
||||
ship-gate:
|
||||
description: "Require the ref to be on main or a legal hotfix/release tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Lambda to ${{ inputs.environment }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: ${{ inputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
fetch-tags: true
|
||||
|
||||
- name: Resolve commit
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Ship-gate
|
||||
if: ${{ inputs.ship-gate }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
INPUT_REF: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
ENVIRONMENT: ${{ inputs.environment }}
|
||||
HEAD_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
status="$(gh api "repos/${REPO}/compare/main...${INPUT_REF}" --jq .status)"
|
||||
if [ "${status}" = "behind" ] || [ "${status}" = "identical" ]; then
|
||||
echo "ship-gate: ${INPUT_REF} is ${status} relative to main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "ship-gate: ${INPUT_REF} is not on main (compare status: ${status}); checking hotfix/release path"
|
||||
|
||||
TAG="${INPUT_REF}"
|
||||
if [[ ! "${TAG}" =~ ^v ]]; then
|
||||
TAG="$(git describe --tags --exact-match "${HEAD_SHA}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ "${ENVIRONMENT}" = "staging" ]; then
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-staging$'
|
||||
else
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
fi
|
||||
|
||||
if [[ -z "${TAG}" ]] || [[ ! "${TAG}" =~ ${PATTERN} ]]; then
|
||||
echo "ship-gate: ref ${INPUT_REF} (tag ${TAG:-none}) does not match ${PATTERN} for environment ${ENVIRONMENT} and is not on main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATTERN TAG
|
||||
# Newest matching release that is an ancestor of TAG. The highest
|
||||
# release overall is not that ancestor when a hotfix is cut from an
|
||||
# older line (v2.0.0 exists, v1.2.1 is cut from v1.2.0).
|
||||
CANDIDATES="$(
|
||||
gh api "repos/${REPO}/releases" --paginate --jq '.[].tag_name' | python3 -c '
|
||||
import os, re, sys
|
||||
pattern = re.compile(os.environ["PATTERN"])
|
||||
current = os.environ["TAG"]
|
||||
tags = [
|
||||
line.strip()
|
||||
for line in sys.stdin
|
||||
if pattern.fullmatch(line.strip()) and line.strip() != current
|
||||
]
|
||||
def key(tag):
|
||||
body = tag[1:]
|
||||
core = body.split("-", 1)[0]
|
||||
return tuple(int(part) for part in core.split("."))
|
||||
tags.sort(key=key, reverse=True)
|
||||
print("\n".join(tags))
|
||||
'
|
||||
)"
|
||||
|
||||
PREV=""
|
||||
if [ -n "${CANDIDATES}" ]; then
|
||||
while IFS= read -r candidate; do
|
||||
if [ -z "${candidate}" ]; then
|
||||
continue
|
||||
fi
|
||||
candidate_status="$(gh api "repos/${REPO}/compare/${candidate}...${TAG}" --jq .status)"
|
||||
if [ "${candidate_status}" = "ahead" ]; then
|
||||
PREV="${candidate}"
|
||||
break
|
||||
fi
|
||||
done <<< "${CANDIDATES}"
|
||||
fi
|
||||
|
||||
if [ -z "${PREV}" ]; then
|
||||
echo "ship-gate: no prior ${PATTERN} release is an ancestor of ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ship-gate: ${TAG} is ahead of ${PREV}"
|
||||
|
||||
from_train=false
|
||||
TARGET_COMMITISH="$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .target_commitish 2>/dev/null || true)"
|
||||
if [[ "${TARGET_COMMITISH}" == hotfix/* || "${TARGET_COMMITISH}" == release/* ]]; then
|
||||
from_train=true
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
git fetch origin '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*' '+refs/heads/release/*:refs/remotes/origin/release/*' || true
|
||||
if git branch -r --contains "${HEAD_SHA}" | grep -Eq 'origin/(hotfix|release)/'; then
|
||||
from_train=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
echo "ship-gate: ${TAG} was not created from hotfix/* or release/* (target_commitish=${TARGET_COMMITISH:-none})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ship-gate: hotfix/release path accepted (${PREV} -> ${TAG} from ${TARGET_COMMITISH:-branch})"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Build function zips
|
||||
env:
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
FUNCTION_KEYS: ${{ inputs.function-keys }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${FUNCTION_KEYS}" ]; then
|
||||
echo "function-keys is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
keys=()
|
||||
IFS=',' read -r -a raw_keys <<< "${FUNCTION_KEYS}"
|
||||
for raw in "${raw_keys[@]}"; do
|
||||
key="${raw#"${raw%%[![:space:]]*}"}"
|
||||
key="${key%"${key##*[![:space:]]}"}"
|
||||
if [ -z "${key}" ]; then
|
||||
echo "function-keys contains an empty key" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${key}" =~ ^[A-Za-z0-9_]+$ ]]; then
|
||||
echo "invalid function key: ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
keys+=("${key}")
|
||||
done
|
||||
if [ "${#keys[@]}" -eq 0 ]; then
|
||||
echo "function-keys is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
clean="$(IFS=,; echo "${keys[*]}")"
|
||||
echo "keys=${clean}" >> "${GITHUB_ENV}"
|
||||
cmd=(node scripts/package_lambdas.mjs --git-sha "${GIT_SHA}" --out-dir build/packages)
|
||||
for key in "${keys[@]}"; do
|
||||
cmd+=(--only "${key}")
|
||||
done
|
||||
"${cmd[@]}"
|
||||
FUNCTION_KEYS_CLEAN="${clean}" python3 - <<'PY'
|
||||
import os, zipfile
|
||||
from pathlib import Path
|
||||
sha = os.environ["GIT_SHA"]
|
||||
keys = [part for part in os.environ["FUNCTION_KEYS_CLEAN"].split(",") if part]
|
||||
for name in keys:
|
||||
path = Path("build/packages") / f"{name}.zip"
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"missing {path}")
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
info = zf.read("src/buildInfo.js").decode()
|
||||
if sha not in info:
|
||||
raise SystemExit(f"{path} missing GIT_SHA {sha}")
|
||||
print("zips ok")
|
||||
PY
|
||||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Upload zips and update function code
|
||||
env:
|
||||
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
FUNCTION_KEYS_CLEAN: ${{ env.keys }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="${SSM_PREFIX%/}"
|
||||
bucket="$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)"
|
||||
IFS=',' read -r -a keys <<< "${FUNCTION_KEYS_CLEAN}"
|
||||
for key in "${keys[@]}"; do
|
||||
fn="$(aws ssm get-parameter --name "${prefix}/${key}-function-name" --query Parameter.Value --output text)"
|
||||
s3_key="functions/${key}/${GIT_SHA}.zip"
|
||||
aws s3 cp "build/packages/${key}.zip" "s3://${bucket}/${s3_key}"
|
||||
aws lambda update-function-code \
|
||||
--function-name "${fn}" \
|
||||
--s3-bucket "${bucket}" \
|
||||
--s3-key "${s3_key}" \
|
||||
--query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \
|
||||
--output table
|
||||
aws lambda wait function-updated-v2 --function-name "${fn}"
|
||||
done
|
||||
108
.github/workflows/cd-hcp-spa.yaml
vendored
108
.github/workflows/cd-hcp-spa.yaml
vendored
|
|
@ -21,6 +21,10 @@ name: CD — HCP SPA
|
|||
# ssm-prefix: /internal-portal/deploy
|
||||
# ship-gate: true
|
||||
#
|
||||
# concurrency-suffix splits two deployables that share one SSM prefix.
|
||||
# verify-companion-api adds cache, asset, and /api/health checks after the
|
||||
# index.html hash matches. Empty defaults keep the previous behavior.
|
||||
#
|
||||
# Nothing here creates an HCP run. Terraform owns the bucket and distribution.
|
||||
|
||||
on:
|
||||
|
|
@ -49,6 +53,16 @@ on:
|
|||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
verify-companion-api:
|
||||
description: "After the index hash matches, check cache headers, hashed assets, and /api/health"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
concurrency-suffix:
|
||||
description: "Optional concurrency group suffix when two deployables share an SSM prefix"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -61,7 +75,7 @@ jobs:
|
|||
timeout-minutes: 45
|
||||
environment: ${{ inputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
|
||||
group: ${{ inputs.concurrency-suffix != '' && format('deploy-{0}-{1}-{2}', inputs.ssm-prefix, inputs.concurrency-suffix, inputs.environment) || format('deploy-{0}-{1}', inputs.ssm-prefix, inputs.environment) }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
|
|
@ -301,3 +315,95 @@ jobs:
|
|||
done
|
||||
echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2
|
||||
exit 1
|
||||
|
||||
- name: Verify companion API
|
||||
if: ${{ inputs.verify-companion-api }}
|
||||
env:
|
||||
SITE_URL: ${{ steps.deploy.outputs.site_url }}
|
||||
HEALTH_BUDGET: "20"
|
||||
HEALTH_INTERVAL: "15"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp}"' EXIT
|
||||
|
||||
curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers"
|
||||
curl -fsS --max-time 30 "${SITE_URL}/signin" -o "${tmp}/signin.html"
|
||||
curl -fsS --max-time 30 "${SITE_URL}/help" -o "${tmp}/route.html"
|
||||
if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then
|
||||
echo "FAIL: HTML Cache-Control is missing no-store." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 -c '
|
||||
import re, sys
|
||||
html = open(sys.argv[1], encoding="utf-8").read()
|
||||
seen = []
|
||||
for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
|
||||
if path not in seen:
|
||||
seen.append(path)
|
||||
print(path)
|
||||
' "${tmp}/index.html" > "${tmp}/asset-paths.txt"
|
||||
|
||||
if [ ! -s "${tmp}/asset-paths.txt" ]; then
|
||||
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
|
||||
exit 1
|
||||
fi
|
||||
: > "${tmp}/assets.txt"
|
||||
immutable_ok="no"
|
||||
while IFS= read -r asset_path; do
|
||||
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \
|
||||
-o "${tmp}/asset-body" -D "${tmp}/asset.headers"
|
||||
cat "${tmp}/asset-body" >> "${tmp}/assets.txt"
|
||||
if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then
|
||||
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
|
||||
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
|
||||
exit 1
|
||||
fi
|
||||
immutable_ok="yes"
|
||||
fi
|
||||
done < "${tmp}/asset-paths.txt"
|
||||
if [ "${immutable_ok}" != "yes" ]; then
|
||||
echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
|
||||
if grep -Eiq 'https?://(localhost|127\.0\.0\.1):[0-9]+' "${tmp}/served.txt"; then
|
||||
echo "FAIL: served assets contain forbidden URL localhost." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
health_code="000"
|
||||
health_sha=""
|
||||
health_attempt=0
|
||||
while [ "${health_attempt}" -lt "${HEALTH_BUDGET}" ]; do
|
||||
health_attempt=$((health_attempt + 1))
|
||||
health_code="$(curl -sS --max-time 30 -o "${tmp}/health.json" -w '%{http_code}' "${SITE_URL}/api/health" || echo "000")"
|
||||
echo "health poll ${health_attempt}/${HEALTH_BUDGET}: GET /api/health http=${health_code}"
|
||||
if [ "${health_code}" = "200" ]; then
|
||||
health_sha="$(python3 -c 'import json,sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1], encoding="utf-8")).get("sha") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' "${tmp}/health.json")"
|
||||
if [ -n "${health_sha}" ] && [ "${health_sha}" != "bootstrap" ]; then
|
||||
break
|
||||
fi
|
||||
echo "health poll ${health_attempt}/${HEALTH_BUDGET}: sha=${health_sha:-missing} (waiting for Deploy API)"
|
||||
fi
|
||||
if [ "${health_attempt}" -lt "${HEALTH_BUDGET}" ]; then
|
||||
sleep "${HEALTH_INTERVAL}"
|
||||
fi
|
||||
done
|
||||
if [ "${health_code}" != "200" ]; then
|
||||
echo "FAIL: GET /api/health returned HTTP ${health_code} after ${HEALTH_BUDGET} polls." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${health_sha}" ] || [ "${health_sha}" = "bootstrap" ]; then
|
||||
echo "FAIL: GET /api/health is still the bootstrap stub after ${HEALTH_BUDGET} polls." >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 -c 'import json,sys; body=json.load(open(sys.argv[1], encoding="utf-8")); raise SystemExit(0 if body.get("stage") and body.get("sha") else 1)' "${tmp}/health.json"
|
||||
echo "PASS: companion API smoke checks passed."
|
||||
|
|
|
|||
312
.github/workflows/cd-hcp-static.yaml
vendored
Normal file
312
.github/workflows/cd-hcp-static.yaml
vendored
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
name: CD — HCP static site
|
||||
|
||||
# Reusable CloudFront/S3 CD for unfingerprinted static sites. The caller owns
|
||||
# triggers and passes `environment` as a `with:` input. This job owns
|
||||
# `environment:`, concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. GitHub
|
||||
# rejects `environment:` beside `uses:`.
|
||||
#
|
||||
# Assets are not content-hashed, so they get a one-day cache. HTML, XML, and
|
||||
# text get no-cache. Do not point a hashed SPA at this workflow.
|
||||
#
|
||||
# Caller example:
|
||||
# jobs:
|
||||
# deploy-prod:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-static.yaml@<sha> # vX.Y.Z
|
||||
# permissions: { contents: read, id-token: write }
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# environment: prod
|
||||
# ref: ${{ inputs.ref }}
|
||||
# ssm-prefix: /seahaven-site/deploy
|
||||
# required-paths: _site/index.html,_site/contact/index.html,_site/404.html
|
||||
# min-file-count: 40
|
||||
# ship-gate: true
|
||||
#
|
||||
# Nothing here creates an HCP run. Terraform owns the bucket and distribution.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
description: "GitHub Environment to deploy to (dev, staging, prod)"
|
||||
type: string
|
||||
required: true
|
||||
ref:
|
||||
description: "Git ref to build. Empty means github.sha."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
ssm-prefix:
|
||||
description: "SSM prefix for deploy parameters (e.g. /seahaven-site/deploy)"
|
||||
type: string
|
||||
required: true
|
||||
ship-gate:
|
||||
description: "Require the ref to be on main or a legal hotfix/release tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
output-dir:
|
||||
description: "Build output directory"
|
||||
type: string
|
||||
required: false
|
||||
default: "_site"
|
||||
required-paths:
|
||||
description: "Comma-separated repo-relative files that must exist after the build"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
min-file-count:
|
||||
description: "Minimum file count under output-dir. Zero skips the count check."
|
||||
type: number
|
||||
required: false
|
||||
default: 1
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy static site to ${{ inputs.environment }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: ${{ inputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
fetch-tags: true
|
||||
|
||||
- name: Resolve commit
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Ship-gate
|
||||
if: ${{ inputs.ship-gate }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
INPUT_REF: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
ENVIRONMENT: ${{ inputs.environment }}
|
||||
HEAD_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
status="$(gh api "repos/${REPO}/compare/main...${INPUT_REF}" --jq .status)"
|
||||
if [ "${status}" = "behind" ] || [ "${status}" = "identical" ]; then
|
||||
echo "ship-gate: ${INPUT_REF} is ${status} relative to main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "ship-gate: ${INPUT_REF} is not on main (compare status: ${status}); checking hotfix/release path"
|
||||
|
||||
TAG="${INPUT_REF}"
|
||||
if [[ ! "${TAG}" =~ ^v ]]; then
|
||||
TAG="$(git describe --tags --exact-match "${HEAD_SHA}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ "${ENVIRONMENT}" = "staging" ]; then
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-staging$'
|
||||
else
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
fi
|
||||
|
||||
if [[ -z "${TAG}" ]] || [[ ! "${TAG}" =~ ${PATTERN} ]]; then
|
||||
echo "ship-gate: ref ${INPUT_REF} (tag ${TAG:-none}) does not match ${PATTERN} for environment ${ENVIRONMENT} and is not on main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATTERN TAG
|
||||
PREV="$(
|
||||
gh api "repos/${REPO}/releases" --paginate --jq '.[].tag_name' | python3 -c '
|
||||
import os, re, sys
|
||||
pattern = re.compile(os.environ["PATTERN"])
|
||||
current = os.environ["TAG"]
|
||||
tags = [
|
||||
line.strip()
|
||||
for line in sys.stdin
|
||||
if pattern.fullmatch(line.strip()) and line.strip() != current
|
||||
]
|
||||
def key(tag):
|
||||
body = tag[1:]
|
||||
core = body.split("-", 1)[0]
|
||||
return tuple(int(part) for part in core.split("."))
|
||||
tags.sort(key=key)
|
||||
print(tags[-1] if tags else "")
|
||||
'
|
||||
)"
|
||||
|
||||
if [ -z "${PREV}" ]; then
|
||||
echo "ship-gate: no previous matching release tag; hotfix path requires a prior ${PATTERN} release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ff_status="$(gh api "repos/${REPO}/compare/${PREV}...${TAG}" --jq .status)"
|
||||
if [ "${ff_status}" != "ahead" ]; then
|
||||
echo "ship-gate: ${TAG} is not a fast-forward of ${PREV} (status: ${ff_status})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
from_train=false
|
||||
TARGET_COMMITISH="$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .target_commitish 2>/dev/null || true)"
|
||||
if [[ "${TARGET_COMMITISH}" == hotfix/* || "${TARGET_COMMITISH}" == release/* ]]; then
|
||||
from_train=true
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
git fetch origin '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*' '+refs/heads/release/*:refs/remotes/origin/release/*' || true
|
||||
if git branch -r --contains "${HEAD_SHA}" | grep -Eq 'origin/(hotfix|release)/'; then
|
||||
from_train=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
echo "ship-gate: ${TAG} was not created from hotfix/* or release/* (target_commitish=${TARGET_COMMITISH:-none})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ship-gate: hotfix/release path accepted (${PREV} -> ${TAG} from ${TARGET_COMMITISH:-branch})"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Build site
|
||||
env:
|
||||
OUTPUT_DIR: ${{ inputs.output-dir }}
|
||||
REQUIRED_PATHS: ${{ inputs.required-paths }}
|
||||
MIN_FILE_COUNT: ${{ inputs.min-file-count }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
python3 - <<'PY'
|
||||
import os, sys
|
||||
output_dir = os.environ["OUTPUT_DIR"]
|
||||
if not os.path.isdir(output_dir):
|
||||
print(f"build did not produce {output_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
missing = []
|
||||
for raw in os.environ.get("REQUIRED_PATHS", "").split(","):
|
||||
path = raw.strip()
|
||||
if path and not os.path.isfile(path):
|
||||
missing.append(path)
|
||||
if missing:
|
||||
print("missing required build files: " + ", ".join(missing), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
count = 0
|
||||
for _root, _dirs, files in os.walk(output_dir):
|
||||
count += len(files)
|
||||
minimum = int(os.environ["MIN_FILE_COUNT"])
|
||||
if minimum > 0 and count < minimum:
|
||||
print(f"build produced only {count} files (expected >= {minimum})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
index = os.path.join(output_dir, "index.html")
|
||||
if not os.path.isfile(index):
|
||||
print(f"missing {index}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"Build OK: {count} files.")
|
||||
PY
|
||||
index_sha="$(python3 -c 'import hashlib,os,pathlib; print(hashlib.sha256(pathlib.Path(os.environ["OUTPUT_DIR"], "index.html").read_bytes()).hexdigest())')"
|
||||
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
|
||||
echo "${OUTPUT_DIR}/index.html sha256=${index_sha}"
|
||||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Get deploy parameters
|
||||
id: deploy
|
||||
env:
|
||||
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="${SSM_PREFIX%/}"
|
||||
BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text)
|
||||
DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text)
|
||||
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
|
||||
origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \
|
||||
--query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')"
|
||||
if [ -n "${origin_paths}" ]; then
|
||||
echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2
|
||||
echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "bucket=${BUCKET}"
|
||||
echo "distribution_id=${DIST_ID}"
|
||||
echo "site_url=https://${DOMAIN}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Sync build output to the bucket root
|
||||
env:
|
||||
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
|
||||
OUTPUT_DIR: ${{ inputs.output-dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \
|
||||
--exclude "*.html" --exclude "*.xml" --exclude "*.txt" \
|
||||
--cache-control "public, max-age=86400"
|
||||
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \
|
||||
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \
|
||||
--cache-control "no-cache"
|
||||
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress --delete
|
||||
aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html
|
||||
|
||||
- name: Invalidate CloudFront
|
||||
env:
|
||||
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
invalidation_id="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DISTRIBUTION_ID}" \
|
||||
--paths "/*" \
|
||||
--query Invalidation.Id --output text)"
|
||||
echo "Invalidation ${invalidation_id} created; waiting"
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DISTRIBUTION_ID}" \
|
||||
--id "${invalidation_id}"
|
||||
|
||||
- name: Verify served release
|
||||
env:
|
||||
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
|
||||
SITE_URL: ${{ steps.deploy.outputs.site_url }}
|
||||
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
sha256_of() {
|
||||
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
|
||||
}
|
||||
last_status="Unknown"
|
||||
last_hash="Unknown"
|
||||
for attempt in $(seq 1 40); do
|
||||
last_status="$(aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --query Distribution.Status --output text)"
|
||||
if last_hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [ -n "${last_hash}" ]; then
|
||||
:
|
||||
else
|
||||
last_hash="unreachable"
|
||||
fi
|
||||
echo "poll ${attempt}/40: status=${last_status} served_sha256=${last_hash}"
|
||||
if [ "${last_status}" = "Deployed" ] && [ "${last_hash}" = "${EXPECTED_INDEX_SHA256}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2
|
||||
exit 1
|
||||
146
.github/workflows/ci-autofix.yaml
vendored
146
.github/workflows/ci-autofix.yaml
vendored
|
|
@ -9,6 +9,12 @@ name: CI — Autofix
|
|||
# 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:
|
||||
|
|
@ -17,38 +23,52 @@ name: CI — Autofix
|
|||
# permissions: { contents: write }
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# format-command: npm run format
|
||||
# lint-fix-command: npm run lint -- --fix
|
||||
# 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:
|
||||
format-command:
|
||||
description: "Write formatter command (e.g. npm run format, ruff format .)"
|
||||
presets:
|
||||
description: "Comma-separated presets: prettier, eslint, ruff, terraform"
|
||||
type: string
|
||||
required: true
|
||||
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 (e.g. ruff check --fix .)"
|
||||
description: "Optional write lint-fix command run after presets"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
extra-command:
|
||||
description: "Optional extra write command (e.g. terraform fmt -write)"
|
||||
description: "Optional extra write command run after presets"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
node-version:
|
||||
description: "Node.js version when package-lock.json is present"
|
||||
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 when extra-command mentions terraform"
|
||||
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"
|
||||
|
|
@ -105,27 +125,93 @@ jobs:
|
|||
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') != '' }}
|
||||
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') != '' }}
|
||||
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' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, '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')) }}
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install ruff
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }}
|
||||
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' && contains(inputs.extra-command, 'terraform') }}
|
||||
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
|
||||
|
|
@ -133,12 +219,32 @@ jobs:
|
|||
- 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
|
||||
|
|
@ -152,7 +258,7 @@ jobs:
|
|||
SKIP_BOT: ${{ steps.skip-bot.outputs.skip }}
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
APP_ID: ${{ secrets.AUTOFMT_APP_ID }}
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -166,8 +272,16 @@ jobs:
|
|||
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 "${APP_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
|
||||
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."
|
||||
|
|
|
|||
86
.github/workflows/ci-python-app.yaml
vendored
86
.github/workflows/ci-python-app.yaml
vendored
|
|
@ -1,19 +1,14 @@
|
|||
name: CI — Python (app)
|
||||
|
||||
# Reusable CI for plain Python apps / locally-run tooling that do NOT deploy via
|
||||
# SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those). Beyond
|
||||
# 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.
|
||||
# SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those).
|
||||
# Runs ruff check + format, plus an optional conventions audit.
|
||||
#
|
||||
# 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`
|
||||
# invoking this workflow reports each job here as `ci / <job>`, so the aggregator
|
||||
# job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on every
|
||||
# other job, so the single required check fails if any sub-job fails.
|
||||
# job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on lint,
|
||||
# so the single required check fails if lint fails.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
|
@ -26,18 +21,6 @@ on:
|
|||
description: "Space-separated directories for ruff (default: repo root)"
|
||||
type: string
|
||||
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:
|
||||
description: "Run the lightweight conventions audit (README + .gitignore covers .env)"
|
||||
type: boolean
|
||||
|
|
@ -52,10 +35,8 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
# The trailing segment of every group in this file is the job id written
|
||||
# out literally, NOT `${{ github.job }}`. In a called workflow that
|
||||
# expression evaluates to the CALLER's job id, so all four jobs here would
|
||||
# expression evaluates to the CALLER's job id, so sibling jobs would
|
||||
# resolve to one group and, with cancel-in-progress on, cancel each other.
|
||||
# Observed live in pr-reviewer: `lint` was cancelled one second in by a
|
||||
# sibling and the aggregator failed on the cancelled dependency.
|
||||
concurrency:
|
||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-lint
|
||||
cancel-in-progress: true
|
||||
|
|
@ -101,68 +82,19 @@ jobs:
|
|||
fi
|
||||
echo "Conventions check passed."
|
||||
|
||||
test-collect:
|
||||
if: ${{ inputs.collect-only }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-test-collect
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: ${{ inputs.requirements }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r "${{ inputs.requirements }}"
|
||||
pip install pytest python-dotenv
|
||||
|
||||
- name: Pytest collect-only
|
||||
run: pytest --collect-only -q
|
||||
|
||||
subproject-tests:
|
||||
if: ${{ inputs.subproject-dir != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-subproject-tests
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: ${{ inputs.requirements }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r "${{ inputs.requirements }}"
|
||||
pip install pytest
|
||||
|
||||
- name: Run subproject suite
|
||||
working-directory: ${{ inputs.subproject-dir }}
|
||||
run: python -m pytest -q
|
||||
|
||||
ci:
|
||||
# Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`.
|
||||
needs: [lint, test-collect, subproject-tests]
|
||||
needs: [lint]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-ci
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Require all jobs to have succeeded
|
||||
- name: Require lint to have succeeded
|
||||
run: |
|
||||
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
|
||||
echo "A required CI job failed or was cancelled."
|
||||
if [ "${{ needs.lint.result }}" != "success" ]; then
|
||||
echo "lint failed or was cancelled."
|
||||
exit 1
|
||||
fi
|
||||
echo "All CI jobs passed."
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -39,6 +39,10 @@ The formatter GitHub App is not on the main-branch bypass list.
|
|||
|
||||
## What's in here
|
||||
|
||||
### Renovate preset
|
||||
|
||||
`default.json` is the file loaded by `local>Sea-Haven-Industries/.github`. It is intentionally empty of policy. Org Renovate rules live in `Sea-Haven-Industries/renovate-config` as `org-inherited-config.json`.
|
||||
|
||||
### Reusable Workflows
|
||||
|
||||
**`.github/workflows/ci-python-sam.yaml`** — Reusable CI workflow for Python / SAM repos. Runs `ruff check` + `ruff format --check`, optional `pytest`, and optional `sam validate --lint`. Also usable for Python CDK repos by disabling SAM validate.
|
||||
|
|
@ -51,17 +55,19 @@ The formatter GitHub App is not on the main-branch bypass list.
|
|||
|
||||
**`.github/workflows/ci-terraform.yaml`** — Terraform `fmt -check`, `init -backend=false`, and `validate`. Default version `1.16.0`.
|
||||
|
||||
**`.github/workflows/ci-autofix.yaml`** — Pull-request-only formatter. Mints a GitHub App token (`AUTOFMT_APP_ID`, `AUTOFMT_APP_PRIVATE_KEY`), runs the caller's write commands, and pushes `style: apply formatter` only when the tree is dirty. Output `committed` lets the caller skip portions on SHA_old. Does not `--no-verify` and does not push to `main`.
|
||||
**`.github/workflows/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-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds.
|
||||
|
||||
**`.github/workflows/ci-python-app.yaml`** — Reusable CI for non-SAM Python apps (ruff check + format, optional pytest; no SAM validate).
|
||||
**`.github/workflows/ci-python-app.yaml`** — Reusable CI for non-SAM Python apps (ruff check + format and conventions; no pytest, no SAM validate). Pytest stays a caller-owned job.
|
||||
|
||||
**`.github/workflows/ci-dotnet.yaml`** — Reusable CI for .NET solutions (`dotnet build`, optional `dotnet test`; SDK version and solution path as inputs).
|
||||
|
||||
|
|
@ -268,8 +274,7 @@ jobs:
|
|||
permissions: { contents: write }
|
||||
secrets: inherit
|
||||
with:
|
||||
format-command: npm run format
|
||||
lint-fix-command: npm run lint -- --fix
|
||||
presets: prettier,terraform
|
||||
|
||||
frontend:
|
||||
needs: autofix
|
||||
|
|
@ -304,7 +309,7 @@ jobs:
|
|||
test "${TERRAFORM}" = success
|
||||
```
|
||||
|
||||
Python HCP callers pass `format-command: ruff format .` and `lint-fix-command: ruff check --fix .`. Optional `extra-command: terraform fmt -write` is available on `ci-autofix.yaml`. Do not run `eslint --fix` unless that repo's `lint` script is already fix-safe. Flip org ruleset membership in the same window as this merge: include on `CI complete`, exclude from `main branch protection`. Never require both `ci / ci` and `ci-complete`. Do not edit native GitHub merge-queue rulesets.
|
||||
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
default.json
Normal file
3
default.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
|
|
@ -1051,146 +1051,6 @@ Resources:
|
|||
Resource:
|
||||
- !GetAtt SamCfnExecutionRole.Arn
|
||||
|
||||
FrontIntegrationsDeployRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: githubdeploy-front-integrations
|
||||
AssumeRolePolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
Condition:
|
||||
StringEquals:
|
||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||
StringLike:
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/front-integrations:ref:refs/heads/main
|
||||
Policies:
|
||||
- PolicyName: sam-deploy
|
||||
PolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- cloudformation:CreateChangeSet
|
||||
- cloudformation:DeleteChangeSet
|
||||
- cloudformation:DescribeChangeSet
|
||||
- cloudformation:DescribeStackEvents
|
||||
- cloudformation:DescribeStacks
|
||||
- cloudformation:ExecuteChangeSet
|
||||
- cloudformation:GetTemplate
|
||||
- cloudformation:ListStackResources
|
||||
- cloudformation:UpdateStack
|
||||
- cloudformation:CreateStack
|
||||
- cloudformation:TagResource
|
||||
Resource:
|
||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/front-integrations/*
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- cloudformation:GetTemplateSummary
|
||||
Resource: "*"
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- cloudformation:DescribeStacks
|
||||
- cloudformation:CreateChangeSet
|
||||
- cloudformation:DescribeChangeSet
|
||||
- cloudformation:ExecuteChangeSet
|
||||
- cloudformation:CreateStack
|
||||
Resource:
|
||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/aws-sam-cli-managed-default/*
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- s3:PutObject
|
||||
- s3:GetObject
|
||||
- s3:ListBucket
|
||||
- s3:GetBucketLocation
|
||||
- s3:CreateBucket
|
||||
- s3:PutBucketPolicy
|
||||
- s3:GetBucketPolicy
|
||||
- s3:PutLifecycleConfiguration
|
||||
- s3:PutBucketVersioning
|
||||
- s3:DeleteObject
|
||||
Resource:
|
||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*
|
||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*/*
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- iam:PassRole
|
||||
Resource:
|
||||
- !GetAtt SamCfnExecutionRole.Arn
|
||||
|
||||
AfiBackupMonitorDeployRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: githubdeploy-afi-backup-monitor
|
||||
AssumeRolePolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
Condition:
|
||||
StringEquals:
|
||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||
StringLike:
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/afi-backup-monitor:ref:refs/heads/main
|
||||
Policies:
|
||||
- PolicyName: sam-deploy
|
||||
PolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- cloudformation:CreateChangeSet
|
||||
- cloudformation:DeleteChangeSet
|
||||
- cloudformation:DescribeChangeSet
|
||||
- cloudformation:DescribeStackEvents
|
||||
- cloudformation:DescribeStacks
|
||||
- cloudformation:ExecuteChangeSet
|
||||
- cloudformation:GetTemplate
|
||||
- cloudformation:ListStackResources
|
||||
- cloudformation:UpdateStack
|
||||
- cloudformation:CreateStack
|
||||
- cloudformation:TagResource
|
||||
Resource:
|
||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/afi-backup-monitor/*
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- cloudformation:GetTemplateSummary
|
||||
Resource: "*"
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- cloudformation:DescribeStacks
|
||||
- cloudformation:CreateChangeSet
|
||||
- cloudformation:DescribeChangeSet
|
||||
- cloudformation:ExecuteChangeSet
|
||||
- cloudformation:CreateStack
|
||||
Resource:
|
||||
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/aws-sam-cli-managed-default/*
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- s3:PutObject
|
||||
- s3:GetObject
|
||||
- s3:ListBucket
|
||||
- s3:GetBucketLocation
|
||||
- s3:CreateBucket
|
||||
- s3:PutBucketPolicy
|
||||
- s3:GetBucketPolicy
|
||||
- s3:PutLifecycleConfiguration
|
||||
- s3:PutBucketVersioning
|
||||
- s3:DeleteObject
|
||||
Resource:
|
||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*
|
||||
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*/*
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- iam:PassRole
|
||||
Resource:
|
||||
- !GetAtt SamCfnExecutionRole.Arn
|
||||
|
||||
PaymentsDashboardDeployRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
|
|
@ -1262,90 +1122,12 @@ Resources:
|
|||
- !GetAtt SamCfnExecutionRole.Arn
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CDK deploy roles (4 repos)
|
||||
# CDK deploy role for seahaven-org-baseline.
|
||||
# Soaked githubdeploy roles for front-integrations, afi-backup-monitor,
|
||||
# exec-aide, seahaven-door-unlock-api, and apm-wo-analysis are removed
|
||||
# here (PLAT-232). Deploying this stack deletes those roles.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ExecAideDeployRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: githubdeploy-exec-aide
|
||||
AssumeRolePolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
Condition:
|
||||
StringEquals:
|
||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||
StringLike:
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/exec-aide:ref:refs/heads/main
|
||||
Policies:
|
||||
- PolicyName: cdk-deploy
|
||||
PolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- sts:AssumeRole
|
||||
Resource:
|
||||
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
|
||||
|
||||
SeahavenDoorUnlockApiDeployRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: githubdeploy-seahaven-door-unlock-api
|
||||
AssumeRolePolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
Condition:
|
||||
StringEquals:
|
||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||
StringLike:
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-door-unlock-api:ref:refs/heads/main
|
||||
Policies:
|
||||
- PolicyName: cdk-deploy
|
||||
PolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- sts:AssumeRole
|
||||
Resource:
|
||||
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
|
||||
|
||||
ApmWoAnalysisDeployRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: githubdeploy-apm-wo-analysis
|
||||
AssumeRolePolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
Condition:
|
||||
StringEquals:
|
||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||
StringLike:
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/apm-wo-analysis:ref:refs/heads/main
|
||||
Policies:
|
||||
- PolicyName: cdk-deploy
|
||||
PolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- sts:AssumeRole
|
||||
Resource:
|
||||
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
|
||||
|
||||
SeahavenAccountBaselineDeployRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
|
|
@ -1380,6 +1162,54 @@ Resources:
|
|||
# Mgmt role github-meal-order-manager-weekly-menu deleted with this stack update.
|
||||
|
||||
|
||||
|
||||
# PLAT-234 principal only. The checks are seahaven-org-baseline pull request
|
||||
# 160: .github/workflows/ci.yaml job iam-policy-check and
|
||||
# scripts/check_iam_policies.py. That job assumes this role. It asserts
|
||||
# StringEquals on the bootstrap trust templates, no lambda write on the
|
||||
# plan template, then ValidatePolicy and CheckNoNewAccess when this role
|
||||
# can be assumed.
|
||||
SeahavenOrgBaselinePolicyCheckRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: githubdeploy-seahaven-org-baseline-policy-check
|
||||
Description: Access Analyzer policy checks for seahaven-org-baseline CI. No deploy permissions.
|
||||
AssumeRolePolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
# pull_request jobs with no environment use sub
|
||||
# repo:ORG/seahaven-org-baseline:pull_request. refs/pull/N/merge is
|
||||
# the ref claim, not sub. A second statement is required: StringEquals
|
||||
# and StringLike in one condition are AND.
|
||||
Condition:
|
||||
StringEquals:
|
||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-org-baseline:pull_request
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
Condition:
|
||||
StringEquals:
|
||||
token.actions.githubusercontent.com:aud: sts.amazonaws.com
|
||||
StringLike:
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-org-baseline:ref:refs/heads/gh-readonly-queue/main/*
|
||||
Policies:
|
||||
- PolicyName: access-analyzer-policy-check
|
||||
PolicyDocument:
|
||||
Version: "2012-10-17"
|
||||
Statement:
|
||||
- Sid: AccessAnalyzerPolicyCheck
|
||||
Effect: Allow
|
||||
Action:
|
||||
- access-analyzer:ValidatePolicy
|
||||
- access-analyzer:CheckNoNewAccess
|
||||
Resource: "*"
|
||||
|
||||
Outputs:
|
||||
LambdaExecutionBoundaryArn:
|
||||
Value: !Ref LambdaExecutionBoundary
|
||||
|
|
@ -1394,24 +1224,20 @@ Outputs:
|
|||
Name: github-cfn-execution-role-arn
|
||||
AfterhoursShiftManagerDeployRoleArn:
|
||||
Value: !GetAtt AfterhoursShiftManagerDeployRole.Arn
|
||||
FrontIntegrationsDeployRoleArn:
|
||||
Value: !GetAtt FrontIntegrationsDeployRole.Arn
|
||||
AfiBackupMonitorDeployRoleArn:
|
||||
Value: !GetAtt AfiBackupMonitorDeployRole.Arn
|
||||
PaymentsDashboardDeployRoleArn:
|
||||
Value: !GetAtt PaymentsDashboardDeployRole.Arn
|
||||
# SeahavenSlackBotDeployRoleArn removed 2026-07-27: the role was deleted
|
||||
# out-of-band on 2026-07-23, so this !GetAtt failed as a live IAM read and
|
||||
# broke every stack update. Nothing imported it (the Output had no
|
||||
# ExportName, and no stack imports any export from this stack).
|
||||
ExecAideDeployRoleArn:
|
||||
Value: !GetAtt ExecAideDeployRole.Arn
|
||||
SeahavenDoorUnlockApiDeployRoleArn:
|
||||
Value: !GetAtt SeahavenDoorUnlockApiDeployRole.Arn
|
||||
# ProcurementIngestDeployRoleArn removed 2026-08-07 (PLAT-88): HCP sole
|
||||
# mutate path; prod githubdeploy role deleted; mgmt twin already gone.
|
||||
ApmWoAnalysisDeployRoleArn:
|
||||
Value: !GetAtt ApmWoAnalysisDeployRole.Arn
|
||||
# FrontIntegrations, AfiBackupMonitor, ExecAide, SeahavenDoorUnlockApi,
|
||||
# and ApmWoAnalysis deploy role outputs removed 2026-09-28 (PLAT-232).
|
||||
# CloudTrail showed no successful mutation for 14 days. The roles are
|
||||
# deleted only when this stack is deployed. That deploy is not this change.
|
||||
SeahavenAccountBaselineDeployRoleArn:
|
||||
Value: !GetAtt SeahavenAccountBaselineDeployRole.Arn
|
||||
SeahavenOrgBaselinePolicyCheckRoleArn:
|
||||
Value: !GetAtt SeahavenOrgBaselinePolicyCheckRole.Arn
|
||||
# MealOrderManagerWeeklyMenuRoleArn removed 2026-08-20 (PLAT-70): role deleted with resource.
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ jobs:
|
|||
contents: write
|
||||
secrets: inherit
|
||||
with:
|
||||
format-command: npm run format
|
||||
lint-fix-command: "npm run lint -- --fix"
|
||||
presets: prettier,terraform
|
||||
|
||||
frontend:
|
||||
needs: autofix
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Sea Haven — CI (Python / app)",
|
||||
"description": "Runs ruff check, ruff format --check, a pytest collect-only import check, and an optional subproject suite via the org reusable workflow. For Python repos that do not deploy via SAM or CDK.",
|
||||
"description": "Runs ruff check, ruff format --check, and a conventions audit via the org reusable workflow. For Python repos that do not deploy via SAM or CDK.",
|
||||
"iconName": "octicon-checklist",
|
||||
"categories": ["Python", "Continuous integration"],
|
||||
"filePatterns": ["requirements.*\\.txt$", "pyproject\\.toml$"]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,5 @@ jobs:
|
|||
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
|
||||
# check context resolves to the required `ci / ci`.
|
||||
#
|
||||
# Every input is optional. Common overrides: `source-dirs` (ruff targets),
|
||||
# `requirements` (non-default requirements file), `subproject-dir` (a
|
||||
# self-contained suite that must run in its own working directory).
|
||||
# Every input is optional. Common override: `source-dirs` (ruff targets).
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue