Web Deploy (Cloudflare Pages) #2519
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Web Deploy (Cloudflare Pages) | |
| # Deploys the web app (web/, a Vite SPA) to Cloudflare Pages — but only AFTER | |
| # the backend it talks to has actually been released. | |
| # | |
| # Why the chain: the SPA used to deploy on every push to main while the Fly | |
| # release ("Deploy to Fly") is gated on Docker + the reliability Ring 1 suite | |
| # ("User Journeys") being green for the same commit. Any Ring 1 red therefore | |
| # parked the backend while the frontend kept shipping, and a frontend built | |
| # against newer node metadata crashed on boot against the old API (17 h of | |
| # version skew on 2026-08-11). So this workflow now workflow_run-triggers on | |
| # "Deploy to Fly" completing, and the `gate` job releases the SPA only when | |
| # that run's "Deploy server" job really ran and succeeded — not when it was | |
| # skipped for a superseded commit (the newer commit's own chain ships both). | |
| # The remaining skew window is the few minutes where the backend is newer than | |
| # the SPA, which is the benign direction: the crash was a NEW frontend | |
| # hydrating OLD metadata, never the reverse. | |
| # | |
| # workflow_run has no path filtering, so the old paths-ignore list is gone: | |
| # every released backend commit also redeploys the SPA. Docker builds on every | |
| # push to main, so web-only changes still flow through; a docs-only commit now | |
| # costs one redundant, idempotent Pages upload. | |
| # | |
| # The web bundle inlines @nodetool-ai/* package SOURCE (the `nodetool-dev` Vite | |
| # resolve condition) and reads generated pricing JSONs produced by | |
| # `build:packages`, so package changes affect the bundle. | |
| # | |
| # Required GitHub config (Settings → Secrets and variables → Actions): | |
| # Secrets: | |
| # CLOUDFLARE_API_TOKEN — token with "Cloudflare Pages: Edit" on the account | |
| # CLOUDFLARE_ACCOUNT_ID — account that owns the Pages project | |
| # Variables: | |
| # CLOUDFLARE_WEB_PAGES_PROJECT — the Cloudflare Pages project name to deploy to | |
| # VITE_API_URL — API base URL (e.g. https://api.nodetool.ai) | |
| # VITE_SUPABASE_URL — Supabase project URL | |
| # VITE_SUPABASE_ANON_KEY — Supabase anon (publishable) key baked into the | |
| # build. It's a Variable, not a Secret: the key is | |
| # public by design (RLS-protected) and is shipped in | |
| # the client bundle anyway, so a Secret would only | |
| # hide it from this workflow, not from users. | |
| # VITE_AUTH_REDIRECT_URL — optional OAuth redirect URL | |
| # | |
| # The deploy job runs in the `web-production` environment, so the secrets/variables | |
| # above must be visible to it: set them either at the repository level or on the | |
| # `web-production` environment itself (Settings → Environments → web-production). | |
| # An env-scoped secret defined only on a DIFFERENT environment (e.g. the | |
| # marketing deploy's `marketing-production`) is NOT available here — it expands to | |
| # an empty string. For the Cloudflare creds, wrangler then fails with error 9106 | |
| # ("Authentication error" / missing Authorization header); for the Supabase build | |
| # vars, the bundle silently falls back to localhost placeholders and breaks auth | |
| # in production. The preflight step below catches both before the deploy runs. | |
| # | |
| # Disconnect the repo from the Cloudflare Pages git integration so the site is | |
| # only deployed from here. | |
| on: | |
| workflow_run: | |
| workflows: ["Deploy to Fly"] | |
| types: [completed] | |
| branches: [main] | |
| # Manual deploy of the current ref, bypassing the backend-release gate — | |
| # for emergencies and web-only hotfixes. | |
| workflow_dispatch: | |
| # The build only reads the repo; the Pages deploy authenticates with its own | |
| # Cloudflare API token. `actions: read` lets the gate inspect the triggering | |
| # Fly run's jobs. | |
| permissions: | |
| contents: read | |
| actions: read | |
| # One deploy at a time; let an in-flight upload finish rather than cancelling it. | |
| concurrency: | |
| group: web-deploy | |
| cancel-in-progress: false | |
| jobs: | |
| # Confirms the triggering "Deploy to Fly" run actually released the backend: | |
| # its "Deploy server" job must have concluded `success`. A `skipped` | |
| # conclusion means Fly's gate declined the release (commit superseded on | |
| # main, or Ring 1/Docker red) — shipping the SPA for that commit would | |
| # recreate exactly the frontend-ahead-of-backend skew this chain exists to | |
| # prevent, so we skip too and let the newer commit's chain deploy both. | |
| gate: | |
| name: Wait for backend release | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' | |
| timeout-minutes: 5 | |
| outputs: | |
| sha: ${{ github.event.workflow_run.head_sha }} | |
| deploy: ${{ steps.check.outputs.deploy }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| steps: | |
| - name: Check the Fly run's deploy job conclusion | |
| id: check | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| repo="${{ github.repository }}" | |
| run_id="${{ github.event.workflow_run.id }}" | |
| sha="${{ github.event.workflow_run.head_sha }}" | |
| conclusion="$(gh api "repos/${repo}/actions/runs/${run_id}/jobs?per_page=100" \ | |
| --jq '[.jobs[] | select(.name == "Deploy server")][0].conclusion // "missing"')" | |
| echo "Fly run ${run_id} (commit ${sha}): 'Deploy server' concluded '${conclusion}'." | |
| if [ "${conclusion}" = "success" ]; then | |
| echo "deploy=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "::notice::Backend was not released for ${sha} ('Deploy server' = '${conclusion}'); skipping the SPA deploy to keep frontend and backend in step." | |
| echo "deploy=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| deploy: | |
| name: Build & deploy to Cloudflare Pages | |
| needs: [gate] | |
| # workflow_dispatch always runs (manual deploy of the current ref); on | |
| # workflow_run the gate must have confirmed the backend release. | |
| if: > | |
| always() && | |
| (github.event_name == 'workflow_dispatch' || | |
| (needs.gate.result == 'success' && needs.gate.outputs.deploy == 'true')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| environment: | |
| name: web-production | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| with: | |
| # Build the SPA at the exact commit whose backend just went live | |
| # (falls back to the dispatch ref for manual runs). | |
| ref: ${{ needs.gate.outputs.sha || github.sha }} | |
| fetch-depth: 1 | |
| # Fail fast with an actionable message if the Cloudflare credentials, the | |
| # Pages project name, or the Supabase build vars are missing, rather than | |
| # letting wrangler fail late with a cryptic "Must specify a project name" or | |
| # 9106 auth error — or, worse, shipping a bundle that silently falls back to | |
| # the `http://localhost` / `public-anon-key` Supabase placeholders in | |
| # web/src/lib/supabaseClient.ts and breaks auth in production with no error. | |
| # Secrets/vars must be visible to the `web-production` environment (see the | |
| # header note); they expand to empty strings when they are not. | |
| - name: Check Cloudflare deploy config | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_WEB_PAGES_PROJECT: ${{ vars.CLOUDFLARE_WEB_PAGES_PROJECT }} | |
| VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }} | |
| VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }} | |
| run: | | |
| missing=() | |
| [ -n "$CLOUDFLARE_API_TOKEN" ] || missing+=("secret CLOUDFLARE_API_TOKEN") | |
| [ -n "$CLOUDFLARE_ACCOUNT_ID" ] || missing+=("secret CLOUDFLARE_ACCOUNT_ID") | |
| [ -n "$CLOUDFLARE_WEB_PAGES_PROJECT" ] || missing+=("variable CLOUDFLARE_WEB_PAGES_PROJECT") | |
| [ -n "$VITE_SUPABASE_URL" ] || missing+=("variable VITE_SUPABASE_URL") | |
| [ -n "$VITE_SUPABASE_ANON_KEY" ] || missing+=("variable VITE_SUPABASE_ANON_KEY") | |
| if [ ${#missing[@]} -ne 0 ]; then | |
| echo "::error::Missing required deploy config: ${missing[*]}" | |
| echo "Set these at the repository level (Settings → Secrets and variables → Actions)" | |
| echo "or on the 'web-production' environment (Settings → Environments → web-production)." | |
| echo "An env-scoped secret on a different environment is NOT visible to this job." | |
| exit 1 | |
| fi | |
| echo "Deploy config present (project: $CLOUDFLARE_WEB_PAGES_PROJECT, Supabase URL: $VITE_SUPABASE_URL)." | |
| # Installs the workspace and builds the backend packages, which generates | |
| # the fal/kie pricing JSONs the Vite build aliases resolve. | |
| - name: Set up Node, install deps, build packages | |
| uses: ./.github/actions/setup-build | |
| with: | |
| build-packages: "true" | |
| - name: Build (Vite) | |
| working-directory: web | |
| run: npx vite build | |
| env: | |
| VITE_API_URL: ${{ vars.VITE_API_URL }} | |
| VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }} | |
| VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }} | |
| VITE_AUTH_REDIRECT_URL: ${{ vars.VITE_AUTH_REDIRECT_URL }} | |
| - name: Deploy to Cloudflare Pages | |
| uses: cloudflare/wrangler-action@v4 | |
| with: | |
| apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| wranglerVersion: "4.85.0" | |
| workingDirectory: web | |
| command: pages deploy dist --project-name=${{ vars.CLOUDFLARE_WEB_PAGES_PROJECT }} --branch=main |