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 byscripts/cut-release.tson 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. Likereleaseit 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 — ifpreviewis still on, previews continue as change-detected; ifpreview:forcewas 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-filterdecides 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.devhost 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_URLis the deterministicbackend-pr-{PR_NUMBER}.perci.devhost, set in the env file before deploy — no post-deploy patch), then registers the Cloudflare route (via the sharedregister-preview-routeaction) pointing that host at the service's discovered Cloud Run origin. It also overridesPLATFORM_APP_URL/CLINICAL_APP_URLwith 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 resolvedBFF_URLintoassets/environment_values/environment.json,flutter build web --release, deploy to a Firebase Hosting channelpr-{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}, regioneurope-west2, projectperci-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.ymldeletes 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. droppingpreview:forcebut keepingpreview) leaves the preview up. preview-janitor.ymlruns 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.