Skip to content

Web Deploy (Cloudflare Pages) #2375

Web Deploy (Cloudflare Pages)

Web Deploy (Cloudflare Pages) #2375

Workflow file for this run

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