From 54dc789b154fa7c88f6d71b8ddf7ebdd128bf4c1 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 24 Sep 2026 12:08:24 -0400 Subject: [PATCH] feat(ci): let HCP deploys share an SSM prefix Optional task-env replace, source-map upload, health attempts, and a concurrency suffix keep existing callers on the same defaults. --- .github/workflows/cd-hcp-fargate.yaml | 119 +++++++++++++++++++++++++- .github/workflows/cd-hcp-spa.yaml | 108 ++++++++++++++++++++++- 2 files changed, 222 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cd-hcp-fargate.yaml b/.github/workflows/cd-hcp-fargate.yaml index c62694b..c766504 100644 --- a/.github/workflows/cd-hcp-fargate.yaml +++ b/.github/workflows/cd-hcp-fargate.yaml @@ -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 diff --git a/.github/workflows/cd-hcp-spa.yaml b/.github/workflows/cd-hcp-spa.yaml index fe2485e..9e7457d 100644 --- a/.github/workflows/cd-hcp-spa.yaml +++ b/.github/workflows/cd-hcp-spa.yaml @@ -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."