From 0a1010e63248c9ca9f042c870eb2c579ba6b9455 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:24:57 +0000 Subject: [PATCH] feat(ci): add a static-site HCP deploy caller (PLAT-225) (#152) Unfingerprinted Eleventy builds need a one-day asset cache, not the SPA immutable sync. --- .github/workflows/cd-hcp-static.yaml | 312 +++++++++++++++++++++++++++ README.md | 2 + 2 files changed, 314 insertions(+) create mode 100644 .github/workflows/cd-hcp-static.yaml diff --git a/.github/workflows/cd-hcp-static.yaml b/.github/workflows/cd-hcp-static.yaml new file mode 100644 index 0000000..6f9f29c --- /dev/null +++ b/.github/workflows/cd-hcp-static.yaml @@ -0,0 +1,312 @@ +name: CD — HCP static site + +# Reusable CloudFront/S3 CD for unfingerprinted static sites. 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:`. +# +# Assets are not content-hashed, so they get a one-day cache. HTML, XML, and +# text get no-cache. Do not point a hashed SPA at this workflow. +# +# Caller example: +# jobs: +# deploy-prod: +# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-static.yaml@ # vX.Y.Z +# permissions: { contents: read, id-token: write } +# secrets: inherit +# with: +# environment: prod +# ref: ${{ inputs.ref }} +# ssm-prefix: /seahaven-site/deploy +# required-paths: _site/index.html,_site/contact/index.html,_site/404.html +# min-file-count: 40 +# ship-gate: true +# +# Nothing here creates an HCP run. Terraform owns the bucket and distribution. + +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. /seahaven-site/deploy)" + type: string + required: true + ship-gate: + description: "Require the ref to be on main or a legal hotfix/release tag" + type: boolean + required: false + default: false + output-dir: + description: "Build output directory" + type: string + required: false + default: "_site" + required-paths: + description: "Comma-separated repo-relative files that must exist after the build" + type: string + required: false + default: "" + min-file-count: + description: "Minimum file count under output-dir. Zero skips the count check." + type: number + required: false + default: 1 + +permissions: + contents: read + id-token: write + +jobs: + deploy: + name: Deploy static site to ${{ inputs.environment }} + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: ${{ inputs.environment }} + concurrency: + group: deploy-${{ 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})" + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Build site + env: + OUTPUT_DIR: ${{ inputs.output-dir }} + REQUIRED_PATHS: ${{ inputs.required-paths }} + MIN_FILE_COUNT: ${{ inputs.min-file-count }} + run: | + set -euo pipefail + npm ci --ignore-scripts + npm run build + python3 - <<'PY' + import os, sys + output_dir = os.environ["OUTPUT_DIR"] + if not os.path.isdir(output_dir): + print(f"build did not produce {output_dir}", file=sys.stderr) + sys.exit(1) + missing = [] + for raw in os.environ.get("REQUIRED_PATHS", "").split(","): + path = raw.strip() + if path and not os.path.isfile(path): + missing.append(path) + if missing: + print("missing required build files: " + ", ".join(missing), file=sys.stderr) + sys.exit(1) + count = 0 + for _root, _dirs, files in os.walk(output_dir): + count += len(files) + minimum = int(os.environ["MIN_FILE_COUNT"]) + if minimum > 0 and count < minimum: + print(f"build produced only {count} files (expected >= {minimum})", file=sys.stderr) + sys.exit(1) + index = os.path.join(output_dir, "index.html") + if not os.path.isfile(index): + print(f"missing {index}", file=sys.stderr) + sys.exit(1) + print(f"Build OK: {count} files.") + PY + index_sha="$(python3 -c 'import hashlib,os,pathlib; print(hashlib.sha256(pathlib.Path(os.environ["OUTPUT_DIR"], "index.html").read_bytes()).hexdigest())')" + echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}" + echo "${OUTPUT_DIR}/index.html sha256=${index_sha}" + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + 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 }} + run: | + set -euo pipefail + prefix="${SSM_PREFIX%/}" + BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text) + DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text) + DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) + origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \ + --query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')" + if [ -n "${origin_paths}" ]; then + echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2 + echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2 + exit 1 + fi + { + echo "bucket=${BUCKET}" + echo "distribution_id=${DIST_ID}" + echo "site_url=https://${DOMAIN}" + } >> "${GITHUB_OUTPUT}" + + - name: Sync build output to the bucket root + env: + SITE_BUCKET: ${{ steps.deploy.outputs.bucket }} + OUTPUT_DIR: ${{ inputs.output-dir }} + run: | + set -euo pipefail + aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \ + --exclude "*.html" --exclude "*.xml" --exclude "*.txt" \ + --cache-control "public, max-age=86400" + aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \ + --exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \ + --cache-control "no-cache" + aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress --delete + aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html + + - name: Invalidate CloudFront + env: + DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }} + run: | + set -euo pipefail + invalidation_id="$(aws cloudfront create-invalidation \ + --distribution-id "${DISTRIBUTION_ID}" \ + --paths "/*" \ + --query Invalidation.Id --output text)" + echo "Invalidation ${invalidation_id} created; waiting" + aws cloudfront wait invalidation-completed \ + --distribution-id "${DISTRIBUTION_ID}" \ + --id "${invalidation_id}" + + - name: Verify served release + env: + DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }} + SITE_URL: ${{ steps.deploy.outputs.site_url }} + EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }} + run: | + set -euo pipefail + SITE_URL="${SITE_URL%/}" + sha256_of() { + python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" + } + last_status="Unknown" + last_hash="Unknown" + for attempt in $(seq 1 40); do + last_status="$(aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --query Distribution.Status --output text)" + if last_hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [ -n "${last_hash}" ]; then + : + else + last_hash="unreachable" + fi + echo "poll ${attempt}/40: status=${last_status} served_sha256=${last_hash}" + if [ "${last_status}" = "Deployed" ] && [ "${last_hash}" = "${EXPECTED_INDEX_SHA256}" ]; then + exit 0 + fi + sleep 15 + done + echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2 + exit 1 diff --git a/README.md b/README.md index 30ee3da..56181aa 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ The formatter GitHub App is not on the main-branch bypass list. **`.github/workflows/cd-hcp-spa.yaml`** — HCP SPA CD. Checkout at `ref`, Node 24, `npm ci` + `npm run build`, origin-path guard, hashed s3 sync then `index.html` last then prune, invalidate, verify. Build env comes from the GitHub Environment (`vars.VITE_*`, `secrets.SENTRY_AUTH_TOKEN`). +**`.github/workflows/cd-hcp-static.yaml`** — HCP static-site CD for unfingerprinted builds such as Eleventy. Checkout at `ref`, Node 24, `npm ci --ignore-scripts` + `npm run build`, one-day cache on assets, `no-cache` on HTML/XML/text, prune, invalidate, verify the served index hash. Reads `//bucket` and `//distribution-id`. Do not use this for a hashed SPA. + **`.github/workflows/cd-sam.yaml`** — Reusable CD workflow for SAM repos. Runs `sam build` + `sam deploy` with OIDC credentials and a CloudFormation execution role. Triggers via `workflow_call` from per-repo `deploy.yaml` on push to `main`. **`.github/workflows/cd-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds.