Skip to content

CI/CD & Deployment

CI/CD & Deployment

Surfc ships from three Git repositories plus a set of Supabase Edge Functions. Every deploy is Git-triggered and static-host-managed — there is no manual upload step for the websites. This page is the single reference for how each pipeline works and how database migrations are applied and verified.

At a glance

SurfaceRepoHostBuild commandOutputTriggerNode
App (app.surfc.app)surfc/Netlifynpm run builddist/push to main20 (CI)
Help (help.surfc.app)surfc/Cloudflare Pagesnpm run docs:build (vitepress build docs)docs/.vitepress/dist/push to main20
Marketing (surfc.app)surfc-web/Cloudflare Pagesnpm run build (astro build)dist/push to main22
Intranet (intranet.surfc.app)surfc-intranet/Cloudflare Pagesnpm run build (astro build)dist/push to main22
Edge Functionssurfc/Supabasesupabase functions deploy <name>manualDeno 2

The static-host binding (which repo deploys where) is configured in the provider dashboard’s Git connection, not in any in-repo file.

1. surfc/ → Netlify

The React PWA at app.surfc.app.

  • Build: npm run build (vite build) → dist/.
  • Trigger: push to main auto-deploys; main is always deployable.
  • Config: netlify.toml at the repo root:
    • SPA fallback: /*/index.html (200) so deep links resolve.
    • /policies/*https://surfc.app/policies/:splat (301, forced) — policies moved to the marketing site post-SUR-218.
    • /.well-known/change-password/change-password (302).
    • manifest.webmanifest served as application/manifest+json.
  • Node: there is no .nvmrc; CI pins Node 20.
  • Env vars: client-side VITE_* keys (VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_APP_URL, VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, VITE_PUBLIC_POSTHOG_HOST, plus VITE_TURNSTILE_SITE_KEY) are set in the Netlify dashboard, not committed. See Supabase setup for what each one is.

The help.surfc.app VitePress site is built from the same surfc/ repo (npm run docs:build over docs/) and deployed to Cloudflare Pages on the same push-to-main trigger.

2. surfc-web/ → Cloudflare Pages

The Astro marketing site at surfc.app.

  • Build: npm run build (astro build) → dist/.
  • Output mode: output: 'static' with no adapter. The @astrojs/cloudflare adapter was tried and rejected (SUR-256) — its dist/{client,server} split breaks the Cloudflare Pages publish-dir convention. Re-add only if real SSR is needed.
  • Node: .nvmrc = 22; package.json engines.node = >=22.12.0.
  • Cache headers: public/_headers sets Cache-Control: public, max-age=31536000, immutable on /_astro/*, the favicon, og-image.png, and icon-*.png; max-age=86400 on robots.txt.
  • Env vars (set in the Cloudflare Pages dashboard): PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL, PUBLIC_APP_URL, PUBLIC_POSTHOG_PROJECT_TOKEN, PUBLIC_POSTHOG_HOST.
  • Blog publish flow: posts are MDX files in the Astro content collection (src/content/, SUR-256). Publishing a post is just merging it to main — Cloudflare Pages rebuilds and deploys automatically. There is no separate publish script or CMS.
  • CI (.github/workflows/):
    • playwright.yml — Playwright E2E on chromium + mobile-chrome (Node 22); blocks PRs.
    • quality.yml — Lychee link check over dist/**/*.html (.lycheeignore is the escape hatch). Lighthouse CI is currently disabled pending SUR-254 cleanup.

3. surfc-intranet/ → Cloudflare Pages

This repo — the internal wiki + admin at intranet.surfc.app.

  • Build: npm run build (astro build) → dist/.
  • Node: .nvmrc = 22; engines.node = >=22.12.0.
  • Cache headers: public/_headers — immutable 1-year on /_astro/* and the favicon, 1-day on robots.txt.
  • No environment variables, no test/lint/typecheck scripts. npm run build is the only verification gate — it surfaces .astro template errors and Starlight content-collection schema violations (a Markdown file missing title frontmatter fails the build).
  • Access control: the entire intranet.surfc.app domain (covering /, /wiki/*, and /admin/*) sits behind Cloudflare Access (Zero Trust, email allowlist) — live since SUR-283. Cloudflare Pages preview URLs are also gated. Access is enforced at the Cloudflare edge; there is no application-level auth code in this repo. See the Intranet Foundations spec §2.8 for the design rationale.

4. Edge Functions → Supabase

Edge Functions live in surfc/supabase/functions/: anthropic-proxy, approve-waitlist, create-billing-portal-session, create-checkout-session, delete-account, image-upload, me-entitlements, stripe-webhook, waitlist-signup (shared code in _shared/).

  • Deploy (manual, one function at a time):

    Terminal window
    supabase functions deploy <name>
    # e.g.
    supabase functions deploy anthropic-proxy
    supabase functions deploy me-entitlements
  • Secrets are NOT passed on the deploy command. Set every secret in Supabase Dashboard → Settings → Edge Functions → Secrets before deploying. Functions that need missing secrets fail loud at module load (e.g. the Stripe functions require STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID_PRO_MONTHLY, STRIPE_PRICE_ID_PRO_ANNUAL; anthropic-proxy requires ANTHROPIC_API_KEY and the Azure Content Safety trio).

  • JWT verification overrides: supabase/config.toml sets verify_jwt = false for approve-waitlist, waitlist-signup, and stripe-webhook (they authenticate by other means — webhook signature, database trigger, captcha). All other functions verify the Supabase JWT.

  • CI: .github/workflows/edge-functions-test.yml runs a Deno v2 type-check + unit tests whenever supabase/functions/** changes in a push or PR to main.

5. Database migrations — apply order & verification

Migrations are plain SQL files in surfc/supabase/migrations/, numbered 0001, 0002, … and always applied in numeric order.

  • Author a new migration:

    Terminal window
    npm run migrate:new -- describe_the_change
    # creates supabase/migrations/<n>_describe_the_change.sql
  • Apply locally (Docker required):

    Terminal window
    npm run db:start # supabase start — brings up the local stack, applies all migrations
    npm run db:reset # supabase db reset --no-seed — re-apply from a clean DB
  • Apply to production: paste the new migration’s SQL into Supabase Dashboard → SQL Editor → Run, in numeric order (apply each un-applied file from lowest number to highest). There is no automated production migration runner.

  • Verify the live schema matches the contract:

    Terminal window
    npm run check:schema # node scripts/check-schema.js — requires DATABASE_URL

    DATABASE_URL is the Session-mode pooler connection string from Supabase Dashboard → Settings → Database. check-schema validates required tables/columns, RLS policies, the note-images storage bucket, and its storage policy. Exit 0 = clean, 1 = drift.

  • Keep the contract in sync: when a migration adds a required table, column, or policy, update scripts/schema-contract.js in the same PR and flag it so the migration is applied before the dependent code deploys.

  • CI:

    • db-test.yml — on any push/PR to main touching supabase/**, spins up the local Supabase stack (Node 20) and runs npm run test:db (schema / RLS / RPC / trigger / soft-delete / concurrency).
    • schema-smoke-test.yml — weekly cron (0 9 * * 1, Mondays 09:00 UTC) runs npm run check:schema against the live database to catch out-of-band SQL edits. Also runnable on demand via workflow_dispatch.