name: Deploy dev content # Dev content CD through Terraform (SH-300). GitHub uploads an immutable # releases/--/ prefix. Terraform owns the pointer, origin # group, and invalidation. Push-to-dev stays off until # vars.TERRAFORM_CONTENT_CD_ENABLED is the string true. # # Quality gates live in Frontend checks (`ci.yaml`). This workflow does not # re-run those gates on pull requests, pushes, or workflow_dispatch. on: push: branches: [dev] paths-ignore: - "terraform/**" workflow_dispatch: {} permissions: contents: read jobs: deploy-dev: name: Deploy shoc-frontend-new-dev through Terraform if: > (github.event_name == 'push' && github.ref == 'refs/heads/dev' && vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') runs-on: ubuntu-latest timeout-minutes: 180 permissions: contents: read id-token: write concurrency: group: deploy-dev cancel-in-progress: false env: AWS_REGION: us-east-1 TF_CLOUD_ORGANIZATION: seahaven TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} SITE_BUCKET: seahaven-shoc-frontend-dev DISTRIBUTION_ID: E2CWLM1AFB964P SITE_URL: https://dev.seahaven.com VITE_API_URL: https://api.dev.seahaven.com/api VITE_APP_COMMIT_SHA: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: npm - name: Build SPA run: | set -euo pipefail npm ci npm run build if grep -Rq "api.staging.seahaven.com" dist/; then echo "::error::Built assets contain the staging API URL." >&2 exit 1 fi if grep -Rq "localhost:5141" dist/; then echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 exit 1 fi grep -Rq "api.dev.seahaven.com" dist/ - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev aws-region: us-east-1 audience: sts.amazonaws.com - name: Assign immutable release identity id: release run: | set -euo pipefail version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" prefix="releases/${version_label}" { echo "version_label=${version_label}" echo "prefix=${prefix}" } >> "${GITHUB_OUTPUT}" # Sentry release is shoc-frontend@${GITHUB_SHA} via VITE_APP_COMMIT_SHA, # distinct from the S3/Terraform version_label. - name: Upload private source maps run: bash scripts/upload-sourcemaps.sh env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Read previous release pointer id: pointer run: | set -euo pipefail body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)" printf '%s' "${body}" | python3 scripts/read-release-pointer.py - name: Upload immutable release prefix run: | set -euo pipefail prefix="${{ steps.release.outputs.prefix }}" aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \ --exclude "index.html" \ --exclude "*.map" \ --cache-control "public,max-age=31536000,immutable" aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \ --cache-control "no-cache,no-store,must-revalidate" \ --content-type "text/html" aws s3api head-object \ --bucket "${SITE_BUCKET}" \ --key "${prefix}/index.html" 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 "Uploaded ${prefix}; index.html sha256=${index_sha}" - name: Capture previous served hash id: previous-hash run: | set -euo pipefail hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)" echo "sha256=${hash}" >> "${GITHUB_OUTPUT}" - name: Discard blocking VCS run before GitHub CD id: discard-vcs env: TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev - name: Create Terraform release run id: release-run uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 env: TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"' TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"' with: workspace: shoc-frontend-new-dev message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions" - name: Read Terraform release plan counts id: release-plan uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: plan: ${{ steps.release-run.outputs.plan_id }} - name: Reject non-release resource counts env: PLAN_ADD: ${{ steps.release-plan.outputs.add }} PLAN_CHANGE: ${{ steps.release-plan.outputs.change }} PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }} run: | set -euo pipefail if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2 exit 1 fi - name: Guard pointer-and-origin-path Terraform plan run: | set -euo pipefail # Flags must match check-terraform-release-plan.py. Pointer `before` # and origin-ID-set stability are asserted from the plan JSON. python3 scripts/check-terraform-release-plan.py \ --plan-id "${{ steps.release-run.outputs.plan_id }}" \ --expected-version-label "${{ steps.release.outputs.version_label }}" \ --expected-previous-version-label "${{ steps.pointer.outputs.live_current }}" - name: Discard release run when the guard fails if: failure() && steps.release-run.outcome == 'success' uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.release-run.outputs.run_id }} comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions - name: Apply Terraform release run id: release-apply continue-on-error: true uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.release-run.outputs.run_id }} comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }} - name: Treat already-applied release run as success env: TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} run: | python3 scripts/hcp-run-guard.py reconcile-apply \ --run-id "${{ steps.release-run.outputs.run_id }}" \ --apply-outcome "${{ steps.release-apply.outcome }}" - name: Verify CloudFront release env: EXPECTED_LABEL: ${{ steps.release.outputs.version_label }} EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }} PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }} run: bash scripts/verify-cloudfront-release.sh - name: Restore previous release on failure if: failure() id: rollback-prepare run: | set -euo pipefail prev="${{ steps.pointer.outputs.live_current }}" if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then echo "No Terraform-managed previous label; cannot roll back through HCP." >&2 exit 0 fi echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}" echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}" - name: Discard blocking VCS run before GitHub rollback id: rollback-discard-vcs if: failure() && steps.rollback-prepare.outputs.rollback_label != '' env: TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev - name: Create Terraform rollback run id: rollback-run if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success' uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 env: TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"' TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"' with: workspace: shoc-frontend-new-dev message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions" - name: Read Terraform rollback plan counts id: rollback-plan if: failure() && steps.rollback-run.outcome == 'success' uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: plan: ${{ steps.rollback-run.outputs.plan_id }} - name: Reject non-release rollback counts id: rollback-count-guard if: failure() && steps.rollback-plan.outcome == 'success' env: PLAN_ADD: ${{ steps.rollback-plan.outputs.add }} PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }} PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }} run: | set -euo pipefail if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2 exit 1 fi - name: Guard pointer-and-origin-path Terraform rollback plan id: rollback-json-guard if: failure() && steps.rollback-count-guard.outcome == 'success' run: | set -euo pipefail python3 scripts/check-terraform-release-plan.py \ --plan-id "${{ steps.rollback-run.outputs.plan_id }}" \ --expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \ --expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}" - name: Discard rollback run when the guard fails if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success' uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.rollback-run.outputs.run_id }} comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions - name: Apply Terraform rollback run id: rollback-apply if: failure() && steps.rollback-json-guard.outcome == 'success' continue-on-error: true uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.rollback-run.outputs.run_id }} comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }} - name: Treat already-applied rollback run as success id: rollback-apply-result if: failure() && steps.rollback-apply.outcome != 'skipped' env: TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} run: | python3 scripts/hcp-run-guard.py reconcile-apply \ --run-id "${{ steps.rollback-run.outputs.run_id }}" \ --apply-outcome "${{ steps.rollback-apply.outcome }}" - name: Verify CloudFront rollback if: failure() && steps.rollback-apply-result.outcome == 'success' env: EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }} run: | set -euo pipefail expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" export EXPECTED_INDEX_SHA256="${expected_sha}" bash scripts/verify-cloudfront-release.sh - name: Live-state summary if: always() continue-on-error: true run: bash scripts/summarize-cloudfront-live-state.sh