diff --git a/.github/workflows/cron-rebuild.yml b/.github/workflows/cron-rebuild.yml index 59ff1afc..ae1821ea 100644 --- a/.github/workflows/cron-rebuild.yml +++ b/.github/workflows/cron-rebuild.yml @@ -1,7 +1,7 @@ name: cron-rebuild -# Periodic redeploy so the events page picks up Google Calendar changes -# without anyone having to push. Triggers `Deploy production` via +# Periodic staging refresh so the events page picks up Google Calendar changes +# without anyone having to push. Triggers `Deploy staging` via # workflow_dispatch — that workflow re-fetches the public LVBT calendar # during its build. # @@ -16,7 +16,7 @@ on: schedule: - cron: '0 2,14 * * *' # Manual button in the Actions UI for ad-hoc rebuilds (e.g. after a - # same-day calendar correction that can't wait two hours). + # same-day calendar correction that cannot wait for the next scheduled build). workflow_dispatch: permissions: @@ -28,7 +28,7 @@ jobs: dispatch: runs-on: ubuntu-latest steps: - - name: Dispatch Deploy production + - name: Dispatch Deploy staging env: GH_TOKEN: ${{ github.token }} run: gh workflow run deploy-production.yml --ref main --repo ${{ github.repository }} diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml deleted file mode 100644 index 08dd74a9..00000000 --- a/.github/workflows/deploy-preview.yml +++ /dev/null @@ -1,193 +0,0 @@ -name: Deploy preview - -on: - pull_request: - branches: [main] - -permissions: - contents: read - deployments: write - packages: read - pull-requests: write - -# Newer pushes to the same PR cancel an in-flight preview deploy. -concurrency: - group: deploy-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - # Same-repo PRs only — fork PRs don't get the prod token. Fork contributors - # land via review + main deploy. (Never use `pull_request_target` to work - # around this; it executes the PR's workflow with full secrets — exactly - # the foot-gun we're avoiding.) - guard: - name: Same-repo guard - runs-on: ubuntu-latest - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - vars.CLOUDFLARE_ACCOUNT_ID != '' - outputs: - run: ${{ steps.set.outputs.run }} - steps: - - id: set - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - run: | - if [ -n "$CLOUDFLARE_API_TOKEN" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice::Pages previews are not configured for this repository." - fi - - build: - name: Build - needs: guard - if: needs.guard.outputs.run == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - # Deployment config baked into the static HTML — see apps/site/src/lib/site.ts. - # Job env propagates into the build-site composite step. - env: - PUBLIC_LVBT_INSTAGRAM: ${{ vars.PUBLIC_LVBT_INSTAGRAM }} - PUBLIC_LVBT_LINKEDIN: ${{ vars.PUBLIC_LVBT_LINKEDIN }} - PUBLIC_LVBT_BLUESKY: ${{ vars.PUBLIC_LVBT_BLUESKY }} - PUBLIC_LVBT_DISCORD: ${{ vars.PUBLIC_LVBT_DISCORD }} - PUBLIC_LVBT_DONATE_URL: ${{ vars.PUBLIC_LVBT_DONATE_URL }} - PUBLIC_LVBT_MEMBERSHIP_FORM_URL: ${{ vars.PUBLIC_LVBT_MEMBERSHIP_FORM_URL }} - PUBLIC_LVBT_NEWSLETTER_URL: ${{ vars.PUBLIC_LVBT_NEWSLETTER_URL }} - PUBLIC_LVBT_NEWSLETTER_FEED_URL: ${{ vars.PUBLIC_LVBT_NEWSLETTER_FEED_URL }} - # Build the preview-only pattern gallery and prototypes, so they are checked and reviewable. - PUBLIC_LVBT_PREVIEW_PAGES: '1' - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup Node + pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Build site - uses: ./.github/actions/build-site - - deploy: - name: Deploy preview - needs: build - runs-on: ubuntu-latest - timeout-minutes: 10 - environment: - name: preview - url: ${{ steps.deploy.outputs.url }} - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - outputs: - url: ${{ steps.deploy.outputs.url }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup Node + pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Deploy - id: deploy - uses: ./.github/actions/deploy-cloudflare-pages - with: - # Cloudflare uses the branch name to namespace preview URLs. github.head_ref - # is the source branch (only set on pull_request events); fine for our - # same-repo gate. - branch: ${{ github.head_ref }} - - headers-check: - name: Verify response headers - needs: deploy - if: needs.deploy.outputs.url != '' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Wait for Cloudflare propagation, then assert headers - env: - PREVIEW_URL: ${{ needs.deploy.outputs.url }} - run: | - set -euo pipefail - # Cloudflare's edge can lag a few seconds behind wrangler returning; - # retry up to 6×5s before failing so transient propagation doesn't - # mark good deploys as bad. - headers="" - for attempt in 1 2 3 4 5 6; do - if headers=$(curl -sI --max-time 10 "$PREVIEW_URL" 2>/dev/null) && [ -n "$headers" ]; then - break - fi - echo "attempt $attempt: no response yet, sleeping 5s..." - sleep 5 - done - if [ -z "$headers" ]; then - echo "::error::Could not fetch headers from $PREVIEW_URL" - exit 1 - fi - echo "$headers" - missing=() - for h in \ - 'Content-Security-Policy' \ - 'Strict-Transport-Security' \ - 'X-Content-Type-Options' \ - 'Referrer-Policy' \ - 'Permissions-Policy'; do - if ! printf '%s' "$headers" | grep -qi "^$h:"; then - missing+=("$h") - fi - done - if [ "${#missing[@]}" -gt 0 ]; then - echo "::error::Missing required response headers: ${missing[*]}" - echo "::error::Check apps/site/public/_headers — Cloudflare Pages may not be applying it." - exit 1 - fi - - comment: - name: Comment preview URL on PR - needs: deploy - if: needs.deploy.outputs.url != '' - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Upsert PR comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - PREVIEW_URL: ${{ needs.deploy.outputs.url }} - with: - script: | - const url = process.env.PREVIEW_URL; - const body = [ - '🚀 **Preview deployed**', - '', - `→ ${url}`, - '', - `_Commit: \`${context.sha.slice(0, 7)}\`_`, - ].join('\n'); - const marker = ''; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find((c) => c.body && c.body.includes(marker)); - const fullBody = `${marker}\n${body}`; - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: fullBody, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: fullBody, - }); - } diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index fb2c920b..7b56aa7b 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -1,4 +1,4 @@ -name: Deploy production +name: Deploy staging on: push: @@ -7,16 +7,14 @@ on: permissions: contents: read + actions: read deployments: write packages: read -# Never cancel a prod deploy in flight — let it finish, then queue the next. concurrency: - group: deploy-prod + group: website-staging cancel-in-progress: false -# The site lives in apps/site; every run step works from there unless it -# says otherwise. Artifact and cache paths stay relative to the checkout. defaults: run: working-directory: apps/site @@ -44,6 +42,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + lfs: true - name: Setup Node + pnpm uses: ./.github/actions/setup-node-pnpm @@ -51,47 +50,92 @@ jobs: - name: Build site uses: ./.github/actions/build-site - # The pattern gallery, prototypes and the en-XA test language exist only - # on previews and the audit. A production build with any of them must - # never deploy. - - name: Check preview-only pages are absent - run: | - if [ -e dist/patterns ] || [ -e dist/prototypes ]; then - echo "::error::dist/patterns or dist/prototypes is in the production build." - exit 1 - fi - # The en-XA long-text language is for the audit only. - if grep -rlq 'lang="en-XA"' dist --include='*.html'; then - echo "::error::The production build is in the en-XA test language. Unset LVBT_PSEUDO_LOCALE." - exit 1 - fi + - name: Validate release + working-directory: . + run: pnpm check + + - name: Package immutable release + env: + RELEASE_ID: ${{ github.run_id }} + run: >- + pnpm worker:release package --directory "$RUNNER_TEMP/website-release" --commit + "$GITHUB_SHA" --release-id "$RELEASE_ID" - deploy: - name: Deploy to Cloudflare Pages + - name: Save reviewed release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: website-release-${{ github.run_id }} + path: ${{ runner.temp }}/website-release/ + include-hidden-files: true + if-no-files-found: error + retention-days: 90 + + staging: + name: Verify and update staging needs: build - if: vars.LVBT_WORKERS_PRODUCTION_ENABLED != 'true' + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - timeout-minutes: 10 - # Scopes secrets to the production environment + surfaces a deployment - # record in the GitHub UI. Add required reviewers via repo settings if - # you want a manual approval gate later. + timeout-minutes: 20 environment: - name: production - url: ${{ steps.deploy.outputs.url }} + name: worker-preview + url: https://preview.lasvegasfortransit.org env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_API_TOKEN }} + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + RELEASE_ID: ${{ github.run_id }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - + lfs: true - name: Setup Node + pnpm uses: ./.github/actions/setup-node-pnpm - - - name: Deploy - id: deploy - uses: ./.github/actions/deploy-cloudflare-pages + - name: Download release + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - branch: main + name: website-release-${{ github.run_id }} + path: ${{ runner.temp }}/website-release + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Verify saved release + run: >- + pnpm worker:release verify --directory "$RUNNER_TEMP/website-release" --commit + "$GITHUB_SHA" --release-id "$RELEASE_ID" + - name: Upload staging version + id: upload + run: pnpm worker:release upload --directory "$RUNNER_TEMP/website-release" --target preview + - name: Verify protected candidate + env: + PREVIEW_URL: ${{ steps.upload.outputs.url }} + run: >- + pnpm worker:smoke --url "$PREVIEW_URL" --protected --commit "$GITHUB_SHA" --release-id + "$RELEASE_ID" + - name: Browser acceptance + env: + PLAYWRIGHT_BASE_URL: ${{ steps.upload.outputs.url }} + run: | + cp -R "$RUNNER_TEMP/website-release/dist" ./dist + pnpm worker:test:browser --project=ui-contracts + - name: Activate staging + env: + VERSION_ID: ${{ steps.upload.outputs.version }} + run: pnpm worker:release activate --target preview --version "$VERSION_ID" + - name: Verify permanent preview domain + run: >- + pnpm worker:smoke --url https://preview.lasvegasfortransit.org --protected --commit + "$GITHUB_SHA" --release-id "$RELEASE_ID" + - name: Record release + env: + VERSION_ID: ${{ steps.upload.outputs.version }} + run: | + { + echo '### Staging release verified' + echo "Release: $RELEASE_ID" + echo "Commit: $GITHUB_SHA" + echo "Staging Worker version: $VERSION_ID" + echo 'Review: https://preview.lasvegasfortransit.org' + echo "Promote by selecting Actions run $GITHUB_RUN_ID in Promote website release." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-worker-candidate.yml b/.github/workflows/deploy-worker-candidate.yml index 55dc0e5b..012910cf 100644 --- a/.github/workflows/deploy-worker-candidate.yml +++ b/.github/workflows/deploy-worker-candidate.yml @@ -1,139 +1,129 @@ -name: Deploy Worker candidate +name: Promote website release on: - workflow_run: - workflows: [Deploy production] - types: [completed] workflow_dispatch: + inputs: + run_id: + description: Successful main Deploy staging Actions run ID to promote + type: string + required: true permissions: contents: read + actions: read deployments: write packages: read concurrency: - group: deploy-worker-candidate + group: website-production cancel-in-progress: false -# The site lives in apps/site; every run step works from there unless it -# says otherwise. defaults: run: working-directory: apps/site jobs: - candidate: - name: Upload and verify candidate - if: >- - vars.CLOUDFLARE_WORKERS_CANDIDATE_ENABLED == 'true' && ((github.event_name == - 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == - 'workflow_run' && github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main')) + source: + name: Validate selected staging release + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 10 + outputs: + commit: ${{ steps.source.outputs.commit }} + release-id: ${{ steps.source.outputs.release-id }} + steps: + - name: Checkout trusted promotion tools + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Node + pnpm + uses: ./.github/actions/setup-node-pnpm + - name: Fetch selected Actions run + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SOURCE_RUN_ID: ${{ inputs.run_id }} + with: + script: | + const id = process.env.SOURCE_RUN_ID; + if (!/^[1-9][0-9]*$/.test(id)) throw new Error('Select an Actions run ID.'); + const { data } = await github.rest.actions.getWorkflowRun({ ...context.repo, run_id: Number(id) }); + require('node:fs').writeFileSync(`${process.env.RUNNER_TEMP}/source-run.json`, JSON.stringify(data)); + - name: Validate provenance + id: source + env: + SOURCE_RUN_ID: ${{ inputs.run_id }} + run: >- + pnpm worker:release source --run-file "$RUNNER_TEMP/source-run.json" --repository + "$GITHUB_REPOSITORY" --run-id "$SOURCE_RUN_ID" + + promote: + name: Verify and publish selected release + needs: source + runs-on: ubuntu-latest + timeout-minutes: 25 + # Existing production credential environment; dispatch is the explicit publish action. environment: name: worker-candidate - url: ${{ steps.upload.outputs.url }} + url: https://lasvegasfortransit.org env: - CANDIDATE_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_API_TOKEN }} - LVBT_BEEHIIV_API_KEY: ${{ secrets.LVBT_BEEHIIV_API_KEY }} - LVBT_BEEHIIV_PUBLICATION_ID: ${{ secrets.LVBT_BEEHIIV_PUBLICATION_ID }} - LVBT_MEMBERSHIP_INTAKE_SECRET: ${{ secrets.LVBT_MEMBERSHIP_INTAKE_SECRET }} - LVBT_NOTION_API_KEY: ${{ secrets.LVBT_NOTION_API_KEY }} - LVBT_NOTION_DATA_SOURCE_ID: ${{ secrets.LVBT_NOTION_DATA_SOURCE_ID }} - LVBT_TRANSIT_NEWS_INTAKE_SECRET: ${{ secrets.LVBT_TRANSIT_NEWS_INTAKE_SECRET }} - PUBLIC_LVBT_INSTAGRAM: ${{ vars.PUBLIC_LVBT_INSTAGRAM }} - PUBLIC_LVBT_LINKEDIN: ${{ vars.PUBLIC_LVBT_LINKEDIN }} - PUBLIC_LVBT_BLUESKY: ${{ vars.PUBLIC_LVBT_BLUESKY }} - PUBLIC_LVBT_DISCORD: ${{ vars.PUBLIC_LVBT_DISCORD }} - PUBLIC_LVBT_DONATE_URL: ${{ vars.PUBLIC_LVBT_DONATE_URL }} - PUBLIC_LVBT_MEMBERSHIP_FORM_URL: ${{ vars.PUBLIC_LVBT_MEMBERSHIP_FORM_URL }} - PUBLIC_LVBT_NEWSLETTER_URL: ${{ vars.PUBLIC_LVBT_NEWSLETTER_URL }} - PUBLIC_LVBT_NEWSLETTER_FEED_URL: ${{ vars.PUBLIC_LVBT_NEWSLETTER_FEED_URL }} - PUBLIC_LVBT_CWA_TOKEN: ${{ vars.PUBLIC_LVBT_CWA_TOKEN }} - LVBT_REQUIRE_ANALYTICS: '1' + RELEASE_ID: ${{ needs.source.outputs.release-id }} + RELEASE_COMMIT: ${{ needs.source.outputs.commit }} steps: - - name: Checkout deployed commit + - name: Checkout release verification tools uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ env.CANDIDATE_SHA }} - lfs: true + ref: ${{ needs.source.outputs.commit }} persist-credentials: false - + lfs: true - name: Setup Node + pnpm uses: ./.github/actions/setup-node-pnpm - + - name: Download selected immutable release + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + run-id: ${{ inputs.run_id }} + name: website-release-${{ needs.source.outputs.release-id }} + path: ${{ runner.temp }}/website-release + - name: Verify artifact identity and files + run: >- + pnpm worker:release verify --directory "$RUNNER_TEMP/website-release" --commit + "$RELEASE_COMMIT" --release-id "$RELEASE_ID" - name: Install Chromium run: pnpm exec playwright install --with-deps chromium - - # pnpm check is the repository's own check, so it runs from the root. - - name: Build and validate - working-directory: . - run: pnpm check - - - name: Upload Worker version + - name: Upload production candidate without rebuilding id: upload - run: >- - pnpm worker:preview --alias candidate --message "Candidate $CANDIDATE_SHA" --secrets - - - name: Wait for candidate - env: - PREVIEW_URL: ${{ steps.upload.outputs.url }} - run: | - set -euo pipefail - for _ in 1 2 3 4 5 6; do - if curl --fail --silent --show-error --max-time 15 "$PREVIEW_URL" > /dev/null; then - exit 0 - fi - sleep 5 - done - exit 1 - - - name: Compare with Pages - if: vars.LVBT_WORKERS_PRODUCTION_ENABLED != 'true' + run: + pnpm worker:release upload --directory "$RUNNER_TEMP/website-release" --target production + - name: Verify production candidate env: PREVIEW_URL: ${{ steps.upload.outputs.url }} run: >- - pnpm worker:test:live --pages https://lasvegasfortransit.org --worker "$PREVIEW_URL" - + pnpm worker:smoke --url "$PREVIEW_URL" --commit "$RELEASE_COMMIT" --release-id + "$RELEASE_ID" - name: Browser acceptance env: PLAYWRIGHT_BASE_URL: ${{ steps.upload.outputs.url }} - run: pnpm worker:test:browser - - - name: Record candidate + run: | + cp -R "$RUNNER_TEMP/website-release/dist" ./dist + pnpm worker:test:browser --project=ui-contracts + - name: Activate verified production version env: - PREVIEW_URL: ${{ steps.upload.outputs.url }} VERSION_ID: ${{ steps.upload.outputs.version }} - run: | - { - echo '### Worker candidate verified' - echo - echo "- Commit: \`$CANDIDATE_SHA\`" - echo "- Version: \`$VERSION_ID\`" - echo "- Preview: $PREVIEW_URL" - echo '- Pages reference: https://lvbt-website-5zh.pages.dev' - } >> "$GITHUB_STEP_SUMMARY" - - - name: Deploy verified Worker - if: vars.LVBT_WORKERS_PRODUCTION_ENABLED == 'true' + run: pnpm worker:release activate --target production --version "$VERSION_ID" + - name: Verify public deployment + run: >- + pnpm worker:smoke --url https://lasvegasfortransit.org --commit "$RELEASE_COMMIT" + --release-id "$RELEASE_ID" + - name: Record publication env: VERSION_ID: ${{ steps.upload.outputs.version }} run: | - set -euo pipefail - CURRENT_SHA="$(git ls-remote "https://github.com/${{ github.repository }}.git" refs/heads/main | cut -f1)" - if [ "$CURRENT_SHA" != "$CANDIDATE_SHA" ]; then - echo "::error::main moved during candidate verification; refusing to deploy $CANDIDATE_SHA" - exit 1 - fi - pnpm exec wrangler versions deploy "$VERSION_ID" --name lvbt-website --env "" --message "Production $CANDIDATE_SHA" --yes - - - name: Verify production hostname - if: vars.LVBT_WORKERS_PRODUCTION_ENABLED == 'true' - env: - PREVIEW_URL: ${{ steps.upload.outputs.url }} - run: >- - pnpm worker:test:live --pages "$PREVIEW_URL" --worker https://lasvegasfortransit.org - --skip-analytics + { + echo '### Website release published' + echo "Release: $RELEASE_ID" + echo "Commit: $RELEASE_COMMIT" + echo "Production Worker version: $VERSION_ID" + echo 'Public site: https://lasvegasfortransit.org' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-worker-preview.yml b/.github/workflows/deploy-worker-preview.yml index ffae58af..15ad73e3 100644 --- a/.github/workflows/deploy-worker-preview.yml +++ b/.github/workflows/deploy-worker-preview.yml @@ -44,6 +44,9 @@ jobs: PUBLIC_LVBT_NEWSLETTER_FEED_URL: ${{ vars.PUBLIC_LVBT_NEWSLETTER_FEED_URL }} PUBLIC_LVBT_CWA_TOKEN: ${{ vars.PUBLIC_LVBT_CWA_TOKEN }} LVBT_REQUIRE_ANALYTICS: '1' + PUBLIC_LVBT_PREVIEW_PAGES: '1' + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -71,28 +74,12 @@ jobs: - name: Verify preview env: PREVIEW_URL: ${{ steps.upload.outputs.url }} - run: | - set -euo pipefail - for _ in 1 2 3 4 5 6; do - if curl --fail --silent --show-error --max-time 15 "$PREVIEW_URL" > /dev/null; then - exit 0 - fi - sleep 5 - done - exit 1 - - - name: Compare with Pages - if: vars.LVBT_WORKERS_PRODUCTION_ENABLED != 'true' - env: - PREVIEW_URL: ${{ steps.upload.outputs.url }} - run: >- - pnpm worker:test:live --pages https://lvbt-website-5zh.pages.dev --worker "$PREVIEW_URL" - --skip-api + run: pnpm worker:smoke --url "$PREVIEW_URL" --protected - name: Browser acceptance env: PLAYWRIGHT_BASE_URL: ${{ steps.upload.outputs.url }} - run: pnpm worker:test:browser + run: pnpm worker:test:browser --project=ui-contracts - name: Upsert pull request comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/AGENTS.md b/AGENTS.md index 83b898d5..b6a1a78a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ Run `pnpm check` after every change. It is the same command CI runs, and a faili command that fixes it (`pnpm check:fix` repairs everything a machine can). This repository is the Astro site for Las Vegans for Better Transit, in `apps/site`, inside a -Turborepo workspace that follows the organization's repository standard. Cloudflare Pages serves -production while the equivalent Workers deployment completes live acceptance. Changes land through +Turborepo workspace that follows the organization's repository standard. Cloudflare Workers serves +production through explicit promotion of a saved, reviewed staging release. Changes land through pull requests with linear history and the current `Validate` check. New to the project (human or agent)? @@ -34,10 +34,10 @@ Every LVBT repository answers to the same commands, run from the repository root `pnpm bootstrap` and `pnpm preflight` run the site's own setup script in `apps/site/scripts/bootstrap/`, because it also sets up the site's environment file, Worker, domains, and secrets; see [`docs/reference/bootstrap.md`](./docs/reference/bootstrap.md). Production -normally deploys from `main` through GitHub Actions, which verifies each Worker version before it -goes live, so `pnpm run deploy` is for recovery only. Commands only the site has, such as -`event:new` or `worker:dev`, live in `apps/site/package.json`: run them with -`pnpm -C apps/site `. +normally deploys through the explicit Promote website release workflow in GitHub Actions, which +verifies a saved staging release before it goes live, so `pnpm run deploy` is for recovery only. +Commands only the site has, such as `event:new` or `worker:dev`, live in `apps/site/package.json`: +run them with `pnpm -C apps/site `. ## Read these first @@ -185,8 +185,8 @@ markers or GitHub-side prose checks. ## Stack quick map - Astro 7 + Tailwind v4 (MDX content collections under `apps/site/src/content/`) -- Cloudflare Pages in production, with an equivalent Cloudflare Worker built and checked on every - change +- Cloudflare Workers in production, with protected staging updated from main and explicit release + promotion - pnpm 11.25.0 and Node 24.20 or newer in the 24.x line, run through Turborepo - Playwright for tests and ad-hoc screenshots - `apps/site/scripts/bootstrap/` is the interactive setup CLI (`pnpm bootstrap`, `pnpm preflight`) diff --git a/README.md b/README.md index 95ee947e..d07ee814 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ New to any of these? Each links to its [glossary](./docs/reference/glossary.md) - [Tailwind](./docs/reference/glossary.md#tailwind) CSS v4 (via `@tailwindcss/vite`) - [Public Sans](https://public-sans.digital.gov/) (USWDS font, self-hosted; Latin woff2 vendored from `@fontsource-variable/public-sans`) -- Hosted on [Cloudflare Pages](./docs/reference/glossary.md#cloudflare-pages) — fully portable to - any static host (Netlify, GitHub Pages, S3+CloudFront). +- Hosted on Cloudflare Workers with static assets and server routes. --- @@ -139,9 +138,10 @@ turbo.json # The tasks every package runs, in order ## Deployment -Pushes to `main` deploy to production at `lasvegasfortransit.org` via GitHub Actions; PRs get a -Cloudflare Pages preview URL commented on the PR. Full pipeline (build settings, env vars, rollback, -manual deploys) is documented in +Pushes to `main` update `preview.lasvegasfortransit.org` behind Cloudflare Access. Explicit +promotion publishes the selected saved release at `lasvegasfortransit.org`. Same-repository PRs +receive independent, protected Worker preview URLs. Full pipeline (build settings, env vars, +rollback, manual deploys) is documented in [`docs/reference/deployment-pipeline.md`](./docs/reference/deployment-pipeline.md). If anything breaks in your environment, run `pnpm preflight` first — it usually points at the @@ -153,12 +153,11 @@ Several workflows in [`.github/workflows/`](./.github/workflows/) build on three in [`.github/actions/`](./.github/actions/) (`setup-node-pnpm`, `build-site`, `deploy-cloudflare-pages`). The ones that talk to Cloudflare: -| Workflow | Trigger | What it does | -| ----------------------------- | ------------------------------------- | ------------------------------------------------------------------------------ | -| `deploy-preview.yml` | Same-repo PRs on `main` | Build + `wrangler pages deploy --branch=` + comment URL | -| `deploy-production.yml` | Pushes to `main`, `workflow_dispatch` | Build + `wrangler pages deploy --branch=main` | -| `deploy-worker-preview.yml` | Same-repo PRs, once enabled | Uploads a version of the separate `lvbt-website-preview` Worker for comparison | -| `deploy-worker-candidate.yml` | After a successful production build | Uploads a `main` Worker version for comparison, then cutover once enabled | +| Workflow | Trigger | What it does | +| ------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------- | +| `deploy-production.yml` (Deploy staging) | Main pushes and manual dispatch | Save a release and verify protected staging | +| `deploy-worker-preview.yml` | Same-repository PR updates | Verify and comment an independent protected preview | +| `deploy-worker-candidate.yml` (Promote website release) | Explicit selection of a successful staging run | Publish the saved release after candidate checks | `ci.yml` (the required `Validate` check: `pnpm check`, a dependency audit, and a secret scan, no deploy), `audit.yml`, `audit-scheduled.yml`, `cron-rebuild.yml`, `seed-baselines.yml` and @@ -168,14 +167,10 @@ each token — is in [`docs/reference/deployment-pipeline.md`](./docs/reference/deployment-pipeline.md) and [`docs/guides/test-the-workers-candidate.md`](./docs/guides/test-the-workers-candidate.md). -**In short:** `deploy-production.yml` and `deploy-preview.yml` both need a repository secret -`CLOUDFLARE_API_TOKEN` (an **Account · Cloudflare Pages · Edit** custom token — Cloudflare has no -ready-made template for Pages alone) and a repository variable `CLOUDFLARE_ACCOUNT_ID`. Both stay at -the repository level, not scoped to an Environment: `deploy-preview.yml`'s fork-safety job, which -declares no environment, reads them too. `deploy-worker-preview.yml` and -`deploy-worker-candidate.yml` each need their own environment-scoped `CLOUDFLARE_WORKERS_API_TOKEN` -(a narrower **Account · Workers Scripts · Edit** token) under the `worker-preview` and -`worker-candidate` GitHub environments. +Workers credentials are scoped to the `worker-preview` and `worker-candidate` GitHub environments. +The preview environment also needs a Cloudflare Access service token. Credentials remain outside +release artifacts; the account ID is a repository variable. See the linked pipeline reference for +exact secret names and review/promotion steps. ## License diff --git a/apps/site/eslint-suppressions.json b/apps/site/eslint-suppressions.json index cc4bc8d7..de6f5dce 100644 --- a/apps/site/eslint-suppressions.json +++ b/apps/site/eslint-suppressions.json @@ -487,21 +487,15 @@ } }, "tests/e2e/body-links.spec.ts": { - "@typescript-eslint/consistent-type-definitions": { - "count": 2 - }, "@typescript-eslint/no-implied-eval": { "count": 1 }, "@typescript-eslint/no-non-null-assertion": { - "count": 9 + "count": 1 }, "@typescript-eslint/no-unnecessary-condition": { "count": 7 }, - "@typescript-eslint/no-unnecessary-type-assertion": { - "count": 1 - }, "@typescript-eslint/no-unsafe-call": { "count": 1 }, diff --git a/apps/site/functions/_middleware.ts b/apps/site/functions/_middleware.ts new file mode 100644 index 00000000..5f7acd96 --- /dev/null +++ b/apps/site/functions/_middleware.ts @@ -0,0 +1,10 @@ +export function deploymentResponse(response: Response, environment: string): Response { + if (environment !== 'preview') return response; + const protectedResponse = new Response(response.body, response); + protectedResponse.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive'); + protectedResponse.headers.set('Cache-Control', 'private, no-store'); + return protectedResponse; +} + +export const onRequest: PagesFunction> = async (context) => + deploymentResponse(await context.next(), context.env.LVBT_DEPLOYMENT_ENV); diff --git a/apps/site/package.json b/apps/site/package.json index 1479be77..8d6b14ee 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -48,15 +48,14 @@ "worker:preview": "tsx scripts/deploy/worker-preview.ts", "worker:upload": "pnpm build && wrangler versions upload", "worker:deploy": "pnpm build && wrangler deploy", - "worker:dev": "pnpm build && pnpm preview" + "worker:dev": "pnpm build && pnpm preview", + "worker:release": "tsx scripts/deploy/worker-release.ts", + "worker:smoke": "tsx scripts/deploy/release-smoke.ts" }, "dependencies": { - "@astrojs/mdx": "catalog:", "@astrojs/sitemap": "catalog:", - "@lasvegasfortransit/analytics": "catalog:", "@tailwindcss/typography": "catalog:", "@tailwindcss/vite": "catalog:", - "astro": "catalog:", "astro-icon": "catalog:", "fast-xml-parser": "catalog:", "ical.js": "catalog:", @@ -66,16 +65,19 @@ }, "devDependencies": { "@astrojs/check": "catalog:", + "@astrojs/mdx": "catalog:", "@axe-core/playwright": "catalog:", "@clack/prompts": "catalog:", "@cloudflare/workers-types": "catalog:", "@iconify-json/mdi": "catalog:", + "@lasvegasfortransit/analytics": "catalog:", "@lasvegasfortransit/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", "@lasvegasfortransit/web-platform": "file:../../.lvbt/web-platform/packages/web-platform", "@lhci/cli": "catalog:", "@playwright/test": "catalog:", "@types/node": "catalog:", "alex": "catalog:", + "astro": "catalog:", "cspell": "catalog:", "eslint": "catalog:", "html-validate": "catalog:", diff --git a/apps/site/scripts/bootstrap/lib/process-runtime.ts b/apps/site/scripts/bootstrap/lib/process-runtime.ts index c0b4e1a6..0ee01287 100644 --- a/apps/site/scripts/bootstrap/lib/process-runtime.ts +++ b/apps/site/scripts/bootstrap/lib/process-runtime.ts @@ -32,11 +32,8 @@ function resolveShell(): string { const SUBPROCESS_ENV_DENYLIST: ReadonlySet = new Set(['CLOUDFLARE_API_TOKEN']); function subprocessEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(process.env)) { - if (SUBPROCESS_ENV_DENYLIST.has(key)) continue; - env[key] = value; - } + const env = { ...process.env }; + for (const key of SUBPROCESS_ENV_DENYLIST) Reflect.deleteProperty(env, key); return env; } diff --git a/apps/site/scripts/bootstrap/phases/deploy.ts b/apps/site/scripts/bootstrap/phases/deploy.ts index 97fdc8c6..f639e89a 100644 --- a/apps/site/scripts/bootstrap/phases/deploy.ts +++ b/apps/site/scripts/bootstrap/phases/deploy.ts @@ -187,12 +187,11 @@ export async function runDeployPhase( ); log.info( pc.dim( - 'Nothing to do. New code reaches production through the Deploy production workflow on every push to main. To push this checkout on purpose, run `pnpm bootstrap --phase deploy --redeploy`.', + 'Nothing to do. Main pushes update protected staging. Publish a reviewed release with the Promote website release workflow. Direct bootstrap redeployment is for setup or recovery.', ), ); return { success: true, followUpItems }; } - if (state.kind === 'unknown') { log.warn(`Couldn't check whether ${projectName} already has a production deployment.`); if (state.detail) logSubline(pc.dim(state.detail)); @@ -250,7 +249,7 @@ export async function runDeployPhase( : 'https://dash.cloudflare.com/'; followUpItems.push({ kind: 'remote', - message: `From now on, every push to main deploys through the "Deploy production" GitHub Actions workflow. It needs the CLOUDFLARE_API_TOKEN secret and CLOUDFLARE_ACCOUNT_ID variable listed in docs/reference/deployment-pipeline.md. Do not also connect the project to Git at ${dashboardUrl}, or each push would deploy twice.`, + message: `The website workflow publishes Workers through protected staging and explicit promotion. See docs/reference/deployment-pipeline.md for credentials. This Pages project is a recovery option; do not connect it to automatic Git deployments at ${dashboardUrl}.`, }); } diff --git a/apps/site/scripts/deploy/access-auth.ts b/apps/site/scripts/deploy/access-auth.ts new file mode 100644 index 00000000..f3dbc2b6 --- /dev/null +++ b/apps/site/scripts/deploy/access-auth.ts @@ -0,0 +1,47 @@ +export interface AccessCredentials { + clientId: string; + clientSecret: string; +} + +export function accessCredentials( + env: Record, +): AccessCredentials | undefined { + const clientId = env.CF_ACCESS_CLIENT_ID?.trim(); + const clientSecret = env.CF_ACCESS_CLIENT_SECRET?.trim(); + if (Boolean(clientId) !== Boolean(clientSecret)) + throw new Error('Set both Cloudflare Access service credentials.'); + return clientId && clientSecret ? { clientId, clientSecret } : undefined; +} + +export function accessHeaders( + url: string, + origin: string, + credentials?: AccessCredentials, +): Record { + if (!credentials) return {}; + const target = new URL(origin); + if (target.protocol !== 'https:') throw new Error('Access credentials require an HTTPS origin.'); + if (new URL(url).origin !== target.origin) return {}; + return { + 'CF-Access-Client-Id': credentials.clientId, + 'CF-Access-Client-Secret': credentials.clientSecret, + }; +} + +export function accessRequestOptions( + url: string, + origin: string, + credentials?: AccessCredentials, +): { headers: Record; maxRedirects: 0 } { + return { headers: accessHeaders(url, origin, credentials), maxRedirects: 0 }; +} + +export async function accessFetch( + url: string, + origin: string, + credentials?: AccessCredentials, +): Promise { + // Never follow redirects with service credentials. Access redirects are authentication + // failures; application redirects can be tested explicitly at their destination. + return fetch(url, { headers: accessHeaders(url, origin, credentials), redirect: 'manual' }); +} diff --git a/apps/site/scripts/deploy/access-browser.ts b/apps/site/scripts/deploy/access-browser.ts new file mode 100644 index 00000000..8ca4322b --- /dev/null +++ b/apps/site/scripts/deploy/access-browser.ts @@ -0,0 +1,41 @@ +import { test as base, type APIRequestContext } from '@playwright/test'; +import { accessCredentials, accessHeaders, accessRequestOptions } from './access-auth'; + +export * from '@playwright/test'; +export const test = base.extend<{ accessRequest: Pick }>({ + accessRequest: async ({ request, baseURL }, use) => { + if (!baseURL) throw new Error('API browser checks require a baseURL.'); + const origin = new URL(baseURL).origin; + const credentials = accessCredentials(process.env); + await use({ + get: (url, options) => { + const absolute = new URL(url, baseURL).href; + const authentication = accessRequestOptions(absolute, origin, credentials); + return request.get(absolute, { + ...options, + ...authentication, + headers: { ...options?.headers, ...authentication.headers }, + }); + }, + }); + }, + context: async ({ context, baseURL }, use) => { + const credentials = accessCredentials(process.env); + if (credentials) { + if (!baseURL) throw new Error('Access browser checks require a baseURL.'); + const origin = new URL(baseURL).origin; + await context.route('**/*', async (route) => { + const headers = accessHeaders(route.request().url(), origin, credentials); + if (Object.keys(headers).length === 0) return route.continue(); + // route.continue headers survive redirects. Fetch with redirects disabled, + // then let the browser follow the returned response as a new request. + const response = await route.fetch({ + headers: { ...route.request().headers(), ...headers }, + maxRedirects: 0, + }); + await route.fulfill({ response }); + }); + } + await use(context); + }, +}); diff --git a/apps/site/scripts/deploy/release-artifact.ts b/apps/site/scripts/deploy/release-artifact.ts new file mode 100644 index 00000000..9c173934 --- /dev/null +++ b/apps/site/scripts/deploy/release-artifact.ts @@ -0,0 +1,123 @@ +import { createHash } from 'node:crypto'; +import { cp, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; + +const digest = z.string().regex(/^[a-f0-9]{64}$/); +const identitySchema = z + .object({ + commit: z.string().regex(/^[a-f0-9]{40}$/), + releaseId: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/), + }) + .strict(); +const releaseSchema = identitySchema + .extend({ + formatVersion: z.literal(1), + artifactHash: digest, + files: z.array(z.tuple([z.string().min(1), digest])), + }) + .strict(); +export type WebsiteRelease = z.infer; +type Identity = z.infer; + +function hash(value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +async function inventory(directory: string, relative = ''): Promise<[string, string][]> { + const stat = await lstat(path.join(directory, relative)); + if (stat.isSymbolicLink()) throw new Error('Release content contains a symbolic link.'); + if (stat.isFile()) return [[relative, hash(await readFile(path.join(directory, relative)))]]; + if (!stat.isDirectory()) throw new Error('Unsupported release file type.'); + const files: [string, string][] = []; + for (const entry of await readdir(path.join(directory, relative))) { + if (relative === '' && entry === 'release.json') continue; + const child = relative ? `${relative}/${entry}` : entry; + files.push(...(await inventory(directory, child))); + } + return files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); +} + +function artifactHash(identity: Identity, files: [string, string][]): string { + return hash( + JSON.stringify({ + formatVersion: 1, + commit: identity.commit, + releaseId: identity.releaseId, + files, + }), + ); +} + +export async function packageRelease( + source: string, + destination: string, + identity: Identity, +): Promise { + identitySchema.parse(identity); + const assets = await inventory(path.join(source, 'dist')); + await inventory(path.join(source, '.wrangler/worker')); + const config = await lstat(path.join(source, 'wrangler.jsonc')); + if (config.isSymbolicLink() || !config.isFile()) + throw new Error('Invalid release configuration.'); + if ( + assets.some(([name]) => /^(patterns|prototypes)(\/|$)/.test(name)) || + (await readdir(path.join(source, 'dist'))).some((name) => + ['patterns', 'prototypes'].includes(name), + ) + ) { + throw new Error('Release contains preview-only routes.'); + } + for (const [name] of assets.filter(([file]) => file.endsWith('.html'))) { + if ((await readFile(path.join(source, 'dist', name), 'utf8')).includes('lang="en-XA"')) + throw new Error('Release contains a preview-only test language.'); + } + await mkdir(destination, { recursive: false }); + await cp(path.join(source, 'dist'), path.join(destination, 'dist'), { recursive: true }); + await mkdir(path.join(destination, '.wrangler')); + await cp(path.join(source, '.wrangler/worker'), path.join(destination, '.wrangler/worker'), { + recursive: true, + }); + await cp(path.join(source, 'wrangler.jsonc'), path.join(destination, 'wrangler.jsonc')); + await writeFile( + path.join(destination, 'dist/lvbt-release.json'), + `${JSON.stringify(identity)}\n`, + ); + const files = await inventory(destination); + if ( + !files.some(([name]) => name === 'dist/index.html') || + !files.some(([name]) => name === '.wrangler/worker/index.js') + ) + throw new Error('Release requires the home page and compiled Worker.'); + const release: WebsiteRelease = { + formatVersion: 1, + ...identity, + files, + artifactHash: artifactHash(identity, files), + }; + await writeFile(path.join(destination, 'release.json'), `${JSON.stringify(release, null, 2)}\n`, { + flag: 'wx', + }); + return release; +} + +export async function verifyRelease(directory: string): Promise { + const marker = await lstat(path.join(directory, 'release.json')); + if (marker.isSymbolicLink() || !marker.isFile()) throw new Error('Invalid release manifest.'); + const release = releaseSchema.parse( + JSON.parse(await readFile(path.join(directory, 'release.json'), 'utf8')), + ); + const files = await inventory(directory); + if ( + JSON.stringify(files) !== JSON.stringify(release.files) || + artifactHash(release, files) !== release.artifactHash + ) + throw new Error('Release identity or files do not match the reviewed artifact.'); + if ( + !files.some(([name]) => name === 'dist/index.html') || + !files.some(([name]) => name === '.wrangler/worker/index.js') || + !files.some(([name]) => name === 'wrangler.jsonc') + ) + throw new Error('Release is incomplete.'); + return release; +} diff --git a/apps/site/scripts/deploy/release-smoke.ts b/apps/site/scripts/deploy/release-smoke.ts new file mode 100644 index 00000000..ce4b6b56 --- /dev/null +++ b/apps/site/scripts/deploy/release-smoke.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { parseArgs } from 'node:util'; +import { chromium } from '@playwright/test'; +import { accessCredentials, accessFetch, accessHeaders } from './access-auth'; + +const { values } = parseArgs({ + options: { + url: { type: 'string' }, + 'release-id': { type: 'string' }, + commit: { type: 'string' }, + protected: { type: 'boolean', default: false }, + }, +}); +if (!values.url) throw new Error('Pass --url with an HTTPS origin.'); +const parsed = new URL(values.url); +if (parsed.protocol !== 'https:' || parsed.pathname !== '/' || parsed.search || parsed.hash) + throw new Error('Pass an HTTPS origin without a path.'); +const origin = parsed.origin; +const credentials = values.protected ? accessCredentials(process.env) : undefined; +if (values.protected && !credentials) + throw new Error('Protected staging verification requires Access credentials.'); +if (values.protected) { + const anonymous = await accessFetch(`${origin}/`, origin); + assert.ok( + [302, 401, 403].includes(anonymous.status), + `Anonymous request reached staging (${anonymous.status}).`, + ); +} +if (values['release-id'] || values.commit) { + const marker = await accessFetch(`${origin}/lvbt-release.json`, origin, credentials); + assert.equal(marker.status, 200, 'Release marker is unavailable or Access rejected credentials.'); + const identity: unknown = await marker.json(); + assert.deepEqual(identity, { commit: values.commit, releaseId: values['release-id'] }); +} +const browser = await chromium.launch(); +try { + const context = await browser.newContext({ serviceWorkers: 'block' }); + await context.route('**/*', async (route) => { + const headers = accessHeaders(route.request().url(), origin, credentials); + if (Object.keys(headers).length === 0) return route.continue(); + const response = await route.fetch({ + headers: { ...route.request().headers(), ...headers }, + maxRedirects: 0, + }); + await route.fulfill({ response }); + }); + const page = await context.newPage(); + const analyticsRequests: string[] = []; + page.on('request', (request) => { + if ( + /cloudflareinsights\.com|events\.lasvegasfortransit\.org/.test( + new URL(request.url()).hostname, + ) + ) + analyticsRequests.push(request.url()); + }); + const response = await page.goto(origin, { waitUntil: 'networkidle' }); + assert.equal(response?.status(), 200, 'The website did not render.'); + assert.equal(new URL(page.url()).origin, origin, 'Browser was redirected away from the website.'); + assert.match(await page.title(), /Las Vegans for Better Transit|Las Vegas|LVBT/i); + assert.ok(await page.locator('main').isVisible(), 'The website main content is not visible.'); + if (values.protected) { + // Cloudflare overwrites this header on versioned workers.dev preview URLs. + const robots = parsed.hostname.endsWith('.workers.dev') + ? 'noindex' + : 'noindex, nofollow, noarchive'; + assert.equal(response.headers()['x-robots-tag'], robots); + assert.deepEqual(analyticsRequests, [], 'Preview sent production analytics.'); + } + process.stdout.write( + `PASS: rendered ${origin}${values['release-id'] ? `, release ${values['release-id']}` : ''}\n`, + ); +} finally { + await browser.close(); +} diff --git a/apps/site/scripts/deploy/release-source.ts b/apps/site/scripts/deploy/release-source.ts new file mode 100644 index 00000000..a2946e32 --- /dev/null +++ b/apps/site/scripts/deploy/release-source.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +const sourceSchema = z.object({ + id: z.number().int().positive(), + run_attempt: z.number().int().positive(), + name: z.literal('Deploy staging'), + path: z.literal('.github/workflows/deploy-production.yml'), + event: z.enum(['push', 'workflow_dispatch']), + head_branch: z.literal('main'), + head_sha: z.string().regex(/^[a-f0-9]{40}$/), + status: z.literal('completed'), + conclusion: z.literal('success'), + repository: z.object({ full_name: z.string() }), + head_repository: z.object({ full_name: z.string() }), +}); + +export function releaseSource( + value: unknown, + repository: string, + runId: string, +): { commit: string; releaseId: string } { + if (!/^[1-9][0-9]*$/.test(runId)) throw new Error('Select an Actions run ID.'); + const source = sourceSchema.parse(value); + if ( + String(source.id) !== runId || + source.repository.full_name !== repository || + source.head_repository.full_name !== repository + ) + throw new Error('The source run must belong to this repository.'); + return { commit: source.head_sha, releaseId: String(source.id) }; +} diff --git a/apps/site/scripts/deploy/worker-release.ts b/apps/site/scripts/deploy/worker-release.ts new file mode 100644 index 00000000..84540c5e --- /dev/null +++ b/apps/site/scripts/deploy/worker-release.ts @@ -0,0 +1,137 @@ +import { execFile } from 'node:child_process'; +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { parseArgs, promisify } from 'node:util'; +import { previewUploadReceipt } from '@lasvegasfortransit/web-platform/release'; +import { packageRelease, verifyRelease, type WebsiteRelease } from './release-artifact'; +import { releaseSource } from './release-source'; + +const execute = promisify(execFile); +function printRelease(release: WebsiteRelease): void { + process.stdout.write( + `${JSON.stringify({ commit: release.commit, releaseId: release.releaseId, artifactHash: release.artifactHash, fileCount: release.files.length })}\n`, + ); +} +const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + directory: { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + target: { type: 'string' }, + version: { type: 'string' }, + 'run-file': { type: 'string' }, + repository: { type: 'string' }, + 'run-id': { type: 'string' }, + }, +}); +const command = positionals[0]; +const directory = values.directory ? path.resolve(values.directory) : undefined; +const target = values.target; +if (command === 'source') { + if (!values['run-file'] || !values.repository || !values['run-id']) + throw new Error('Pass --run-file, --repository and --run-id.'); + const source = releaseSource( + JSON.parse(await readFile(values['run-file'], 'utf8')), + values.repository, + values['run-id'], + ); + // eslint-disable-next-line turbo/no-undeclared-env-vars -- Actions provides this output file. + const output = process.env.GITHUB_OUTPUT; + if (output) + await writeFile(output, `commit=${source.commit}\nrelease-id=${source.releaseId}\n`, { + flag: 'a', + }); + process.stdout.write(`${JSON.stringify(source)}\n`); +} else if (command === 'package') { + if (!directory || !values.commit || !values['release-id']) + throw new Error('Pass --directory, --commit and --release-id.'); + printRelease( + await packageRelease(process.cwd(), directory, { + commit: values.commit, + releaseId: values['release-id'], + }), + ); +} else if (command === 'verify' || command === 'upload') { + if (!directory) throw new Error('Pass --directory.'); + const release = await verifyRelease(directory); + if (values.commit && release.commit !== values.commit) + throw new Error('Release commit does not match the selected Actions run.'); + if (values['release-id'] && release.releaseId !== values['release-id']) + throw new Error('Release ID does not match the selected Actions run.'); + if (command === 'verify') { + printRelease(release); + } else { + if (target !== 'preview' && target !== 'production') + throw new Error('Pass --target preview or production.'); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-release-upload-')); + try { + // Wrangler may write cache files; its working copy cannot modify the saved release. + const copy = path.join(temporary, 'release'); + await cp(directory, copy, { recursive: true }); + const receiptPath = path.join(temporary, 'receipt.jsonl'); + await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'versions', + 'upload', + '--config', + path.join(copy, 'wrangler.jsonc'), + '--env', + target === 'preview' ? 'preview' : '', + '--no-bundle', + '--preview-alias', + `release-${release.releaseId}`, + '--message', + `Release ${release.releaseId} at ${release.commit}`, + ], + { + env: { + ...process.env, + WRANGLER_LOG_SANITIZE: 'true', + WRANGLER_OUTPUT_FILE_PATH: receiptPath, + }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + const receipt = previewUploadReceipt( + await readFile(receiptPath, 'utf8'), + target === 'preview' ? 'lvbt-website-preview' : 'lvbt-website', + ); + // eslint-disable-next-line turbo/no-undeclared-env-vars -- Actions provides this output file. + const output = process.env.GITHUB_OUTPUT; + if (output) + await writeFile(output, `url=${receipt.url}\nversion=${receipt.version}\n`, { flag: 'a' }); + process.stdout.write( + `${JSON.stringify({ ...receipt, releaseId: release.releaseId, commit: release.commit, artifactHash: release.artifactHash })}\n`, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } + } +} else if (command === 'activate') { + if (target !== 'preview' && target !== 'production') + throw new Error('Pass --target preview or production.'); + if (!values.version || !/^[a-f0-9-]{36}$/.test(values.version)) + throw new Error('Pass an explicit Worker version ID.'); + const { stdout } = await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'versions', + 'deploy', + `${values.version}@100%`, + '--name', + target === 'preview' ? 'lvbt-website-preview' : 'lvbt-website', + '--env', + target === 'preview' ? 'preview' : '', + '--yes', + ], + { maxBuffer: 16 * 1024 * 1024 }, + ); + process.stdout.write(stdout); +} else throw new Error('Use package, verify, upload, or activate.'); diff --git a/apps/site/tests/e2e/a11y-forms.spec.ts b/apps/site/tests/e2e/a11y-forms.spec.ts index 5f09b0b5..3574fe39 100644 --- a/apps/site/tests/e2e/a11y-forms.spec.ts +++ b/apps/site/tests/e2e/a11y-forms.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; test.describe('newsletter accessibility', () => { test('exposes the hosted newsletter through a named link', async ({ page }) => { diff --git a/apps/site/tests/e2e/a11y-keyboard.spec.ts b/apps/site/tests/e2e/a11y-keyboard.spec.ts index c8314f00..a9d41103 100644 --- a/apps/site/tests/e2e/a11y-keyboard.spec.ts +++ b/apps/site/tests/e2e/a11y-keyboard.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; import { preparePageForA11y } from '../support/a11y-helpers'; test.describe('keyboard accessibility', () => { diff --git a/apps/site/tests/e2e/a11y-landmarks.spec.ts b/apps/site/tests/e2e/a11y-landmarks.spec.ts index 572b9b4e..64329cac 100644 --- a/apps/site/tests/e2e/a11y-landmarks.spec.ts +++ b/apps/site/tests/e2e/a11y-landmarks.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; import { preparePageForA11y } from '../support/a11y-helpers'; test.describe('landmark accessibility', () => { diff --git a/apps/site/tests/e2e/a11y-patterns.spec.ts b/apps/site/tests/e2e/a11y-patterns.spec.ts index 1c5c4d7e..539cdcea 100644 --- a/apps/site/tests/e2e/a11y-patterns.spec.ts +++ b/apps/site/tests/e2e/a11y-patterns.spec.ts @@ -7,7 +7,7 @@ // builds skip these checks. import AxeBuilder from '@axe-core/playwright'; import { existsSync } from 'node:fs'; -import { expect, test, type Page } from '@playwright/test'; +import { expect, test, type Page } from '../../scripts/deploy/access-browser'; import { preparePageForA11y } from '../support/a11y-helpers'; const galleryBuilt = existsSync(new URL('../../dist/patterns/index.html', import.meta.url)); diff --git a/apps/site/tests/e2e/a11y-pseudo-locale.spec.ts b/apps/site/tests/e2e/a11y-pseudo-locale.spec.ts index 2662b112..afa4b8e6 100644 --- a/apps/site/tests/e2e/a11y-pseudo-locale.spec.ts +++ b/apps/site/tests/e2e/a11y-pseudo-locale.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; // The long-text check: with the site built in the en-XA pseudo-language // (LVBT_PSEUDO_LOCALE=1), every member-facing message is about 40 percent diff --git a/apps/site/tests/e2e/a11y.spec.ts b/apps/site/tests/e2e/a11y.spec.ts index 9de2c049..783b898b 100644 --- a/apps/site/tests/e2e/a11y.spec.ts +++ b/apps/site/tests/e2e/a11y.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; import { builtHtmlPagePaths } from '../support/sitemap-paths'; import { axeBlockingViolations, diff --git a/apps/site/tests/e2e/body-links.spec.ts b/apps/site/tests/e2e/body-links.spec.ts index 7af3a78d..df20641b 100644 --- a/apps/site/tests/e2e/body-links.spec.ts +++ b/apps/site/tests/e2e/body-links.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Locator, type Page } from '@playwright/test'; +import { expect, test, type Locator, type Page } from '../../scripts/deploy/access-browser'; const LINKEDIN_URL = 'https://www.linkedin.com/company/lasvegasfortransit/'; const SITE_URL = 'https://lasvegasfortransit.org'; @@ -128,7 +128,7 @@ test.describe('body content links', () => { test('indexes brand and colophon while keeping QR out of the sitemap', async ({ page, - request, + accessRequest, }) => { for (const path of ['/brand', '/colophon']) { await page.goto(path); @@ -143,7 +143,7 @@ test.describe('body content links', () => { 'noindex,nofollow', ); - const sitemap = await request.get('/sitemap-0.xml'); + const sitemap = await accessRequest.get('/sitemap-0.xml'); expect(sitemap.ok()).toBe(true); const body = await sitemap.text(); expect(body).toContain('https://lasvegasfortransit.org/brand/'); @@ -157,8 +157,12 @@ test.describe('body content links', () => { await page.waitForLoadState('networkidle'); const results = await page.evaluate(async () => { - type PagefindResult = { data: () => Promise<{ url: string }> }; - type Pagefind = { search: (query: string) => Promise<{ results: PagefindResult[] }> }; + interface PagefindResult { + data: () => Promise<{ url: string }>; + } + interface Pagefind { + search: (query: string) => Promise<{ results: PagefindResult[] }>; + } const pagefind = (await Function('return import("/pagefind/pagefind.js")')()) as Pagefind; const urlsFor = async (query: string) => { @@ -260,11 +264,12 @@ test.describe('body content links', () => { document .querySelectorAll('.reveal, .reveal-stat, .reveal-quote') .forEach((el) => el.classList.add('is-visible')); - const footer = document.querySelector('footer') as HTMLElement | null; + const footer = document.querySelector('footer'); window.scrollTo({ top: footer?.offsetTop ?? 0, left: 0, behavior: 'instant' }); }); await page.waitForFunction( - () => Math.abs(document.querySelector('footer')!.getBoundingClientRect().top) < 1, + () => + Math.abs(document.querySelector('footer')?.getBoundingClientRect().top ?? Infinity) < 1, ); return page.evaluate(() => { @@ -337,11 +342,10 @@ test.describe('body content links', () => { const meta = page.locator('footer .footer-meta'); const [metaBox, navBox] = await Promise.all([meta.boundingBox(), utilityNav.boundingBox()]); - expect(metaBox).not.toBeNull(); - expect(navBox).not.toBeNull(); - expect(metaBox!.height).toBeLessThanOrEqual(48); - expect(navBox!.x).toBeGreaterThanOrEqual(metaBox!.x); - expect(navBox!.x + navBox!.width).toBeLessThanOrEqual(metaBox!.x + metaBox!.width); + if (!metaBox || !navBox) throw new Error('Footer meta and utility navigation must render.'); + expect(metaBox.height).toBeLessThanOrEqual(48); + expect(navBox.x).toBeGreaterThanOrEqual(metaBox.x); + expect(navBox.x + navBox.width).toBeLessThanOrEqual(metaBox.x + metaBox.width); }); test('keeps the colophon on the standard type scale without overline labels', async ({ @@ -1170,7 +1174,7 @@ test.describe('body content links', () => { // /events/.ics. Guards two things at once: (a) the route still // emits at build time; (b) the file is RFC 5545 enough that the OS // calendar handler will recognise it. - test('virtual event publishes a valid .ics feed', async ({ page, request }) => { + test('virtual event publishes a valid .ics feed', async ({ page, accessRequest }) => { // firstVirtualEventPath leaves the page on the chosen event's detail view, // so the title and join URL come from the rendered page rather than // hardcoded calendar strings that drift when the event is renamed. @@ -1185,7 +1189,7 @@ test.describe('body content links', () => { .first() .getAttribute('href'); - const response = await request.get(`${eventPath}.ics`); + const response = await accessRequest.get(`${eventPath}.ics`); expect(response.status()).toBe(200); const body = await response.text(); expect(body).toContain('BEGIN:VCALENDAR'); diff --git a/apps/site/tests/e2e/color-contracts.spec.ts b/apps/site/tests/e2e/color-contracts.spec.ts index 2134ef1c..10090ce6 100644 --- a/apps/site/tests/e2e/color-contracts.spec.ts +++ b/apps/site/tests/e2e/color-contracts.spec.ts @@ -1,7 +1,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { expect, test, type Locator, type Page } from '@playwright/test'; +import { expect, test, type Locator, type Page } from '../../scripts/deploy/access-browser'; import { builtSitemapPaths } from '../support/sitemap-paths'; const paths = [...builtSitemapPaths(import.meta.url), '/totally-missing-seo-audit-test/']; diff --git a/apps/site/tests/e2e/event-metadata.spec.ts b/apps/site/tests/e2e/event-metadata.spec.ts index 12dc5617..d029981a 100644 --- a/apps/site/tests/e2e/event-metadata.spec.ts +++ b/apps/site/tests/e2e/event-metadata.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; import { eventSchema } from '../../src/lib/structured-data'; type JsonLd = Record; diff --git a/apps/site/tests/e2e/footer-colophon.spec.ts b/apps/site/tests/e2e/footer-colophon.spec.ts index 045b8e8b..c9b99ee0 100644 --- a/apps/site/tests/e2e/footer-colophon.spec.ts +++ b/apps/site/tests/e2e/footer-colophon.spec.ts @@ -1,7 +1,7 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; test.describe('footer colophon', () => { - test('indexes colophon while keeping QR out of the sitemap', async ({ page, request }) => { + test('indexes colophon while keeping QR out of the sitemap', async ({ page, accessRequest }) => { await page.goto('/colophon'); await page.waitForLoadState('networkidle'); await expect(page.locator('meta[name="robots"]')).toHaveCount(0); @@ -12,10 +12,9 @@ test.describe('footer colophon', () => { 'content', 'noindex,nofollow', ); - - const sitemap = await request.get('/sitemap-0.xml'); - expect(sitemap.ok()).toBe(true); + const sitemap = await accessRequest.get('/sitemap-0.xml'); const body = await sitemap.text(); + expect(sitemap.ok()).toBe(true); expect(body).toContain('https://lasvegasfortransit.org/colophon/'); expect(body).not.toContain('https://lasvegasfortransit.org/qr/'); expect(body).not.toContain('https://lasvegasfortransit.org/vision/'); diff --git a/apps/site/tests/e2e/header-current.spec.ts b/apps/site/tests/e2e/header-current.spec.ts index f7827348..2ad4f1b7 100644 --- a/apps/site/tests/e2e/header-current.spec.ts +++ b/apps/site/tests/e2e/header-current.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; test.describe('header current navigation', () => { test('marks nav links current only on exact route matches', async ({ page }) => { diff --git a/apps/site/tests/e2e/not-found-search.spec.ts b/apps/site/tests/e2e/not-found-search.spec.ts index 917ddbce..ed96bc6b 100644 --- a/apps/site/tests/e2e/not-found-search.spec.ts +++ b/apps/site/tests/e2e/not-found-search.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; test.describe('not found search recovery', () => { test('serves a transit-flavored not found page for missing routes', async ({ page }) => { diff --git a/apps/site/tests/e2e/perf-memory.spec.ts b/apps/site/tests/e2e/perf-memory.spec.ts index 726462b7..f4361fe3 100644 --- a/apps/site/tests/e2e/perf-memory.spec.ts +++ b/apps/site/tests/e2e/perf-memory.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; import { builtSitemapPaths } from '../support/sitemap-paths'; // Same sitemap-driven URL list as a11y.spec.ts / screenshots.spec.ts so diff --git a/apps/site/tests/e2e/print-layout.spec.ts b/apps/site/tests/e2e/print-layout.spec.ts index ef7ebbf1..19fbb034 100644 --- a/apps/site/tests/e2e/print-layout.spec.ts +++ b/apps/site/tests/e2e/print-layout.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from '@playwright/test'; +import { expect, test, type Page } from '../../scripts/deploy/access-browser'; async function openForPrint(page: Page, path: string) { await page.emulateMedia({ media: 'print' }); diff --git a/apps/site/tests/e2e/screenshots.spec.ts b/apps/site/tests/e2e/screenshots.spec.ts index c35f9a59..36cc6e7f 100644 --- a/apps/site/tests/e2e/screenshots.spec.ts +++ b/apps/site/tests/e2e/screenshots.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; -import { expect, test } from '@playwright/test'; +import { expect, test } from '../../scripts/deploy/access-browser'; // Routes are sourced from the built sitemap so dynamic content-collection // routes (projects, events) stay in sync without a hand-maintained list. diff --git a/apps/site/tests/e2e/typography-contracts.spec.ts b/apps/site/tests/e2e/typography-contracts.spec.ts index 2cd8680b..b04fcc10 100644 --- a/apps/site/tests/e2e/typography-contracts.spec.ts +++ b/apps/site/tests/e2e/typography-contracts.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from '@playwright/test'; +import { expect, test, type Page } from '../../scripts/deploy/access-browser'; import { builtHtmlPagePaths } from '../support/sitemap-paths'; const paths = builtHtmlPagePaths(import.meta.url); diff --git a/apps/site/tests/release-artifact.test.ts b/apps/site/tests/release-artifact.test.ts new file mode 100644 index 00000000..5849db89 --- /dev/null +++ b/apps/site/tests/release-artifact.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { packageRelease, verifyRelease } from '../scripts/deploy/release-artifact'; + +const identity = { commit: 'a'.repeat(40), releaseId: '12345' }; + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'lvbt-release-test-')); + const source = path.join(root, 'source'); + const release = path.join(root, 'release'); + await mkdir(path.join(source, 'dist'), { recursive: true }); + await mkdir(path.join(source, '.wrangler/worker'), { recursive: true }); + await writeFile(path.join(source, 'dist/index.html'), '

Arts District

'); + await writeFile(path.join(source, '.wrangler/worker/index.js'), 'export default {}'); + await writeFile(path.join(source, 'wrangler.jsonc'), '{"name":"lvbt-website"}'); + return { root, source, release }; +} + +void test('a release retains reviewed files and identity after its source changes', async () => { + const f = await fixture(); + try { + const release = await packageRelease(f.source, f.release, identity); + await writeFile(path.join(f.source, 'dist/index.html'), '

Newer build

'); + assert.deepEqual(await verifyRelease(f.release), release); + assert.equal( + await readFile(path.join(f.release, 'dist/index.html'), 'utf8'), + '

Arts District

', + ); + assert.equal(release.releaseId, '12345'); + assert.equal( + release.files.some(([name]) => name === '.wrangler/worker/index.js'), + true, + ); + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); + +for (const mutation of ['changed', 'added', 'removed', 'identity', 'symlink']) { + void test(`verification rejects ${mutation} release content`, async () => { + const f = await fixture(); + try { + await packageRelease(f.source, f.release, identity); + const index = path.join(f.release, 'dist/index.html'); + if (mutation === 'changed') await writeFile(index, 'tampered'); + if (mutation === 'added') + await writeFile(path.join(f.release, 'dist/extra.txt'), 'unreviewed'); + if (mutation === 'removed') await rm(index); + if (mutation === 'symlink') { + await rm(index); + await symlink(path.join(f.source, 'dist/index.html'), index); + } + if (mutation === 'identity') { + const manifest = await verifyRelease(f.release); + manifest.commit = 'b'.repeat(40); + await writeFile(path.join(f.release, 'release.json'), JSON.stringify(manifest)); + } + await assert.rejects(verifyRelease(f.release)); + } finally { + await rm(f.root, { recursive: true, force: true }); + } + }); +} + +void test('release packaging rejects prototype routes and symlinked source assets', async () => { + const f = await fixture(); + try { + await mkdir(path.join(f.source, 'dist/prototypes')); + await assert.rejects(packageRelease(f.source, f.release, identity), /preview-only/i); + await rm(path.join(f.source, 'dist/prototypes'), { recursive: true }); + await symlink(path.join(f.source, 'dist/index.html'), path.join(f.source, 'dist/linked.html')); + await assert.rejects(packageRelease(f.source, f.release, identity), /symbolic/i); + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); diff --git a/apps/site/tests/release-source.test.ts b/apps/site/tests/release-source.test.ts new file mode 100644 index 00000000..2f09dbfd --- /dev/null +++ b/apps/site/tests/release-source.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { releaseSource } from '../scripts/deploy/release-source'; + +const run = { + id: 123, + run_attempt: 1, + name: 'Deploy staging', + path: '.github/workflows/deploy-production.yml', + event: 'push', + head_branch: 'main', + head_sha: 'a'.repeat(40), + status: 'completed', + conclusion: 'success', + repository: { full_name: 'LasVegasForTransit/website' }, + head_repository: { full_name: 'LasVegasForTransit/website' }, +}; +void test('promotion selects the immutable successful main staging run', () => { + assert.deepEqual(releaseSource(run, 'LasVegasForTransit/website', '123'), { + commit: 'a'.repeat(40), + releaseId: '123', + }); +}); + +void test('retrying a failed staging job preserves the previously saved release identity', () => { + assert.deepEqual(releaseSource({ ...run, run_attempt: 2 }, 'LasVegasForTransit/website', '123'), { + commit: 'a'.repeat(40), + releaseId: '123', + }); +}); +for (const mutation of [ + { event: 'pull_request' }, + { head_branch: 'feature' }, + { status: 'in_progress' }, + { conclusion: 'failure' }, + { path: '.github/workflows/deploy-worker-preview.yml' }, + { name: 'Deploy production' }, + { id: 124 }, + { head_repository: { full_name: 'outsider/website' } }, +]) { + void test(`promotion rejects ${JSON.stringify(mutation)}`, () => + assert.throws(() => + releaseSource({ ...run, ...mutation }, 'LasVegasForTransit/website', '123'), + )); +} diff --git a/apps/site/tests/staging-environment.test.ts b/apps/site/tests/staging-environment.test.ts new file mode 100644 index 00000000..d9903289 --- /dev/null +++ b/apps/site/tests/staging-environment.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { shouldEnable } from '@lasvegasfortransit/analytics'; +import { + accessHeaders, + accessCredentials, + accessRequestOptions, +} from '../scripts/deploy/access-auth'; +import { deploymentResponse } from '../functions/_middleware'; + +void test('Access headers are supplied only to the configured HTTPS origin', () => { + const credentials = { clientId: 'test-id', clientSecret: 'test-secret' }; + const origin = 'https://preview.lasvegasfortransit.org'; + assert.equal( + accessHeaders(`${origin}/about`, origin, credentials)['CF-Access-Client-Id'], + 'test-id', + ); + for (const url of [ + 'https://example.com/', + 'https://preview.lasvegasfortransit.org.evil.test/', + 'http://preview.lasvegasfortransit.org/', + ]) { + assert.deepEqual(accessHeaders(url, origin, credentials), {}); + } + assert.throws(() => accessCredentials({ CF_ACCESS_CLIENT_ID: 'id' }), /both/); + assert.equal(accessCredentials({}), undefined); +}); + +void test('API and browser request options authenticate the target without following redirects', () => { + const origin = 'https://preview.lasvegasfortransit.org'; + const credentials = { clientId: 'test-id', clientSecret: 'test-secret' }; + assert.deepEqual(accessRequestOptions(`${origin}/sitemap-0.xml`, origin, credentials), { + headers: { 'CF-Access-Client-Id': 'test-id', 'CF-Access-Client-Secret': 'test-secret' }, + maxRedirects: 0, + }); + assert.deepEqual(accessRequestOptions('https://example.com/', origin, credentials), { + headers: {}, + maxRedirects: 0, + }); + assert.deepEqual(accessRequestOptions('http://localhost/sitemap-0.xml', 'http://localhost'), { + headers: {}, + maxRedirects: 0, + }); +}); + +void test('preview responses cannot be indexed or cached, including redirects', () => { + for (const status of [200, 302, 404]) { + const response = deploymentResponse(new Response(null, { status }), 'preview'); + assert.equal(response.headers.get('x-robots-tag'), 'noindex, nofollow, noarchive'); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.status, status); + } + const publicResponse = deploymentResponse(new Response('public'), 'production'); + assert.equal(publicResponse.headers.get('x-robots-tag'), null); +}); + +void test('the same production analytics bundle stays disabled on every preview hostname', () => { + const input = { + site: 'lasvegasfortransit.org', + token: 'public-token', + pathname: '/', + gpc: false, + dnt: false, + framed: false, + }; + for (const hostname of [ + 'preview.lasvegasfortransit.org', + 'pr-123-lvbt-website-preview.las-vegas-for-better-transit.workers.dev', + ]) { + assert.equal(shouldEnable({ ...input, hostname }).enabled, false); + } + assert.equal(shouldEnable({ ...input, hostname: 'lasvegasfortransit.org' }).enabled, true); +}); diff --git a/apps/site/tests/worker-candidate-workflow.test.ts b/apps/site/tests/worker-candidate-workflow.test.ts index 84badfca..18617bfb 100644 --- a/apps/site/tests/worker-candidate-workflow.test.ts +++ b/apps/site/tests/worker-candidate-workflow.test.ts @@ -11,22 +11,27 @@ const previewWorkflowUrl = new URL( import.meta.url, ); -void test('Worker candidates remain parallel to Pages production', async () => { +void test('production promotion requires an explicit selection and never rebuilds', async () => { const workflow = await readFile(workflowUrl, 'utf8'); - assert.match(workflow, /workflows: \[Deploy production\]/); - assert.match(workflow, /github\.event\.workflow_run\.conclusion == 'success'/); - assert.match(workflow, /CLOUDFLARE_WORKERS_CANDIDATE_ENABLED == 'true'/); - assert.match(workflow, /pnpm worker:preview/); - assert.match(workflow, /pnpm worker:test:live/); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /run_id:/); + assert.doesNotMatch(workflow, /workflow_run:/); + assert.match(workflow, /pnpm worker:release source/); + assert.match(workflow, /run-id: \$\{\{ inputs.run_id \}\}/); + assert.match(workflow, /pnpm worker:release verify/); + assert.match(workflow, /pnpm worker:release upload/); + assert.match(workflow, /pnpm worker:release activate/); assert.match(workflow, /pnpm worker:test:browser/); - assert.doesNotMatch(workflow, /wrangler deploy/); - assert.doesNotMatch(workflow, /worker:deploy/); + assert.doesNotMatch(workflow, /pnpm build|pnpm check\s|wrangler deploy|worker:deploy/); }); void test('Worker builds use the production analytics contract', async () => { const workflows = await Promise.all( - [workflowUrl, previewWorkflowUrl].map((url) => readFile(url, 'utf8')), + [ + new URL('../../../.github/workflows/deploy-production.yml', import.meta.url), + previewWorkflowUrl, + ].map((url) => readFile(url, 'utf8')), ); for (const workflow of workflows) { @@ -34,3 +39,15 @@ void test('Worker builds use the production analytics contract', async () => { assert.match(workflow, /LVBT_REQUIRE_ANALYTICS: '1'/); } }); + +void test('main deploys staging, stores hidden compiled files and never activates production', async () => { + const workflow = await readFile( + new URL('../../../.github/workflows/deploy-production.yml', import.meta.url), + 'utf8', + ); + assert.match(workflow, /name: Deploy staging/); + assert.match(workflow, /include-hidden-files: true/); + assert.match(workflow, /worker:release package/); + assert.match(workflow, /worker:release activate --target preview/); + assert.doesNotMatch(workflow, /--target production|deploy-cloudflare-pages/); +}); diff --git a/apps/site/worker-configuration.d.ts b/apps/site/worker-configuration.d.ts index 63b333d1..e7f153ad 100644 --- a/apps/site/worker-configuration.d.ts +++ b/apps/site/worker-configuration.d.ts @@ -1,8 +1,9 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: f91e94b7377d5d1b9b8261edcb4e9a7a) +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 4869a2ef1757cceddc057ab664adb02c) interface __BaseEnv_Env { PLATFORM_DB: D1Database; ASSETS: Fetcher; + LVBT_DEPLOYMENT_ENV: "preview" | "production"; } declare namespace Cloudflare { interface GlobalProps { @@ -11,7 +12,14 @@ declare namespace Cloudflare { interface PreviewEnv { PLATFORM_DB: D1Database; ASSETS: Fetcher; + LVBT_DEPLOYMENT_ENV: "preview"; } interface Env extends __BaseEnv_Env {} } interface Env extends __BaseEnv_Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} diff --git a/apps/site/wrangler.jsonc b/apps/site/wrangler.jsonc index 048bf1bd..ac2fb4cb 100644 --- a/apps/site/wrangler.jsonc +++ b/apps/site/wrangler.jsonc @@ -6,6 +6,7 @@ "main": ".wrangler/worker/index.js", "workers_dev": false, "preview_urls": true, + "vars": { "LVBT_DEPLOYMENT_ENV": "production" }, "assets": { "directory": "./dist", "binding": "ASSETS", @@ -42,6 +43,14 @@ ], "env": { "preview": { + "vars": { "LVBT_DEPLOYMENT_ENV": "preview" }, + "routes": [{ "pattern": "preview.lasvegasfortransit.org", "custom_domain": true }], + "assets": { + "directory": "./dist", + "binding": "ASSETS", + "not_found_handling": "404-page", + "run_worker_first": true, + }, "d1_databases": [ { "binding": "PLATFORM_DB", diff --git a/docs/explanation/events-pipeline.md b/docs/explanation/events-pipeline.md index 0cc788a7..27664b96 100644 --- a/docs/explanation/events-pipeline.md +++ b/docs/explanation/events-pipeline.md @@ -1,8 +1,8 @@ # Events pipeline This page explains how events get onto the site — for anyone adding an event or touching the events -code. The short version: you create events in Google Calendar, and the site pulls them in -automatically when it rebuilds. +code. The short version: you create events in Google Calendar, and the site pulls them in when a +rebuilt release is promoted. Events on the site come from a public Google Calendar. The site rebuilds against the calendar on a schedule; the calendar is the source of truth for both event metadata (title, time, location, join @@ -122,12 +122,13 @@ the calendar's UID isn't user-friendly enough to use as the slug. A GitHub Actions (GitHub's built-in automation that runs scripts on a schedule or on each push) scheduled workflow — a "cron" job, meaning it runs on a fixed timetable — at [`.github/workflows/cron-rebuild.yml`](../../.github/workflows/cron-rebuild.yml) fires twice a day -(roughly morning and evening PT) and dispatches the `Deploy production` workflow. The build -re-fetches the calendar before the Worker version is deployed. +(roughly morning and evening PT) and dispatches the `Deploy staging` workflow. The build re-fetches +the calendar and updates the protected preview site. Production changes only after an editor +promotes that successful run with `Promote website release`. Why twice a day: event metadata changes a few times a week at most; morning and evening rebuilds -keep the site current without unnecessary deployments. Tighten the cron in the workflow file if -events start moving faster than that. +keep staging current for review. Tighten the cron in the workflow file if events start moving faster +than that. Why GitHub Actions and not a Cloudflare Worker: the trigger needs zero long-lived credentials this way. The workflow uses the auto-issued `GITHUB_TOKEN`, scope-limited to `actions: write` on this @@ -135,11 +136,13 @@ repo. No PATs, no Worker secrets, no API token rotation. Logs surface in the Act every other deploy. For a same-day correction, push a commit or click **Run workflow** on the `cron-rebuild` (or -`Deploy production`) workflow page — both trigger an immediate redeploy. +`Deploy staging`) workflow page. Review the updated event information on staging, then promote that +run to publish the correction. -Worst-case staleness: ~12 hours (gap between the morning and evening rebuilds) + however long +Staging refresh delay: ~12 hours (gap between the morning and evening rebuilds) + however long Google's ICS edge cache holds (typically near-instant, can be a few hours for public calendars). For -anything time-sensitive, hit **Run workflow** in the Actions UI rather than wait. +anything time-sensitive, build and promote an updated release in the Actions UI. Production has no +automatic freshness guarantee while a release awaits review. ## Failure modes the build will surface diff --git a/docs/guides/test-the-workers-candidate.md b/docs/guides/test-the-workers-candidate.md index e5adba4c..bceadbd6 100644 --- a/docs/guides/test-the-workers-candidate.md +++ b/docs/guides/test-the-workers-candidate.md @@ -1,121 +1,79 @@ -# Test the Workers candidate +# Review and promote a website release -A candidate is a version of the production Worker that receives a versioned preview URL before -deployment. It uses the production bindings, so browser tests use non-destructive data. The public -hostnames stay on the previously deployed version until candidate checks pass. +Use the permanent preview site to review changes, then publish a selected saved release. A merge +into `main` updates staging and leaves the public website on its last promoted release. ## Check locally -Install the pinned toolchain and run the complete repository check: +Use the pinned Node and pnpm versions, then run: ```sh pnpm bootstrap pnpm check ``` -The check compiles the existing Pages Functions into one Worker, validates the upload bundle, and -starts it on an unused local port. A passing result includes `Worker parity checks passed.` +For interactive local inspection, run `pnpm -C apps/site worker:dev`. Local requests use local +storage. Missing integration secrets intentionally return `503 service_unavailable`. -For interactive inspection, build and start the Worker directly: +## Configure protected staging -```sh -pnpm -C apps/site worker:dev -``` +1. Open the `lvbt-website-preview` Worker in Cloudflare, then **Access**. +2. Configure Worker-specific protection with scope **All traffic** and the existing LVBT staff + Access policy. Do not enable an account-wide policy. +3. Create a dedicated preview verification service token. Add a **Service Auth** policy including + that exact token, and attach it to this Worker alongside the staff policy. +4. Save its client ID and secret in the website repository's `worker-preview` GitHub environment as + `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET`. Use secret input prompts; never place + credentials in command arguments, documentation, or release artifacts. +5. Confirm the existing `CLOUDFLARE_WORKERS_API_TOKEN` environment secret and repository-level + `CLOUDFLARE_ACCOUNT_ID` variable are present. +6. After Access is configured, attach `preview.lasvegasfortransit.org` as a custom domain of + `lvbt-website-preview`. The matching configuration is in `apps/site/wrangler.jsonc`. -Open the printed local URL. Visit the home page, an ordinary content page, and an unknown path. -Submit only test payloads to local API routes; missing secrets intentionally return -`503 service_unavailable`. - -## Enable pull request previews - -Create the `worker-preview` GitHub environment: repository → **Settings → Environments → New -environment**, type `worker-preview`, then **Configure environment**. If it is already listed, open -it instead — nothing below needs redoing. - -Create its token: open `https://dash.cloudflare.com//api-tokens`, click **Create -Token**, then **Create Custom Token** → **Get started** (there is no ready-made template this -narrow). Name it `lvbt-website-preview (GitHub Actions)`. Under **Permissions**, add one row: -**Account · Workers Scripts · Edit** — this uploads Worker versions but cannot touch a zone's routes -or DNS. Under **Account Resources**, choose **Include** and the LVBT account. Leave **Zone -Resources** at its default. Leave the TTL empty. Click **Continue to summary**, then **Create -Token**, and copy it: Cloudflare shows it only once. - -Under the `worker-preview` environment's **Environment secrets**, click **Add environment secret**, -name it `CLOUDFLARE_WORKERS_API_TOKEN`, and paste the token — or run -`gh secret set CLOUDFLARE_WORKERS_API_TOKEN --env worker-preview` and paste it at the prompt. -`CLOUDFLARE_ACCOUNT_ID` is not secret and is read by jobs that declare no environment at all (the -fork-safety check in `deploy-preview.yml`), so add it once as a plain repository variable instead: -**Settings → Secrets and variables → Actions → Variables → New repository variable**, or -`gh variable set CLOUDFLARE_ACCOUNT_ID`. Skip creating it again if it already exists — every -workflow that needs a Cloudflare account ID reads this same one. - -Set the repository variable `CLOUDFLARE_WORKERS_PREVIEW_ENABLED` to `true` after -`pnpm -C apps/site worker:upload --env preview` succeeds for `lvbt-website-preview`, the separate -Worker that pull request previews use. Re-run the pull request workflow and open the -`Worker candidate` link in its comment. - -The preview workflow runs the complete Playwright suite against the Worker URL. The same checks run -locally against an uploaded candidate: +Anonymous requests must reach Access rather than the website. Staff should see the site after +signing in. Automation uses the service token; it does not require an interactive browser login. +Preview integrations use separate test credentials and the preview database. -```sh -pnpm -C apps/site worker:test:live \ - --pages https://lasvegasfortransit.org \ - --worker https://-lvbt-website-preview..workers.dev \ - --skip-api -PLAYWRIGHT_BASE_URL=https://-lvbt-website-preview..workers.dev \ - pnpm -C apps/site worker:test:browser -``` +## Review a pull request + +Open the Worker URL in the pull request comment and sign in through Access. The URL represents that +PR's uploaded version; it does not move the permanent preview site. Fork PRs receive no deployment +secrets. + +Inspect phone and desktop layouts, navigation, nested routes, event calendars, and the branded 404 +page. Use test data for preview APIs. Browser contract checks run automatically through Access. +Visual and accessibility audits also remain available through the ordinary audit workflow. + +## Review staging + +After `Deploy staging` succeeds, open [the preview site](https://preview.lasvegasfortransit.org). +Record the commit and release ID from its Actions summary. `/lvbt-release.json` shows which release +the domain currently serves; record its originating Actions run ID for promotion. + +A new main build can replace this domain while review is ongoing. Review the version URL recorded in +that run's upload step when you need to return to the selected build. + +## Publish the reviewed release + +1. Open **Actions → Promote website release → Run workflow** in the website repository. +2. Select the `main` branch. +3. Enter the successful **Deploy staging Actions run ID** you reviewed. +4. Run the workflow and satisfy any GitHub environment approval required by the repository. + +The workflow rejects invalid source runs, downloads the saved artifact, verifies every file, uploads +a candidate with production bindings, and runs browser checks. It activates the candidate's exact +version ID only after those checks pass. It does not rebuild or promote preview data. + +After success, verify [the public site](https://lasvegasfortransit.org), a nested page, an event +calendar, and the `www` redirect. Check `/lvbt-release.json` against the selected release and the +recorded production version in the workflow summary. + +## Refresh calendar content or roll back + +For a calendar correction, run **Deploy staging**, review the refreshed events, then promote its +run. Scheduled calendar refreshes also stop at staging. -Inspect the navigation at phone and desktop widths. Check the browser console, refresh a nested -route, follow the sitemap redirect, open an event calendar file, and exercise each API with test -credentials. Confirm Cloudflare Web Analytics does not record the preview hostname. - -## Verify a main candidate - -Create a separate `worker-candidate` GitHub environment the same way: **Settings → Environments → -New environment**, type `worker-candidate`, **Configure environment**. It needs its own -`CLOUDFLARE_WORKERS_API_TOKEN` environment secret — create a second custom token exactly as above -(**Account · Workers Scripts · Edit**, scoped to the LVBT account; name it -`lvbt-website candidate (GitHub Actions)` so it reads differently from the preview one in the token -list) and add it under this environment's **Environment secrets**. It reads the same -`CLOUDFLARE_ACCOUNT_ID` repository variable created above — do not make a second copy. Set the -repository variable `CLOUDFLARE_WORKERS_CANDIDATE_ENABLED` to `true` only after the preview workflow -passes. - -Each successful `Deploy production` build starts `Deploy Worker candidate` for the same commit. The -workflow uploads a version with the stable `candidate` preview alias and runs the browser suite. -With `LVBT_WORKERS_PRODUCTION_ENABLED=true`, it deploys that version only after confirming that -`main` still points at the tested commit. Use **Run workflow** on `main` to repeat the release -without another build trigger. - -Record the commit, version, and preview URL from the workflow summary with the release. - -## Check production - -After `Deploy Worker candidate` succeeds, compare `https://lasvegasfortransit.org` with the version -URL recorded in its workflow summary. Check `https://www.lasvegasfortransit.org` redirects to the -apex over valid TLS. Follow a content link, refresh a nested page, inspect an unknown path, and -check an event calendar file. Confirm that production includes the Cloudflare Web Analytics beacon -while version previews do not. - -The `lvbt-website` Worker owns both public hostnames as custom domains. The Pages project has no -custom-domain attachment. Its `lvbt-website-5zh.pages.dev` address remains available for emergency -rollback; an ordinary release regression rolls back the Worker version without changing DNS. The -[deployment pipeline](../reference/deployment-pipeline.md#rollback) records the recovery path. - -## Record acceptance - -Record the commit, Worker version, preview URL, and check time in the release record. Compare these -behaviors before and after deployment: - -| Surface | Required result | -| --------------------- | --------------------------------------------------------------------- | -| `/` and content pages | Status, HTML, canonical metadata, and navigation match the candidate | -| unknown path | Branded `404.html` with status 404 | -| `_headers` | Security and cache headers match the candidate | -| `_redirects` | Every permanent redirect returns 301 to the same location | -| `/events/*.ics` | `text/calendar; charset=utf-8` | -| `/api/*` | Status, CORS, validation, and downstream behavior match the candidate | -| analytics | Production hostname included; preview hostname excluded | - -Keep the previous Worker version and the Pages fallback available until the production check passes. +To republish an older release, select its successful staging run while its artifact is retained. For +urgent recovery using a recorded Worker version, follow the +[rollback reference](../reference/deployment-pipeline.md#rollback). Never rebuild an expired +artifact and treat the new output as the previously reviewed release. diff --git a/docs/reference/deployment-pipeline.md b/docs/reference/deployment-pipeline.md index 14ff73a9..ccfa4870 100644 --- a/docs/reference/deployment-pipeline.md +++ b/docs/reference/deployment-pipeline.md @@ -1,119 +1,117 @@ # Deployment pipeline -GitHub Actions owns builds and deployment. A change is built from a clean checkout, validated, and -sent to Cloudflare with the package and Wrangler versions recorded in the repository. - -The `lvbt-website` Worker serves `lasvegasfortransit.org` and `www.lasvegasfortransit.org` through -Cloudflare custom domains. The former Pages project stays available at `lvbt-website-5zh.pages.dev` -as a rollback artifact; it owns neither public hostname. - -## Build contract - -`pnpm build` creates the static site in `apps/site/dist/`, builds the Pagefind index, and compiles -`apps/site/functions/` into `apps/site/.wrangler/worker/index.js`. Wrangler serves the static tree -through the `ASSETS` binding and invokes the Worker first only for `/api/*`. - -`pnpm check` covers the vendored LVBT standard, formatting, Markdown lint, the organization's -repository-shape rules, lint, type checks, unit tests, documentation links, the production build, -generated Worker binding types, a Wrangler dry run, and local Worker parity. Turborepo runs the -site's part of it in the order `apps/site/turbo.json` declares; the build sees the `PUBLIC_LVBT_*` -and analytics variables that file lists, and no others. The parity check starts the built Worker on -an unused local port and verifies: - -- the home page and branded 404 response; -- security headers and the calendar MIME override; -- the permanent `/get-involved` redirect; -- execution of the compiled subscription API. - -The checked Worker configuration lives in `apps/site/wrangler.jsonc`. Production custom domains are -attached to the existing Worker in Cloudflare. Version uploads do not change those domains, and the -deployment token cannot edit DNS or routes. - -## Pull requests - -Every pull request receives ordinary validation. Same-repository pull requests also receive Pages -and Worker previews. - -The `Deploy Worker preview` workflow runs when the repository variable -`CLOUDFLARE_WORKERS_PREVIEW_ENABLED` is `true`. Its token comes only from the `worker-preview` -GitHub environment. The workflow uploads a version of the separate `lvbt-website-preview` Worker -without deploying it. That Worker is the `preview` environment in `apps/site/wrangler.jsonc` and -uses the preview platform database, so test data never reaches the production database. The workflow -verifies the versioned preview URL, runs the Playwright accessibility and visual suites against the -edge deployment, and updates one pull request comment. Forks never receive the token. The Pages -comparison runs only when Workers production is disabled. - -The preview environment contains: - -| Setting | Kind | Purpose | -| ------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `CLOUDFLARE_ACCOUNT_ID` | repository variable | Selects the LVBT Cloudflare account. It is a repository variable, not scoped to this environment, because jobs with no environment of their own (the fork-safety check in `deploy-preview.yml`) also read it | -| `CLOUDFLARE_WORKERS_API_TOKEN` | environment secret | Uploads versions for `lvbt-website` and `lvbt-website-preview` without editing zones | -| `CLOUDFLARE_WORKERS_PREVIEW_ENABLED` | repository variable | Enables the candidate workflow after credentials are verified | - -See [test the Workers candidate](../guides/test-the-workers-candidate.md) for the exact clicks to -create the environment and its token. - -Application secrets bind directly to the Worker before endpoint acceptance. Build-time -`PUBLIC_LVBT_*` variables remain GitHub Actions variables and are included in the generated HTML. - -## Platform database - -The Organizing Platform keeps its data in Cloudflare D1 (a SQL database; see the -[glossary](./glossary.md#d1)). Code reaches it through the `PLATFORM_DB` -[binding](./glossary.md#binding), which `apps/site/wrangler.jsonc` points at a different database -for each Worker: - -| Worker | Used by | Database | -| ---------------------- | --------------------------------------------- | ----------------------- | -| `lvbt-website` | production, and the `main` candidate versions | `lvbt-platform` | -| `lvbt-website-preview` | pull request previews | `lvbt-platform-preview` | - -Both databases are on the free plan in Western North America. `pnpm dev` and -`pnpm -C apps/site worker:dev` use a local copy that Wrangler keeps in `apps/site/.wrangler/`, so -local work never touches either one. The -[platform decision record](../explanation/decisions/organizing-platform.md) explains why there is -one database. - -## Production - -`Deploy production` builds `main`. With `LVBT_WORKERS_PRODUCTION_ENABLED=true`, its Pages job is -skipped. The Pages project remains online at its `pages.dev` address but receives no new production -builds. - -After a successful build, `Deploy Worker candidate` uploads the same `main` commit as a versioned -Worker. It runs the browser acceptance suite, records the commit, version, and preview URL, and -confirms that `main` still points at the verified commit before deploying that version. The final -check compares the production hostname with the version preview. A manual run is accepted only from -`main`. - -The `worker-candidate` GitHub environment contains `CLOUDFLARE_WORKERS_API_TOKEN`, scoped to the -LVBT account with Workers Scripts Edit permission. It reads the account ID from the repository-level -`CLOUDFLARE_ACCOUNT_ID` variable. The separate `worker-preview` environment has its own token. -Neither token can change zone DNS or Worker routes. The repository-level `CLOUDFLARE_API_TOKEN` -remains for Pages pull request previews; it is not used for production Worker deployment. - -Cloudflare owns the DNS records and certificates for the two Worker custom domains. The former `/*` -overlay routes and Pages custom-domain associations are absent. The Pages deployment remains -reachable through its `pages.dev` address for emergency recovery. +GitHub Actions builds one release from `main`, deploys it to protected staging, and stores its files +for explicit production promotion. Merging a pull request updates staging; it does not publish the +public website. + +## Hosts and environments + +| Host | Worker | Data | Update trigger | +| --------------------------------------------------------- | ---------------------------------- | ----------------------- | -------------------------- | +| `lasvegasfortransit.org` and `www.lasvegasfortransit.org` | `lvbt-website` | `lvbt-platform` | Explicit promotion | +| `preview.lasvegasfortransit.org` | `lvbt-website-preview` | `lvbt-platform-preview` | Successful main build | +| Versioned PR Worker URLs | Versions of `lvbt-website-preview` | `lvbt-platform-preview` | Same-repository PR updates | + +Staging and PR previews share a preview database. They do not have a separate database per PR. +Production secrets remain bound to the production Worker. Preview integrations need separate test +credentials; missing credentials make affected endpoints unavailable. Promotion moves code and +assets, never databases or test records. + +The former Pages project remains at `lvbt-website-5zh.pages.dev` as an older public recovery option. +New Pages PR deployments are disabled so they cannot expose an unprotected copy of private work. + +## Build and artifact contract + +`pnpm check` covers the repository standard, formatting, lint, types, tests, documentation links, +production build, generated Worker types, Wrangler dry run, and local Worker parity. `pnpm build` +writes static assets and Pagefind to `apps/site/dist/` and compiled Pages Functions to +`apps/site/.wrangler/worker/`. + +`Deploy staging` retains a `website-release-` Actions artifact for 90 days. It contains only +the compiled Worker, static assets, Wrangler configuration, and `release.json`. The manifest records +the commit, release ID, sorted file inventory, and SHA-256 hashes. The `/lvbt-release.json` response +identifies the deployed release. Prototype routes and the audit-only language cannot enter a +promotable artifact. + +Promotion downloads this artifact from its originating run, verifies its identity and all files, and +uploads a temporary copy with bundling disabled. It performs no site or Worker rebuild. Production +and staging have different Worker version IDs because their bindings differ; their compiled code and +asset files come from the same artifact. + +## Cloudflare Access + +The preview Worker must have a Worker-specific Access policy covering **All traffic**. This covers +the custom staging domain and versioned PR URLs without protecting unrelated public Workers. Staff +authenticate with the existing LVBT staff identity policy. Automation uses a dedicated Access +service token in a **Service Auth** policy attached only to this Worker. + +The `worker-preview` GitHub environment supplies: + +| Secret | Purpose | +| ------------------------------ | --------------------------------------------- | +| `CLOUDFLARE_WORKERS_API_TOKEN` | Upload and activate preview versions | +| `CF_ACCESS_CLIENT_ID` | Identify the preview verification service | +| `CF_ACCESS_CLIENT_SECRET` | Authenticate the preview verification service | + +`CLOUDFLARE_ACCOUNT_ID` remains a repository variable. Access credentials are supplied only to the +specific preview origin. HTTP verification disables automatic redirects; browser verification +fetches authenticated responses without following redirects, then lets the browser navigate the +returned response. Third-party origins receive no Access headers. + +Preview responses carry `X-Robots-Tag: noindex, nofollow, noarchive` and +`Cache-Control: private, no-store`. Preview runs the Worker before every asset request to apply +those headers. Production uses selective Worker routing for application endpoints and ordinary asset +delivery for static pages. The shared analytics package enables tracking only on the public apex and +`www` hostname, so the production-shaped artifact sends no analytics on staging or PR URLs. + +A failed staging job can be retried with **Re-run failed jobs**. Each run ID identifies one +immutable artifact; **Re-run all jobs** cannot replace an artifact that already exists. Dispatch a +new staging run when a rebuild is needed. + +## Main and PR deployment + +Main pushes and scheduled calendar rebuilds run `Deploy staging`. The workflow validates and saves +the artifact, uploads a preview version, checks anonymous denial and authenticated browser +rendering, runs browser contract checks, activates that exact preview version, and checks the +permanent staging hostname. Its summary records the release, commit, version, and originating run. + +Same-repository PRs use `Deploy Worker preview` when `CLOUDFLARE_WORKERS_PREVIEW_ENABLED=true`. It +uploads a preview version, verifies Access and browser contracts, and comments its URL on the PR. PR +uploads never activate a version or move the permanent staging domain. Forks receive no Cloudflare +or Access secrets. PRs may include preview-only prototype pages; those builds cannot be promoted. + +## Production promotion + +`Promote website release` accepts the originating **Actions run ID**, and runs only from `main`. It +requires a completed, successful `Deploy staging` run from this repository on `main`, triggered by a +push or manual dispatch. PR runs, old automatic production runs, failed runs, and foreign +repositories are rejected. + +The workflow downloads the selected run's artifact, verifies it against the recorded commit, uploads +a production candidate, checks its release marker and browser contracts, and activates its exact +version ID. It then checks the public release marker and rendered page. Staging and `main` may +advance during this process without changing the selected artifact. + +The existing `worker-candidate` GitHub environment supplies the production Workers token. Its +historical name is retained to reuse the established credential scope. Environment reviewers may +provide an additional publication gate. Dispatching this workflow is the explicit publication +request; nothing automatically dispatches it. ## Rollback -For a Worker release regression, `wrangler rollback --message ` sends all -Worker traffic to the recorded version without changing custom domains or DNS. Verify both public -hostnames afterward. +A previous successful staging run can be promoted again while its artifact is retained. For urgent +Worker recovery, use `wrangler rollback ` with the recorded production version and +verify both public hostnames. Deleted or incompatible data bindings can prevent rollback; schema +migrations require separate compatible rollout planning. -If the Worker itself cannot serve traffic, remove each Worker custom domain, restore the Pages -custom-domain association and its proxied Pages CNAME, then verify TLS and the site contract on both -hostnames. The Pages deployment remains accessible at `lvbt-website-5zh.pages.dev` throughout this -procedure. Restoring Pages changes routing and requires a separate incident decision; it is not the -response to an ordinary bad release. +If the Worker itself cannot serve traffic, restoring the older Pages project requires a separate +routing decision and DNS/custom-domain changes. Normal release rollback changes no domains. Expired +Actions artifacts cannot be silently rebuilt and presented as reviewed releases. Build a new release +and review it, or use an already recorded compatible Worker version. -The rollback version must retain every binding used by that release. Deleted or incompatible storage -bindings prevent Cloudflare from applying an older version. +## Caching and external content -## Caching - -HTML revalidates on every request. Astro gives changed static assets new hashed filenames, while -`_headers` assigns long-lived immutable caching to `/_astro/*` and `/fonts/*`. Normal releases do -not require a cache purge. +Production HTML revalidates; hashed assets retain their existing immutable cache headers. Staging is +private and does not cache responses. Calendar, newsletter, and other build-time content is captured +in the saved release. Scheduled refreshes update staging only; public content becomes current when +an updated release is promoted. diff --git a/docs/superpowers/specs/2026-10-01-staging-promotion.md b/docs/superpowers/specs/2026-10-01-staging-promotion.md new file mode 100644 index 00000000..5e28c86d --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-staging-promotion.md @@ -0,0 +1,33 @@ +# Website staging and release promotion + +## Purpose + +Give maintainers a persistent, protected website at `preview.lasvegasfortransit.org` and an explicit +way to publish the release they reviewed to `lasvegasfortransit.org`. + +## Release contract + +- Successful builds of `main` update staging after repository validation. +- Each release records its commit, originating Actions run, file inventory, and SHA-256 digest. +- Store the production-shaped static assets and compiled Worker together. Promotion verifies and + reuses those files, even after a newer release updates staging. +- Staging uses the existing preview database and Cloudflare Access. Production uses its own database + and secrets. Test data and Access settings never travel with the release. +- Suppress analytics and indexing through staging runtime settings, keeping release files identical. +- Production deployment requires an explicit Actions dispatch naming a successful staging run. +- Validate the production candidate before activating its exact Worker version; verify afterward. +- PR previews continue independently. Automated HTTP and browser checks authenticate only to their + intended preview origin, never forwarding Access credentials to external links or redirects. +- Existing production rollback remains available. Database migrations require separate review. + +## Delivery boundaries + +Configure Access before exposing staging. Reuse the organization's existing identity provider and +reviewer policy where available. Keep service credentials out of source, artifacts, and logs. + +## Acceptance + +Tests reject modified or incomplete artifacts, unsafe paths, untrusted workflow runs, and unpinned +promotion. Live staging denies anonymous access and serves the recorded release to authorized +reviewers and automated checks. Production remains public and changes only through explicit +promotion. The approved production version is recorded for verification and rollback. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 943e0eb1..4fc57a59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,24 +166,15 @@ importers: apps/site: dependencies: - '@astrojs/mdx': - specifier: 'catalog:' - version: 8.0.1(@astrojs/markdown-satteri@0.4.1)(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@astrojs/sitemap': specifier: 'catalog:' version: 3.7.4 - '@lasvegasfortransit/analytics': - specifier: 'catalog:' - version: 0.1.0(@playwright/test@1.62.1)(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@tailwindcss/typography': specifier: 'catalog:' version: 0.5.20(tailwindcss@4.3.3) '@tailwindcss/vite': specifier: 'catalog:' version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - astro: - specifier: 'catalog:' - version: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) astro-icon: specifier: 'catalog:' version: 1.2.0 @@ -206,6 +197,9 @@ importers: '@astrojs/check': specifier: 'catalog:' version: 0.9.10(prettier-plugin-astro@0.14.1)(prettier@3.9.6)(typescript@6.0.3) + '@astrojs/mdx': + specifier: 'catalog:' + version: 8.0.1(@astrojs/markdown-satteri@0.4.1)(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@axe-core/playwright': specifier: 'catalog:' version: 4.13.0(playwright-core@1.62.1) @@ -218,6 +212,9 @@ importers: '@iconify-json/mdi': specifier: 'catalog:' version: 1.2.3 + '@lasvegasfortransit/analytics': + specifier: 'catalog:' + version: 0.1.0(@playwright/test@1.62.1)(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@lasvegasfortransit/eslint-config': specifier: file:../../.lvbt/web-platform/packages/eslint-config version: file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3) @@ -236,6 +233,9 @@ importers: alex: specifier: 'catalog:' version: 11.0.1(supports-color@10.2.2) + astro: + specifier: 'catalog:' + version: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) cspell: specifier: 'catalog:' version: 10.0.0