From 324951ca931599accdced3f84f0b5f0803a3f80a Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:56:05 +0000 Subject: [PATCH] feat(cd): add build-command, index-required, and verify-path to cd-hcp-static (#160) Optional inputs so a matrix caller can publish small static trees that have no index page. Defaults keep the current seahaven-site behavior: the build runs npm ci --ignore-scripts && npm run build, index.html is required, and the served hash of / is polled after invalidation. - build-command runs under bash -euo pipefail - index-required: false skips the local and bucket index.html checks - verify-path picks the local file and served URL used for the hash poll; a trailing slash means index.html, and .. is rejected - header documents a matrix caller keyed on ssm-prefix --- .github/workflows/cd-hcp-static.yaml | 99 ++++++++++++++++++++++++---- README.md | 2 +- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cd-hcp-static.yaml b/.github/workflows/cd-hcp-static.yaml index 6f9f29c..2be4b71 100644 --- a/.github/workflows/cd-hcp-static.yaml +++ b/.github/workflows/cd-hcp-static.yaml @@ -22,6 +22,34 @@ name: CD — HCP static site # min-file-count: 40 # ship-gate: true # +# Small static trees without an index page (for example an MTA-STS policy +# host) set `index-required: false` and point `verify-path` at the file that +# proves the release landed. A matrix caller can deploy several buckets from +# one workflow because concurrency is keyed on `ssm-prefix`: +# jobs: +# deploy-prod: +# strategy: +# fail-fast: false +# matrix: +# include: +# - { domain: example.com, slug: example-com } +# - { domain: example.org, slug: example-org } +# 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 +# ssm-prefix: /mta-sts/deploy/${{ matrix.slug }} +# output-dir: dist/${{ matrix.domain }} +# required-paths: dist/${{ matrix.domain }}/.well-known/mta-sts.txt +# min-file-count: 1 +# index-required: false +# verify-path: /.well-known/mta-sts.txt +# ship-gate: true +# +# `build-command` runs under `bash -euo pipefail` and still needs a +# package-lock.json in the caller for the setup-node npm cache. +# # Nothing here creates an HCP run. Terraform owns the bucket and distribution. on: @@ -60,6 +88,21 @@ on: type: number required: false default: 1 + build-command: + description: "Shell command that produces output-dir" + type: string + required: false + default: "npm ci --ignore-scripts && npm run build" + index-required: + description: "Require index.html at the output-dir and bucket root" + type: boolean + required: false + default: true + verify-path: + description: "Served path whose sha256 must match the built file after invalidation. A trailing slash means index.html." + type: string + required: false + default: "/" permissions: contents: read @@ -183,15 +226,39 @@ jobs: node-version: "24" cache: npm + - name: Resolve verify path + env: + VERIFY_PATH: ${{ inputs.verify-path }} + run: | + set -euo pipefail + verify_path="${VERIFY_PATH}" + case "${verify_path}" in + /*) ;; + *) verify_path="/${verify_path}" ;; + esac + case "${verify_path}" in + */) verify_key="${verify_path#/}index.html" ;; + *) verify_key="${verify_path#/}" ;; + esac + case "${verify_key}" in + *..*) echo "verify-path must not contain '..': ${VERIFY_PATH}" >&2; exit 1 ;; + esac + { + echo "VERIFY_URL_PATH=${verify_path}" + echo "VERIFY_KEY=${verify_key}" + } >> "${GITHUB_ENV}" + echo "verify ${verify_path} against ${verify_key}" + - name: Build site env: + BUILD_COMMAND: ${{ inputs.build-command }} OUTPUT_DIR: ${{ inputs.output-dir }} REQUIRED_PATHS: ${{ inputs.required-paths }} MIN_FILE_COUNT: ${{ inputs.min-file-count }} + INDEX_REQUIRED: ${{ inputs.index-required }} run: | set -euo pipefail - npm ci --ignore-scripts - npm run build + bash -euo pipefail -c "${BUILD_COMMAND}" python3 - <<'PY' import os, sys output_dir = os.environ["OUTPUT_DIR"] @@ -214,14 +281,18 @@ jobs: 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): + if os.environ["INDEX_REQUIRED"] == "true" and not os.path.isfile(index): print(f"missing {index}", file=sys.stderr) sys.exit(1) + verify_file = os.path.join(output_dir, os.environ["VERIFY_KEY"]) + if not os.path.isfile(verify_file): + print(f"missing verify file {verify_file}", 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}" + verify_sha="$(python3 -c 'import hashlib,os,pathlib; print(hashlib.sha256(pathlib.Path(os.environ["OUTPUT_DIR"], os.environ["VERIFY_KEY"]).read_bytes()).hexdigest())')" + echo "VERIFY_SHA256=${verify_sha}" >> "${GITHUB_ENV}" + echo "${OUTPUT_DIR}/${VERIFY_KEY} sha256=${verify_sha}" - name: Configure AWS credentials using OIDC uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 @@ -257,6 +328,7 @@ jobs: env: SITE_BUCKET: ${{ steps.deploy.outputs.bucket }} OUTPUT_DIR: ${{ inputs.output-dir }} + INDEX_REQUIRED: ${{ inputs.index-required }} run: | set -euo pipefail aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \ @@ -266,7 +338,10 @@ jobs: --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 + aws s3api head-object --bucket "${SITE_BUCKET}" --key "${VERIFY_KEY}" + if [ "${INDEX_REQUIRED}" = "true" ] && [ "${VERIFY_KEY}" != "index.html" ]; then + aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html + fi - name: Invalidate CloudFront env: @@ -286,7 +361,7 @@ jobs: env: DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }} SITE_URL: ${{ steps.deploy.outputs.site_url }} - EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }} + EXPECTED_SHA256: ${{ env.VERIFY_SHA256 }} run: | set -euo pipefail SITE_URL="${SITE_URL%/}" @@ -297,16 +372,16 @@ jobs: 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 + if last_hash="$(curl -fsS --max-time 30 "${SITE_URL}${VERIFY_URL_PATH}" | 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 + echo "poll ${attempt}/40: status=${last_status} path=${VERIFY_URL_PATH} served_sha256=${last_hash}" + if [ "${last_status}" = "Deployed" ] && [ "${last_hash}" = "${EXPECTED_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 + echo "release did not converge: status=${last_status} path=${VERIFY_URL_PATH} served_sha256=${last_hash} expected=${EXPECTED_SHA256}" >&2 exit 1 diff --git a/README.md b/README.md index 56181aa..ec4fc6b 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ 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-hcp-static.yaml`** — HCP static-site CD for unfingerprinted builds such as Eleventy. Checkout at `ref`, Node 24, `build-command` (default `npm ci --ignore-scripts && npm run build`), one-day cache on assets, `no-cache` on HTML/XML/text, prune, invalidate, verify the served hash at `verify-path` (default `/`). Reads `//bucket` and `//distribution-id`. `index-required: false` drops the `index.html` checks for trees that have no index page, such as an MTA-STS policy host; a matrix caller can deploy several prefixes from one workflow. 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`.