From c96a2593655bd04d7680848e6ef6e82500d5235c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:30:20 -0400 Subject: [PATCH 1/8] refactor(cd): ship SPA content from GitHub on main (#220) * ci(cd): convert SPA hosting to handbook HCP and GitHub content CD Give HCP the bucket and CloudFront with an empty origin path. GitHub owns bucket-root sync and invalidation so merge-to-main and a human staging tag can deploy without creating HCP runs. G13 fails PRs that mix terraform/ with deployable application files. * ci: run Frontend checks and Terraform CI on PRs to main and dev Match backend 148 so a PR targeting origin/dev still gets the required checks. Push remains main only. * refactor(terraform): keep live/dev and live/staging as HCP roots Leave the adopted working directories in place so this CD PR does not retarget two live HCP workspaces. Flattening stays a later change. * style: prettier terraform-validate.mjs * fix(terraform): pin githubdeploy assume-role policy in import checker Reject controlled role updates whose trust document is not the rendered GitHub OIDC policy, matching the bucket-policy pin. --- .github/workflows/ci-terraform.yaml | 56 +++ .github/workflows/ci.yaml | 17 +- .github/workflows/deploy-web.yaml | 286 +++++++++++++ .github/workflows/terraform-isolation.yaml | 43 -- AGENTS.md | 7 +- QUALITY_GATES.md | 64 ++- README.md | 102 +++-- REVIEW_AND_PR_FRAMEWORK.md | 7 + package.json | 3 +- scripts/check-terraform-import-plan.py | 156 ++++++- scripts/check-terraform-isolation.mjs | 137 ------- scripts/check-terraform-isolation.test.mjs | 179 -------- scripts/check_app_terraform_isolation.py | 82 ++++ scripts/governance-check.mjs | 35 +- scripts/summarize-cloudfront-live-state.sh | 22 +- scripts/terraform-validate.mjs | 23 +- scripts/terraform_import_plan_resources.py | 26 +- scripts/test-terraform-import-plan-check.py | 239 +++++++---- scripts/test-verify-cloudfront-release.sh | 152 ++----- scripts/test_check_app_terraform_isolation.py | 70 ++++ scripts/verify-cloudfront-release.sh | 106 ++--- terraform/README.md | 388 +++--------------- terraform/live/README.md | 8 + terraform/live/dev/main.tf | 90 ++-- terraform/live/dev/outputs.tf | 18 +- terraform/live/dev/variables.tf | 33 -- .../live/modules/environment-owned/main.tf | 173 +++----- .../live/modules/environment-owned/outputs.tf | 29 +- .../modules/environment-owned/variables.tf | 69 +--- terraform/live/staging/.terraform.lock.hcl | 30 ++ terraform/live/staging/imports.tf | 64 +++ terraform/live/staging/main.tf | 95 +++++ terraform/live/staging/outputs.tf | 19 + terraform/live/staging/providers.tf | 3 + terraform/live/staging/versions.tf | 19 + 35 files changed, 1488 insertions(+), 1362 deletions(-) create mode 100644 .github/workflows/ci-terraform.yaml create mode 100644 .github/workflows/deploy-web.yaml delete mode 100644 .github/workflows/terraform-isolation.yaml delete mode 100644 scripts/check-terraform-isolation.mjs delete mode 100644 scripts/check-terraform-isolation.test.mjs create mode 100644 scripts/check_app_terraform_isolation.py create mode 100644 scripts/test_check_app_terraform_isolation.py create mode 100644 terraform/live/README.md delete mode 100644 terraform/live/dev/variables.tf create mode 100644 terraform/live/staging/.terraform.lock.hcl create mode 100644 terraform/live/staging/imports.tf create mode 100644 terraform/live/staging/main.tf create mode 100644 terraform/live/staging/outputs.tf create mode 100644 terraform/live/staging/providers.tf create mode 100644 terraform/live/staging/versions.tf diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml new file mode 100644 index 00000000..a4902d15 --- /dev/null +++ b/.github/workflows/ci-terraform.yaml @@ -0,0 +1,56 @@ +name: Terraform CI + +# Static checks only. Plans run in HCP Terraform as speculative VCS runs on +# the PR (shoc-frontend-new-dev and shoc-frontend-new-staging). Applies are +# HCP auto-apply on merge to main (dev) and on a vX.Y.Z-staging tag (staging). + +on: + pull_request: + branches: [main, dev] + paths: + - "terraform/**" + - "scripts/**" + - ".github/workflows/ci-terraform.yaml" + - ".github/workflows/deploy-web.yaml" + push: + branches: [main] + paths: + - "terraform/**" + - "scripts/**" + - ".github/workflows/ci-terraform.yaml" + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false + + - name: Terraform fmt + run: terraform fmt -check -recursive terraform + + - name: Validate live/dev + run: | + terraform -chdir=terraform/live/dev init -backend=false -input=false -lockfile=readonly -no-color + terraform -chdir=terraform/live/dev validate -no-color + + - name: Validate live/staging + run: | + terraform -chdir=terraform/live/staging init -backend=false -input=false -lockfile=readonly -no-color + terraform -chdir=terraform/live/staging validate -no-color + + - name: Import plan guard tests + run: python3 scripts/test-terraform-import-plan-check.py + + - name: App/Terraform isolation tests + run: python3 scripts/test_check_app_terraform_isolation.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8a49b667..0409abf0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: Frontend checks on: pull_request: - branches: [main, dev, staging] + branches: [main, dev] push: - branches: [main, dev, staging] + branches: [main] workflow_dispatch: {} permissions: @@ -24,15 +24,16 @@ jobs: # `npm run verify` is the single command that chains: format check, lint # (--max-warnings=0), type-check + build, unit tests, then the governance # checks in scripts/governance-check.mjs (godfile ratchet, changed-file - # maintainability gate, Terraform fmt/validate, Terraform import-plan and - # release-plan guards, isolation tests, HCP run guard, CloudFront verify, - # and GitHub workflow shell). If the reusable workflow is later confirmed - # to run every gate, this job can be slimmed to `npm run governance`. + # maintainability gate, Terraform fmt/validate, Terraform import-plan + # guard, HCP run guard, CloudFront verify, GitHub workflow shell, and G13 + # app/Terraform isolation). Runs on PRs to main or dev; push is main only. + # If the reusable workflow is later confirmed to run every gate, this job + # can be slimmed to `npm run governance`. # # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) # push-> the previous commit on the branch (github.event.before) - # manual -> dev, for exact-head recovery runs + # manual -> main, for exact-head recovery runs runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -52,7 +53,7 @@ jobs: elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then base="${EVENT_BEFORE}" else - base="origin/dev" + base="origin/main" fi printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" - name: Set up Terraform diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml new file mode 100644 index 00000000..7fa28e70 --- /dev/null +++ b/.github/workflows/deploy-web.yaml @@ -0,0 +1,286 @@ +name: Deploy Web + +# SPA CD. GitHub Actions builds dist/ and syncs it to the S3 origin bucket +# root, then invalidates CloudFront. Terraform owns the bucket and the +# distribution and never touches content. +# +# push to main -> dev, at github.sha +# release: published -> staging, at vX.Y.Z-staging (must be on main) +# workflow_dispatch -> chosen environment at a chosen ref +# +# Releases are cut by a human with +# `gh release create vX.Y.Z-staging --target main --generate-notes`. +# A workflow cannot do it: releases created with GITHUB_TOKEN do not fire +# `release: published`. Core vX.Y.Z waits until a prod distribution exists. +# +# Bucket and distribution come from SSM after assuming the Environment's +# DEPLOY_ROLE_ARN. Nothing here creates an HCP run. Quality gates live in CI. + +on: + push: + branches: [main] + paths-ignore: + - "terraform/**" + - "docs/**" + - "**/*.md" + - ".github/workflows/deploy.yml" + - ".github/workflows/deploy-staging.yml" + - ".github/workflows/ci.yaml" + - ".github/workflows/ci-terraform.yaml" + - ".github/workflows/deploy-web.yaml" + release: + types: [published] + workflow_dispatch: + inputs: + environment: + description: "Target Environment" + required: true + type: choice + options: [dev, staging] + ref: + description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + target: + name: Resolve target + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + environment: ${{ steps.resolve.outputs.environment }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF_NAME_IN: ${{ github.ref }} + GITHUB_SHA_IN: ${{ github.sha }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + case "${EVENT_NAME}" in + push) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "push deploys only run from main" >&2 + exit 1 + fi + environment=dev + ref="${GITHUB_SHA_IN}" + ;; + release) + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-staging$ ]]; then + echo "release tag ${RELEASE_TAG} is not vX.Y.Z-staging; refusing until a prod distribution exists." >&2 + exit 1 + fi + environment=staging + ref="${RELEASE_TAG}" + status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)" + if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then + echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2 + exit 1 + fi + ;; + workflow_dispatch) + environment="${INPUT_ENVIRONMENT}" + ref="${INPUT_REF:-${GITHUB_SHA_IN}}" + ;; + *) + echo "unsupported event ${EVENT_NAME}" >&2 + exit 1 + ;; + esac + { + echo "environment=${environment}" + echo "ref=${ref}" + } >> "${GITHUB_OUTPUT}" + echo "Deploying ${ref} to ${environment}" + + deploy: + name: Deploy SPA to ${{ needs.target.outputs.environment }} + needs: target + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: ${{ needs.target.outputs.environment }} + concurrency: + group: deploy-web-${{ needs.target.outputs.environment }} + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.ref }} + persist-credentials: false + + - name: Resolve commit + id: commit + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "Building ${sha}" + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Build SPA + env: + TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }} + VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ needs.target.outputs.environment }} + VITE_SENTRY_RELEASE: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + case "${TARGET_ENVIRONMENT}" in + dev) + export VITE_API_URL="https://api.dev.seahaven.com/api" + forbidden="api.staging.seahaven.com" + required="api.dev.seahaven.com" + ;; + staging) + export VITE_API_URL="https://api.staging.seahaven.com/api" + forbidden="api.dev.seahaven.com" + required="api.staging.seahaven.com" + ;; + *) + echo "unsupported environment ${TARGET_ENVIRONMENT}" >&2 + exit 1 + ;; + esac + npm ci + npm run build + test -f dist/index.html + if grep -Rq "${forbidden}" dist/; then + echo "Built assets contain the forbidden URL ${forbidden}." >&2 + exit 1 + fi + if grep -Rq "localhost:5141" dist/; then + echo "Built assets contain the Vite proxy target localhost:5141." >&2 + exit 1 + fi + grep -Rq "${required}" dist/ + index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')" + echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}" + echo "VITE_API_URL=${VITE_API_URL}" >> "${GITHUB_ENV}" + echo "dist/index.html sha256=${index_sha}" + + - name: Upload private source maps + run: bash scripts/upload-sourcemaps.sh + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }} + + - name: Strip source maps from dist/ + run: | + set -euo pipefail + find dist -name '*.map' -delete + if find dist -name '*.map' | grep -q .; then + echo "SPA source maps must not ship in dist/" >&2 + exit 1 + fi + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: spa-dist-${{ needs.target.outputs.environment }}-${{ steps.commit.outputs.sha }} + path: dist/ + if-no-files-found: error + retention-days: 7 + + - 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: + TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }} + run: | + set -euo pipefail + prefix="/shoc-frontend-new/${TARGET_ENVIRONMENT}/deploy" + 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) + # Refuse to touch the bucket until Terraform has moved every origin + # to the bucket root. The previous CD pointed origins at + # /releases/