Skip to content

Preview environments

Every pull request can get an ephemeral preview: a backend on Cloud Run plus the Members and Clinicians apps on Firebase Hosting preview channels. This lets a change be tested in isolation before it merges to develop.

The pipeline is the Preview - Backend and Apps workflow (preview-pr.yml); teardown is Preview - Cleanup Resources (preview-cleanup.yml) plus a scheduled Preview - Janitor (preview-janitor.yml). See GitHub workflows.

Triggers

Previews run on any trusted (same-repo) PR carrying one of these labels:

  • preview — the standard opt-in feature preview; each component deploys only when its own paths changed.
  • release — applied automatically by scripts/cut-release.ts on release PRs; deploys the full stack (backend + both apps) regardless of which paths changed (force_all), so QA tests the exact production candidate.
  • preview:force — deploy all three environments on demand. Like release it forces the full stack, and it goes one further: it bypasses the per-component content-hash gate so every component redeploys even when nothing changed. Use it when the frontend is untouched but you still need a fresh preview — e.g. a new backend API the apps should be exercised against (PPL-3019). It is presence-based: applying the label deploys all three at the current commit, and while it stays on every push force-redeploys all three (a superseded deploy for an older commit is cancelled and the latest commit is deployed). Remove it to stop forcing — if preview is still on, previews continue as change-detected; if preview:force was the only preview label, removing it tears the preview down (see Cleanup).

How the pipeline works

To keep full rebuilds (15-30m+) rare, the workflow optimises in layers:

  • Change detection (detect_changes) — dorny/paths-filter decides which components (backend, members, clinicians) changed; unchanged components skip their whole chain.
  • Content hashing (check_*_changes) — for each changed component it hashes source + lock files (pnpm-lock.yaml / pubspec.lock), shared packages, and the Dockerfile, and compares to a value cached per PR. Hash match → skip; mismatch or force → deploy.

Jobs:

  • resolve_preview_urls — computes all three preview URLs once, up front. Every preview host is a deterministic, Cloudflare-owned *.perci.dev host derived from the PR number — backend-pr-{PR_NUMBER} (backend), members-pr-{PR_NUMBER}, clinicians-pr-{PR_NUMBER} — so consumers read these outputs instead of discovering URLs post-deploy. The backend rides a Cloudflare host (not a raw Cloud Run URL) because Cloud Run URLs are not predictable — some services use the legacy hash form (…-<hash>.a.run.app), others the project-number form (…-<num>.<region>.run.app). Frontends point at the backend preview when the backend is in play for this push (paths changed, force_all, or a stacked PR), otherwise the shared staging backend (so frontend-only PRs test against a stable API).
  • build_and_preview (backend) — builds the functions, Dockerises them (GHA layer cache; image tagged with the content hash), deploys to Cloud Run in a single revision (PLATFORM_BACKEND_URL is the deterministic backend-pr-{PR_NUMBER}.perci.dev host, set in the env file before deploy — no post-deploy patch), then registers the Cloudflare route (via the shared register-preview-route action) pointing that host at the service's discovered Cloud Run origin. It also overrides PLATFORM_APP_URL / CLINICAL_APP_URL with the deterministic member/clinician preview hosts — but only for an app in play for this PR (members_changed / clinicians_changed); an app that isn't previewed keeps its staging default — so backend-generated deep links (recovery, email verification, guest invites) land on the preview apps rather than an app that never deploys.
  • members_preview / clinicians_preview — install Flutter via FVM, inject the resolved BFF_URL into assets/environment_values/environment.json, flutter build web --release, deploy to a Firebase Hosting channel pr-{PR_NUMBER} (14-day expiry), then register their *-pr-{PR_NUMBER} Cloudflare route (same shared action).
  • comment_deployment_status — posts/updates one sticky PR comment with per-component status (✅ deployed, ⏭️ skipped, ⚪ no change, ❌ failed), URLs, API endpoints, and the content hash + commit SHA.

Backend Cloud Run details

Production runs on Firebase Cloud Functions; previews reuse the same code on Cloud Run via an adapter:

  • functions/src/cloudrun.ts — wraps the Firebase Functions as a plain Express app.
  • Dockerfile / .dockerignore — multi-stage container build.

Each preview service:

  • Name backend-preview-pr-{PR_NUMBER}, region europe-west2, project perci-platform-staging.
  • Publicly accessible (test data only).
  • Limits: 2 GB memory, 1 CPU, 300s timeout, concurrency 80, min instances 0 (scale to zero), max 10.

Environment uses the staging config with preview overrides: ENV=preview, DD_ENV=preview, DD_SERVICE=perci-platform-backend-preview, DD_TRACE_ENABLED=false, SSO_CLEANUP_ENABLED=false, SLACK_PLATFORM_CHANNEL=platform-preview. Secrets are fetched from Google Secret Manager at deploy time.

Endpoints

  • API: {SERVICE_URL}/api/v1
  • Member BFF: {SERVICE_URL}/bff/v1
  • Clinical BFF: {SERVICE_URL}/bff_clinical/v1
  • Clinical Media: {SERVICE_URL}/bff_clinical_media/v1
  • Health: {SERVICE_URL}/_health · Info: {SERVICE_URL}/

The Redoc docs for each API are at …/openapi — see API reference.

Cleanup

  • On PR close/merge, preview-cleanup.yml deletes the Cloud Run service, its Artifact Registry images, the PR's GitHub Actions caches, Firebase Hosting preview channels, and Cloudflare frontend preview routes, then comments confirmation.
  • On removing the last preview label (preview / release / preview:force) from an open PR, the same workflow tears everything down immediately — so "remove the label to disable previews" frees resources at once rather than leaving them for the janitor. Removing one label while another preview label remains (e.g. dropping preview:force but keeping preview) leaves the preview up.
  • preview-janitor.yml runs weekly (Sun 03:00 UTC) to sweep orphaned preview resources; a manual run supports a dry-run input. It loads the open PR list once from GitHub before deleting anything; if that lookup fails, the janitor stops instead of treating resources as orphaned.

Local testing

Run the Cloud Run adapter locally:

cd apps/perci-platform-backend/functions
pnpm install
pnpm run build            # clean && generate && tsc -b (generate needs secrets)
cp .env.sample .env       # then edit with local config
node lib/cloudrun.js      # serves at http://localhost:8080

Or build the container the same way CI does:

cd apps/perci-platform-backend
docker build -t backend-preview .
docker run -p 8080:8080 --env-file functions/.env backend-preview

Troubleshooting

Preview not deploying — check the workflow logs; common causes are TypeScript build errors, Docker build failures, missing GCP permissions, or inaccessible secrets.

503 errors — the container failed to start or crashed on init. Read the logs:

gcloud run services logs read backend-preview-pr-{PR_NUMBER} \
  --region europe-west2 --project perci-platform-staging

Environment variable issues — inspect the deployed service:

gcloud run services describe backend-preview-pr-{PR_NUMBER} \
  --region europe-west2 --project perci-platform-staging --format yaml

Security

Previews use staging credentials and are publicly reachable, so they must only handle test data. Secrets come from Secret Manager; preview URLs are posted as PR comments (visible to repo collaborators); services are deleted when the PR closes.