Merge branch 'main' into feat/ab/sh-304-services-registry-ui

This commit is contained in:
Alexandre Brandizzi 2026-09-18 18:57:58 -03:00 • committed by GitHub
commit 29caecd449
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1524 additions and 1918 deletions

56
.github/workflows/ci-terraform.yaml vendored Normal file
View file

@ -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

View file

@ -2,9 +2,9 @@ name: Frontend checks
on: on:
pull_request: pull_request:
branches: [main, dev, staging] branches: [main, dev]
push: push:
branches: [main, dev, staging] branches: [main]
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
@ -24,15 +24,16 @@ jobs:
# `npm run verify` is the single command that chains: format check, lint # `npm run verify` is the single command that chains: format check, lint
# (--max-warnings=0), type-check + build, unit tests, then the governance # (--max-warnings=0), type-check + build, unit tests, then the governance
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file # checks in scripts/governance-check.mjs (godfile ratchet, changed-file
# maintainability gate, Terraform fmt/validate, Terraform import-plan and # maintainability gate, Terraform fmt/validate, Terraform import-plan
# release-plan guards, isolation tests, HCP run guard, CloudFront verify, # guard, HCP run guard, CloudFront verify, GitHub workflow shell, and G13
# and GitHub workflow shell). If the reusable workflow is later confirmed # app/Terraform isolation). Runs on PRs to main or dev; push is main only.
# to run every gate, this job can be slimmed to `npm run governance`. # 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: # GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>) # PR -> the PR target branch (origin/<base_ref>)
# push-> the previous commit on the branch (github.event.before) # 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -52,7 +53,7 @@ jobs:
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}" base="${EVENT_BEFORE}"
else else
base="origin/dev" base="origin/main"
fi fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- name: Set up Terraform - name: Set up Terraform

View file

@ -1,148 +0,0 @@
name: Deploy staging
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
# job to declare `environment: staging` and run in this repo, with the
# non-secret role ARN pinned below (created by the staging stack itself).
#
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
# No secrets are used — OIDC + the static role ARN are the only credentials.
on:
push:
branches: [staging]
workflow_dispatch: {}
permissions:
id-token: write
contents: read
concurrency:
group: deploy-staging
cancel-in-progress: false
jobs:
deploy:
name: Deploy to staging
# Deploy only the exact staging branch ref, never a tag or other ref
# (workflow_dispatch can be invoked from arbitrary refs).
if: github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
environment: staging
env:
VITE_API_URL: https://api.staging.seahaven.com/api
VITE_SENTRY_ENVIRONMENT: staging
VITE_APP_COMMIT_SHA: ${{ github.sha }}
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Resolve governance comparison ref
id: governance-ref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else
base="origin/dev"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
# Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin
# (npm@11.16.0) matches this CI environment.
- name: Quality gates (full verify before any deploy)
run: npm ci && npm run verify
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
- name: Assume staging deploy role (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
aws-region: us-east-1
# Builds the SPA with the staging VITE_API_URL and Sentry environment
# label (process env overrides the dev values committed in
# .env.production), syncs to the staging bucket, and invalidates
# CloudFront.
- name: Build and publish SPA
run: bash scripts/deploy-web.sh
env:
STACK_NAME: shoc-frontend-staging
WAIT_FOR_INVALIDATION: "true"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Verify deployment
run: |
set -euo pipefail
stack_output() {
aws cloudformation describe-stacks \
--stack-name shoc-frontend-staging \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
DIST_DOMAIN="$(stack_output DistributionDomainName)"
SITE_URL="$(stack_output SiteUrl)"
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then
echo "::error::Could not resolve bucket/distribution from stack outputs." >&2
exit 1
fi
echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}"
aws s3api head-bucket --bucket "${BUCKET}"
echo "Bucket exists."
# The distribution is proven to exist and serve by the HTTPS check
# below: the custom domain is an alias to this distribution, and the
# deploy role deliberately carries no cloudfront:GetDistribution
# (least privilege; the dev template is shared and must not drift).
if grep -Rq "api.dev.seahaven.com" dist/; then
echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2
grep -Rl "api.dev.seahaven.com" dist/ >&2 || true
exit 1
fi
echo "Built assets carry no dev API URL."
grep -Rq "api.staging.seahaven.com" dist/
echo "Built assets reference the staging API URL."
# Verify the actual post-invalidation HTML and its referenced assets,
# not only the local build or a generic endpoint response.
remote_dir="$(mktemp -d)"
trap 'rm -rf "${remote_dir}"' EXIT
for i in 1 2 3 4 5 6; do
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then
break
fi
echo "Endpoint not ready (attempt ${i}); retrying in 20s..."
sleep 20
done
test -s "${remote_dir}/index.html"
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \
| sed -E 's/^(src|href)="([^"]+)"$/\2/' \
| sort -u > "${remote_dir}/asset-paths.txt"
test -s "${remote_dir}/asset-paths.txt"
while IFS= read -r asset_path; do
curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \
>> "${remote_dir}/assets.txt"
done < "${remote_dir}/asset-paths.txt"
if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then
echo "::error::Deployed assets contain the dev API URL." >&2
exit 1
fi
grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt"
echo "Deployed staging assets reference only the staging API URL."

317
.github/workflows/deploy-web.yaml vendored Normal file
View file

@ -0,0 +1,317 @@
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.
#
# 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:
branches: [main]
paths-ignore:
- "terraform/**"
- "docs/**"
- "**/*.md"
- ".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}"
- 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"
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
# 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"
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/<label>; syncing and pruning under that layout would
# serve a broken site or delete the live prefix.
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 dist/ to the bucket root
env:
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
run: |
set -euo pipefail
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--delete \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
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 }}
API_URL: ${{ env.VITE_API_URL }}
run: bash scripts/verify-cloudfront-release.sh
- name: Live-state summary
if: always()
continue-on-error: true
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
SITE_URL: ${{ steps.deploy.outputs.site_url }}
run: bash scripts/summarize-cloudfront-live-state.sh

View file

@ -1,300 +0,0 @@
name: Deploy dev content
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable
# releases/<sha>-<run>-<attempt>/ 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

View file

@ -1,43 +0,0 @@
name: Terraform isolation
# Own workflow so labeled/unlabeled re-evaluate this gate without starting a
# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events
# would report required checks as success and could merge a failing SHA.
on:
pull_request:
branches: [main, dev, staging]
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
permissions:
contents: read
jobs:
terraform-isolation:
# Fails a pull request that changes Terraform infrastructure together with
# deployable application code (scripts/check-terraform-isolation.mjs). A
# merge that does both queues an HCP VCS run and a content release at the
# same time, and the two race for the workspace lock. The
# `terraform-isolation-override` label is the reviewed exception. This
# job is unconditional so adding or removing that label always reads the
# current label set; a previous green check does not survive removal.
name: Terraform and application changes are isolated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Check changed files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"

View file

@ -28,9 +28,10 @@ npm run verify
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
checks (`npm run governance`). **Do not claim a task is done until `npm run checks (`npm run governance`), including G13 app/Terraform isolation. **Do not
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned claim a task is done until `npm run verify` is green locally.** CI runs the same
`governance` job, so a green local run mirrors CI. `npm run verify` in a repo-owned `governance` job, so a green local run mirrors
CI.
## Non-negotiable rules (enforced; do not work around) ## Non-negotiable rules (enforced; do not work around)

View file

@ -8,32 +8,30 @@ npm run verify
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) → `verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
`test` (`vitest run`) → `governance`. Governance also runs the repository `test` (`vitest run`) → `governance`. Governance also runs the repository
gates: Terraform import-plan and release-plan checkers, isolation tests, gates: Terraform import-plan checker, Terraform formatting and validation, the
Terraform formatting and validation, the HCP run guard, CloudFront verify, and HCP run guard, CloudFront verify, workflow shell checks, and G13 (app/Terraform
workflow shell checks. A task is not done until this is green. isolation). A task is not done until this is green.
## Gate matrix ## Gate matrix
| Gate | Command / rule source | Enforced by | Scope | | Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | | Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | | Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | | Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | | Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | | Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | | Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | | Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | | Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | | Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | | Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | | Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures | | Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier | | HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` | | CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | | GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | | G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
## No-false-pass guarantees ## No-false-pass guarantees
@ -51,14 +49,12 @@ workflow shell checks. A task is not done until this is green.
-lockfile=readonly` and `validate` run offline; the plan checker is tested -lockfile=readonly` and `validate` run offline; the plan checker is tested
against synthetic plan JSON. Real import and controlled-update plans from HCP against synthetic plan JSON. Real import and controlled-update plans from HCP
are migration evidence reviewed by a human before an approved apply are migration evidence reviewed by a human before an approved apply
(`terraform/README.md`). (`terraform/README.md`). G13 fails a diff that contains both `terraform/`
- **The isolation gate re-evaluates on label changes** — the and deployable application files (`src/`, `public/`, `pages/`, `config/`,
`terraform-isolation-override` label is the only way to merge a mixed `index.html`, Vite/tsconfig, or `.env*`). Workflow,
Terraform/application PR. `.github/workflows/terraform-isolation.yaml` docs, and gate-script changes may travel with either side. Runtime isolation
runs `terraform-isolation` on `labeled` and `unlabeled` as well as the stays: `deploy-web.yaml` ignores `terraform/**`, and app-only tags skip HCP
default pull-request types, so adding or removing the label re-checks when workspace trigger patterns miss.
the current labels without starting a new Frontend checks run. Removing
the label fails a mixed PR that had previously passed with the override.
## Where the gates run ## Where the gates run
@ -70,9 +66,9 @@ workflow shell checks. A task is not done until this is green.
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability `npm run verify` (with Terraform 1.16.0 installed) so the maintainability
ratchets and repository gates are guaranteed from this repository regardless ratchets and repository gates are guaranteed from this repository regardless
of the reusable workflow. of the reusable workflow.
- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):** - **Terraform CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):**
on pull requests, fails when Terraform infrastructure and application code fmt, `init -backend=false`, validate, import-plan unit tests, and G13
change together. Label add/remove re-runs only this workflow. classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`.
## Toolchain pin ## Toolchain pin

102
README.md
View file

@ -1,7 +1,7 @@
# SHOC Frontend (`shoc-frontend-new`) # SHOC Frontend (`shoc-frontend-new`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml) [![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml) [![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white) ![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
@ -20,22 +20,23 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
Static SPA hosting on AWS, owned by HCP Terraform Static SPA hosting on AWS, owned by HCP Terraform
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built ([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
`dist/` from a private S3 bucket using a current/previous origin group; `dist/` from a private S3 bucket at the bucket root;
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api` the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
proxy at the CDN — the backend allows CORS). proxy at the CDN — the backend allows CORS).
```mermaid ```mermaid
graph LR graph LR
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront] U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev] CF -->|OAC bucket root| S3[S3 seahaven-shoc-frontend-dev]
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html] CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API] U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
GH[GitHub Actions] -->|OIDC upload releases/*| S3 GH[GitHub Actions] -->|OIDC s3 sync dist/| S3
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF TF[HCP Terraform] -->|bucket CloudFront IAM SSM| CF
``` ```
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still Dev and staging hosting live in `terraform/live/dev` and
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287). `terraform/live/staging`. GitHub `.github/workflows/deploy-web.yaml` syncs
content.
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod, Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
@ -47,13 +48,13 @@ architecture plan for the keep/discard migration matrix).
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev). `us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
| Resource | Name | Purpose | | Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) | | S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` | | CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) | | CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | | IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to Environment `dev` plus `deploy-web.yaml` |
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront | | Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
No Lambdas, queues, or databases — this stack is static hosting only. No Lambdas, queues, or databases — this stack is static hosting only.
@ -61,8 +62,8 @@ No Lambdas, queues, or databases — this stack is static hosting only.
### Secrets ### Secrets
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role No Secrets Manager. Deploy looks up `/shoc-frontend-new/<env>/deploy/{bucket,distribution-id}`
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions after assuming `DEPLOY_ROLE_ARN`. AWS access is OIDC only. The one **GitHub Actions
repo secret** is: repo secret** is:
| Secret | Purpose | | Secret | Purpose |
@ -127,56 +128,45 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
- Commit messages follow - Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) — commitlint [Conventional Commits](https://www.conventionalcommits.org) — commitlint
rejects anything else at commit time. rejects anything else at commit time.
- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs - Open PRs against `main`. Protected branches need a green CI run and an
a green CI run and an approving review from a code owner approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals. (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically. Merged branches are deleted automatically.
- A PR that changes `terraform/**` may not also change application code (the - PRs cannot mix `terraform/` with deployable application files (G13). Workflow,
`terraform-isolation` CI job); ship Terraform in its own PR. docs, and gate-script changes may travel with either side. `deploy-web.yaml`
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through still ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`) - Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts
`→ main` (production promotion — no prod environment exists yet). `vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
prod distribution exists.
## Deployment ## Deployment
No stored AWS keys — OIDC only. Infrastructure and content deploy separately: No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`/`dev`/`staging`, calls and PRs to `main`, calls
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24): `Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
format check, lint, build, tests; **and** runs a repo-owned `governance` job format check, lint, build, tests; **and** runs a repo-owned `governance` job
that calls `npm run verify` so every gate (including the maintainability that calls `npm run verify` so every gate (including the maintainability
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs), ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)
the Terraform gates, and the content-CD guards) is guaranteed from this and the Terraform gates) is guaranteed from this repository. Conventions
repository. Conventions and gates are documented under and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **Terraform isolation** - **Terraform CI**
([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)) ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml))
— fails a PR that mixes `terraform/**` with application code, so a Terraform — fmt, `init -backend=false`, validate, and import-plan tests.
merge never races a content release for the HCP workspace. - **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml))
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
— `workflow_dispatch` on `dev`, and push-to-`dev` when `staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
`vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`). - **Infrastructure** — HCP workspaces `shoc-frontend-new-dev` and
GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates `shoc-frontend-new-staging` ([`terraform/README.md`](terraform/README.md)).
`.release/current`, both origin paths, and the invalidation action. Verify
and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
a live-state summary.
- **Staging content**
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
— on push to `staging`, unchanged.
- **Infrastructure** — administrator-run HCP Terraform workspace
`shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
Staging hosting stays on the existing CloudFormation stack until SH-287.
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
content publisher only.
## Operations ## Operations
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev - **Verify:** open <https://dev.seahaven.com> after a green **Deploy Web**
content** run in the Actions tab; confirm a deep link (e.g. a work-orders run in the Actions tab; confirm a deep link (e.g. a work-orders
route) loads directly and API calls succeed. route) loads directly and API calls succeed.
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There - **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
are no CloudWatch application logs — the stack is static hosting; runtime are no CloudWatch application logs — the stack is static hosting; runtime
@ -185,20 +175,18 @@ content publisher only.
- _Stale content after deploy_ — CloudFront is still `InProgress` or an edge - _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
still serves the previous `index.html` hash. Read the live-state summary still serves the previous `index.html` hash. Read the live-state summary
before assuming the site is down. before assuming the site is down.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref - _OIDC `AssumeRole` errors_ — the trust policy is scoped to Environment
on this repo; dispatching the workflow from another branch is rejected by `dev` or `staging` plus `deploy-web.yaml`. A job without `environment:`
design. cannot assume the role.
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api` - _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
suffix or carrying the wrong environment's host (it is baked in at build time). suffix or carrying the wrong environment's host (it is baked in at build time).
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does - _CORS errors_ — the backend must allow the frontend origin; CloudFront does
not proxy `/api`. not proxy `/api`.
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when - _Non-empty origin path_ — `deploy-web.yaml` refuses to sync until Terraform
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues has moved every origin to the bucket root.
an HCP Terraform run that a human confirms or discards before the next
content release (see the operational rules in `terraform/README.md`).
## Documentation ## Documentation
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md) - Terraform runbook: [`terraform/README.md`](terraform/README.md)
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md); - Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
design system and UI docs under [`docs/`](docs/) design system and UI docs under [`docs/`](docs/)

View file

@ -83,3 +83,10 @@ A review is complete when it records, briefly:
Do not write a monolithic review body or a validation transcript into the PR Do not write a monolithic review body or a validation transcript into the PR
surface; keep comments inline and high-signal. 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, or `.env*`)
fails G13. Workflow, docs, and gate-script changes may travel with either
side.

View file

@ -13,12 +13,11 @@
"test:e2e:visual": "playwright test --config playwright.visual.config.ts", "test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py", "test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
"test:terraform-release-plan": "python3 scripts/test-terraform-release-plan-check.py",
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
"test:terraform": "node scripts/terraform-validate.mjs", "test:terraform": "node scripts/terraform-validate.mjs",
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py", "test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh", "test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
"test:github-workflows": "bash scripts/check-github-workflows.sh", "test:github-workflows": "bash scripts/check-github-workflows.sh",
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py",
"lint": "eslint . --max-warnings=0", "lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .", "format": "prettier --write .",

View file

@ -10,8 +10,11 @@ from pathlib import Path
from typing import Any from typing import Any
from terraform_import_plan_resources import ( from terraform_import_plan_resources import (
ALLOWED_CREATE_ADDRESSES,
CONTROLLED_UPDATE_ADDRESSES, CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG, ENVIRONMENT_CONFIG,
GITHUB_OIDC_PROVIDER_ARN,
GITHUB_REPO,
REQUIRED_IMPORT_IDS, REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES, REQUIRED_RESOURCES,
) )
@ -22,7 +25,12 @@ DISTRIBUTION_ADDRESS = (
"module.environment_owned.aws_cloudfront_distribution.site" "module.environment_owned.aws_cloudfront_distribution.site"
) )
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy" ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS} ROLE_POLICY_ADDRESS = "module.environment_owned.aws_iam_role_policy.github_deploy"
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
BUCKET_POLICY_ADDRESS,
ROLE_ADDRESS,
ROLE_POLICY_ADDRESS,
}
OWNERSHIP_TAGS = { OWNERSHIP_TAGS = {
"Environment": None, "Environment": None,
"ManagedBy": "terraform", "ManagedBy": "terraform",
@ -330,6 +338,113 @@ def _validate_policy_update(
return violations return violations
def _expected_github_deploy_assume_policy(environment: str) -> dict[str, Any]:
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GithubDeployOidc",
"Effect": "Allow",
"Action": "sts:AssumeRoleWithWebIdentity",
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": (
"sts.amazonaws.com"
),
"token.actions.githubusercontent.com:sub": (
f"repo:{GITHUB_REPO}:environment:{environment}"
),
},
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": [
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/heads/main"
),
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/tags/v*"
),
],
},
},
}
],
}
)
def _validate_role_assume_policy(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
before_policy, violations = _parse_policy(
before.get("assume_role_policy"), address, "before"
)
after_policy, after_violations = _parse_policy(
after.get("assume_role_policy"), address, "after"
)
violations.extend(after_violations)
if before_policy == after_policy:
violations.append(f"{address}: assume_role_policy semantics did not change")
expected_after = _expected_github_deploy_assume_policy(environment)
if after_policy is not None and after_policy != expected_after:
violations.append(
f"{address}: post-adoption assume_role_policy semantics are not exact"
)
return violations
def _validate_role_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
changed = _changed_leaf_paths(before, after)
allowed_roots = {"tags", "tags_all", "assume_role_policy", "description"}
invalid = {path for path in changed if not path or path[0] not in allowed_roots}
violations = [
f"{address}: controlled role update changes forbidden path {'.'.join(path)}"
for path in sorted(invalid)
]
if not changed:
violations.append(f"{address}: update has no changed leaf values")
expected = {
**OWNERSHIP_TAGS,
"Environment": environment,
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"],
}
expected_after = {key: value for key, value in expected.items() if value is not None}
for tag_attribute in ("tags", "tags_all"):
if after.get(tag_attribute) != expected_after:
violations.append(
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
)
if any(path and path[0] == "assume_role_policy" for path in changed):
violations.extend(
_validate_role_assume_policy(address, before, after, environment)
)
return violations
def _validate_iam_policy_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
) -> list[str]:
changed = _changed_leaf_paths(before, after)
if changed != {("policy",)}:
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
if before.get("policy") == after.get("policy"):
return [f"{address}: policy semantics did not change"]
return []
def _validate_controlled_update( def _validate_controlled_update(
address: str, address: str,
change: dict[str, Any], change: dict[str, Any],
@ -348,6 +463,10 @@ def _validate_controlled_update(
return [*violations, f"{address}: controlled update requires before/after objects"] return [*violations, f"{address}: controlled update requires before/after objects"]
if address in TAG_UPDATE_ADDRESSES: if address in TAG_UPDATE_ADDRESSES:
violations.extend(_validate_tag_update(address, before, after, environment)) violations.extend(_validate_tag_update(address, before, after, environment))
elif address == ROLE_ADDRESS:
violations.extend(_validate_role_update(address, before, after, environment))
elif address == ROLE_POLICY_ADDRESS:
violations.extend(_validate_iam_policy_update(address, before, after))
elif address == BUCKET_POLICY_ADDRESS: elif address == BUCKET_POLICY_ADDRESS:
violations.extend( violations.extend(
_validate_policy_update( _validate_policy_update(
@ -416,19 +535,30 @@ def check_plan(
if change.get("replace_paths") not in (None, []): if change.get("replace_paths") not in (None, []):
violations.append(f"{address}: replace_paths must be empty") violations.append(f"{address}: replace_paths must be empty")
import_id = REQUIRED_IMPORT_IDS[environment].get(address)
if mode == "import": if mode == "import":
if actions != ["no-op"]: if address in ALLOWED_CREATE_ADDRESSES and import_id is None:
violations.append( if actions != ["create"]:
f"{address}: import mode requires no-op, got {actions!r}" violations.append(
) f"{address}: import mode requires create for deploy parameters, got {actions!r}"
if expected_type is not None: )
violations.extend( if "importing" in change:
_validate_import_metadata( violations.append(
address=address, f"{address}: import metadata is forbidden for created deploy parameters"
change=change, )
environment=environment, else:
if actions != ["no-op"]:
violations.append(
f"{address}: import mode requires no-op, got {actions!r}"
)
if expected_type is not None:
violations.extend(
_validate_import_metadata(
address=address,
change=change,
environment=environment,
)
) )
)
elif mode == "post-import": elif mode == "post-import":
if actions != ["no-op"]: if actions != ["no-op"]:
violations.append( violations.append(
@ -456,6 +586,8 @@ def check_plan(
distribution_id, distribution_id,
) )
) )
elif actions == ["create"] and address in ALLOWED_CREATE_ADDRESSES:
pass
elif actions != ["no-op"]: elif actions != ["no-op"]:
violations.append(f"{address}: unsafe controlled actions {actions!r}") violations.append(f"{address}: unsafe controlled actions {actions!r}")

View file

@ -1,137 +0,0 @@
// Terraform/application change isolation gate.
//
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
// on the workspace. If the same merge also changes deployable application
// code, the content release and the VCS run race for the workspace lock
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
// or discarded by a human before the next content release.
//
// Files that may accompany a Terraform change without triggering a release:
// the Terraform tree itself, its plan-guard tooling, and documentation.
//
// Usage:
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
//
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
// it only when the PR carries the `terraform-isolation-override` label, which
// reviewers grant to the rare change that must introduce Terraform variables
// together with the workflow that consumes them. The checker has no memory of
// a previous pass: the same mixed diff fails again as soon as the override
// env is unset (label removal).
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const OVERRIDE_LABEL = "terraform-isolation-override";
export function isTerraformPath(file) {
return file.startsWith("terraform/");
}
// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers
// are terraform/live/dev/** and terraform/live/modules/**), so it is not a
// Terraform change for the mixed-PR check.
export function isTerraformInfrastructurePath(file) {
return isTerraformPath(file) && !file.endsWith(".md");
}
export function mayAccompanyTerraform(file) {
if (isTerraformPath(file)) return true;
if (file.endsWith(".md")) return true;
if (file.startsWith("docs/")) return true;
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
if (
/^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test(
file,
)
) {
return true;
}
if (file.startsWith("scripts/testdata/terraform-")) return true;
return false;
}
/**
* @param {string[]} files changed paths relative to the repository root
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
*/
export function classifyChangedFiles(files) {
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
const terraform = unique.filter(isTerraformInfrastructurePath);
const application = unique.filter((file) => !mayAccompanyTerraform(file));
return {
terraform,
application,
mixed: terraform.length > 0 && application.length > 0,
};
}
function changedFilesFromGit(base, head) {
const mergeBase = execFileSync("git", ["merge-base", base, head], {
cwd: ROOT,
encoding: "utf8",
}).trim();
return execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
{ cwd: ROOT, encoding: "utf8" },
)
.split("\n")
.filter(Boolean);
}
function parseArgs(argv) {
const options = { base: null, head: "HEAD", stdin: false };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--base") options.base = argv[++index];
else if (argument === "--head") options.head = argv[++index];
else if (argument === "--stdin") options.stdin = true;
else throw new Error(`unknown argument: ${argument}`);
}
if (!options.stdin && !options.base) {
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
}
return options;
}
function main(argv) {
const options = parseArgs(argv);
const files = options.stdin
? readFileSync(0, "utf8").split("\n")
: changedFilesFromGit(options.base, options.head);
const result = classifyChangedFiles(files);
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
console.log("─".repeat(64));
console.log(
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
);
if (!result.mixed) {
console.log(" PASS: Terraform and application changes are not mixed");
return 0;
}
console.log(" Terraform files:");
for (const file of result.terraform) console.log(` ${file}`);
console.log(" Application files that cannot ship in the same PR:");
for (const file of result.application) console.log(` ${file}`);
if (override) {
console.log(
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
);
return 0;
}
console.log(
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
);
return 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exit(main(process.argv.slice(2)));
}

View file

@ -1,179 +0,0 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
OVERRIDE_LABEL,
classifyChangedFiles,
isTerraformInfrastructurePath,
mayAccompanyTerraform,
} from "./check-terraform-isolation.mjs";
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"check-terraform-isolation.mjs",
);
function runGate(files, env = {}) {
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
input: `${files.join("\n")}\n`,
encoding: "utf8",
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
});
}
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
for (const file of [
"terraform/live/dev/main.tf",
"terraform/live/modules/environment-owned/main.tf",
"terraform/README.md",
"README.md",
"docs/adr/0003-terraform.md",
"scripts/check-terraform-import-plan.py",
"scripts/terraform_import_plan_resources.py",
"scripts/test-terraform-import-plan-check.py",
"scripts/terraform-validate.mjs",
"scripts/check-terraform-isolation.mjs",
"scripts/check-terraform-release-plan.py",
"scripts/hcp-run-guard.py",
"scripts/test-hcp-run-guard.py",
"scripts/verify-cloudfront-release.sh",
"scripts/test-verify-cloudfront-release.sh",
"scripts/summarize-cloudfront-live-state.sh",
"scripts/check-github-workflows.sh",
"scripts/read-release-pointer.py",
"scripts/testdata/terraform-release-plans/version-only.json",
]) {
assert.equal(mayAccompanyTerraform(file), true, file);
}
});
test("application, workflow, and dependency files count as application changes", () => {
for (const file of [
"src/App.tsx",
"public/favicon.ico",
"index.html",
"package.json",
"package-lock.json",
".env.production",
"vite.config.ts",
".github/workflows/deploy.yml",
"scripts/deploy-web.sh",
"scripts/governance-check.mjs",
"e2e/login.spec.ts",
]) {
assert.equal(mayAccompanyTerraform(file), false, file);
}
});
test("terraform-only and application-only changes are not mixed", () => {
assert.equal(
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
false,
);
assert.equal(
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
false,
);
assert.equal(classifyChangedFiles([]).mixed, false);
});
test("terraform documentation does not mix with application or workflow changes", () => {
assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false);
assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true);
assert.equal(
classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed,
false,
);
const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]);
assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr);
assert.match(docsOnly.stdout, /PASS/);
});
test("terraform plus application is mixed and lists the offending files", () => {
const result = classifyChangedFiles([
"terraform/live/dev/main.tf",
"src/App.tsx",
"README.md",
" ",
"src/App.tsx",
]);
assert.equal(result.mixed, true);
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
assert.deepEqual(result.application, ["src/App.tsx"]);
});
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
assert.match(mixed.stdout, /FAIL/);
assert.match(mixed.stdout, /src\/App\.tsx/);
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
assert.match(isolated.stdout, /PASS/);
});
test("CLI override downgrades a mixed change to a warning that names the label", () => {
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /WARNING/);
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "yes",
});
assert.equal(notTrue.status, 1);
});
test("removing the override fails a mixed change that was previously green", () => {
const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"];
const previouslyGreen = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr);
assert.match(previouslyGreen.stdout, /WARNING/);
// CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is
// the string "false" after the label is removed. A stale green check must
// not survive that.
const afterLabelRemoved = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "false",
});
assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr);
assert.match(afterLabelRemoved.stdout, /FAIL/);
assert.match(afterLabelRemoved.stdout, /deploy\.yml/);
});
test("CLI refuses to run without a base ref or --stdin", () => {
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
assert.notEqual(result.status, 0);
});
test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => {
const workflows = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
".github/workflows",
);
const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8");
const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8");
for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) {
assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType);
}
assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m);
assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m);
assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m);
assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/);
assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m);
assert.match(isolationYaml, /name: Terraform and application changes are isolated/);
assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/);
});

View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Fail when a change set mixes Terraform with deployable application files.
Workflow, docs, and gate-script changes may travel with either side.
"""
from __future__ import annotations
import argparse
import sys
APP_ROOTS = (
"src/",
"public/",
"pages/",
"config/",
)
APP_FILES = {
"index.html",
"vite.config.ts",
"vitest.config.ts",
}
def is_terraform_path(path: str) -> bool:
return path == "terraform" or path.startswith("terraform/")
def is_app_path(path: str) -> bool:
normalized = path.replace("\\", "/")
if normalized in APP_FILES:
return True
if normalized in {"src", "public", "pages", "config"}:
return True
if normalized.startswith(APP_ROOTS):
return True
if normalized.startswith("tsconfig"):
return True
return normalized.startswith(".env")
def isolation_violation(paths: list[str]) -> tuple[list[str], list[str]] | None:
terraform_files = sorted({path for path in paths if is_terraform_path(path)})
app_files = sorted({path for path in paths if is_app_path(path)})
if terraform_files and app_files:
return terraform_files, app_files
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"paths",
nargs="*",
help="Changed paths. Omit and pass newline-separated paths on stdin.",
)
args = parser.parse_args()
paths = list(args.paths)
if not paths and not sys.stdin.isatty():
paths = [line.strip() for line in sys.stdin if line.strip()]
violation = isolation_violation(paths)
if violation is None:
print("PASS: application and Terraform changes are isolated")
return 0
terraform_files, app_files = violation
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr)
print("terraform:", file=sys.stderr)
for path in terraform_files:
print(f" {path}", file=sys.stderr)
print("application:", file=sys.stderr)
for path in app_files:
print(f" {path}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -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."

View file

@ -21,12 +21,11 @@ const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// script so it can also be run on its own. // script so it can also be run on its own.
const REPOSITORY_GATES = [ const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"], ["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform release-plan contract", "test:terraform-release-plan"],
["Terraform isolation gate", "test:terraform-isolation"],
["Terraform formatting and validation", "test:terraform"], ["Terraform formatting and validation", "test:terraform"],
["HCP run guard", "test:hcp-run-guard"], ["HCP run guard", "test:hcp-run-guard"],
["CloudFront release verify", "test:cloudfront-release-verify"], ["CloudFront release verify", "test:cloudfront-release-verify"],
["GitHub workflow shell", "test:github-workflows"], ["GitHub workflow shell", "test:github-workflows"],
["App/Terraform isolation tests", "test:app-terraform-isolation"],
]; ];
function isGoverned(relativePath) { function isGoverned(relativePath) {
@ -142,7 +141,7 @@ function godfileRatchet(baseRef) {
function resolveBaseRef() { function resolveBaseRef() {
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE; if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`; if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
for (const candidate of ["origin/dev", "origin/main"]) { for (const candidate of ["origin/main", "origin/dev"]) {
try { try {
execFileSync("git", ["rev-parse", "--verify", candidate], { execFileSync("git", ["rev-parse", "--verify", candidate], {
cwd: ROOT, cwd: ROOT,
@ -219,6 +218,15 @@ function runRepositoryGate(label, script) {
return { label, status: result.status, error: result.error }; return { label, status: result.status, error: result.error };
} }
function runIsolationGate(baseRef) {
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]);
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
cwd: ROOT,
encoding: "utf8",
input: `${files.join("\n")}\n`,
});
}
function main() { function main() {
const failures = []; const failures = [];
const baseRef = resolveBaseRef(); const baseRef = resolveBaseRef();
@ -317,6 +325,27 @@ function main() {
} }
} }
console.log("─".repeat(64));
console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`);
if (!baseRef) {
console.log(" FAIL (no valid base ref)");
failures.push(
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
const isolation = runIsolationGate(baseRef);
if (isolation.error) {
console.log(" FAIL (could not start)");
failures.push(`G13: could not start: ${isolation.error.message}`);
} else {
const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim();
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
if (isolation.status !== 0) {
failures.push("G13: do not mix deployable application files with terraform/");
}
}
}
console.log("─".repeat(64)); console.log("─".repeat(64));
if (failures.length > 0) { if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`); console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);

View file

@ -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 "<empty>")
+ " previous="
+ (previous or "<empty>")
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,14 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Print pointer body, origin paths, distribution status, and served index hash. # Print origin paths, distribution status, and served index hash.
# Used by deploy.yml's always() summary. Never fails the job on a missing pointer. # Used by deploy-web.yaml's always() summary. Never fails the job.
set -u set -u
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}" DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}" SITE_URL="${SITE_URL:-}"
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
echo "=== CloudFront live state ===" echo "=== CloudFront live state ==="
echo "pointer:" if [[ -z "${DISTRIBUTION_ID}" ]]; then
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)" echo "DISTRIBUTION_ID unset"
echo exit 0
fi
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c ' aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
import json, sys import json, sys
payload = json.load(sys.stdin) payload = json.load(sys.stdin)
@ -19,5 +19,7 @@ for origin in ((config.get("Origins") or {}).get("Items") or []):
print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or "")) print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or ""))
' '
echo echo
echo -n "served index sha256: " if [[ -n "${SITE_URL}" ]]; then
curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable" echo -n "served index sha256: "
curl -fsS --max-time 30 "${SITE_URL%/}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
fi

View file

@ -1,18 +1,18 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform"; const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
// Only dev has a live root. Staging adoption (SH-287) adds its own root here. const LIVE_ROOTS = ["terraform/live/dev", "terraform/live/staging"];
const ENVIRONMENTS = ["dev"];
const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment));
function run(args, cwd = ROOT) { function run(args, cwd = ROOT, env = process.env) {
const result = spawnSync(TERRAFORM, args, { const result = spawnSync(TERRAFORM, args, {
cwd, cwd,
encoding: "utf8", encoding: "utf8",
stdio: "inherit", stdio: "inherit",
env,
}); });
if (result.error) { if (result.error) {
throw new Error(`could not start Terraform: ${result.error.message}`, { throw new Error(`could not start Terraform: ${result.error.message}`, {
@ -24,12 +24,13 @@ function run(args, cwd = ROOT) {
} }
} }
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]); run(["fmt", "-check", "-recursive", "terraform"]);
for (const root of ROOTS) { for (const liveRoot of LIVE_ROOTS) {
// -backend=false never touches HCP state; -lockfile=readonly refuses to const abs = path.join(ROOT, liveRoot);
// silently rewrite the committed provider lock. run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], abs);
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root); run(["validate", "-no-color"], abs);
run(["validate", "-no-color"], root);
} }
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`); console.log(
"Terraform formatting and validation passed for terraform/live/dev and terraform/live/staging.",
);

View file

@ -1,8 +1,7 @@
"""Canonical frontend Terraform ownership and import-ID maps. """Canonical frontend Terraform ownership and import-ID maps.
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants Dev is already in HCP state. Staging constants authorize the first import of
are kept so the checker can prove that a dev plan carrying a staging identifier the live CDK stack onto terraform/live/staging.
is rejected; they do not authorize a staging import.
""" """
COMMON_RESOURCES = { COMMON_RESOURCES = {
@ -31,6 +30,10 @@ COMMON_RESOURCES = {
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record", "module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role", "module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy", "module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
"module.environment_owned.aws_ssm_parameter.deploy_bucket": "aws_ssm_parameter",
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": (
"aws_ssm_parameter"
),
} }
REQUIRED_RESOURCES = { REQUIRED_RESOURCES = {
@ -45,9 +48,22 @@ CONTROLLED_UPDATE_ADDRESSES = frozenset(
"module.environment_owned.aws_cloudfront_distribution.site", "module.environment_owned.aws_cloudfront_distribution.site",
"module.environment_owned.aws_cloudfront_function.spa_rewrite", "module.environment_owned.aws_cloudfront_function.spa_rewrite",
"module.environment_owned.aws_iam_role.github_deploy", "module.environment_owned.aws_iam_role.github_deploy",
"module.environment_owned.aws_iam_role_policy.github_deploy",
} }
) )
ALLOWED_CREATE_ADDRESSES = frozenset(
{
"module.environment_owned.aws_ssm_parameter.deploy_bucket",
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id",
}
)
GITHUB_REPO = "Sea-Haven-Industries/shoc-frontend-new"
GITHUB_OIDC_PROVIDER_ARN = (
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
)
ENVIRONMENT_CONFIG = { ENVIRONMENT_CONFIG = {
"dev": { "dev": {
"bucket_name": "seahaven-shoc-frontend-dev", "bucket_name": "seahaven-shoc-frontend-dev",
@ -103,6 +119,8 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"githubdeploy-shoc-frontend-new-dev:" "githubdeploy-shoc-frontend-new-dev:"
"GithubDeployRoleDefaultPolicyE8F540D1" "GithubDeployRoleDefaultPolicyE8F540D1"
), ),
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
}, },
"staging": { "staging": {
**_bucket_imports("seahaven-shoc-frontend-staging"), **_bucket_imports("seahaven-shoc-frontend-staging"),
@ -126,5 +144,7 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"githubdeploy-shoc-frontend-new-staging:" "githubdeploy-shoc-frontend-new-staging:"
"GithubDeployRoleDefaultPolicyE8F540D1" "GithubDeployRoleDefaultPolicyE8F540D1"
), ),
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
}, },
} }

View file

@ -14,8 +14,11 @@ from pathlib import Path
from typing import Any from typing import Any
from terraform_import_plan_resources import ( from terraform_import_plan_resources import (
ALLOWED_CREATE_ADDRESSES,
CONTROLLED_UPDATE_ADDRESSES, CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG, ENVIRONMENT_CONFIG,
GITHUB_OIDC_PROVIDER_ARN,
GITHUB_REPO,
REQUIRED_IMPORT_IDS, REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES, REQUIRED_RESOURCES,
) )
@ -27,7 +30,7 @@ BUCKET = "module.environment_owned.aws_s3_bucket.site"
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy" DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
ROLE = "module.environment_owned.aws_iam_role.github_deploy" ROLE = "module.environment_owned.aws_iam_role.github_deploy"
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site" DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY} TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
def import_id(environment: str, address: str) -> str: def import_id(environment: str, address: str) -> str:
@ -83,6 +86,40 @@ def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
} }
def github_deploy_assume_policy(environment: str) -> dict[str, Any]:
return {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GithubDeployOidc",
"Effect": "Allow",
"Action": "sts:AssumeRoleWithWebIdentity",
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": (
f"repo:{GITHUB_REPO}:environment:{environment}"
),
},
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": [
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/heads/main"
),
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/tags/v*"
),
],
},
},
}
],
}
def bucket_policy(environment: str) -> dict[str, Any]: def bucket_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}" bucket_arn = f"arn:aws:s3:::{bucket}"
@ -146,6 +183,19 @@ def tag_change(environment: str, address: str) -> dict[str, Any]:
def policy_change(environment: str, address: str) -> dict[str, Any]: def policy_change(environment: str, address: str) -> dict[str, Any]:
if address == DEPLOY_POLICY:
return {
"actions": ["update"],
"before": {"policy": json.dumps({"Version": "2012-10-17", "Statement": []})},
"after": {
"policy": json.dumps(
{
"Version": "2012-10-17",
"Statement": [{"Sid": "ListWebBucket", "Effect": "Allow"}],
}
)
},
}
if address != BUCKET_POLICY: if address != BUCKET_POLICY:
raise AssertionError(f"{address} is not a reviewed policy update") raise AssertionError(f"{address} is not a reviewed policy update")
return { return {
@ -165,10 +215,14 @@ def make_plan(
updates = controlled_updates or set() updates = controlled_updates or set()
for address, resource_type in REQUIRED_RESOURCES[environment].items(): for address, resource_type in REQUIRED_RESOURCES[environment].items():
if mode == "import": if mode == "import":
change: dict[str, Any] = { import_id_value = REQUIRED_IMPORT_IDS[environment][address]
"actions": ["no-op"], if import_id_value is None:
"importing": {"id": import_id(environment, address)}, change = {"actions": ["create"]}
} else:
change = {
"actions": ["no-op"],
"importing": {"id": import_id_value},
}
elif mode == "post-import": elif mode == "post-import":
change = {"actions": ["no-op"]} change = {"actions": ["no-op"]}
elif address in updates: elif address in updates:
@ -177,6 +231,8 @@ def make_plan(
if address in TAG_ADDRESSES if address in TAG_ADDRESSES
else policy_change(environment, address) else policy_change(environment, address)
) )
elif address in ALLOWED_CREATE_ADDRESSES:
change = {"actions": ["create"]}
else: else:
change = {"actions": ["no-op"]} change = {"actions": ["no-op"]}
if address == DISTRIBUTION: if address == DISTRIBUTION:
@ -259,8 +315,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None: def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
source = ( source = (
REPOSITORY REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
/ "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
expected = """ spa_rewrite_code = join("\\n", [ expected = """ spa_rewrite_code = join("\\n", [
"function handler(event) {", "function handler(event) {",
@ -275,55 +330,41 @@ class ImportPlanCheckerTests(unittest.TestCase):
])""" ])"""
self.assertIn(expected, source) self.assertIn(expected, source)
def test_only_dev_has_a_live_root(self) -> None: def test_live_roots_are_not_flattened(self) -> None:
live_roots = sorted( live = REPOSITORY / "terraform" / "live"
path.name self.assertTrue((live / "dev" / "versions.tf").is_file())
for path in (REPOSITORY / "terraform/live").iterdir() self.assertTrue((live / "dev" / "main.tf").is_file())
if path.is_dir() and path.name != "modules" self.assertTrue((live / "staging" / "versions.tf").is_file())
) self.assertTrue((live / "staging" / "main.tf").is_file())
self.assertEqual(["dev"], live_roots) self.assertTrue((live / "modules" / "environment-owned" / "main.tf").is_file())
self.assertFalse((REPOSITORY / "terraform" / "versions.tf").exists())
self.assertFalse((REPOSITORY / "terraform" / "main.tf").exists())
def test_dev_root_pins_adoption_complete_in_code(self) -> None: def test_adoption_complete_is_pinned_in_locals(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8") dev = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n") staging = (REPOSITORY / "terraform/live/staging/main.tf").read_text(
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete") encoding="utf-8"
self.assertNotIn('variable "adoption_complete"', source) )
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"): self.assertRegex(dev, r"adoption_complete\s+= true")
self.assertNotIn( self.assertRegex(staging, r"adoption_complete\s+= true")
"variable ", self.assertNotIn('variable "adoption_complete"', dev)
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"), self.assertNotIn('variable "environment"', dev)
root_file, self.assertNotIn('variable "release_version_label"', dev)
)
def test_managed_modules_use_direct_pinned_inputs(self) -> None: def test_managed_modules_use_direct_pinned_inputs(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
expected = { expected = {
"dev": ( "hosted_zone_id": "local.hosted_zone_id",
"local.hosted_zone_id", "certificate_arn": "local.certificate_arn",
"local.certificate_arn", "github_oidc_provider_arn": "local.github_oidc_arn",
"local.github_oidc_arn", "cache_policy_id": "local.cache_policy_id",
"local.cache_policy_id",
),
} }
for environment, values in expected.items(): for name, value in expected.items():
source = ( self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
REPOSITORY / f"terraform/live/{environment}/main.tf" self.assertNotRegex(
).read_text(encoding="utf-8") source,
for name, value in zip( r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
( )
"hosted_zone_id",
"certificate_arn",
"github_oidc_provider_arn",
"cache_policy_id",
),
values,
strict=True,
):
self.assertIn(f"{name}", source)
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
self.assertNotRegex(
source,
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
)
def test_exact_import_plan_passes_for_every_environment(self) -> None: def test_exact_import_plan_passes_for_every_environment(self) -> None:
for environment in REQUIRED_RESOURCES: for environment in REQUIRED_RESOURCES:
@ -409,7 +450,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None: def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}" resource(plan, ROLE)["change"]["after"]["max_session_duration"] = 7200
self.assert_fails(plan, "dev", ROLE) self.assert_fails(plan, "dev", ROLE)
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker" resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
@ -420,12 +461,34 @@ class ImportPlanCheckerTests(unittest.TestCase):
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"] del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
self.assert_fails(plan, "dev", BUCKET) self.assert_fails(plan, "dev", BUCKET)
def test_role_trust_change_is_rejected(self) -> None: def test_role_trust_change_is_allowed(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) for environment in REQUIRED_RESOURCES:
role = resource(plan, ROLE)["change"] plan = make_plan(
role["before"]["assume_role_policy"] = '{"Statement":[]}' environment, mode="controlled", controlled_updates={ROLE}
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}' )
self.assert_fails(plan, "dev", ROLE) role = resource(plan, ROLE)["change"]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = json.dumps(
github_deploy_assume_policy(environment)
)
with self.subTest(environment=environment):
self.assert_passes(plan, environment, ROLE)
def test_role_trust_rejects_mutated_document(self) -> None:
for mutation in ("principal", "missing-sub"):
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
role = resource(plan, ROLE)["change"]
policy = github_deploy_assume_policy("dev")
if mutation == "principal":
policy["Statement"][0]["Principal"] = {"AWS": "*"}
else:
del policy["Statement"][0]["Condition"]["StringEquals"][
"token.actions.githubusercontent.com:sub"
]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = json.dumps(policy)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", ROLE)
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None: def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
for mutation in ("principal", "extra"): for mutation in ("principal", "extra"):
@ -452,40 +515,70 @@ class ImportPlanCheckerTests(unittest.TestCase):
with self.subTest(mutation=mutation): with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", BUCKET_POLICY) self.assert_fails(plan, "dev", BUCKET_POLICY)
def test_github_deploy_policy_is_release_prefix_only(self) -> None: def test_github_deploy_policy_is_bucket_root_sync(self) -> None:
source = ( source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf" REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1] document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
document = document.split("resource ", 1)[0] document = document.split("resource ", 1)[0]
self.assertNotIn("var.adoption_complete", document) self.assertNotIn("var.adoption_complete", document)
self.assertIn("ListReleasePrefixes", document) self.assertIn("ListWebBucket", document)
self.assertIn("PublishReleasePrefix", document) self.assertIn("SyncWebBucket", document)
self.assertIn("ReadReleasePointer", document) self.assertIn("InvalidateDistribution", document)
self.assertIn("ReadDistribution", document) self.assertIn("DeployParams", document)
self.assertIn("s3:DeleteObject", document)
self.assertIn("cloudfront:CreateInvalidation", document)
self.assertIn("cloudfront:GetInvalidation", document)
self.assertIn("cloudfront:GetDistribution", document) self.assertIn("cloudfront:GetDistribution", document)
self.assertIn("cloudfront:GetDistributionConfig", document) self.assertIn("ssm:GetParameter", document)
self.assertIn("releases/*", document) self.assertNotIn("ListReleasePrefixes", document)
self.assertNotIn("PublishReleasePrefix", document)
self.assertNotIn("ReadReleasePointer", document)
self.assertNotIn("releases/*", document)
self.assertNotIn("AssumeCdkBootstrapRoles", document) self.assertNotIn("AssumeCdkBootstrapRoles", document)
self.assertNotIn("DescribeStack", document) self.assertNotIn("DescribeStack", document)
self.assertNotIn("CreateInvalidation", document) self.assertIn(
self.assertNotIn("ReadDeploymentBucket", document)
self.assertNotIn("PublishAndRollbackSiteObjects", document)
self.assertNotIn(
"module.environment_owned.aws_iam_role_policy.github_deploy", "module.environment_owned.aws_iam_role_policy.github_deploy",
CONTROLLED_UPDATE_ADDRESSES, CONTROLLED_UPDATE_ADDRESSES,
) )
def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None: def test_github_deploy_assume_document_matches_module(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates=set()) source = (
self.assert_fails(plan, "dev", DEPLOY_POLICY) REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
document = source.split(
'data "aws_iam_policy_document" "github_deploy_assume" {', 1
)[1]
document = document.split(
'data "aws_iam_policy_document" "github_deploy" {', 1
)[0]
self.assertIn("GithubDeployOidc", document)
self.assertIn("sts:AssumeRoleWithWebIdentity", document)
self.assertIn("token.actions.githubusercontent.com:aud", document)
self.assertIn("sts.amazonaws.com", document)
self.assertIn("token.actions.githubusercontent.com:sub", document)
self.assertIn("local.github_subject", document)
self.assertIn("token.actions.githubusercontent.com:job_workflow_ref", document)
self.assertIn("deploy-web.yaml@refs/heads/main", document)
self.assertIn("deploy-web.yaml@refs/tags/v*", document)
def test_deploy_policy_controlled_update_passes(self) -> None:
self.assert_passes(
make_plan(
"dev",
mode="controlled",
controlled_updates={DEPLOY_POLICY},
),
"dev",
DEPLOY_POLICY,
)
plan = make_plan("dev", mode="controlled", controlled_updates=set()) plan = make_plan("dev", mode="controlled", controlled_updates=set())
resource(plan, DEPLOY_POLICY)["change"] = { resource(plan, DEPLOY_POLICY)["change"] = {
"actions": ["update"], "actions": ["update"],
"before": {"policy": "{}"}, "before": {"policy": "{}"},
"after": {"policy": '{"Version":"2012-10-17"}'}, "after": {"policy": '{"Version":"2012-10-17"}'},
} }
self.assert_fails(plan, "dev", DEPLOY_POLICY) self.assert_fails(plan, "dev")
def test_policy_updates_require_exact_pre_adoption_state(self) -> None: def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
for environment in REQUIRED_RESOURCES: for environment in REQUIRED_RESOURCES:

View file

@ -4,12 +4,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh" VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh"
CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111" NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111"
OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000"
# Vite writes index.html with a trailing newline. Keep it in the fixture so
# the expected hash covers every served byte, exactly like dist/index.html.
INDEX_HTML=$'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>\n' INDEX_HTML=$'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>\n'
INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
@ -31,12 +26,7 @@ make_stubs() {
cat > "${bin}/aws" << 'AWS' cat > "${bin}/aws" << 'AWS'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
state_dir="${STUB_STATE}" cat "${STUB_STATE}/distribution.json"
if [[ "${1:-}" == "s3" ]]; then
cat "${state_dir}/pointer.json"
exit 0
fi
cat "${state_dir}/distribution.json"
AWS AWS
cat > "${bin}/curl" << 'CURL' cat > "${bin}/curl" << 'CURL'
#!/usr/bin/env bash #!/usr/bin/env bash
@ -77,7 +67,6 @@ if [[ "${url}" == *"/assets/"* ]]; then
[[ -z "${output}" ]] && printf '%s' "${body}" [[ -z "${output}" ]] && printf '%s' "${body}"
exit 0 exit 0
fi fi
# Serve index.html byte-for-byte, trailing newline included, like real curl.
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}" [[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
if [[ -n "${output}" ]]; then if [[ -n "${output}" ]]; then
cat "${state_dir}/index.html" > "${output}" cat "${state_dir}/index.html" > "${output}"
@ -98,149 +87,94 @@ print(json.dumps({
"Status": status, "Status": status,
"DistributionConfig": { "DistributionConfig": {
"Origins": {"Items": [ "Origins": {"Items": [
{"Id": "current", "OriginPath": path}, {"Id": "current", "OriginPath": path}
{"Id": "previous", "OriginPath": ""},
]} ]}
} }
} }
}))' "${status}" "${current_path}" }))' "${status}" "${current_path}"
} }
pointer_json() { # 1. Empty origin, then propagates (InProgress -> Deployed, hash already matches).
python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2"
}
run_case() {
local name="$1"
local dir
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}"
export PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com"
export SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=3
export INTERVAL=0
local log="${dir}/log.txt"
set +e
bash "${VERIFY}" > "${log}" 2>&1
local code=$?
set -e
assert_exit "${name}" "$2" "${code}" "${log}"
rm -rf "${dir}"
}
# 1. Right config, then propagates (InProgress -> Deployed, hash already matches).
{ {
dir="$(mktemp -d)" dir="$(mktemp -d)"
make_stubs "${dir}/bin" make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html" printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
printf 'InProgress\n' > "${dir}/status" printf 'InProgress\n' > "${dir}/status"
cat > "${dir}/bin/aws" << AWS cat > "${dir}/bin/aws" << AWS
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
if [[ "\${1:-}" == "s3" ]]; then
cat "${dir}/pointer.json"
exit 0
fi
status="\$(cat "${dir}/status")" status="\$(cat "${dir}/status")"
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}" python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":""}]}}}}))' "\${status}"
echo Deployed > "${dir}/status" echo Deployed > "${dir}/status"
AWS AWS
chmod +x "${dir}/bin/aws" chmod +x "${dir}/bin/aws"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=5 INTERVAL=0 export BUDGET=5 INTERVAL=0
set +e set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1 bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$? code=$?
set -e set -e
assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt" assert_exit "empty-origin-then-propagates" 0 "${code}" "${dir}/log.txt"
rm -rf "${dir}" rm -rf "${dir}"
} }
# 2. Right config never propagates (Deployed, stale hash). # 2. Empty origin never propagates (Deployed, stale hash).
{ {
dir="$(mktemp -d)" dir="$(mktemp -d)"
make_stubs "${dir}/bin" make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" dist_json "Deployed" "" > "${dir}/distribution.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf 'stale' > "${dir}/index.html" printf 'stale' > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0 export BUDGET=2 INTERVAL=0
set +e set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1 bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$? code=$?
set -e set -e
assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt" assert_exit "empty-origin-never-propagates" 1 "${code}" "${dir}/log.txt"
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); } grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); }
rm -rf "${dir}" rm -rf "${dir}"
} }
# 3. Wrong origin path fails fast. # 3. Non-empty origin path fails fast.
{ {
dir="$(mktemp -d)" dir="$(mktemp -d)"
make_stubs "${dir}/bin" make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" dist_json "Deployed" "/releases/deadbeef" > "${dir}/distribution.json"
dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html" printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0 export BUDGET=2 INTERVAL=0
set +e set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1 bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$? code=$?
set -e set -e
assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt" assert_exit "nonempty-origin-path" 1 "${code}" "${dir}/log.txt"
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); } grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: nonempty origin path did not name origin_path" >&2; failures=$((failures + 1)); }
rm -rf "${dir}" rm -rf "${dir}"
} }
# 4. Wrong pointer fails fast. # 4. Never Deployed.
{ {
dir="$(mktemp -d)" dir="$(mktemp -d)"
make_stubs "${dir}/bin" make_stubs "${dir}/bin"
pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json" dist_json "InProgress" "" > "${dir}/distribution.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html" printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" export SITE_URL="https://dev.seahaven.com"
export BUDGET=2 INTERVAL=0 export API_URL="https://api.dev.seahaven.com/api"
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt"
grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 5. Never Deployed.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0 export BUDGET=2 INTERVAL=0
set +e set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1 bash "${VERIFY}" > "${dir}/log.txt" 2>&1
@ -251,40 +185,40 @@ AWS
rm -rf "${dir}" rm -rf "${dir}"
} }
# 6. Hash-matched Deployed release whose JS assets omit the baked API URL. # 5. Hash-matched Deployed release whose JS assets omit the baked API URL.
{ {
dir="$(mktemp -d)" dir="$(mktemp -d)"
make_stubs "${dir}/bin" make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" dist_json "Deployed" "" > "${dir}/distribution.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html" printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const x=1;' > "${dir}/asset.js" printf 'const x=1;' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0 export BUDGET=2 INTERVAL=0
set +e set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1 bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$? code=$?
set -e set -e
assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt" assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt"
grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); } grep -q "baked API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked API URL" >&2; failures=$((failures + 1)); }
rm -rf "${dir}" rm -rf "${dir}"
} }
# 7. Hash-matched Deployed release whose JS assets contain the staging API URL. # 6. Hash-matched Deployed release whose JS assets contain the staging API URL.
{ {
dir="$(mktemp -d)" dir="$(mktemp -d)"
make_stubs "${dir}/bin" make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" dist_json "Deployed" "" > "${dir}/distribution.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html" printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js" printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0 export BUDGET=2 INTERVAL=0
set +e set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1 bash "${VERIFY}" > "${dir}/log.txt" 2>&1

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Tests for check_app_terraform_isolation.isolation_violation."""
from __future__ import annotations
import unittest
from check_app_terraform_isolation import isolation_violation
class IsolationTests(unittest.TestCase):
def test_terraform_only(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/live/dev/main.tf",
"terraform/live/README.md",
]
)
)
def test_app_only(self) -> None:
self.assertIsNone(
isolation_violation(
[
"src/app/routes.tsx",
"public/favicon.ico",
"index.html",
]
)
)
def test_docs_workflows_and_gate_scripts_with_terraform(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/live/modules/environment-owned/main.tf",
".github/workflows/deploy-web.yaml",
"QUALITY_GATES.md",
"scripts/governance-check.mjs",
"scripts/check_app_terraform_isolation.py",
"package.json",
]
)
)
def test_mixed_src_and_terraform_fails(self) -> None:
violation = isolation_violation(
[
"terraform/live/dev/main.tf",
"src/app/routes.tsx",
]
)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
self.assertEqual(app_files, ["src/app/routes.tsx"])
def test_mixed_vite_config_and_terraform_fails(self) -> None:
violation = isolation_violation(["terraform/live/dev/versions.tf", "vite.config.ts"])
self.assertIsNotNone(violation)
def test_mixed_env_and_terraform_fails(self) -> None:
violation = isolation_violation(["terraform/live/dev/main.tf", ".env.production"])
self.assertIsNotNone(violation)
if __name__ == "__main__":
unittest.main()

View file

@ -14,6 +14,11 @@ fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')" COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}" 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 \ npm exec --no -- sentry-cli sourcemaps upload \
--org "${SENTRY_ORG}" \ --org "${SENTRY_ORG}" \
--project "${SENTRY_PROJECT}" \ --project "${SENTRY_PROJECT}" \

View file

@ -1,87 +1,62 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Verify a CloudFront content release or rollback. # Verify a CloudFront SPA deploy from deploy-web.yaml.
# #
# Fail fast when origin_path or .release/current is the wrong label. # Fail fast when any origin_path is still non-empty. Poll while the
# Poll while the distribution is InProgress or the served index.html hash # distribution is InProgress or the served index.html hash does not match
# still matches the previous release. On timeout, print last observed state. # the build. Then smoke-check caching headers, hashed assets, the baked
# API URL, and CORS against the target API.
set -euo pipefail set -euo pipefail
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}" DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
EXPECTED_LABEL="${EXPECTED_LABEL:-}"
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}" EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
SITE_URL="${SITE_URL:-}" SITE_URL="${SITE_URL:-}"
SITE_BUCKET="${SITE_BUCKET:-}"
PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}"
API_URL="${API_URL:-https://api.dev.seahaven.com/api}" API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
BUDGET="${BUDGET:-40}" BUDGET="${BUDGET:-40}"
INTERVAL="${INTERVAL:-15}" INTERVAL="${INTERVAL:-15}"
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" ]]; then
echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2 echo "Usage: DISTRIBUTION_ID EXPECTED_INDEX_SHA256 SITE_URL must be set." >&2
exit 2 exit 2
fi fi
SITE_URL="${SITE_URL%/}" SITE_URL="${SITE_URL%/}"
if [[ -n "${EXPECTED_LABEL}" ]]; then API_URL="${API_URL%/}"
EXPECTED_PATH="/releases/${EXPECTED_LABEL}"
else
EXPECTED_PATH=""
fi
sha256_of() { sha256_of() {
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
} }
read_pointer() {
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true
}
read_distribution_json() { read_distribution_json() {
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
} }
parse_distribution() { parse_distribution() {
python3 -c ' python3 -c '
import json, os, sys import json, sys
payload = json.load(sys.stdin) payload = json.load(sys.stdin)
dist = payload.get("Distribution") or payload dist = payload.get("Distribution") or payload
status = dist.get("Status") or "Unknown" status = dist.get("Status") or "Unknown"
config = dist.get("DistributionConfig") or {} config = dist.get("DistributionConfig") or {}
origins = ((config.get("Origins") or {}).get("Items")) or [] origins = ((config.get("Origins") or {}).get("Items")) or []
paths = [origin.get("OriginPath") or "" for origin in origins] paths = [origin.get("OriginPath") or "" for origin in origins]
expected = os.environ["EXPECTED_PATH"] nonempty = [path for path in paths if path]
print(status) print(status)
print("\x1f".join(paths)) print("\x1f".join(paths))
print("yes" if expected in paths else "no") print("yes" if nonempty else "no")
'
}
pointer_current() {
POINTER_BODY="$1" python3 -c '
import json, os
raw = os.environ.get("POINTER_BODY", "").strip()
if not raw:
print("")
raise SystemExit
print(json.loads(raw).get("current") or "")
' '
} }
last_status="Unknown" last_status="Unknown"
last_paths="Unknown" last_paths="Unknown"
last_pointer="Unknown"
last_hash="Unknown" last_hash="Unknown"
last_path_ok="no" last_path_nonempty="no"
observe() { observe() {
last_pointer="$(read_pointer)"
local parsed local parsed
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)" parsed="$(read_distribution_json | parse_distribution)"
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')" last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')" last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')" last_path_nonempty="$(printf '%s\n' "${parsed}" | sed -n '3p')"
# Hash the response stream directly. Capturing the body in "$(...)" strips
# trailing newlines, so the hash never matched dist/index.html.
local hash local hash
if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then
last_hash="${hash}" last_hash="${hash}"
@ -91,32 +66,25 @@ observe() {
} }
report_state() { report_state() {
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}" echo "last observed: status=${last_status} origins=${last_paths} served_sha256=${last_hash}"
} }
fail_fast_if_misconfigured() { fail_fast_if_origin_path() {
local current if [[ "${last_path_nonempty}" == "yes" ]]; then
current="$(pointer_current "${last_pointer}")" echo "FAIL: live origin_path values are '${last_paths}'; expected empty bucket-root origins." >&2
if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then
echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2
report_state >&2
exit 1
fi
if [[ "${last_path_ok}" != "yes" ]]; then
echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2
report_state >&2 report_state >&2
exit 1 exit 1
fi fi
} }
observe observe
fail_fast_if_misconfigured fail_fast_if_origin_path
attempt=0 attempt=0
while [[ "${attempt}" -lt "${BUDGET}" ]]; do while [[ "${attempt}" -lt "${BUDGET}" ]]; do
attempt=$((attempt + 1)) attempt=$((attempt + 1))
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}" echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
fail_fast_if_misconfigured fail_fast_if_origin_path
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
break break
fi fi
@ -142,6 +110,10 @@ for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
' "$1" ' "$1"
} }
api_host() {
python3 -c 'import os,urllib.parse; print(urllib.parse.urlparse(os.environ["API_URL"]).hostname or "")'
}
assert_baked_api_url() { assert_baked_api_url() {
local tmp="$1" local tmp="$1"
if [[ ! -s "${tmp}/asset-paths.txt" ]]; then if [[ ! -s "${tmp}/asset-paths.txt" ]]; then
@ -168,15 +140,27 @@ assert_baked_api_url() {
exit 1 exit 1
fi fi
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt" cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
local forbidden local host
for forbidden in api.staging.seahaven.com localhost:5141; do host="$(api_host)"
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then local forbidden=""
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2 case "${host}" in
api.dev.seahaven.com) forbidden="api.staging.seahaven.com" ;;
api.staging.seahaven.com) forbidden="api.dev.seahaven.com" ;;
*)
echo "FAIL: API_URL host '${host}' is not a known SHOC API." >&2
exit 1 exit 1
fi ;;
done esac
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
echo "FAIL: served JS assets are missing the baked dev API URL." >&2 echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
exit 1
fi
if grep -Fq "localhost:5141" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL localhost:5141." >&2
exit 1
fi
if ! grep -Fq "${host}" "${tmp}/served.txt"; then
echo "FAIL: served JS assets are missing the baked API URL ${host}." >&2
exit 1 exit 1
fi fi
} }
@ -206,5 +190,5 @@ if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
exit 1 exit 1
fi fi
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean." echo "PASS: CloudFront release is Deployed, hash-matched, and smoke-clean."
report_state report_state

View file

@ -1,352 +1,74 @@
# Frontend Terraform adoption runbook (dev) # Frontend Terraform (SPA CD)
This tree adopts the existing Sea Haven SHOC frontend dev hosting resources `terraform/live/dev` and `terraform/live/staging` in AWS account `396287094661`.
into HCP Terraform without recreating them. It mirrors the backend adoption HCP Terraform owns the bucket, CloudFront, DNS, and the GitHub deploy role.
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs: GitHub Actions owns content: `.github/workflows/deploy-web.yaml` syncs `dist/`
to the bucket root and invalidates `/*`.
| PR | Branch | Change | Creating, formatting, initializing with `-backend=false`, and validating these
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | files does not authorize an AWS, HCP Terraform, GitHub, or deployment
| A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. | mutation.
| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, rollback. |
Creating these files, formatting them, initializing with `-backend=false`, and Do not collapse these roots into one `terraform/` tree. Flattening retargets
validating them does not authorize an AWS, HCP Terraform, GitHub, two live HCP working directories and is its own change.
CloudFormation, DNS, or deployment mutation. Every live step below is gated on
an explicit go from the owner, with the production impact stated first.
Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked
separately (SH-287) and adds its own root under `live/staging` when it starts.
The `staging` constants in `scripts/terraform_import_plan_resources.py` exist
only so the checker can prove a dev plan carrying a staging identifier fails.
## Fixed targets ## Fixed targets
- AWS account: `396287094661` | | dev | staging |
- AWS region: `us-east-1` | ------------- | ------------------------------------ | ---------------------------------------- |
- HCP organization: `seahaven` | Site | `dev.seahaven.com` | `staging.seahaven.com` |
- HCP project: `seahaven-external-dev` | Bucket | `seahaven-shoc-frontend-dev` | `seahaven-shoc-frontend-staging` |
- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working | Distribution | `E2CWLM1AFB964P` | `E2JDVEZ6EGD49J` |
directory `terraform/live/dev` | Deploy role | `githubdeploy-shoc-frontend-new-dev` | `githubdeploy-shoc-frontend-new-staging` |
- Site: `dev.seahaven.com` | HCP workspace | `shoc-frontend-new-dev` | `shoc-frontend-new-staging` |
- API build value: `https://api.dev.seahaven.com/api` | Working dir | `terraform/live/dev` | `terraform/live/staging` |
## Workspace invariants There is no prod CloudFront in this round. Do not create
`shoc-frontend-new-prod`.
Set before any Terraform lands on `dev`, read back after setting, and re-read ## Ownership
before the first release after any Terraform merge:
- Auto-apply **off**. GitHub or a human applies every run. `module.environment_owned` keeps the same addresses as the adopted HCP
- Automatic speculative plans **on** (PR plans are read-only evidence). `shoc-frontend-new-dev` state. The SPA origin path is empty. The release
- Automatic run triggering: **patterns** pointer is forgotten (`removed { destroy = false }`), not destroyed.
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
prefixes, no tags regex. Do not switch to tag-based triggering.
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
`>= 1.14.0, < 2.0.0`; CI validates with `1.16.0`).
- Dynamic AWS credentials only: environment variables
`TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and
`TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan`
and `hcptf-shoc-frontend-new-dev`. No access keys.
- **No** `adoption_complete` workspace variable. The dev root pins it in code
(`local.adoption_complete`) so the value under review is the value that
applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable`
block reappears in the root.
## Ownership boundary Deploy parameters live under `/shoc-frontend-new/<env>/deploy/{bucket,distribution-id}`.
`githubdeploy` may List/Get/Put/Delete the bucket root, CreateInvalidation, and
GetParameter on those two names. OIDC trust is `environment:<env>` plus
`job_workflow_ref` for `.github/workflows/deploy-web.yaml` at `refs/heads/main`
and `refs/tags/v*`.
`live/modules/environment-owned` owns these 14 addresses (13 imported hosting `adoption_complete` is pinned in each live root. It is not a workspace
resources plus the release pointer created in Phase 3): variable.
1. `module.environment_owned.aws_s3_bucket.site` ## Local checks (no apply)
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
3. `module.environment_owned.aws_s3_bucket_ownership_controls.site`
4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site`
5. `module.environment_owned.aws_s3_bucket_versioning.site`
6. `module.environment_owned.aws_s3_bucket_policy.site`
7. `module.environment_owned.aws_cloudfront_distribution.site`
8. `module.environment_owned.aws_cloudfront_origin_access_control.site`
9. `module.environment_owned.aws_cloudfront_function.spa_rewrite`
10. `module.environment_owned.aws_route53_record.site_a`
11. `module.environment_owned.aws_route53_record.site_aaaa`
12. `module.environment_owned.aws_iam_role.github_deploy`
13. `module.environment_owned.aws_iam_role_policy.github_deploy`
14. `module.environment_owned.aws_s3_object.release_pointer`
The CloudFront invalidation is a Terraform action
(`action.aws_cloudfront_create_invalidation.release`), not a managed resource.
Every managed resource has `prevent_destroy = true`.
`live/modules/environment-inventory` is data-only. It resolves and checks the
caller account, provider region, public hosted zone, ACM certificate, account
GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache
policy against pinned values, and fails the plan on any mismatch.
The following remain outside state:
- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate
- the account-global GitHub OIDC provider
- the AWS managed CloudFront cache policy
- `CDKToolkit` resources and CDK metadata
- the S3 auto-delete custom resource, its provider Lambda and role
- the HCP plan/apply roles and the deploy-role permissions boundary
## Exact live inventory (dev)
- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev`
- Distribution: `E2CWLM1AFB964P`
- OAC: `E30VSIK87N8H64`, name
`shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`,
description modeled as `""`
- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1`
- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8`
- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A`
- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA`
- Deploy role: `githubdeploy-shoc-frontend-new-dev`
- Inline policy import ID:
`githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1`
- Hosted zone: `Z07671212N75U4YLPWZR8`
- Certificate:
`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`
- Legacy stack: `shoc-frontend-dev`
- Auto-delete helper role:
`arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV`
- Permissions boundary:
`arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary`
With `adoption_complete = false` the root declares the configuration observed
after the CDK retain deploy (Phase 1, step 2), not the configuration live
today:
- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the
S3-only `aws-cdk:auto-delete-objects=true` tag
- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag
- the permissions boundary attached to the deploy role
- `StringEquals` on the OIDC subject
`repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev`
- the legacy bucket policy including the auto-delete helper grant
- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`,
bucket read/write, `InvalidateDistribution`)
The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing.
If read-back after that deploy differs from the root in any other way, update
the root to the observed value and prove a zero-change import plan. Do not
approve drift through the controlled-update checker.
## Phase 1: import-first adoption (merged)
Each step is gated. State the impact, get the go, act, read back, record.
1. **Workspace invariants.** Set the invariants above on
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
PR.
2. **CDK retain deploy.** Completed from the reviewed PR A head. The CDK app
is no longer in this repository.
Expected: an update-only change set (no create, no delete, no replace)
that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the
13 transferred resources and the `Custom::S3AutoDeleteObjects` resource,
attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows
the trust operator. Read back the role, bucket policy, and stack resources
as JSON and attach it to the PR.
3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply
off). Download the plan JSON and run the guard:
```bash
python3 scripts/check-terraform-import-plan.py plan.json --environment dev
```
Confirm the apply only when the plan is exactly 13 imports, 0 create,
0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the
run and fix the root in a new PR.
4. **Post-import no-op.** Queue a plan and require it to be no-op:
```bash
python3 scripts/check-terraform-import-plan.py post-import.json \
--environment dev --post-import-no-op
```
Post the run URLs and the guard output on SH-300.
After Phase 1 CloudFormation still owns every resource. Terraform holds state
for them and nothing else.
## Phase 2: controlled ownership transfer (merged #178)
PR B pins `adoption_complete = true`. The controlled apply may update only:
- `module.environment_owned.aws_s3_bucket.site` (tags)
- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the
auto-delete helper grant)
- `module.environment_owned.aws_cloudfront_distribution.site` (tags)
- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags)
- `module.environment_owned.aws_iam_role.github_deploy` (tags)
The OAC, both Route 53 records, and the deploy inline policy must be no-op.
PR B keeps the GitHub deploy inline policy byte-identical to live so
`aws_iam_role_policy.github_deploy` does not appear in the plan. Run the
checker with one `--allow-update-address` per updating address; it rejects
unused allowlist entries, unknown values, and replacements:
```bash ```bash
python3 scripts/check-terraform-import-plan.py plan.json --environment dev \ terraform fmt -check -recursive terraform
--allow-update-address module.environment_owned.aws_s3_bucket.site \ terraform -chdir=terraform/live/dev init -backend=false -lockfile=readonly
--allow-update-address module.environment_owned.aws_s3_bucket_policy.site \ terraform -chdir=terraform/live/dev validate
--allow-update-address module.environment_owned.aws_cloudfront_distribution.site \ terraform -chdir=terraform/live/staging init -backend=false -lockfile=readonly
--allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \ terraform -chdir=terraform/live/staging validate
--allow-update-address module.environment_owned.aws_iam_role.github_deploy python3 scripts/test-terraform-import-plan-check.py
python3 scripts/test_check_app_terraform_isolation.py
bash scripts/test-verify-cloudfront-release.sh
``` ```
After the apply and a no-op plan, the CDK stack was relinquished with PRs cannot mix `terraform/` with deployable application files. Workflow, docs,
`ManageSiteInfrastructure=false`. Never deploy that stack with and gate-script changes may travel with either side. G13 is
`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C. `python3 scripts/check_app_terraform_isolation.py` against the PR base.
Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual `npm run test:terraform` and `npm run verify` wrap the same gates. They never
`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged create an HCP run or touch AWS.
GitHub content policy. PR C replaces that policy with the release-prefix
document during bootstrap.
## Phase 3: content CD through Terraform (this PR) ## Workspaces
GitHub builds the SPA and uploads only `releases/<sha>-<run>-<attempt>/`. Dev (`shoc-frontend-new-dev`) watches `main` with working directory
The GitHub role may `GetObject` on `.release/current` and read the exact `terraform/live/dev` and auto-apply on. Staging (`shoc-frontend-new-staging`)
distribution (`GetDistribution` / `GetDistributionConfig`) so verify and watches tag regex `^v[0-9]+\.[0-9]+\.[0-9]+-staging$` with working directory
live-state summary can observe origin paths. It cannot invalidate or write `terraform/live/staging` and auto-apply on. Merges to `main` do not apply
the pointer. Terraform owns `.release/current`, both origin paths of the staging.
CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one
guarded Terraform run that swaps the labels. Push-to-`dev` stays off until
`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls
`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287).
Release vars `release_version_label` and `previous_release_version_label` are GitHub Environments `dev` and `staging` set `DEPLOY_ROLE_ARN` and allow `main`
nullable, default null, and must not be set on the workspace or in tfvars. plus tag `v*`. Promote staging with `gh release create vX.Y.Z-staging --target
Null VCS plans read the pointer back from S3. Empty string is the legacy root main`.
layout.
Per GitHub content release after bootstrap: exactly two managed updates plus
one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py`
accepts a plan that updates only the pointer `content` and
`origin[*].origin_path`, with `after` equal to the expected labels, `before`
equal to the pointer's prior values, and exactly one invalidation
`action_invocations` entry.
The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates
`.release/current` (legacy empty labels), adds the previous origin and origin
group, switches the default behavior to the group, replaces the GitHub inline
policy with the release-prefix document, and invokes invalidation. A human
confirms that apply. GitHub CD starts only after bootstrap is applied.
Activation (each step gated; do not run without an explicit go):
1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard
the HCP VCS run. Apply bootstrap as a human-confirmed controlled update.
2. Re-read workspace invariants (auto-apply off, speculative on, trigger
patterns only, no prefixes, no tags-regex).
3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation,
smoke, and rollback readiness from the live-state summary.
4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner
approval.
5. Confirm the first push-to-`dev` run. Close SH-300 on that proof.
A red job does not mean the site is down. Read the live-state summary first.
## Operational rules
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
deployable application code. The `terraform-isolation` job in
`.github/workflows/terraform-isolation.yaml` enforces this; documentation and the
`scripts/*terraform*` tooling are allowed alongside. A reviewer may add the
`terraform-isolation-override` label for the rare change that must introduce
Terraform variables together with the workflow that consumes them (PR C).
Adding or removing that label re-runs only that workflow against the labels
currently on the PR; Frontend checks does not start a new run. Removing the
label fails a mixed PR that had previously passed with the override, so a
stale green check cannot merge. Markdown under `terraform/` does not count as a Terraform
change for this gate; it does not match the workspace trigger patterns.
The label is the approval record. The override is temporary:
a follow-up PR after PR C removes the label path from the checker and
workflow so the gate has no exception.
- **Every Terraform merge produces a VCS run.** A human confirms or discards
it before the next content release. Do not leave a pending run on the
workspace.
- **Re-read the workspace invariants** before the first release after any
Terraform merge or workspace settings change.
- **A red job does not mean the site is down.** Read the live-state summary
first (served `index.html` hash, distribution status, pointer body, both
origin paths), then triage.
- **Exact-head evidence.** Every live step records the run URL, the SHA, and a
machine-readable read-back on the PR or SH-300.
## Local validation
From the repository root (also run by `npm run verify` through
`scripts/governance-check.mjs`):
```bash
npm run test:terraform # fmt -check, init -backend=false, validate
npm run test:terraform-import-plan # checker unit tests against synthetic plans
npm run test:terraform-release-plan # content-release plan guard
npm run test:terraform-isolation # isolation gate unit tests
npm run test:hcp-run-guard # workspace invariant and apply reconcile
npm run test:cloudfront-release-verify
npm run test:github-workflows # bash -n and actionlint
```
`terraform init -backend=false -lockfile=readonly` may download the provider
but never contacts HCP state or plans against AWS. Only HCP runs plan against
the account.
The lock file must carry `h1:` hashes for every platform that runs the gate
(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the
provider version, refresh them with:
```bash
terraform -chdir=terraform/live/dev providers lock \
-platform=linux_amd64 -platform=linux_arm64 \
-platform=darwin_amd64 -platform=darwin_arm64
```
## Import plan safety
Import mode requires exactly the canonical 13 addresses and AWS types, valid
import metadata for every resource, the exact dev import IDs (a staging ID in a
dev plan fails), and zero create, update, delete, or replace actions.
Post-import mode requires all 13 resources to be no-op and rejects any
remaining import metadata.
Controlled mode permits only in-place updates to the addresses explicitly
listed with `--allow-update-address`, verifies `before` against the exact
pre-adoption policies and tags and `after` against the exact adopted values,
and rejects create, delete, replace, import metadata, unknown values,
unapproved addresses, and unused allowlist entries.
## Rollback
- Before import apply: discard the run and correct the root.
- After import, before the controlled update (end of Phase 1): remove only the
13 imported addresses from state under a separately reviewed state
operation. CloudFormation remains authoritative; a
`ManageSiteInfrastructure=true` stack is unchanged by this.
- After the controlled update, before detachment: either complete the reviewed
detachment or restore the exact pre-adoption policy and tags under a
separate approval. Do not remove state or redeploy CloudFormation blindly.
- After detachment: Terraform is authoritative. Restore content from the
versioned bucket. Re-establishing CloudFormation ownership requires a
reviewed `IMPORT` change set, never an ordinary update.
Any replacement, destroy, cross-environment ID, missing import, broad policy
change, or failed smoke check is a hard stop.
## Evidence per phase
- HCP run URL and the workspace settings read-back
- plan JSON and checker output
- `terraform state list` showing exactly the 13 addresses
- read-only inventory before and after each mutation
- synthesized CloudFormation template, change set, and stack events
- deploy, invalidation, and smoke output
- the post-action no-op plan
- phase close-out on SH-300: completed work, validation, risks, deviations,
remaining work

8
terraform/live/README.md Normal file
View file

@ -0,0 +1,8 @@
# Live Terraform roots
`live/dev/` is the adopted HCP workspace `shoc-frontend-new-dev`.
`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
own change.

View file

@ -2,20 +2,25 @@ locals {
# Controlled ownership transfer. Pinned in code, never a workspace variable. # Controlled ownership transfer. Pinned in code, never a workspace variable.
adoption_complete = true adoption_complete = true
environment = "dev" environment = "dev"
workspace_name = "shoc-frontend-new-dev" workspace_name = "shoc-frontend-new-dev"
aws_account_id = "396287094661" github_repo = "Sea-Haven-Industries/shoc-frontend-new"
aws_region = "us-east-1" aws_account_id = "396287094661"
bucket_name = "seahaven-shoc-frontend-dev" aws_region = "us-east-1"
distribution_id = "E2CWLM1AFB964P" bucket_name = "seahaven-shoc-frontend-dev"
oac_id = "E30VSIK87N8H64" distribution_id = "E2CWLM1AFB964P"
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620" oac_id = "E30VSIK87N8H64"
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1" oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8" origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
domain_name = "dev.seahaven.com" function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
hosted_zone_id = "Z07671212N75U4YLPWZR8" domain_name = "dev.seahaven.com"
certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00" hosted_zone_id = "Z07671212N75U4YLPWZR8"
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com" certificate_arn = (
"arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
)
github_oidc_arn = (
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
)
deploy_role_name = "githubdeploy-shoc-frontend-new-dev" deploy_role_name = "githubdeploy-shoc-frontend-new-dev"
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1" inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
stack_name = "shoc-frontend-dev" stack_name = "shoc-frontend-dev"
@ -61,35 +66,30 @@ module "inventory" {
module "environment_owned" { module "environment_owned" {
source = "../modules/environment-owned" source = "../modules/environment-owned"
environment = local.environment environment = local.environment
adoption_complete = local.adoption_complete adoption_complete = local.adoption_complete
aws_account_id = local.aws_account_id aws_account_id = local.aws_account_id
aws_region = local.aws_region aws_region = local.aws_region
bucket_name = local.bucket_name github_repo = local.github_repo
distribution_id = local.distribution_id bucket_name = local.bucket_name
origin_access_control_name = local.oac_name distribution_id = local.distribution_id
origin_access_control_description = "" origin_access_control_name = local.oac_name
origin_id = local.origin_id origin_access_control_description = ""
function_name = local.function_name origin_id = local.origin_id
domain_name = local.domain_name function_name = local.function_name
hosted_zone_id = local.hosted_zone_id domain_name = local.domain_name
certificate_arn = local.certificate_arn hosted_zone_id = local.hosted_zone_id
cache_policy_id = local.cache_policy_id certificate_arn = local.certificate_arn
github_oidc_provider_arn = local.github_oidc_arn cache_policy_id = local.cache_policy_id
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev" github_oidc_provider_arn = local.github_oidc_arn
pre_adoption_github_subject_operator = "StringEquals" deploy_role_name = local.deploy_role_name
post_adoption_github_subject_operator = "StringEquals" deploy_inline_policy_name = local.inline_policy
deploy_branch = "dev" deploy_permissions_boundary_arn = local.permissions_boundary_arn
deploy_role_name = local.deploy_role_name cloudformation_stack_name = local.stack_name
deploy_inline_policy_name = local.inline_policy bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
deploy_permissions_boundary_arn = local.permissions_boundary_arn pre_adoption_tags = local.legacy_tags
cloudformation_stack_name = local.stack_name pre_adoption_bucket_tags = local.legacy_bucket_tags
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn ownership_tags = local.terraform_tags
pre_adoption_tags = local.legacy_tags pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
pre_adoption_bucket_tags = local.legacy_bucket_tags post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
ownership_tags = local.terraform_tags
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
release_version_label = var.release_version_label
previous_release_version_label = var.previous_release_version_label
} }

View file

@ -1,19 +1,19 @@
output "bucket_name" { output "bucket_name" {
value = module.environment_owned.bucket_name value = module.environment_owned.bucket_name
description = "SPA origin bucket name."
} }
output "distribution_id" { output "distribution_id" {
value = module.environment_owned.distribution_id value = module.environment_owned.distribution_id
description = "CloudFront distribution ID."
} }
output "deploy_role_arn" { output "deploy_role_arn" {
value = module.environment_owned.deploy_role_arn value = module.environment_owned.deploy_role_arn
description = "GitHub Actions deploy role ARN."
} }
output "current_origin_id" { output "origin_id" {
value = module.environment_owned.current_origin_id value = module.environment_owned.origin_id
} description = "CloudFront origin ID for the bucket-root SPA."
output "previous_origin_id" {
value = module.environment_owned.previous_origin_id
} }

View file

@ -1,33 +0,0 @@
variable "release_version_label" {
type = string
default = null
nullable = true
description = "Immutable content release label. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.release_version_label == null ||
var.release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
)
error_message = "release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}
variable "previous_release_version_label" {
type = string
default = null
nullable = true
description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.previous_release_version_label == null ||
var.previous_release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label))
)
error_message = "previous_release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}

View file

@ -1,28 +1,11 @@
locals { locals {
bucket_arn = "arn:aws:s3:::${var.bucket_name}" bucket_arn = "arn:aws:s3:::${var.bucket_name}"
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}" distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
github_subject_operator = var.pre_adoption_github_subject_operator ssm_prefix = "/shoc-frontend-new/${var.environment}"
previous_origin_id = "${var.origin_id}-previous" github_subject = "repo:${var.github_repo}:environment:${var.environment}"
origin_group_id = "${var.origin_id}-group"
pointer_key = ".release/current"
pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {})
# coalesce() skips empty strings, so a null var plus a missing pointer
# would error. Empty string is the legacy root layout and must be valid.
current_label = (
var.release_version_label != null
? var.release_version_label
: try(local.pointer_body.current, "")
)
previous_label = (
var.previous_release_version_label != null
? var.previous_release_version_label
: try(local.pointer_body.previous, "")
)
current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}"
previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}"
spa_rewrite_code = join("\n", [ spa_rewrite_code = join("\n", [
"function handler(event) {", "function handler(event) {",
@ -37,17 +20,6 @@ locals {
]) ])
} }
data "aws_s3_objects" "release_prefix" {
bucket = aws_s3_bucket.site.bucket
prefix = ".release/"
}
data "aws_s3_object" "release_pointer" {
count = contains(coalesce(data.aws_s3_objects.release_prefix.keys, []), local.pointer_key) ? 1 : 0
bucket = aws_s3_bucket.site.bucket
key = local.pointer_key
}
data "aws_iam_policy_document" "site_bucket" { data "aws_iam_policy_document" "site_bucket" {
dynamic "statement" { dynamic "statement" {
for_each = var.adoption_complete ? [] : [1] for_each = var.adoption_complete ? [] : [1]
@ -115,6 +87,7 @@ data "aws_iam_policy_document" "site_bucket" {
data "aws_iam_policy_document" "github_deploy_assume" { data "aws_iam_policy_document" "github_deploy_assume" {
statement { statement {
sid = "GithubDeployOidc"
effect = "Allow" effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"] actions = ["sts:AssumeRoleWithWebIdentity"]
@ -130,59 +103,70 @@ data "aws_iam_policy_document" "github_deploy_assume" {
} }
condition { condition {
test = local.github_subject_operator test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub" variable = "token.actions.githubusercontent.com:sub"
values = [var.github_subject] values = [local.github_subject]
}
# StringLike: a release-triggered job loads the workflow file from the tag,
# so job_workflow_ref ends in @refs/tags/vX.Y.Z-staging there and
# @refs/heads/main on push and workflow_dispatch. The environment claim in
# sub is the gate.
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"${var.github_repo}/.github/workflows/deploy-web.yaml@refs/heads/main",
"${var.github_repo}/.github/workflows/deploy-web.yaml@refs/tags/v*",
]
} }
} }
} }
data "aws_iam_policy_document" "github_deploy" { data "aws_iam_policy_document" "github_deploy" {
statement { statement {
sid = "ListReleasePrefixes" sid = "ListWebBucket"
effect = "Allow" effect = "Allow"
actions = [ actions = [
"s3:GetBucketLocation", "s3:GetBucketLocation",
"s3:ListBucket", "s3:ListBucket",
] ]
resources = [local.bucket_arn] resources = [local.bucket_arn]
condition {
test = "StringLike"
variable = "s3:prefix"
values = [
"releases/",
"releases/*",
]
}
} }
statement { statement {
sid = "PublishReleasePrefix" sid = "SyncWebBucket"
effect = "Allow" effect = "Allow"
actions = [ actions = [
"s3:GetObject", "s3:GetObject",
"s3:PutObject", "s3:PutObject",
"s3:DeleteObject",
] ]
resources = ["${local.bucket_arn}/releases/*"] resources = ["${local.bucket_arn}/*"]
} }
statement { statement {
sid = "ReadReleasePointer" sid = "InvalidateDistribution"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${local.bucket_arn}/${local.pointer_key}"]
}
statement {
sid = "ReadDistribution"
effect = "Allow" effect = "Allow"
actions = [ actions = [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
"cloudfront:GetDistribution", "cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
] ]
resources = [local.distribution_arn] resources = [local.distribution_arn]
} }
statement {
sid = "DeployParams"
effect = "Allow"
actions = [
"ssm:GetParameter",
]
resources = [
aws_ssm_parameter.deploy_bucket.arn,
aws_ssm_parameter.deploy_distribution_id.arn,
]
}
} }
resource "aws_s3_bucket" "site" { resource "aws_s3_bucket" "site" {
@ -257,17 +241,13 @@ resource "aws_s3_bucket_policy" "site" {
} }
} }
resource "aws_s3_object" "release_pointer" { # Pointer leftover from Terraform-promoted content CD. Forgotten, not destroyed.
bucket = aws_s3_bucket.site.bucket # deploy-web.yaml syncs dist/ to the bucket root with --delete.
key = local.pointer_key removed {
content_type = "application/json" from = aws_s3_object.release_pointer
content = jsonencode({
current = local.current_label
previous = local.previous_label
})
lifecycle { lifecycle {
prevent_destroy = true destroy = false
} }
} }
@ -313,32 +293,7 @@ resource "aws_cloudfront_distribution" "site" {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.site.id origin_access_control_id = aws_cloudfront_origin_access_control.site.id
origin_id = var.origin_id origin_id = var.origin_id
origin_path = local.current_origin_path origin_path = ""
}
origin {
connection_attempts = 3
connection_timeout = 10
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
origin_id = local.previous_origin_id
origin_path = local.previous_origin_path
}
origin_group {
origin_id = local.origin_group_id
failover_criteria {
status_codes = [403, 404]
}
member {
origin_id = var.origin_id
}
member {
origin_id = local.previous_origin_id
}
} }
default_cache_behavior { default_cache_behavior {
@ -346,7 +301,7 @@ resource "aws_cloudfront_distribution" "site" {
cache_policy_id = var.cache_policy_id cache_policy_id = var.cache_policy_id
cached_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"]
compress = true compress = true
target_origin_id = local.origin_group_id target_origin_id = var.origin_id
viewer_protocol_policy = "redirect-to-https" viewer_protocol_policy = "redirect-to-https"
function_association { function_association {
@ -369,18 +324,6 @@ resource "aws_cloudfront_distribution" "site" {
lifecycle { lifecycle {
prevent_destroy = true prevent_destroy = true
action_trigger {
events = [after_update]
actions = [action.aws_cloudfront_create_invalidation.release]
}
}
}
action "aws_cloudfront_create_invalidation" "release" {
config {
distribution_id = aws_cloudfront_distribution.site.id
paths = ["/*"]
} }
} }
@ -419,7 +362,7 @@ resource "aws_route53_record" "site_aaaa" {
resource "aws_iam_role" "github_deploy" { resource "aws_iam_role" "github_deploy" {
name = var.deploy_role_name name = var.deploy_role_name
path = "/" path = "/"
description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}" description = "GitHub Actions SPA deploy role for ${var.github_repo} Environment ${var.environment}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600 max_session_duration = 3600
permissions_boundary = var.deploy_permissions_boundary_arn permissions_boundary = var.deploy_permissions_boundary_arn
@ -427,6 +370,9 @@ resource "aws_iam_role" "github_deploy" {
lifecycle { lifecycle {
prevent_destroy = true prevent_destroy = true
# SCP ProtectDeploymentPrincipalLifecycle denies UpdateRoleDescription on
# githubdeploy-* for HCP apply roles.
ignore_changes = [description]
} }
} }
@ -439,3 +385,23 @@ resource "aws_iam_role_policy" "github_deploy" {
prevent_destroy = true prevent_destroy = true
} }
} }
resource "aws_ssm_parameter" "deploy_bucket" {
name = "${local.ssm_prefix}/deploy/bucket"
type = "String"
data_type = "text"
tier = "Standard"
value = aws_s3_bucket.site.id
description = "SPA origin bucket; deploy-web syncs dist/ to the bucket root"
tags = local.resource_tags
}
resource "aws_ssm_parameter" "deploy_distribution_id" {
name = "${local.ssm_prefix}/deploy/distribution-id"
type = "String"
data_type = "text"
tier = "Standard"
value = aws_cloudfront_distribution.site.id
description = "CloudFront distribution ID; deploy-web invalidates /* after sync"
tags = local.resource_tags
}

View file

@ -13,32 +13,7 @@ output "deploy_role_arn" {
description = "Imported GitHub deployment role ARN." description = "Imported GitHub deployment role ARN."
} }
output "current_release_label" { output "origin_id" {
value = local.current_label
description = "Pointer current release label. Empty string is the legacy root layout."
}
output "previous_release_label" {
value = local.previous_label
description = "Pointer previous release label. Empty string is the legacy root layout."
}
output "current_origin_path" {
value = local.current_origin_path
description = "CloudFront origin_path for the current member of the origin group."
}
output "previous_origin_path" {
value = local.previous_origin_path
description = "CloudFront origin_path for the previous member of the origin group."
}
output "current_origin_id" {
value = var.origin_id value = var.origin_id
description = "CloudFront origin ID for the current release." description = "CloudFront origin ID for the bucket-root SPA."
}
output "previous_origin_id" {
value = local.previous_origin_id
description = "CloudFront origin ID for the previous release."
} }

View file

@ -14,40 +14,6 @@ variable "adoption_complete" {
default = false default = false
} }
variable "release_version_label" {
type = string
default = null
nullable = true
description = "Immutable content release label. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.release_version_label == null ||
var.release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
)
error_message = "release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}
variable "previous_release_version_label" {
type = string
default = null
nullable = true
description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.previous_release_version_label == null ||
var.previous_release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label))
)
error_message = "previous_release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}
variable "aws_account_id" { variable "aws_account_id" {
type = string type = string
description = "AWS account containing the resources." description = "AWS account containing the resources."
@ -58,6 +24,11 @@ variable "aws_region" {
description = "AWS region used by the environment." description = "AWS region used by the environment."
} }
variable "github_repo" {
type = string
description = "owner/name used in OIDC job_workflow_ref."
}
variable "bucket_name" { variable "bucket_name" {
type = string type = string
description = "Existing private S3 origin bucket." description = "Existing private S3 origin bucket."
@ -113,36 +84,6 @@ variable "github_oidc_provider_arn" {
description = "Inventory-verified GitHub OIDC provider ARN." description = "Inventory-verified GitHub OIDC provider ARN."
} }
variable "github_subject" {
type = string
description = "Exact GitHub OIDC subject in the existing role."
}
variable "pre_adoption_github_subject_operator" {
type = string
description = "Condition operator used by the role before adoption."
validation {
condition = contains(["StringEquals", "StringLike"], var.pre_adoption_github_subject_operator)
error_message = "pre_adoption_github_subject_operator must be StringEquals or StringLike."
}
}
variable "post_adoption_github_subject_operator" {
type = string
description = "Condition operator used by the role after adoption."
validation {
condition = contains(["StringEquals", "StringLike"], var.post_adoption_github_subject_operator)
error_message = "post_adoption_github_subject_operator must be StringEquals or StringLike."
}
}
variable "deploy_branch" {
type = string
description = "Branch or environment named in the existing role description."
}
variable "deploy_role_name" { variable "deploy_role_name" {
type = string type = string
description = "Existing GitHub deployment role name." description = "Existing GitHub deployment role name."

View file

@ -0,0 +1,30 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.62.0"
constraints = "~> 6.57"
hashes = [
"h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=",
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
"h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=",
"h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=",
"h1:yOSEz5G8b/n5uhFCZ0gbEsKkAQATtVuhXJEXR3OM5qs=",
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
]
}

View file

@ -0,0 +1,64 @@
import {
to = module.environment_owned.aws_s3_bucket.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_public_access_block.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_ownership_controls.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_versioning.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_policy.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_cloudfront_distribution.site
id = local.distribution_id
}
import {
to = module.environment_owned.aws_cloudfront_origin_access_control.site
id = local.oac_id
}
import {
to = module.environment_owned.aws_cloudfront_function.spa_rewrite
id = local.function_name
}
import {
to = module.environment_owned.aws_route53_record.site_a
id = "${local.hosted_zone_id}_${local.domain_name}_A"
}
import {
to = module.environment_owned.aws_route53_record.site_aaaa
id = "${local.hosted_zone_id}_${local.domain_name}_AAAA"
}
import {
to = module.environment_owned.aws_iam_role.github_deploy
id = local.deploy_role_name
}
import {
to = module.environment_owned.aws_iam_role_policy.github_deploy
id = "${local.deploy_role_name}:${local.inline_policy}"
}

View file

@ -0,0 +1,95 @@
locals {
# Controlled ownership transfer. Pinned in code, never a workspace variable.
adoption_complete = true
environment = "staging"
workspace_name = "shoc-frontend-new-staging"
github_repo = "Sea-Haven-Industries/shoc-frontend-new"
aws_account_id = "396287094661"
aws_region = "us-east-1"
bucket_name = "seahaven-shoc-frontend-staging"
distribution_id = "E2JDVEZ6EGD49J"
oac_id = "E1PF5R6QQNBZAI"
oac_name = "shocfrontendstagingDistributOrigin1S3OriginAccessControl82B1C17D"
origin_id = "shocfrontendstagingDistributionOrigin16E4628FC"
function_name = "us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
domain_name = "staging.seahaven.com"
hosted_zone_id = "Z02602739VQWBWCAGXP4"
certificate_arn = (
"arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
)
github_oidc_arn = (
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
)
deploy_role_name = "githubdeploy-shoc-frontend-new-staging"
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
stack_name = "shoc-frontend-staging"
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
permissions_boundary_arn = (
"arn:aws:iam::396287094661:policy/shoc-frontend-new-staging-deploy-boundary"
)
bucket_auto_delete_helper_role_arn = (
"arn:aws:iam::396287094661:role/shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
)
legacy_tags = {
Environment = "staging"
ManagedBy = "cdk"
Project = "shoc-frontend"
}
legacy_bucket_tags = merge(local.legacy_tags, {
"aws-cdk:auto-delete-objects" = "true"
})
terraform_tags = {
Environment = "staging"
ManagedBy = "terraform"
Ownership = "terraform"
Project = "shoc-frontend"
}
manager_tag = {
HcpTerraformWorkspace = local.workspace_name
}
}
module "inventory" {
source = "../modules/environment-inventory"
aws_account_id = local.aws_account_id
aws_region = local.aws_region
hosted_zone_name = local.domain_name
expected_hosted_zone_id = local.hosted_zone_id
certificate_domain = "*.seahaven.com"
expected_certificate_arn = local.certificate_arn
expected_github_oidc_provider_arn = local.github_oidc_arn
expected_cache_policy_id = local.cache_policy_id
}
module "environment_owned" {
source = "../modules/environment-owned"
environment = local.environment
adoption_complete = local.adoption_complete
aws_account_id = local.aws_account_id
aws_region = local.aws_region
github_repo = local.github_repo
bucket_name = local.bucket_name
distribution_id = local.distribution_id
origin_access_control_name = local.oac_name
origin_access_control_description = ""
origin_id = local.origin_id
function_name = local.function_name
domain_name = local.domain_name
hosted_zone_id = local.hosted_zone_id
certificate_arn = local.certificate_arn
cache_policy_id = local.cache_policy_id
github_oidc_provider_arn = local.github_oidc_arn
deploy_role_name = local.deploy_role_name
deploy_inline_policy_name = local.inline_policy
deploy_permissions_boundary_arn = local.permissions_boundary_arn
cloudformation_stack_name = local.stack_name
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
pre_adoption_tags = local.legacy_tags
pre_adoption_bucket_tags = local.legacy_bucket_tags
ownership_tags = local.terraform_tags
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
}

View file

@ -0,0 +1,19 @@
output "bucket_name" {
value = module.environment_owned.bucket_name
description = "SPA origin bucket name."
}
output "distribution_id" {
value = module.environment_owned.distribution_id
description = "CloudFront distribution ID."
}
output "deploy_role_arn" {
value = module.environment_owned.deploy_role_arn
description = "GitHub Actions deploy role ARN."
}
output "origin_id" {
value = module.environment_owned.origin_id
description = "CloudFront origin ID for the bucket-root SPA."
}

View file

@ -0,0 +1,3 @@
provider "aws" {
region = local.aws_region
}

View file

@ -0,0 +1,19 @@
terraform {
required_version = ">= 1.14.0, < 2.0.0"
cloud {
organization = "seahaven"
workspaces {
project = "seahaven-external-dev"
name = "shoc-frontend-new-staging"
}
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
}
}