From 4df6e761929d8e7fa136d609758bdf90146adaab Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 28 Aug 2026 11:59:55 -0300 Subject: [PATCH] ci: add protected staging frontend deployment lane (#151) * ci: add protected staging deployment lane * fix: constrain staging publisher permissions * fix: handle first-push governance baseline --------- Co-authored-by: Codex Review Integration --- .github/workflows/ci.yaml | 25 ++++- .github/workflows/deploy-staging.yml | 140 +++++++++++++++++++++++++++ infra/cdk/README.md | 90 ++++++++++++++--- infra/cdk/bin/app.ts | 11 +++ infra/cdk/lib/frontend-stack.ts | 71 +++++++++----- scripts/deploy-web.sh | 16 ++- 6 files changed, 308 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/deploy-staging.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f54345e6..2222ef4f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: Frontend checks on: pull_request: - branches: [main, dev] + branches: [main, dev, staging] push: - branches: [main, dev] + branches: [main, dev, staging] workflow_dispatch: {} permissions: @@ -32,18 +32,35 @@ jobs: # push-> the previous commit on the branch (github.event.before) # manual -> dev, for exact-head recovery runs runs-on: ubuntu-latest - env: - GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'origin/dev' }} 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 }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + base="${PR_BASE_SHA}" + elif [[ "${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 - run: npm ci - run: npm run verify + env: + GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} visual-regression: name: Visual regression diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..eed6460e --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,140 @@ +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 + 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 (process env overrides the + # dev value 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: 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." diff --git a/infra/cdk/README.md b/infra/cdk/README.md index e876a0c7..364780af 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -17,7 +17,8 @@ deployed through the org's **reusable** GitHub Actions workflow. (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). - **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is created by this stack, not added to the central `oidc-deploy-roles.yaml`. -- **Environments:** `dev` only today, deployed on push to the `dev` branch. +- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and + `staging` (push to `staging`, via the standalone `deploy-staging.yml`). ``` infra/cdk/ @@ -25,8 +26,9 @@ infra/cdk/ lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation .github/workflows/ - ci.yml quality gates (lint / build / test / e2e) + ci.yaml quality gates (lint / build / test / e2e) deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) + deploy-staging.yml standalone staging deploy (push to staging) ``` ## What the stack creates @@ -137,25 +139,83 @@ the `SiteUrl` output. --- -## Adding staging / prod later +## Staging environment (same account, exact OIDC subject) -Separate accounts: deploy this stack there with per-env `domainNames`, -`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's -`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`. +Staging lives in the same AWS account (396287094661) but deploys through its +own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org +reusable `cd-cdk.yaml`: -Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is -baked into `vite build`** — so each environment needs its own build with its own -API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment -in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than -relying on the committed `.env.production` (which carries the dev value). The -backend must also allow CORS from each frontend origin. +- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role + (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub + environment subject + `repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging` + (`StringEquals` on both `aud` and `sub`). The workflow declares + `environment: staging`, so only runs in that environment can assume the role. + Without `githubEnvironment`, the dev stack keeps its branch-ref trust + unchanged. +- **No secret:** the role ARN is static (the role name is deterministic), so + the workflow pins + `arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging` + directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set. +- **Gates first:** the workflow runs the full `npm run verify` before assuming + the staging role, then runs `scripts/deploy-web.sh` with + `STACK_NAME=shoc-frontend-staging`, + `VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the + CloudFront invalidation to complete. +- **Application-only role:** the recurring staging workflow can describe only + its exact stack, publish only to its exact bucket, and invalidate only its + exact distribution. It cannot assume the shared CDK bootstrap roles or + modify infrastructure. Staging infrastructure changes use the Administrator + command below. +- **Post-deploy checks:** bucket + distribution existence, HTTPS on + `https://staging.seahaven.com`, and the actual post-invalidation remote assets + contain the staging API URL and no dev API URL. (Not browser QA.) + +### One-time setup (run by a human with admin AWS creds + GitHub Admin) + +1. **GitHub Admin — create the `staging` environment** (Settings → + Environments → New environment → `staging`). Add protection rules as + appropriate (e.g. required reviewers, restrict to the `staging` branch). If + the environment does not exist, GitHub creates it unprotected on first use. +2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the + OIDC provider and bootstrap already exist in this account): + + ```bash + cd infra/cdk + npx cdk deploy shoc-frontend-staging \ + -c envName=staging \ + -c deployBranch=staging \ + -c githubEnvironment=staging \ + -c domainNames=staging.seahaven.com \ + -c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \ + -c hostedZoneId=Z02602739VQWBWCAGXP4 \ + -c hostedZoneName=staging.seahaven.com + ``` + + The `DeployRoleArn` output must match the ARN pinned in + `deploy-staging.yml` (it will — the role name is deterministic). + +3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must + allow the `https://staging.seahaven.com` origin. +4. Push to `staging` — `ci.yaml` runs the quality gates and + `deploy-staging.yml` deploys. + +### Adding prod later + +Same pattern: a prod account/stack with its own contexts and, ideally, its own +`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked +into each environment's build, and the bucket's `RemovalPolicy.DESTROY` + +`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited +for prod. ## Notes - **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. -- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still - deploys (matches the org's push-time-CD model). Gating deploy on CI is a - follow-up, not part of enabling CICD. +- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging + differs only in that its CD workflow also runs `npm run verify` itself + before deploying); a red-CI commit still deploys on `dev` (matches the + org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part + of enabling CICD. - **npm is pinned to v11.16.0**; the committed `package-lock.json` uses lockfileVersion 3, matching the Node 24 / npm 11 CI environment. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 9ef86c8e..876463fe 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -8,6 +8,10 @@ const app = new App(); const envName = app.node.tryGetContext("envName") ?? "dev"; const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; +// When set (e.g. "staging"), the deploy role trusts the exact GitHub +// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style +// branch-ref trust. +const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? ""; // Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com // The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. @@ -21,10 +25,17 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; +// Staging and beyond protect their stacks from accidental deletion; dev +// stays teardown-friendly (its artifacts are reproducible). CDK applies this +// at deploy time — it is not part of the synthesized template. +const terminationProtection = envName !== "dev"; + const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { envName, githubRepo, deployBranch, + githubEnvironment, + terminationProtection, domainNames, certificateArn, hostedZoneId, diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index be7a4681..dda5f67e 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -15,6 +15,13 @@ export interface FrontendStackProps extends StackProps { readonly githubRepo: string; /** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */ readonly deployBranch: string; + /** + * GitHub Actions environment name (e.g. "staging"). When set, the OIDC + * trust uses the EXACT environment subject + * `repo::environment:` (StringEquals) instead of the + * deploy-branch ref match below. Unset = dev-style branch-ref trust. + */ + readonly githubEnvironment?: string; /** * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. * Empty = serve on the default *.cloudfront.net domain. @@ -57,6 +64,7 @@ export class FrontendStack extends Stack { envName, githubRepo, deployBranch, + githubEnvironment = "", domainNames, certificateArn, hostedZoneId, @@ -147,35 +155,52 @@ export class FrontendStack extends Stack { `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`, ); + // Trust conditions for the OIDC principal. With a GitHub environment + // (staging): exact StringEquals match on both aud and the environment + // subject — the staging workflow declares `environment: staging`, so only + // runs in that environment can assume the role. Without one (dev): keep + // the branch-ref trust, where StringLike scopes `sub` to pushes on the + // deploy branch (reusable-workflow runs still carry the caller-based sub). + const oidcConditions = githubEnvironment + ? { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, + }, + } + : { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + // Tightly scoped: only pushes to this repo's deploy branch. For a + // reusable-workflow run the OIDC `sub` is still caller-based, so this + // matches even though the deploy job lives in the `.github` repo. + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + }; + const deployRole = new iam.Role(this, "GithubDeployRole", { roleName: `githubdeploy-shoc-frontend-new-${envName}`, description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, maxSessionDuration: Duration.hours(1), - assumedBy: new iam.OpenIdConnectPrincipal(provider, { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - }, - StringLike: { - // Tightly scoped: only pushes to this repo's deploy branch. For a - // reusable-workflow run the OIDC `sub` is still caller-based, so this - // matches even though the deploy job lives in the `.github` repo. - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, - }, - }), + assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions), }); - // The whole `cd-cdk.yaml` job runs as this role. Permissions it needs: - // 1. assume the CDK bootstrap roles -> `cdk deploy` - // 2. describe the stack -> cd-cdk pre-flight / health-check / output reads - // 3. read/write the bucket -> post-deploy `aws s3 sync` - // 4. invalidate the distribution -> post-deploy cache bust - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "AssumeCdkBootstrapRoles", - actions: ["sts:AssumeRole"], - resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], - }), - ); + // Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is + // intentionally narrower: its recurring promotion workflow only publishes + // application assets to this stack's bucket/distribution. Infrastructure + // changes remain an administrator-run CDK operation, so the staging OIDC + // role cannot inherit the bootstrap roles' account-wide deployment power. + if (!githubEnvironment) { + deployRole.addToPolicy( + new iam.PolicyStatement({ + sid: "AssumeCdkBootstrapRoles", + actions: ["sts:AssumeRole"], + resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], + }), + ); + } deployRole.addToPolicy( new iam.PolicyStatement({ sid: "DescribeStack", diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 12c1b021..59b302ef 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -13,8 +13,9 @@ 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 .env.production)..." +echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..." npm ci npm run build @@ -48,8 +49,17 @@ aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \ --content-type "text/html" echo "Invalidating CloudFront ${DIST_ID}..." -aws cloudfront create-invalidation \ +INVALIDATION_ID="$(aws cloudfront create-invalidation \ --distribution-id "${DIST_ID}" \ - --paths "/*" + --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."