mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 09:23:11 +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
|
# docker-platform: linux/amd64
|
||||||
# ship-gate: true
|
# 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,
|
# Nothing here creates an HCP run. Terraform owns the cluster, service, ALB,
|
||||||
# and ignores container_definitions / task_definition.
|
# and ignores container_definitions / task_definition.
|
||||||
|
|
||||||
|
|
@ -57,6 +62,36 @@ on:
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "{}"
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -69,7 +104,7 @@ jobs:
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
concurrency:
|
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
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
AWS_REGION: us-east-1
|
AWS_REGION: us-east-1
|
||||||
|
|
@ -232,6 +267,57 @@ jobs:
|
||||||
--push \
|
--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
|
- name: Register task definition and update service
|
||||||
env:
|
env:
|
||||||
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
||||||
|
|
@ -241,8 +327,19 @@ jobs:
|
||||||
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
||||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
EXTRA_TASK_ENV: ${{ inputs.extra-task-env }}
|
EXTRA_TASK_ENV: ${{ inputs.extra-task-env }}
|
||||||
|
APPLY_TASK_ENVIRONMENT: ${{ inputs.apply-task-environment }}
|
||||||
|
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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 \
|
aws ecs describe-task-definition \
|
||||||
--task-definition "${FAMILY}" \
|
--task-definition "${FAMILY}" \
|
||||||
--query taskDefinition \
|
--query taskDefinition \
|
||||||
|
|
@ -268,16 +365,25 @@ jobs:
|
||||||
extra_env = json.loads(extra_raw)
|
extra_env = json.loads(extra_raw)
|
||||||
if not isinstance(extra_env, dict):
|
if not isinstance(extra_env, dict):
|
||||||
sys.exit("extra-task-env must be a JSON object")
|
sys.exit("extra-task-env must be a JSON object")
|
||||||
|
apply = os.environ.get("APPLY_TASK_ENVIRONMENT") == "true"
|
||||||
found = False
|
found = False
|
||||||
for container in td["containerDefinitions"]:
|
for container in td["containerDefinitions"]:
|
||||||
if container["name"] != name:
|
if container["name"] != name:
|
||||||
continue
|
continue
|
||||||
found = True
|
found = True
|
||||||
container["image"] = image
|
container["image"] = image
|
||||||
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
if apply:
|
||||||
env["GIT_SHA"] = sha
|
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():
|
for key, value in extra_env.items():
|
||||||
env[str(key)] = str(value)
|
env[str(key)] = str(value)
|
||||||
|
env["GIT_SHA"] = sha
|
||||||
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
||||||
container.pop("command", None)
|
container.pop("command", None)
|
||||||
if not found:
|
if not found:
|
||||||
|
|
@ -298,6 +404,7 @@ jobs:
|
||||||
API_URL: ${{ steps.deploy.outputs.api_url }}
|
API_URL: ${{ steps.deploy.outputs.api_url }}
|
||||||
HEALTH_PATH: ${{ inputs.health-path }}
|
HEALTH_PATH: ${{ inputs.health-path }}
|
||||||
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
HEALTH_ATTEMPTS: ${{ inputs.health-attempts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
path="${HEALTH_PATH}"
|
path="${HEALTH_PATH}"
|
||||||
|
|
@ -306,7 +413,11 @@ jobs:
|
||||||
*) path="/${path}" ;;
|
*) path="/${path}" ;;
|
||||||
esac
|
esac
|
||||||
url="${API_URL%/}${path}"
|
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)"
|
BODY="$(curl -fsS "${url}" || true)"
|
||||||
echo "${BODY}"
|
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
|
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
|
# ssm-prefix: /internal-portal/deploy
|
||||||
# ship-gate: true
|
# 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.
|
# Nothing here creates an HCP run. Terraform owns the bucket and distribution.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|
@ -49,6 +53,16 @@ on:
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -61,7 +75,7 @@ jobs:
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
concurrency:
|
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
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
AWS_REGION: us-east-1
|
AWS_REGION: us-east-1
|
||||||
|
|
@ -301,3 +315,95 @@ jobs:
|
||||||
done
|
done
|
||||||
echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2
|
echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2
|
||||||
exit 1
|
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