From 3c53282cb48aecf0ed03737741616da5911d1add Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Fri, 2 Oct 2026 10:55:59 -0700 Subject: [PATCH 1/3] feat: stage main builds and promote reviewed releases to production Merging to main no longer changes the public website. Each main build, including the scheduled calendar refresh, is validated and saved as a release artifact that records its commit, Actions run ID, and the SHA-256 hash of every file. That release is deployed to preview.lasvegasfortransit.org, which sits behind Cloudflare Access. The new "Promote website release" workflow publishes a release only when a maintainer dispatches it with the ID of a successful main "Deploy staging" run. It rejects pull request, failed, and foreign runs, verifies every saved file, uploads the same compiled Worker and assets with production bindings without rebuilding, runs the browser contract checks, and then activates that exact Worker version. Each deployment serves /lvbt-release.json so staging and production can be checked against the selected release. Preview responses carry noindex and private, no-store headers, and analytics stay off on preview hostnames. Automated checks send the Access service token only to the preview origin and never follow a redirect with it. Pull requests no longer get unprotected Cloudflare Pages previews; same-repository pull requests keep their protected Worker previews. Staging needs CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET in the worker-preview environment and an Access policy on the lvbt-website-preview Worker before its custom domain is attached. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cron-rebuild.yml | 8 +- .github/workflows/deploy-preview.yml | 193 --------------- .github/workflows/deploy-production.yml | 114 ++++++--- .github/workflows/deploy-worker-candidate.yml | 182 +++++++-------- .github/workflows/deploy-worker-preview.yml | 23 +- AGENTS.md | 16 +- README.md | 33 ++- apps/site/eslint-suppressions.json | 8 +- apps/site/functions/_middleware.ts | 10 + apps/site/package.json | 4 +- .../scripts/bootstrap/lib/process-runtime.ts | 7 +- apps/site/scripts/bootstrap/phases/deploy.ts | 5 +- apps/site/scripts/deploy/access-auth.ts | 47 ++++ apps/site/scripts/deploy/access-browser.ts | 41 ++++ apps/site/scripts/deploy/release-artifact.ts | 123 ++++++++++ apps/site/scripts/deploy/release-smoke.ts | 71 ++++++ apps/site/scripts/deploy/release-source.ts | 31 +++ apps/site/scripts/deploy/worker-release.ts | 137 +++++++++++ apps/site/tests/e2e/a11y-forms.spec.ts | 2 +- apps/site/tests/e2e/a11y-keyboard.spec.ts | 2 +- apps/site/tests/e2e/a11y-landmarks.spec.ts | 2 +- apps/site/tests/e2e/a11y-patterns.spec.ts | 2 +- .../site/tests/e2e/a11y-pseudo-locale.spec.ts | 2 +- apps/site/tests/e2e/a11y.spec.ts | 2 +- apps/site/tests/e2e/body-links.spec.ts | 32 +-- apps/site/tests/e2e/color-contracts.spec.ts | 2 +- apps/site/tests/e2e/event-metadata.spec.ts | 2 +- apps/site/tests/e2e/footer-colophon.spec.ts | 9 +- apps/site/tests/e2e/header-current.spec.ts | 2 +- apps/site/tests/e2e/not-found-search.spec.ts | 2 +- apps/site/tests/e2e/perf-memory.spec.ts | 2 +- apps/site/tests/e2e/print-layout.spec.ts | 2 +- apps/site/tests/e2e/screenshots.spec.ts | 2 +- .../tests/e2e/typography-contracts.spec.ts | 2 +- apps/site/tests/release-artifact.test.ts | 79 +++++++ apps/site/tests/release-source.test.ts | 45 ++++ apps/site/tests/staging-environment.test.ts | 73 ++++++ .../tests/worker-candidate-workflow.test.ts | 35 ++- apps/site/worker-configuration.d.ts | 10 +- apps/site/wrangler.jsonc | 9 + docs/explanation/events-pipeline.md | 21 +- docs/guides/test-the-workers-candidate.md | 170 +++++--------- docs/reference/deployment-pipeline.md | 220 +++++++++--------- .../specs/2026-10-01-staging-promotion.md | 33 +++ 44 files changed, 1159 insertions(+), 658 deletions(-) delete mode 100644 .github/workflows/deploy-preview.yml create mode 100644 apps/site/functions/_middleware.ts create mode 100644 apps/site/scripts/deploy/access-auth.ts create mode 100644 apps/site/scripts/deploy/access-browser.ts create mode 100644 apps/site/scripts/deploy/release-artifact.ts create mode 100644 apps/site/scripts/deploy/release-smoke.ts create mode 100644 apps/site/scripts/deploy/release-source.ts create mode 100644 apps/site/scripts/deploy/worker-release.ts create mode 100644 apps/site/tests/release-artifact.test.ts create mode 100644 apps/site/tests/release-source.test.ts create mode 100644 apps/site/tests/staging-environment.test.ts create mode 100644 docs/superpowers/specs/2026-10-01-staging-promotion.md 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..02e66791 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -48,7 +48,9 @@ "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:", 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..836d610b --- /dev/null +++ b/apps/site/scripts/deploy/release-smoke.ts @@ -0,0 +1,71 @@ +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 Vegas|LVBT/i); + assert.ok(await page.locator('main').isVisible(), 'The website main content is not visible.'); + if (values.protected) { + assert.equal(response.headers()['x-robots-tag'], 'noindex, nofollow, noarchive'); + 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. From 02448c5deeb20c66f9346e597a09980669e114f5 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Sat, 3 Oct 2026 17:48:20 -0700 Subject: [PATCH 2/3] fix(dx): verify protected Worker previews correctly Release verification accepts the organization name in the live title and Cloudflare's noindex header on versioned Worker URLs. Custom staging domains still require the full privacy header, authentication, and rendering checks. Co-authored-by: Codex --- apps/site/scripts/deploy/release-smoke.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/site/scripts/deploy/release-smoke.ts b/apps/site/scripts/deploy/release-smoke.ts index 836d610b..ce4b6b56 100644 --- a/apps/site/scripts/deploy/release-smoke.ts +++ b/apps/site/scripts/deploy/release-smoke.ts @@ -57,10 +57,14 @@ try { 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 Vegas|LVBT/i); + 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) { - assert.equal(response.headers()['x-robots-tag'], 'noindex, nofollow, noarchive'); + // 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( From e7a6d33c44682ae731603d37b7e388e10c919d7a Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Sat, 3 Oct 2026 17:57:46 -0700 Subject: [PATCH 3/3] fix(dx): classify static site tooling as build dependencies Astro, MDX, and the analytics integration run during the static build and do not ship in the production Worker. Declare them as development dependencies so the production audit reflects the deployed runtime. Co-authored-by: Codex --- apps/site/package.json | 6 +++--- pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/site/package.json b/apps/site/package.json index 02e66791..8d6b14ee 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -53,12 +53,9 @@ "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:", @@ -68,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/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