mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-10-07 10:28:54 +00:00
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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
441 lines
17 KiB
YAML
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
|