mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 15:03:12 +00:00
An unset vars.VITE_SENTRY_DSN becomes an empty env value and Vite will not let .env.production overwrite it, which would ship with Sentry off.
292 lines
11 KiB
YAML
292 lines
11 KiB
YAML
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.
|
|
|
|
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}"
|
|
|
|
- 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
|