fix(cd): ignore githubdeploy description and retire leftover pointer CD

SCP denies UpdateRoleDescription on githubdeploy-*, so HCP apply cannot
rewrite the role description. Pointer workflows must not land on main.
This commit is contained in:
Adam Moussa 2026-09-18 15:04:51 -04:00
parent c96a259365
commit ceecab5438
No known key found for this signature in database
6 changed files with 8 additions and 456 deletions

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

View file

@ -23,8 +23,6 @@ on:
- "terraform/**"
- "docs/**"
- "**/*.md"
- ".github/workflows/deploy.yml"
- ".github/workflows/deploy-staging.yml"
- ".github/workflows/ci.yaml"
- ".github/workflows/ci-terraform.yaml"
- ".github/workflows/deploy-web.yaml"

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,7 +1,7 @@
# 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)
[![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)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
@ -36,7 +36,7 @@ graph LR
Dev and staging hosting live in `terraform/live/dev` and
`terraform/live/staging`. GitHub `.github/workflows/deploy-web.yaml` syncs
content. The older pointer CD (`deploy.yml`) stays in the tree until cutover.
content.
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
@ -160,8 +160,6 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
- **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml))
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
- **Legacy pointer CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
stays until cutover proof. Do not enable it on `main`.
- **Infrastructure** — HCP workspaces `shoc-frontend-new-dev` and
`shoc-frontend-new-staging` ([`terraform/README.md`](terraform/README.md)).

View file

@ -70,5 +70,6 @@ create an HCP run or touch AWS.
`--delete` root sync.
3. Create GitHub Environment `dev` (staging already exists). Set
`DEPLOY_ROLE_ARN` on each.
4. Enable `deploy-web.yaml`. Then retire `deploy.yml`, `TF_API_TOKEN`, and
`TERRAFORM_CONTENT_CD_ENABLED`.
4. Enable `deploy-web.yaml`. Then delete leftover `deploy.yml` /
`deploy-staging.yml` and repo `TF_API_TOKEN`, `TERRAFORM_CONTENT_CD_ENABLED`,
and `AWS_DEPLOY_ROLE_ARN`.

View file

@ -370,6 +370,9 @@ resource "aws_iam_role" "github_deploy" {
lifecycle {
prevent_destroy = true
# SCP ProtectDeploymentPrincipalLifecycle denies UpdateRoleDescription on
# githubdeploy-* for HCP apply roles.
ignore_changes = [description]
}
}