Skip to content

Environments and releases

Three environments, one codebase, promoted forward only.

Dev UAT Production
Where Your machine + a Vercel preview per pull request Vercel preview of main (stable URL: fab-dispatch-git-main-<team>.vercel.app) fab-dispatch.vercel.app (Vercel production, branch production)
Deploys when You run it / every push to a PR Every merge to main A release tag vX.Y.Z is pushed
Database SQLite (local) · schema uat (PR previews) Supabase, schema uat Supabase, schema public
Auth Demo sign-in (local) · Supabase (previews) Supabase Auth Supabase Auth
Who uses it Engineers Fab key users test before release Dispatchers on shift

Flow

flowchart LR
  F[feature branch] -->|pull request: CI + preview deploy| M[main]
  M -->|auto deploy| U[UAT]
  U -->|key users sign off| T[tag vX.Y.Z]
  T -->|Release workflow: must be on main, CI again| I[images on GHCR with provenance attestations]
  I -->|UAT deployed + smoke green for this commit| P[production branch]
  P -->|auto deploy| PR[Production]
  PR -->|serves the new release?| R[GitHub release]
  1. Develop on a short-lived branch. Open a pull request: CI runs every check in the pipeline table below and Vercel builds a preview.
  2. Merge to main: UAT redeploys automatically. Its migrations run on the UAT database first, so a bad migration is caught there, not in production.
  3. Sign off in UAT with the fab's key users. Add the release notes under a new version heading in CHANGELOG.md.
  4. Release: git tag -a v2.1.0 -m v2.1.0 && git push origin v2.1.0. The Release workflow first refuses a tag that isn't on main (before anything is published), re-runs CI on that exact commit, publishes the images to GHCR (SBOM, build-provenance attestation, vulnerability scan), then waits for UAT: GitHub must hold a successful Preview deployment of the tagged commit and a passing run of the post-deploy smoke workflow for it, polled for up to 15 minutes (a tag pushed seconds after a merge used to promote before UAT had even built). Only then does it fast-forward production, wait until production reports the new release (and, when the EXPECTED_DATABASES repository variable is set, that many databases), and publish the GitHub release from the changelog.
  5. Smoke test: every successful deployment (preview, UAT, production) is checked automatically: /api/health must report status: ok and schema_ok: true, and auth must be supabase, never demo.

Pipeline

Stage Check Fails the build when
Code ruff lint and format, ESLint any finding
Code CodeQL (Python, JavaScript; security-extended) a security finding; also weekly on old code
Tests pytest on Python 3.11, 3.12, 3.14 and against Postgres 16 any failure; API coverage < 90%
Tests Vitest component tests any failure; UI coverage below its floors
Tests Playwright end-to-end, desktop and phone, with axe WCAG 2.1 AA audits any failure or violation
Tests Assistant evaluation (job summary) wrong article cited or off-topic answered
Supply chain pip-audit, npm audit a known vulnerability (npm: high and above)
Supply chain Dependency review (pull requests) a new dependency with a high vulnerability or a GPL/AGPL/SSPL license
Images Build, start the full stack, sign in, scan with Trivy the stack doesn't serve the app, or a fixable critical/high CVE
Workflows actionlint with shellcheck a broken workflow or shell bug
Hygiene Conventional Commits, single author (pull requests) a non-conventional commit or a co-author trailer
Release Tag is on main it isn't (nothing is published)
Release Images to GHCR with SBOM and build-provenance attestation; scan a fixable critical/high CVE in the published image
Release UAT gate: a successful Preview deployment of the tagged commit and a passing smoke run for it neither appears within 15 minutes
Release Production serves the tagged release, healthy, schema current, expected number of databases it doesn't within 10 minutes
Deploy Smoke test on every deployment unhealthy, stale schema, demo auth, or app tables open to the public API
Ongoing Uptime every 30 minutes production down: opens an incident issue, closes it on recovery

Every job has a timeout and every action is pinned to a commit SHA (a test in backend/tests/test_release.py checks both). main requires the CI, CodeQL, workflow, image and hygiene checks.

"Signed images" means GitHub Artifact Attestations: a Sigstore-signed build-provenance statement that GitHub's own workflow built the image digest from this repository at that commit, stored with the image in GHCR. The images are not cosign-signed with a key the project holds. Verify one with:

gh attestation verify oci://ghcr.io/pavansky/fab-dispatch-api:latest -R pavansky/fab-dispatch

Rollback

  • App: in Vercel → Deployments, Promote the previous production deployment (instant, no rebuild). Then fix forward on main and release a patch version.
  • Database: migrations are additive (new tables, new columns with defaults), so the previous app version keeps working against a newer schema. Never write a destructive migration in the same release as the code that stops using the old structure; split it across two releases.

Configuration per environment

Set in Vercel → Settings → Environment Variables, scoped to Production or Preview (UAT and PR previews). The Supabase integration injects POSTGRES_URL, SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY and SUPABASE_JWT_SECRET per scope. Sign-in emails, the sender and guest access are configured in Supabase itself: see supabase/README.md.

Variable Production Preview (UAT)
FAB_AUTH_MODE supabase supabase
FAB_DISPATCHER_EMAILS the fab's dispatch leads the UAT testers
FAB_DEFAULT_ROLE viewer viewer
CRON_SECRET random, ≥ 32 chars random, ≥ 32 chars
FAB_DB_SCHEMA unset (public) uat
FAB_GUEST_ROLE dispatcher for a public demo; none for a real fab dispatcher
FAB_TURNSTILE_SITE_KEY the Turnstile site key the same key (add the preview hostnames to the widget)
FAB_ASSISTANT_LLM none (or anthropic with ANTHROPIC_API_KEY) none
FAB_TENANT_DATABASES {"fab2-200mm-analog": "env:FAB2_DATABASE_URL"} unset (one database)
FAB_STREAM_TICKET_SECRET unset: the integration's SUPABASE_JWT_SECRET signs stream tickets; set it if that is ever removed (several instances need one shared key) same
EXPECTED_DATABASES (repository variable, not Vercel) 2: smoke and release fail if production reports fewer databases not checked

Production runs a database per fab. Fab 1 uses the default database (Supabase). Fab 2 has its own: a Neon Postgres added through the Vercel Marketplace, connected to Production only with the prefix FAB2, so Vercel injects FAB2_DATABASE_URL and nobody copies the secret. /api/health reports "databases": 2. Verified on the live site: a fab 2 live shift, its events and its 22 assignments exist in Neon and nowhere in Supabase. Preview deployments get no fab 2 credentials.

Why UAT is a schema, not a second database

Supabase's free tier allows one database per account through the Vercel integration. UAT therefore lives in its own Postgres schema (FAB_DB_SCHEMA=uat) in the same database: every table the app touches is schema-qualified, so UAT and production never see each other's data, and migrations run on UAT first. Both share the instance's capacity and the same Supabase Auth user pool. Moving UAT to its own project later (on a paid plan) only means pointing Preview's POSTGRES_URL at it and unsetting FAB_DB_SCHEMA.

Database migrations

Defined in backend/app/store.py (MIGRATIONS), numbered and append-only. They run at startup under a Postgres advisory lock, so concurrent serverless cold starts can't race. /api/health reports schema_ok, which the smoke test checks after every deploy.

Data retention

Vercel Cron calls POST /api/internal/prune daily at 03:00 UTC with CRON_SECRET. It removes plan cache entries older than 7 days, idempotency keys older than 24 hours, shifts (with their events and presence) not updated for 30 days, and guest (anonymous) accounts in Supabase Auth that have had no sign-in, session refresh or live-shift activity in any fab's database for 30 days (a no-op on SQLite and on a Postgres without an auth schema, such as a fab's Neon database). The count is logged and returned as guest_accounts.

Vercel runs crons on the production deployment only, so the UAT schema is never pruned by the platform. To prune UAT, call its endpoint from elsewhere (for example a scheduled GitHub Actions job, or by hand): curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://<uat-url>/api/internal/prune (with x-vercel-protection-bypass when the preview is protected).