shoc-frontend-new/.github/workflows/deploy.yml
Adam Moussa 7716b4afc8
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Validate and deploy / Validate production build (push) Waiting to run
Validate and deploy / Deploy shoc-frontend-new-dev through Terraform (push) Blocked by required conditions
fix(ci): confirm release prefix with s3api head-object (SH-300) (#182)
grep -q closed the aws s3 ls pipe after a successful upload and failed the deploy.
2026-09-11 18:26:19 +00:00

347 lines
15 KiB
YAML

name: Validate and deploy
# 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.
on:
pull_request:
branches: [dev]
push:
branches: [dev]
paths-ignore:
- "terraform/**"
workflow_dispatch: {}
permissions:
contents: read
jobs:
validate:
name: Validate production build
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"
cache: npm
- name: Set up Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Install actionlint
env:
ACTIONLINT_VERSION: "1.7.12"
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
run: |
set -euo pipefail
curl -fsSL -o actionlint.tar.gz \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c -
tar -xzf actionlint.tar.gz actionlint
sudo mv actionlint /usr/local/bin/actionlint
- name: Quality gates
run: npm ci && npm run verify
env:
GOVERNANCE_BASE: ${{ github.event.pull_request.base.sha || 'origin/dev' }}
- name: Build with pinned API URL
env:
VITE_API_URL: https://api.dev.seahaven.com/api
VITE_APP_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
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/
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')
needs: validate
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}"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE: ${{ steps.release.outputs.version_label }}
- 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