Skip to main content

Preview environments (per-PR)

M16 gives each PR to main an isolated, browsable preview environment. This page covers the per-PR isolated database (M16-1, #169), the per-PR API preview that consumes it (M16-2, #170), the per-PR web preview whose /api proxy targets that API (M16-3, #171), the per-PR docs preview that renders the PR's Docusaurus site (M45), and the teardown on PR close (M16-4, #172). The create side lives in one workflow, preview.yml, as the provision, deploy-server, deploy-web, and deploy-docs jobs (plus a comment job that posts the URLs back to the PR); teardown is its own workflow, preview-teardown.yml, on the PR-close event.

This is a different lane from the pre-merge e2e run: e2e-preview.yml already isolates the automated-test path with an in-memory store that dies with the job. The preview database exists for the persistent path - a deployed per-PR stack a human can click around in - without ever touching the real dev/prod data.

Per-PR isolated database - the provision job

On every PR to main (and on manual dispatch with a PR number), the job:

  1. Creates a Neon branch database named preview/pr-<number> on the project referenced by the NEON_PROJECT_ID secret, via neondatabase/create-branch-action@v6. A Neon branch is copy-on-write - it starts as a pointer to the parent's storage, so it is ready in seconds and costs nothing until written to. Idempotent: a re-run (a new push to the PR) reuses the existing branch (created=false) instead of erroring or duplicating.
  2. Applies the schema and seed by running apps/server/scripts/provision-preview-db.ts against the branch database (see below).
  3. Publishes the connection info for downstream jobs: the pooled URL lands in a 1-day workflow artifact (preview-db-pr-<number>, a preview-db.env file with PREVIEW_DATABASE_URL = pooled and PREVIEW_DATABASE_URL_DIRECT = direct), and the branch name is a job output. The URLs embed the branch DB password, so they are masked and never printed to logs; the artifact is the hand-off (the deploy-server job consumes it).

Why Neon (not Supabase branching)

Bloom's data layer is plain Postgres over asyncpg - no GoTrue, PostgREST, RLS, or storage; auth is Bloom's own OIDC + test-login. So the preview DB can be any Postgres. Supabase branching is a Pro-plan-only feature (~$25/mo floor) plus ~$0.32/day per open PR; Neon's free tier gives copy-on-write branching, official create/delete GitHub Actions, and scale-to-zero compute at no cost for this usage. Prod stays on Supabase, untouched - the preview path stops depending on it.

How schema is applied (what we found, and reuse)

Bloom has no separate migrations tool: the API creates its schema idempotently at boot - the server's boot path connects the Postgres-backed stores (bloom_runs, bloom_jobs, bloom_runtime_correlations, and the account tables), each of which runs CREATE TABLE IF NOT EXISTS (see persistence.md). The provision script reuses exactly that mechanism - the same store classes, the same shared-pool wiring - rather than inventing a parallel migration system. The branch DB is therefore ready before the API preview first boots, and the API's own boot re-asserts the schema harmlessly when #170 points BLOOM_SUPABASE_DB_URL at it.

The seed is minimal and deterministic, with no real or PII data: the same e2e-user@bloom.test identity the non-production test-login route (POST /api/auth/test-login) upserts, created through the same upsert_user_from_oauth path so re-runs converge on one user.

Setup (already configured)

  • Repo secrets NEON_API_KEY and NEON_PROJECT_ID - a Neon API key and the preview project's ID. No paid plan and no dashboard/OAuth step: free-tier branching, driven entirely from CI.

Cost

A Neon branch is copy-on-write (no storage duplicated at creation) and its compute scales to zero after 5 minutes idle (suspend_timeout in the workflow), waking in ~1s on the next query. Under the free tier (0.5 GB storage, 100 compute-hours/project/month, 10 branches) short-lived preview branches sit at $0/month for this usage. Branches persist until deleted - the teardown workflow (see below) deletes the branch on PR close via the Neon REST API.

Per-PR server preview - the deploy-server job

After provision succeeds, the deploy-server job gives the PR a reachable HTTPS API running that PR's code against that PR's branch database:

  1. Builds the PR's server image from the same apps/server/Dockerfile and gha build cache as ci-build.yml, and pushes it to the Artifact Registry preview repo tagged bloom-server:pr-<number> (ci-build's GHCR push stays main-only and untouched; the pr-<n> tag is overwritten on every push to the PR).
  2. Deploys it to Cloud Run as one service per PR, named bloom-server-pr-<number> - so a re-run updates the same service in place (a new revision, never a duplicate) and teardown deletes it by name with gcloud run services delete. --min-instances=0 --max-instances=1 scales the preview to zero when idle. Auth uses the official google-github-actions/auth + setup-gcloud actions with the GCP_SA_KEY service-account key.
  3. Points it at the branch DB: BLOOM_STORE_BACKEND=supabase with BLOOM_SUPABASE_DB_URL set to the pooled PREVIEW_DATABASE_URL from the provision artifact (re-masked in this job before use - masks do not carry across jobs). The API's boot re-asserts the schema harmlessly (see above). The rest of the env mirrors the e2e-preview lane: BLOOM_ENV=development, BLOOM_LLM_PROVIDER=fake (deterministic, no real keys - never prod secrets), and BLOOM_ENABLE_TEST_LOGIN=true so a human can sign in to the browsable preview. Because the preview URLs are publicly reachable (unauthenticated Cloud Run) even though the repo is private, test-login is hardened behind a per-PR throwaway password (BLOOM_TEST_LOGIN_PASSWORD, generated fresh each deploy and masked in logs): signing in requires the value from the sticky PR comment - a collaborator-only surface - sent as the X-Bloom-Test-Login-Password header (the login page prompts for it). The data remains throwaway per-PR, and teardown removes it on PR close.
  4. Publishes the URL for downstream (#171) after polling /health (tolerating the scale-to-zero cold start): as the job output server-url and as a 1-day workflow artifact (preview-server-pr-<number>, a preview-server.env file with PREVIEW_SERVER_URL, PREVIEW_PR, and PREVIEW_TEST_LOGIN_PASSWORD for the comment job).

Runtime service account

The service runs as the deployer SA (bloom-ci-deployer@<project>.iam.gserviceaccount.com) on purpose: the project has no Compute default SA (the Compute API is not enabled) and the deployer cannot create service accounts. This is acceptable for previews because they are ephemeral (torn down by #172) and the Bloom API makes no Google Cloud API calls at runtime, so the SA's roles are never exercised by the app. Do not reuse this shortcut for a long-lived environment.

Setup (already configured)

  • Repo secrets GCP_SA_KEY (the bloom-ci-deployer service-account JSON key, roles run.admin + artifactregistry.admin + serviceAccountUser), GCP_PROJECT_ID, GCP_REGION, and GCP_AR_REPO (the Artifact Registry docker repo the pr-<n> images land in).

Cost

A preview service scales to zero when idle and caps at one instance, so Cloud Run charges only for actual request-handling time on an open PR - effectively $0/month within the always-free tier for this usage. Images accumulate one pr-<n> tag per open PR (overwritten per push) in Artifact Registry; the teardown workflow (see below) deletes both the service and the image tag on PR close.

Per-PR web preview - the deploy-web job

After deploy-server succeeds, the deploy-web job publishes that PR's dashboard talking to that PR's API - the human-facing entry point to the whole preview stack:

  1. Builds the PR's web bundle with the same toolchain and steps as deploy-web-dev.yml: same-origin (VITE_API_BASE_URL empty), so the browser talks only to the Pages origin and the /api Pages Function (apps/web/functions/api/[[path]].ts) reverse-proxies to the API, keeping the session cookie first-party.
  2. Points the proxy at the PR's API by baking the origin into the build. The proxy normally resolves the API_ORIGIN Pages env var, but Pages env vars are scoped per project (production/preview), not per deployment - every PR preview would share one value. So the job overwrites the committed null constant in apps/web/preview-api-origin.ts (which the proxy prefers over API_ORIGIN) with the PREVIEW_SERVER_URL from the preview-server-pr-<n> artifact, and the value ships inside that deployment's Function bundle. No per-PR dashboard step; dev/production deploys keep the committed null and resolve API_ORIGIN exactly as before.
  3. Deploys to the existing Cloudflare Pages project as a preview, via the same cloudflare/wrangler-action@v3 pattern as the dev deploy but with --branch=pr-<n>. That makes it a preview deployment (the project's production branch is main) with the stable branch alias https://pr-<n>.<project>.pages.dev. Idempotent: a re-run redeploys the same branch and the alias always points at the latest build. Teardown removes these pr-<n> deployments on PR close - see the teardown-pages job below.
  4. Verifies end-to-end and publishes the URL: polls <preview>/api/auth/me through the deployed Function until the PR's API answers (401 unauthenticated is the expected success - the API's health route is not under /api, and a broken proxy target surfaces as a Function 5xx), tolerating alias propagation and the API's scale-to-zero cold start. The URL lands in the job output web-url and the 1-day artifact preview-web-pr-<n> (preview-web.env with PREVIEW_WEB_URL and PREVIEW_PR).

To click around a preview: open the alias URL, sign in via the test-login path (the API runs with BLOOM_ENABLE_TEST_LOGIN=true), and everything under /api hits bloom-server-pr-<n> on Cloud Run against the preview/pr-<n> Neon branch - never the dev API or dev data.

Setup (already configured)

Reuses the dev web deploy's Cloudflare setup unchanged: the CLOUDFLARE_API_TOKEN secret and the CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_PAGES_PROJECT repo vars. No new Pages project, token, or env var.

Cost

Pages preview deployments are part of the existing free-plan project (500 builds/month quota is untouched - the bundle is built in CI and only uploaded); the alias serves static assets from the edge and the Function invocations sit well inside the free tier for preview traffic.

Per-PR docs preview - the deploy-docs job

Alongside the app, each PR also gets a preview of the docs site the PR produces - the canonical docs/ tree plus the generated TypeDoc reference, rendered by the apps/docs Docusaurus app (M45). Because the docs site is fully static (no API, no database), deploy-docs is independent of the provision -> deploy-server -> deploy-web chain and runs in parallel with it.

  1. Builds the PR's docs bundle with the same toolchain and steps as deploy-docs-dev.yml (pnpm run build in apps/docs, which also generates the TypeDoc reference); Docusaurus emits to apps/docs/build.
  2. Deploys to the existing bloom-docs Pages project as a preview, via the same cloudflare/wrangler-action@v3 pattern as the dev docs deploy but with --branch=pr-<n>, giving the stable branch alias https://pr-<n>.bloom-docs.pages.dev. Idempotent: a re-run redeploys the same branch and the alias always points at the latest build.
  3. Verifies it serves and publishes the URL: a static site has no /api to probe, so the job polls the preview root for a 200 (the app's baseUrl is /), tolerating alias propagation. The URL lands in the job output docs-url and the 1-day artifact preview-docs-pr-<n> (preview-docs.env with PREVIEW_DOCS_URL and PREVIEW_PR).

All three preview URLs (dashboard, API, docs) are then gathered into the single sticky preview-environment comment on the PR.

Setup (already configured)

Reuses the dev docs deploy's Cloudflare setup unchanged: the CLOUDFLARE_API_TOKEN secret and the CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_DOCS_PAGES_PROJECT repo vars (the bloom-docs project). No new project, token, or env var beyond what the dev docs deploy already needs.

Teardown - the preview-teardown.yml workflow

When a PR to main closes - merged or not - preview-teardown.yml destroys the PR's preview resources so nothing lingers or accrues cost. It also runs on manual dispatch with a PR number, for a stale preview whose close event was missed. Three independent jobs (so a failure in one lane never leaves another lane's resources behind):

  • teardown-db deletes the Neon branch database preview/pr-<number> via the Neon REST API directly - it resolves the branch's id with a read-only list, then DELETEs that id. It does not use neondatabase/delete-branch-action: that action npm i -g neonctl into the global prefix, which fails EACCES on the non-root self-hosted/ARC runners (the runner user cannot write /usr/lib/node_modules); REST is root-free and drops the dependency (#533).
  • teardown-api deletes the Cloud Run service bloom-server-pr-<number> (gcloud run services delete) and the Artifact Registry image tag bloom-server:pr-<number> (gcloud artifacts docker images delete --delete-tags), using the same google-github-actions/auth + setup-gcloud setup as the deploy.
  • teardown-pages deletes the PR's Cloudflare Pages pr-<number> preview deployment(s) across both Pages projects a preview stands up - the dashboard (CLOUDFLARE_PAGES_PROJECT, default bloom-dashboard) and the docs site (CLOUDFLARE_DOCS_PAGES_PROJECT, default bloom-docs) - as a fail-fast: false matrix so one project's failure never leaves the other's deployments behind. Cloudflare exposes no bulk/branch delete, so for each project the job pages through its deployments, collects the ones on the pr-<n> branch in the preview environment, and removes each via a per-deployment REST DELETE with force=true (required to drop an aliased deployment). Guarded on CLOUDFLARE_ACCOUNT_ID, so a fork/config without Cloudflare is a clean skip rather than a failure.

Idempotency

Re-running the teardown - or closing a PR that never had a preview (e.g. preview.yml failed or never ran) - is a clean no-op, never an error. Every delete is gated on an existence check first: the Neon job lists the project's branches to resolve the id and skips the DELETE when preview/pr-<n> is absent (an empty id), the gcloud deletes each describe their target first and treat only "not found" as the no-op, and the Pages job's per-deployment DELETE set is empty when no pr-<n> deployment matched. Check-then-delete rather than delete || true on purpose: a real failure (auth, quota) still fails the job loudly instead of silently orphaning resources.

Teardown shares preview.yml's per-PR concurrency group, so a close event that lands while a preview run is still provisioning queues behind it instead of racing it.

Why the Pages deployments are torn down

Earlier the Cloudflare Pages pr-<n> preview deployment was left in place - it is an inert static upload with no compute cost once the PR's API and DB are gone. But leaving it accretes stale deployments whose pr-<n> alias keeps resolving: a hollow frontend pointing at an already-deleted server, which reads as "live" but is not. So teardown-pages now removes them too (see above), via per-deployment REST DELETEs since Cloudflare offers no official bulk/branch delete action.

Teardown needs no new setup and no DB connection URL - it reuses the same repo secrets and vars the create side already uses: NEON_API_KEY, NEON_PROJECT_ID, GCP_SA_KEY, GCP_PROJECT_ID, GCP_REGION, GCP_AR_REPO, and the Cloudflare CLOUDFLARE_API_TOKEN secret plus the CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_PAGES_PROJECT vars.