.github/.github/workflows/cd-hcp-fargate.yaml
renovate[bot] ee6102f52f
Some checks are pending
ci / isolation-tests (push) Waiting to run
ci / actionlint (push) Waiting to run
ci / ci-complete (push) Blocked by required conditions
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
chore(deps): update docker/setup-buildx-action action to v4 (#159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 19:08:16 +00:00

441 lines
17 KiB
YAML

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@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
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@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.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