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]
- Develop on a short-lived branch. Open a pull request: CI runs every check in the pipeline table below and Vercel builds a preview.
- Merge to
main: UAT redeploys automatically. Its migrations run on the UAT database first, so a bad migration is caught there, not in production. - Sign off in UAT with the fab's key users. Add the release notes under a new version
heading in
CHANGELOG.md. - 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 onmain(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 successfulPreviewdeployment 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-forwardproduction, wait until production reports the new release (and, when theEXPECTED_DATABASESrepository variable is set, that many databases), and publish the GitHub release from the changelog. - Smoke test: every successful deployment (preview, UAT, production) is checked automatically:
/api/healthmust reportstatus: okandschema_ok: true, and auth must besupabase, 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
mainand 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).