diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml index 96502f69..943cad51 100644 --- a/.github/workflows/deploy-web.yaml +++ b/.github/workflows/deploy-web.yaml @@ -15,6 +15,10 @@ name: Deploy Web # # 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. +# +# The SPA checkout is the resolved content ref. Deploy scripts are copied +# from github.workflow_sha so workflow_dispatch of an older SHA still runs +# the current upload/verify path. on: push: @@ -131,6 +135,27 @@ jobs: echo "sha=${sha}" >> "${GITHUB_OUTPUT}" echo "Building ${sha}" + - name: Checkout workflow deploy scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: | + scripts + sparse-checkout-cone-mode: true + path: .workflow-scripts + + - name: Install workflow deploy scripts + run: | + set -euo pipefail + test -f .workflow-scripts/scripts/upload-sourcemaps.sh + test -f .workflow-scripts/scripts/verify-cloudfront-release.sh + test -f .workflow-scripts/scripts/summarize-cloudfront-live-state.sh + mkdir -p scripts + cp .workflow-scripts/scripts/upload-sourcemaps.sh scripts/ + cp .workflow-scripts/scripts/verify-cloudfront-release.sh scripts/ + cp .workflow-scripts/scripts/summarize-cloudfront-live-state.sh scripts/ + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" @@ -145,6 +170,14 @@ jobs: VITE_SENTRY_RELEASE: ${{ steps.commit.outputs.sha }} run: | set -euo pipefail + # vars.VITE_SENTRY_DSN is unset today. An empty env value would + # override .env.production and disable Sentry (Vite does not let + # .env overwrite an existing variable). + if [ -n "${VITE_SENTRY_DSN:-}" ]; then + export VITE_SENTRY_DSN + else + unset VITE_SENTRY_DSN + fi case "${TARGET_ENVIRONMENT}" in dev) export VITE_API_URL="https://api.dev.seahaven.com/api" diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 62ae26c5..75693239 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -51,7 +51,7 @@ isolation). A task is not done until this is green. are migration evidence reviewed by a human before an approved apply (`terraform/README.md`). G13 fails a diff that contains both `terraform/` and deployable application files (`src/`, `public/`, `pages/`, `config/`, - `index.html`, Vite/tsconfig, `.env*`, or `scripts/deploy-web.sh`). Workflow, + `index.html`, Vite/tsconfig, or `.env*`). Workflow, docs, and gate-script changes may travel with either side. Runtime isolation stays: `deploy-web.yaml` ignores `terraform/**`, and app-only tags skip HCP when workspace trigger patterns miss. diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md index 68b4b07b..911e8042 100644 --- a/REVIEW_AND_PR_FRAMEWORK.md +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -87,6 +87,6 @@ surface; keep comments inline and high-signal. Infra and application **PRs** stay separate. GitHub Actions owns SPA content (`deploy-web.yaml`). HCP Terraform owns the bucket and CloudFront. A change set that includes both `terraform/` and deployable application files (`src/`, -`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, `.env*`, or -`scripts/deploy-web.sh`) fails G13. Workflow, docs, and gate-script changes may -travel with either side. +`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`) +fails G13. Workflow, docs, and gate-script changes may travel with either +side. diff --git a/scripts/check_app_terraform_isolation.py b/scripts/check_app_terraform_isolation.py index 13266963..72dff249 100644 --- a/scripts/check_app_terraform_isolation.py +++ b/scripts/check_app_terraform_isolation.py @@ -9,10 +9,6 @@ from __future__ import annotations import argparse import sys -APP_SCRIPT_NAMES = { - "scripts/deploy-web.sh", -} - APP_ROOTS = ( "src/", "public/", @@ -33,7 +29,7 @@ def is_terraform_path(path: str) -> bool: def is_app_path(path: str) -> bool: normalized = path.replace("\\", "/") - if normalized in APP_SCRIPT_NAMES or normalized in APP_FILES: + if normalized in APP_FILES: return True if normalized in {"src", "public", "pages", "config"}: return True diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh deleted file mode 100755 index ec64a742..00000000 --- a/scripts/deploy-web.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# -# Content publish step for the environment deploy workflows -# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`). -# -# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the -# environment's S3 bucket with the right cache headers, and invalidates -# CloudFront. It never touches infrastructure. -# -# Runs from the repo root. The target is resolved from, in order: -# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by -# dev, whose CloudFormation outputs disappear during Terraform adoption) -# 2. the BucketName/DistributionId outputs of STACK_NAME (staging) -set -euo pipefail - -STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" -REGION="${AWS_REGION:-us-east-1}" -WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}" - -echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..." -export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" -npm ci -npm run build - -BUCKET="${SITE_BUCKET:-}" -DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}" - -if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then - echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}." -elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then - echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2 - exit 1 -else - echo "Reading stack outputs from ${STACK_NAME}..." - stack_output() { - aws cloudformation describe-stacks \ - --stack-name "${STACK_NAME}" \ - --region "${REGION}" \ - --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ - --output text - } - - BUCKET="$(stack_output BucketName)" - DIST_ID="$(stack_output DistributionId)" - - if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then - echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 - exit 1 - fi -fi - -echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." -# Everything except index.html: long-lived + immutable, prune stale objects. -aws s3 sync dist/ "s3://${BUCKET}/" \ - --delete \ - --exclude "index.html" \ - --exclude "*.map" \ - --cache-control "public,max-age=31536000,immutable" - -echo "Uploading index.html (never cached)..." -aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \ - --cache-control "no-cache,no-store,must-revalidate" \ - --content-type "text/html" - -echo "Invalidating CloudFront ${DIST_ID}..." -INVALIDATION_ID="$(aws cloudfront create-invalidation \ - --distribution-id "${DIST_ID}" \ - --paths "/*" \ - --query 'Invalidation.Id' \ - --output text)" - -if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then - echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..." - aws cloudfront wait invalidation-completed \ - --distribution-id "${DIST_ID}" \ - --id "${INVALIDATION_ID}" -fi - -echo "Web deploy complete." diff --git a/scripts/read-release-pointer.py b/scripts/read-release-pointer.py deleted file mode 100755 index d570310f..00000000 --- a/scripts/read-release-pointer.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -"""Read .release/current JSON from stdin and write GitHub Actions outputs.""" -from __future__ import annotations - -import json -import os -import sys - - -def main() -> int: - raw = sys.stdin.read().strip() - data = json.loads(raw) if raw else {} - current = data.get("current") or "" - previous = data.get("previous") or "" - output_path = os.environ["GITHUB_OUTPUT"] - with open(output_path, "a", encoding="utf-8") as handle: - handle.write(f"live_current={current}\n") - handle.write(f"live_previous={previous}\n") - print( - "Pointer live current=" - + (current or "") - + " previous=" - + (previous or "") - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py index 460a0cc1..465d2506 100755 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -340,13 +340,13 @@ class ImportPlanCheckerTests(unittest.TestCase): self.assertFalse((REPOSITORY / "terraform" / "versions.tf").exists()) self.assertFalse((REPOSITORY / "terraform" / "main.tf").exists()) - def test_dev_adoption_complete_is_pinned_in_locals(self) -> None: + def test_adoption_complete_is_pinned_in_locals(self) -> None: dev = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8") staging = (REPOSITORY / "terraform/live/staging/main.tf").read_text( encoding="utf-8" ) self.assertRegex(dev, r"adoption_complete\s+= true") - self.assertRegex(staging, r"adoption_complete\s+= false") + self.assertRegex(staging, r"adoption_complete\s+= true") self.assertNotIn('variable "adoption_complete"', dev) self.assertNotIn('variable "environment"', dev) self.assertNotIn('variable "release_version_label"', dev) diff --git a/scripts/test_check_app_terraform_isolation.py b/scripts/test_check_app_terraform_isolation.py index 22ba06ac..661f10de 100644 --- a/scripts/test_check_app_terraform_isolation.py +++ b/scripts/test_check_app_terraform_isolation.py @@ -26,7 +26,6 @@ class IsolationTests(unittest.TestCase): "src/app/routes.tsx", "public/favicon.ico", "index.html", - "scripts/deploy-web.sh", ] ) ) diff --git a/scripts/upload-sourcemaps.sh b/scripts/upload-sourcemaps.sh index d790e674..e1fb8916 100755 --- a/scripts/upload-sourcemaps.sh +++ b/scripts/upload-sourcemaps.sh @@ -14,6 +14,11 @@ fi COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')" RELEASE="shoc-frontend@${COMMIT_SHA}" +if ! npm exec --no -- sentry-cli --version >/dev/null 2>&1; then + echo "sentry-cli is not in this SPA tree; skipping source-map upload" + exit 0 +fi + npm exec --no -- sentry-cli sourcemaps upload \ --org "${SENTRY_ORG}" \ --project "${SENTRY_PROJECT}" \ diff --git a/terraform/README.md b/terraform/README.md index 8bfd6bcb..a2922962 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -7,10 +7,10 @@ to the bucket root and invalidates `/*`. Creating, formatting, initializing with `-backend=false`, and validating these files does not authorize an AWS, HCP Terraform, GitHub, or deployment -mutation. Live cutover waits for an explicit greenlight. +mutation. -Do not collapse these roots into one `terraform/` tree in this PR. Flattening -retargets two live HCP working directories and is its own change. +Do not collapse these roots into one `terraform/` tree. Flattening retargets +two live HCP working directories and is its own change. ## Fixed targets @@ -61,15 +61,14 @@ and gate-script changes may travel with either side. G13 is `npm run test:terraform` and `npm run verify` wrap the same gates. They never create an HCP run or touch AWS. -## Cutover (greenlight only) +## Workspaces -1. Keep HCP working directories `terraform/live/dev` and - `terraform/live/staging`. Dev VCS branch `main`. Staging tag regex - `^v[0-9]+\.[0-9]+\.[0-9]+-staging$`. -2. Auto-apply off. Apply the origin-path move for **dev** before any - `--delete` root sync. -3. Create GitHub Environment `dev` (staging already exists). Set - `DEPLOY_ROLE_ARN` on each. -4. Enable `deploy-web.yaml`. Then delete leftover `deploy.yml` / - `deploy-staging.yml` and repo `TF_API_TOKEN`, `TERRAFORM_CONTENT_CD_ENABLED`, - and `AWS_DEPLOY_ROLE_ARN`. +Dev (`shoc-frontend-new-dev`) watches `main` with working directory +`terraform/live/dev` and auto-apply on. Staging (`shoc-frontend-new-staging`) +watches tag regex `^v[0-9]+\.[0-9]+\.[0-9]+-staging$` with working directory +`terraform/live/staging` and auto-apply on. Merges to `main` do not apply +staging. + +GitHub Environments `dev` and `staging` set `DEPLOY_ROLE_ARN` and allow `main` +plus tag `v*`. Promote staging with `gh release create vX.Y.Z-staging --target +main`. diff --git a/terraform/live/README.md b/terraform/live/README.md index aa85eec0..46fbfb20 100644 --- a/terraform/live/README.md +++ b/terraform/live/README.md @@ -1,7 +1,7 @@ # Live Terraform roots `live/dev/` is the adopted HCP workspace `shoc-frontend-new-dev`. -`live/staging/` is `shoc-frontend-new-staging` (`adoption_complete = false`). +`live/staging/` is `shoc-frontend-new-staging` (`adoption_complete = true`). Do not collapse these into one `terraform/` root in the same PR as application CD. Flattening retargets two live HCP working directories and belongs in its diff --git a/terraform/live/staging/main.tf b/terraform/live/staging/main.tf index 6d26831c..56606900 100644 --- a/terraform/live/staging/main.tf +++ b/terraform/live/staging/main.tf @@ -1,6 +1,6 @@ locals { - # Staging is not fully adopted. Pinned in code, never a workspace variable. - adoption_complete = false + # Controlled ownership transfer. Pinned in code, never a workspace variable. + adoption_complete = true environment = "staging" workspace_name = "shoc-frontend-new-staging"