mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 03:43:10 +00:00
feat(ci): let HCP deploys share an SSM prefix (#149)
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:
parent
2e2b3a282f
commit
acaf2bfc0d
2 changed files with 222 additions and 5 deletions
119
.github/workflows/cd-hcp-fargate.yaml
vendored
119
.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,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
|
||||
|
|
|
|||
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."
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue