feat(ci): let HCP deploys share an SSM prefix (#149)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions

Optional task-env replace, source-map upload, health attempts, and a concurrency suffix keep existing callers on the same defaults.
This commit is contained in:
Adam Moussa 2026-09-24 16:15:53 +00:00 • committed by GitHub
parent 2e2b3a282f
commit acaf2bfc0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 222 additions and 5 deletions

View file

@ -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,36 @@ 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
concurrency-suffix:
description: "Optional concurrency group suffix when two deployables share an SSM prefix"
type: string
required: false
default: ""
permissions:
contents: read
@ -69,7 +104,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
@ -232,6 +267,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 +327,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 +365,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
env = {item["name"]: item["value"] for item in container.get("environment", [])}
env["GIT_SHA"] = sha
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:
@ -298,6 +404,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 +413,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

View file

@ -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."