Everything you need to deploy, configure, and keep Oslr running. Oslr is a mobile-first app that helps doctors and medical students organise clinical bedside teaching on hospital wards — one React codebase shipped to web and to native iOS/Android.
All development lands via pull requests into dev; main is production and only moves when you merge dev → main. Full detail in Deployment → Branching & release workflow.
Branch
What it deploys
dev
Preview web (dev DB) · a release-candidate app to TestFlight for App Review — the app always points at the prod DB · the docs site
main
Production web + prod backend, and the reviewed app version is released to the stores at the same time
How it ships
Three pipelines, all triggered by pushing to GitHub. There are no manual release steps in the normal path.
Target
Tool
Trigger
Ships to
Web
Vercel
Push (any branch)
Website
Mobile
Codemagic
Push to dev
TestFlight + Google Play internal
Backend
GitHub Actions
Push touching supabase/**
Migrations, Edge Functions, email templates
Two environments
The backend runs as two separate Supabase projects, chosen by branch. The frontend picks its project from build-time VITE_SUPABASE_* variables.
Branch
Web (Vercel)
Supabase project
main
Production
prod
dev + feature branches
Preview
dev (jeibuakusgpcyybfnmos)
Secrets live in the provider dashboards (GitHub, Vercel, Codemagic, Supabase) — never in the repo. Ownership and rotation are in the Runbook.
Also in the repo
Architecture, conventions, and the data model live in CLAUDE.MD; the project overview is in the README. Point-in-time product notes (the July 2026 usability audit, action plan, and wave 1–2 fixes) and debugging reproduction scripts stay as markdown under docs/ — see the docs index.
01 · Operations
Deployment guide
How Oslr ships to web, mobile, and backend. For a command cheat-sheet see Quick reference; for first-time environment setup see Production setup.
Branching & release workflow
All development happens through pull requests into dev. Nothing is committed directly to main.dev is the integration branch that everyone builds on; main is production and is only updated by opening a release PR from dev → main.
Merging to…
Deploys
dev
Vercel Preview web (→ dev DB) · the dev Supabase project · a release-candidate app that Codemagic builds and uploads to TestFlight for App Review · the docs site (docs.oslr.co.uk)
main(via a dev → main PR)
Vercel Production web · the prod Supabase project (required reviewer) · and the reviewed app version is released to the stores at the same time
Intended: the native app is prod-backed on every track — Codemagic bakes the prod Supabase URL/key into every build, because the TestFlight build goes for App Review and needs a stable, populated backend. So the dev/prod split described below is a web concern; mobile should be prod for both the review build and the released version.
✅ Now prod-backed:codemagic.yaml injects the prod project values (mxthnwdweubpkjostkbd) into both mobile workflows, so the shipped/review build hits prod (#132 resolved). VITE_SUPABASE_ANON_KEY holds the project's publishable key (sb_publishable_…).
So a release looks like: push to dev → the app is built and submitted to TestFlight for review while you keep iterating on Preview web (dev DB). When the change is signed off, open a dev → main PR; merging it deploys production web + prod backend and releases the already-reviewed app version to the stores in the same step.
Overview
Three pipelines, all triggered by pushing to GitHub. There are no manual release steps in the normal path.
Target
Tool
Trigger
Ships
Web
Vercel
main → Production, other branches → Preview
oslr-web/ → website
Mobile
Codemagic
Push to dev
iOS → TestFlight, Android → Google Play internal
Backend
GitHub Actions
Push to main/dev touching supabase/**
Migrations, Edge Functions, auth email templates
Pull requests touching oslr-web/** also run a quality gate (.github/workflows/web-quality-gate.yml): npm ci, npm run build, npm run lint, plus npm run test:unit in a second job on Node 22 (Node's test runner strips the TypeScript itself, which the Node 20 build job cannot do). This is what protects dev — a red gate blocks the merge.
Health check — is it working right now?
Every pipeline above answers a question about a change. .github/workflows/health-check.yml answers a question about the running platform: every six hours it opens a real browser against a deployed environment, signs in as a monitoring account, and reads its way through the main pages, failing on any console error, failed request or 5xx. Failures go to Slack with the Playwright trace attached to the run. The alert path itself is testable on demand: Actions → Health check → Run workflow → tick test_alert posts a test message and checks nothing, which is worth doing after wiring up the webhook and whenever it is rotated.
Why a browser rather than a ping: the web app is a static bundle behind a CDN with a catch-all SPA rewrite, so curl .../login answers 200 without Supabase being involved at all — and keeps answering it when the bundle white-screens, when it carries the wrong project's credentials, or when an RLS change has hidden every session from everyone.
It is read-only by construction: oslr-web/e2e/health-fixtures.ts blocks every write the browser attempts and fails the run if one is tried, so the check can never create anything on a live site. (This is why /activity is not walked — it marks notifications read on mount.) It runs as a matrix over production and dev, one job each, with fail-fast off so a failure in one still reports the other. The same monitoring account exists in both Supabase projects under the same password, so one pair of repository secrets serves both. Background and the tiers around it: docs/health-check-plan-2026-09.md.
Two environments
The backend runs as two separate Supabase projects, chosen by branch. This split governs the web app and backend deploys; the native app is always built against prod (see the note above).
Branch
Vercel
GitHub Environment
Supabase project
main
Production
Production
prod
dev + feature branches
Preview
Preview
dev (ref jeibuakusgpcyybfnmos)
There is no hardcoded fallback in oslr-web/src/lib/supabase.ts — a build with missing vars builds fine but white-screens at runtime with a "Missing Supabase configuration" error. Verify both scopes are set.
1 · Web (Vercel)
Two separate Vercel projects are built from this repo: the app (oslr-web/) and the marketing site (oslr-landing/). Each has its own vercel.json and its own Root Directory setting.
The app — oslr-web/
Config:vercel.json — SPA rewrites so client-side routes resolve.
Trigger: every push. main → Production, other branches → Preview URLs.
Build: Vercel runs npm ci + npm run build in oslr-web/, serves dist/.
The marketing site — oslr-landing/
Config:oslr-landing/vercel.json — rewrites to index.html for the client-side routes (/about, /help, /privacy, /terms), plus cache headers on /assets/*.
VITE_GA_MEASUREMENT_ID — the GA4 Measurement ID (e.g. G-XXXXXXXXXX). Set it in the Production scope only, so preview deploys never report into the analytics property. With it unset, the analytics code is stripped from the build entirely.
VITE_VIDEO_BASE_URL — the S3 base the /help page loads walkthrough videos from (defaults to the bucket convention in config.ts if unset).
Vite bakes both in at build time, so redeploy after changing either.
Analytics: GA4, loaded directly — there is no consent banner, and advertising signals are explicitly denied. Event schema, the one-time GA4 custom-dimension setup, and the two conversion funnels are documented in oslr-landing/README.md.
Help videos: the /help page is built from oslr-landing/src/lib/helpWorkflows.ts and embeds the demo videos from S3 (published by demo-videos/publish.sh) — see the Demo videos page.
Outbound links (app URL, store listings, contact addresses) all live in oslr-landing/src/lib/config.ts.
Environment variables (Vercel → Settings → Environment Variables — set each twice, once per scope):
Variable
Production scope
Preview + Development scope
VITE_SUPABASE_URL
https://<prod-ref>.supabase.co
https://jeibuakusgpcyybfnmos.supabase.co
VITE_SUPABASE_ANON_KEY
prod anon key
dev anon key
Vite bakes these in at build time — redeploy after changing them.
Manual deploy (rarely needed):
cd oslr-web
npm run build
vercel --prod
2 · Mobile (Codemagic + Capgo live updates)
Most changes to this app are JavaScript. Of the last 200 commits on dev, four touched anything native — the rest were rebuilding and re-uploading a binary whose native half was identical to the one before it. So the pipeline splits in two: JavaScript ships over the air on every push; a new binary is built only when something native changed.
Push to
Native changed?
What happens
dev
no
web build → JS uploaded to the Capgo development channel; testers get it on next launch
dev
yes
as above, plus a TestFlight + Play-internal binary
main
no
web build → JS uploaded to the Capgo production channel; live users get it
main
yes
as above, plus a binary to TestFlight and the App Store, auto-submitted for review
either
nothing app-related
nothing runs at all
Config:codemagic.yaml — six triggered workflows split by what they produce, not by platform: ota_dev / ota_main build the JS bundle on linux_x2; ios_dev / ios_main and android_dev / android_main build native binaries on mac_mini_m2. Plus three manual ones for store listings: ios_appstore_metadata, android_play_download, android_play_metadata.
Only the ota_* workflows upload to Capgo, and they run on Linux because they need only node — npm ci, vite build, one upload — and never open Xcode. Machine prep alone is 0.2m on Linux against 2.7m on a Mac. A web bundle is platform-agnostic so one upload serves both platforms; two uploaders would race on the same version.
What counts as native is decided twice. First by paths — the native_paths list at the top of codemagic.yaml: ios/, android/, capacitor.config.ts, package.json, package-lock.json, assets/, icons/, codemagic.yaml. That is all Codemagic can match a changeset against, and it only decides whether a Mac starts.
Then by content, in scripts/native-change.sh, which runs as the first step of every native workflow and can stop it dead. Three of those paths move for reasons that never reach a binary: a scripts entry or a devDependency in package.json, any install at all in package-lock.json, and codemagic.yaml — which Codemagic adds to every changeset implicitly, so editing the pipeline used to submit a build for App Store review. The script compares only the ios/ and android/ trees, assets/, icons/, capacitor.config.ts, the declared .dependencies, and every lockfile entry npm does not mark "dev": true — the runtime closure. A Capacitor plugin bumped inside its ^ range by npm update is caught; a Playwright bump is not.
This is not hypothetical. The App Store build for release b3df094 was triggered by two test: entries added to the scripts block of package.json. A binary was built, signed, uploaded and submitted to Apple for review because someone wired up two test runners.
To force a native build anyway — you changed a bundle ID, an entitlement or a build flag in codemagic.yaml and it has to reach a device — add the trailer Native-Build: true to a commit message in the push (a line of its own at the end of the message), or start the workflow from the Codemagic UI with FORCE_NATIVE_BUILD=true. It must be a real git trailer, not a substring: the first version grepped for [native], and the commit that introduced the gate tripped it by explaining the escape hatch in its own message.
Codemagic measures changeset against the last successful build, not against the previous commit. So a failed or cancelled native build stays outstanding: the next push rebuilds it rather than shipping JS onto a binary that never got the native half.
A skipped workflow still starts. Codemagic does not skip at webhook time — it creates the build, starts a machine, fetches sources, and only then evaluates the changeset. So a JS-only push shows three runs: ota_dev doing the work and two native workflows starting and stopping at ~0.5m each. Expected, not a misconfiguration. Measured: JS-only ≈ 1.7m on Linux; native ≈ 8m iOS + 3m Android + 1.7m OTA; nothing app-related ≈ 0.5m × 3.
Signing & secrets: managed in Codemagic (variable groups code-signing, ios-signing, capgo-dev, capgo-prod, supabase-dev; Android keystore mercury_keystore; App Store Connect integration CodemagicOslr). No signing files are stored in the repo.
The two tracks
dev
main
Supabase
jeibuakusgpcyybfnmos
mxthnwdweubpkjostkbd
Capgo app
com.oslr.app
com.mercury.mercuryapp
Capgo channel
development
production
iOS bundle ID
com.oslr.app ("Oslr Dev")
com.mercury.mercuryapp ("Oslr")
Android applicationId
com.mercury.mercuryapp — internal track vs production track
The dev track is prod-backed no longer: it builds against the dev Supabase project. That reverses the earlier decision (#132) and is only safe because the App Store release path moved to main — the dev build is no longer the App Review candidate.
The two tracks are separate Capgo apps, not just separate channels, so a dev bundle cannot reach production users even if a channel name were mistyped — it is addressed to a different app entirely. On both platforms that is also a separate binary in a separate store listing — com.oslr.app on iOS, com.embercordova.oslrUi2 on Android — so a tester can hold dev and production side by side and a dev build cannot be promoted into the production listing by accident. The Android dev applicationId deliberately does not match its Capgo app (com.oslr.app); the CapacitorUpdater.appId override in capacitor.config.ts is what routes Android dev builds to the dev Capgo app, and the two identifiers are not meant to be reconciled.
A Capgo bundle is a set of credentials, not neutral code.VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY are frozen into the JS by vite build, so a dev-built bundle uploaded to the production channel would point every live user at the dev database — silently, with nothing crashing. scripts/verify-build-env.sh runs after every web build and refuses to continue unless the track, the credentials, the Capgo routing and the emitted JS all agree. Keep it that way.
Never write a Supabase project ref into a source file. Every ref must arrive through import.meta.env, because Vite inlines literals and a bundle naming two projects cannot be shown to talk to only one of them. On 2026-08-16 a ref was hardcoded purely to choose which verification URL to display — nothing ever connected anywhere wrong — and the guard was still right to refuse the bundle: it cannot tell a decorative literal from a live one, and should not have to. Codemagic caught it, but only after the merge into dev, because the dev/prod checks need real credentials and can only run on a native build. So the Web Quality Gate now runs scripts/verify-build-env.sh neutral dist on every pull request: built with no environment at all, the bundle must name no Supabase project. That needs no secrets, and it fails on the PR instead of at build #103.
Live updates (Capgo)
Each channel must be set as its app's “default download channel”. Creating it and enabling iOS/Android is not enough. Without it every device gets {"error":"no_channel"} and no bundle ever ships — while the build stays green, the bundle shows against the channel, and Capgo's Logs tab stays empty (a device that is never offered an update logs no update events). defaultChannel in capacitor.config.ts does not substitute for it: a device asserting its own channel counts as self-assignment, which the channel must separately allow. To diagnose, curlhttps://api.capgo.app/updates with the device's own parameters — it answers in one call what the dashboard does not. A per-device channel override confirms the device can receive bundles, but remove it afterwards or that device keeps working while everyone else is broken.
Plugin:@capgo/capacitor-updater (the lts-v7 line — latest is for Capacitor 8). Configured under plugins.CapacitorUpdater in capacitor.config.ts.
Channel routing is welded into the binary.capacitor.config.ts is evaluated by the Capacitor CLI at cap sync time, so it reads the CI environment (CAPGO_APP_ID, CAPGO_CHANNEL), and the result is written to capacitor.config.jsoninside the native project — outside the public/ folder an OTA bundle replaces. A bundle therefore cannot re-point a binary at a different app or channel.
Live updates are off unless CI turns them on. Without that guard a local npx cap run ios would fall back to the native bundle ID plus the cloud default channel — i.e. pull the production bundle down over the developer's own build.
notifyAppReady() is load-bearing.src/lib/liveUpdates.ts calls it after render. If it is not called within 10s the plugin assumes the bundle is broken and rolls back. That rollback is the entire safety net for OTA — there is no store review in front of a live update.
The compatibility floor is what makes the split safe. The OTA workflow does not wait for, or know about, the native ones — on a native change they run in parallel. Every bundle is uploaded with --min-update-version set to the version of the binary built from the most recent commit whose native fingerprint changed. Versions derive from the commit timestamp, so both workflows compute the same value from git alone, with no coordination. If the native build fails, the floor names a version no device has, so nothing ships until a binary at or above it exists — it fails closed.
The floor is the gate's answer, not a second list.script_native_floor and the native gate are two callers of one function in scripts/native-change.sh. They have to be: if the floor counted a commit as native that the gate refused to build, it would name a version no binary carries and updates would stop reaching devices with nothing going red. That is exactly what happened once, when a comment-only change to codemagic.yaml raised the floor above every installed binary and Capgo correctly refused to serve anything at all. codemagic.yaml is outside the fingerprint on both sides now, so it cannot happen from either direction.
Do not also pass --auto-min-update-version: Capgo rejects an upload carrying both. The explicit floor is strictly broader anyway — --auto only inspects native plugin versions, while the floor covers every runtime path, including changes --auto cannot see (AppDelegate, Info.plist, entitlements).
A failed upload must fail the build. The step checks the CLI's exit code and greps its output, because the CLI has printed a fatal error and still exited 0. A green build that shipped no bundle is the worst outcome available here: you believe users got an update they did not get. Output is captured to capgo-upload.log as an artifact.
Versioning is a single UTC timestamp, unified across platforms so a version is self-documenting (a user on 2026.730.143012 was built from a commit made 30 Jul 14:30:12 UTC):
Version name (iOS marketing + Android versionName + the Capgo bundle version, all identical): YYYY.MDD.HMMSS, derived from the commit timestamp rather than the wall clock — the iOS and Android builds of one commit run minutes apart and must agree, because Capgo compares a device's native version against the bundle version.
No leading zeros, because Capgo parses these as semver and semver forbids them: 2026.0730.1200 is not a valid version. Numerically each component is unchanged, so App Store ordering against previously released versions is unaffected.
iOS build (CFBundleVersion): YYYYMMDDHHMMSS on the wall clock — Apple requires it to be unique per upload, so re-running a build of the same commit must not reuse it.
Android versionCode (internal only): Google Play's latest build number + 1. Play's existing versionCode is already inflated near its ~2.1B cap — higher than the current Unix time — so a timestamp can't reliably beat it; we query the latest and increment.
Android — android_dev / android_main
Package: com.mercury.mercuryapp on both tracks (env ANDROID_PACKAGE_NAME).
Gated at the workflow level, not per step: these workflows never upload to Capgo, so when nothing native changed there is nothing for them to do. Android devices get their JS from the bundle the iOS workflow uploaded — same web bundle, both platforms enabled on the channel.
Builds a signed AAB (./gradlew bundleRelease). Needs Play Developer API access for GOOGLE_PLAY_SERVICE_ACCOUNT.
android_dev publishes to the internal track with Codemagic's built-in publishing.google_play — binary only, which is all the internal track needs. The iOS counterpart stops at TestFlight in the same way.
android_main runs the Fastlane android release lane (supply) instead: it uploads the AAB to the production track with release_status: completed, pushes the Play listing, and writes the changelog. Google reviews it and it goes to 100% of users on approval — the same shape as the App Store submission, not a draft waiting for a manual rollout.
Never both.android_main has no publishing.google_play block: Codemagic's publisher and supply would each upload the same version code, which is an error.
Release notes come from the iOS release_notes.txt. Play keys "What's new" by version code, and ours is Play's highest + 1 resolved at build time, so it can't be a committed filename — the lane writes changelogs/default.txt (supply's any-version fallback) into each locale during the build. Play caps it at 500 characters against the App Store's 4000, so over the limit it truncates at a line boundary and logs how much it dropped. Put the headline first.
Bootstrap the listing once: run the manual android_play_download workflow, unzip its artifact at the repo root, and commit fastlane/metadata/android/. Until that exists android_main uploads the binary and skips the listing — deliberate, because the locales on a live listing aren't guessable from the repo and inventing one either errors or quietly creates a second empty storefront. Afterwards, android_play_metadata pushes listing changes without a build, mirroring ios_appstore_metadata.
Play's review login is not managed here. There's no supply equivalent of app_review_information — Google reads it from Play Console → App content → App access. Set it by hand to the same seed_prod.sql accounts Apple gets.
iOS — ios_dev / ios_main
Bundle ID from IOS_BUNDLE_ID, overridden at archive time via --archive-xcargs PRODUCT_BUNDLE_IDENTIFIER=… (it is hardcoded in project.pbxproj). Distribution type app_store.
Automatic signing via the App Store Connect integration (app-store-connect fetch-signing-files), CocoaPods install, build-ipa.
Sets ITSAppUsesNonExemptEncryption = false (no export-compliance prompt) and CFBundleDisplayName, so a tester with both apps installed can tell them apart.
Release (ios_main only, two steps): after the IPA is built, Upload IPA to TestFlight uploads it with app-store-connect publish (codemagic-cli-tools, waiting for Apple to finish processing), then the Fastlane release lane creates the App Store version, attaches the build, pushes metadata from fastlane/metadata/**, and submits for review. Phased release is off: an approved release goes to everyone at once, which is what you want while the user base is small and the bug rate high. ios_dev stops at TestFlight.
Why the split: the binary upload uses the CLI (native ASC-key handling) rather than fastlane's upload_to_testflight, because Apple's altool rejects the SEC1-converted key that spaceship/deliver requires ("invalid AuthKey −39"). So the CLI does the upload and Fastlane (needs ruby: 3.3.6) does only the version + metadata + submission via spaceship — no altool.
Expect this one: Apple allows one version in "Waiting for Review" at a time. A second native change landing on main while the first is still queued fails the release lane. That is correct behaviour, but the error from deliver is not obvious.
iOS code signing — what to do (step by step)
Signing here is automatic: Codemagic uses the App Store Connect API key (integration CodemagicOslr) to fetch/create the certificate and provisioning profile at build time — nothing is stored in the repo. The mental model that makes this painless:
A distribution certificate is account-wide — one certificate signs all your apps. It is not per-app.
A provisioning profile is per-bundle-ID, but the build auto-creates it (app-store-connect fetch-signing-files … --create).
The App ID com.mercury.mercuryapp already exists in the Apple account (from the original app), so a profile can be made for it.
One required one-time step: add CERTIFICATE_PRIVATE_KEY. Verified via the Codemagic API — the signing groups only held APPLE_TEAM_ID, and a test build failed instantly with "No matching profiles found for com.mercury.mercuryapp": Codemagic only had a stored provisioning profile for the old bundle ID. The pipeline now signs fully automatically via the App Store Connect API key (no stored profiles), which needs this one key. Do the three steps below once and it works for any bundle ID from then on.
Step 1 — Confirm the API key can manage signing. App Store Connect → Users and Access → Integrations → Keys → the key behind CodemagicOslr. Its role must be App Manager or Admin (a "Developer" key can't create certificates/profiles). Bump it if needed.
Step 2 — Generate a signing private key (one command on your Mac; copies itself to the clipboard):
Step 3 — Add it to Codemagic. Codemagic → oslr3 → Environment variables → add:
Name: CERTIFICATE_PRIVATE_KEY
Value: paste from the clipboard (the whole -----BEGIN…END----- block)
Group: ios-signing · tick Secure
That's it. The next build's Fetch signing files step creates (or reuses) the distribution certificate from that key and auto-creates the com.mercury.mercuryapp profile.
The certificate cap — what actually happened here (and will again if you rotate keys). Apple caps you at 2 Apple Distribution certificates. If the build fails at "Fetch signing files" with a 409 — "You already have a current Distribution certificate or a pending certificate request" — it means the key in CERTIFICATE_PRIVATE_KEY doesn't match either existing certificate, so --create tried to make a 3rd and Apple refused. A freshly-generated key won't match, and (unless you have the exact private key of a current cert) neither will an "old" one — so the reliable fix is to free a slot:
Recommended — revoke one, then re-run.Apple Developer → Certificates, revoke an old, unused Apple Distribution certificate (this does not unpublish or break apps already on the store), then re-run the build. With a slot free, --create mints a fresh certificate from your CERTIFICATE_PRIVATE_KEY, and every build after that reuses it automatically — no more matching or revoking.
See your certificates first (to pick a safe one to revoke): in the portal, or run app-store-connect certificates list in a one-off Codemagic step (it uses the same ASC API key, read-only) — it prints each cert's name, serial, and expiry.
Alternative — reuse a cert's key (no revoke), if you have it. Export the matching Apple Distribution cert from Keychain Access (login → Certificates → the entry with a key under it → Export .p12), extract the key — openssl pkcs12 -in cert.p12 -nodes -nocerts | openssl rsa -out dist_key.pem — and use it as CERTIFICATE_PRIVATE_KEY. Then fetch-signing-files finds the match and only creates the profile. In practice most people don't have the exact key, so revoking is usually simpler.
Push notifications
iOS push (FCM) needs the Firebase config to match the bundle ID, and there are now two bundle IDs:
oslr-web/ios/App/GoogleService-Info.plist — the com.mercury.mercuryapp iOS app (production track).
oslr-web/ios/App/GoogleService-Info.dev.plist — the com.oslr.app iOS app (dev track). The ios_dev workflow copies it over the production one before cap sync, and fails the build if it is missing rather than shipping a dev binary wired to the production Firebase app.
Both are registered in the same Firebase project (oslr3-12501). To add or replace one: Firebase console → Project settings → your iOS apps, register the bundle ID, download the GoogleService-Info.plist and commit it under the right name. Upload the APNs auth key against each iOS app entry. Get this wrong and push fails silently — the app registers happily and simply never receives anything.
Android needs the same treatment, and fails loudly instead. Both applicationIds must have a client in oslr-web/android/app/google-services.json: com.mercury.mercuryapp (prod) and com.embercordova.oslrUi2 (dev). Without a matching client the Google Services Gradle plugin fails the build outright ("No matching client found for package name"), which is why the Set Android app identity step checks first and prints the fix rather than letting Gradle report it several minutes and two thousand log lines later.
Merge that file, never replace it. Unlike iOS, Android has onegoogle-services.json holding a client array — but a download from Firebase contains only the app you downloaded it for. Dropping a fresh download over the committed file silently removes the other package, and the build stays green right up until the track whose client you deleted next runs. Add the new client to the existing array and confirm both packages are present before committing.
Four reasons Android push produced nothing until 2026-07-31, all invisible. The build was green, the app started, and the Edge Function reported success throughout.
The committed google-services.json mapped com.mercury.mercuryapp to a mobilesdk_app_id Firebase had registered against a different package. FCM validates that at registration and refuses a token — so check the app id, not just the package name.
POST_NOTIFICATIONS was never declared in AndroidManifest.xml. The Capacitor plugin requests it, but Android denies a request for an undeclared permission instantly and without a dialog, so every Android 13+ device answered "denied" in milliseconds.
No notification channel was created. Android 8+ displays nothing without one, and the channel — not the payload — carries importance, sound and vibration. It is now created at push-registration time, which is necessarily before any message can arrive.
The FCM message had no android block, so it went at normal priority, which Doze can hold until the next maintenance window.
The channel id oslr-teaching is named in three places and they must agree: ANDROID_PUSH_CHANNEL_ID in src/services/capacitorService.ts, channelId in supabase/functions/SendPushNotifications/index.ts, and default_notification_channel_id in AndroidManifest.xml. The status-bar icon is res/drawable-*/ic_stat_oslr.png, the Oslr mark as a white-on-transparent silhouette — Android draws these flat, so pointing at the full-colour launcher icon produces a white square.
Target API level is an annual deadline, not a one-off. Google stops accepting updates to an app targeting below the current bar every 31 August, one API level higher each year — API 36 from 31 August 2026, set in android/variables.gradle. Targeting 36 enables predictive back and stops onBackPressed being called; @capacitor/app registers through getOnBackPressedDispatcher() so it is already on the supported path, but android:enableOnBackInvokedCallback="false" is set in the manifest to hold today's behaviour until back navigation is verified on a handset. That opt-out stops working at API 37, so remove and test it before the next bump rather than during it.
App Store listing as code
The App Store listing text is version-controlled in fastlane/metadata/en-US/ — name, subtitle, description, keywords, promotional_text, release_notes, privacy_url, and the support/marketing URLs. It is pushed with Fastlane deliver in two places:
Automatically by the release lane in ios_main, on a push to main that changed something native (see above). This lane does submit for review, and runs precheck first to catch the metadata problems Apple rejects for. If precheck ever blocks a legitimate release, set run_precheck_before_submit: false and re-run.
Manually, via the ios_appstore_metadata workflow (Codemagic UI → Start build → ios_appstore_metadata) — the metadata lane, with submit_for_review: false, for tweaking copy on the current editable version between builds without a rebuild.
Both lanes share one helper that handles Codemagic's @file: key reference and converts Apple's PKCS#8 .p8 to SEC1 (an OpenSSL 3 quirk); both need ruby: 3.3.6.
Gemfile.lock is committed, and the Gemfile pins fastlane ~> 2.237. Both workarounds above are tied to fastlane's internals, so letting CI resolve a fresh version on each run would let the App Store submission step change behaviour without a commit. Regenerate on macOS with bundle install, and keep the generic platforms in the lock (bundle lock --add-platform arm64-darwin x86_64-darwin) so it isn't tied to one macOS version.
Because we ship as com.mercury.mercuryapp (the established app), deliver just updates the existing listing — the "Oslr" app-name clash and the brand-new-app "No data" first-version error that would hit a fresh app record both go away, so name/subtitle are now managed here.
Primary locale: metadata lives under en-US/ — it must match the app's primary locale in App Store Connect (English (U.S.), the locale the 3.0.0 version was created under). privacy_url points at https://www.oslr.co.uk/privacy; keep that page live or a submit will bounce.
Nothing under fastlane/ triggers a build. It is neither in native_paths nor under oslr-web/, so a push that only changes copy, screenshots or release notes runs no workflow. (In effect neither does codemagic.yaml: the native workflows start on any edit to it, and the content gate stops them.) Land text changes by running ios_appstore_metadata by hand; a change to the submission itself only takes effect on the next real submit, so it needs a native change on main or a manual re-run of ios_main.
Screenshots stay FLAT in screenshots/en-US/, never in subfolders. deliver's loader globs File.join(path, "*.{png,jpg,jpeg}") and does not recurse, so a subfoldered screenshot is invisible to it — it uploads nothing and the submission then fails on missing screenshots. The slot is chosen by image resolution; the one exception is the 12.9-inch iPad Pro, where 2nd and 3rd generation share 2048×2732, so the filename must contain ipadPro129. That ambiguity is what put one image set into two slots and looked like duplicates. iPhone images are 1242×2688 (6.5") — a submission carrying only 6.7" was rejected for a missing APP_IPHONE_65. scripts/check-store-metadata.sh enforces all three on every push.
Apple needs a login.app_review_information carries the demo accounts seeded by supabase/seed_prod.sql — that file is the source of truth, so change the password in both places together. They are deliberately @example.com so they do not pick up the @oslr.co.uk superadmin RLS bypass, and hold no organisation membership: the reviewer sees what an ordinary user sees.
Age rating and the medical-device declaration are answered by hand in App Store Connect and those answers win. fastlane/metadata/app_rating_config.json is kept as a record of the intended answers and is deliberately not passed to deliver, which would overwrite them.
release_notes.txt is not automatic — whatever is in the file becomes the "What's New" text. Update it in the release PR or the store ships the previous release's copy.
The old Appflow leftovers (appflow.yml, Disabled.codemagic.yaml.TMP, @trapezedev/configure) have been removed — Ionic is shutting Appflow down at the end of 2027, and Codemagic + Capgo is the live pipeline. Native builds and store uploads happen in Codemagic; opening ios/ in Xcode or android/ in Android Studio is for local debugging only.
3 · Backend (GitHub Actions → Supabase)
Config:.github/workflows/deploy-supabase.yml.
Trigger: push to main or dev that changes supabase/** (also manual via workflow_dispatch).
Environment:main → Production, otherwise → Preview. Environment secrets resolve the target project automatically, and protection rules (e.g. required reviewers on Production) gate the run.
What it does, against the selected project:
Installs the Supabase CLI and links the project.
supabase db push — applies migrations in supabase/migrations/.
Deploys every Edge Function with supabase functions deploy <name> --use-api, adding --no-verify-jwt only for the names in PUBLIC_FUNCTIONS (see the note below).
Sets the FCM_SERVICE_ACCOUNT_JSON function secret.
PATCHes the branded auth email templates (supabase/templates/*.html) via the Management API — only the mailer_* fields, leaving site_url/SMTP untouched.
(prod only) Applies supabase/seed_prod.sql — reference data, the App Review logins, this project's push-dispatch address, and both clock-driven schedules.
Runs scripts/verify-notification-schedules.sh and fails the release unless the live session-reminders and review-reminders pg_cron jobs each match their own source of truth — public.session_reminder_schedule() (every 5 minutes) and public.review_reminder_schedule() (every 15). A project still on an older cadence fails here too, which catches a migration that never applied.
Why the last step exists. Two notifications run on a clock rather than off a row change — the reminder before a session (#264) and the chase for its review afterwards (#266) — and each clock is a pg_cron job created by a migration. Those migrations warn instead of raising when pg_cron cannot be created — raising would strand the migration chain and block every later migration on the project — so without a check a project missing the extension deploys green and simply never reminds or chases anyone, which looks exactly like a quiet week. The review half is the more expensive silence: the Review page is the only thing in the app that writes attendance, so a session nobody is chased about records no attendance for anybody on it and never asks anyone for feedback either. The check runs last, so it can fail a release without taking the schema deploy with it. To fix a red one: enable pg_cron (Dashboard → Database → Extensions) and re-run the deploy; on prod seed_prod.sql re-schedules both jobs by itself, and on dev run SELECT public.ensure_session_reminder_schedule(); and SELECT public.ensure_review_reminder_schedule();.
Four functions are public. The deploy loop passes --no-verify-jwt to the names in PUBLIC_FUNCTIONS and nothing else. calendar-feed, because Google/Apple/Outlook fetch it with no Authorization header and authenticate with the token in the URL. verifyTeachingRecord, because the appraiser, ARCP panel or medical school checking a certificate — a teaching record or a learning record — has no Oslr account and is not going to make one: the code printed on the certificate is the credential, and it buys the headline figures alone. And getLogs and teachingRecord, because a WebView cannot save a file, so the native apps hand a URL to Safari / Chrome — and a top-level navigation cannot carry a header, which is why the credential is a one-time token in the URL instead. Public at the gateway is not unauthenticated: all four still verify every caller in their own code, and teachingRecord refuses any record that is not the caller's. SendPushNotifications and deleteAccount require a JWT at the gateway and verify the caller themselves.
The gateway runs before the function, so omitting a URL-authenticated function from that list is an outage rather than a hardening. A header-less request gets {"code":"UNAUTHORIZED_NO_AUTH_HEADER"} from the gateway and the token-checking code never executes. teachingRecord shipped that way on 2026-08-17 and every native certificate download failed; getLogs was missing from the list too and worked only because it predated it, so a deploy could have taken the CSV download with it. No test could see this — the Deno suites invoke each handler directly, so their one-time-token cases pass against code a real device never reaches. scripts/check-public-functions.sh now fails the build if a function reads a token query parameter without being named in PUBLIC_FUNCTIONS. Adding a name to that list is a disclosure decision, not a convenience: deploying every function that way is what once left SendPushNotifications callable by anyone who knew the URL.
getLogs and teachingRecord each also serve a GET ?token= route for the native apps, which open the file in the system browser — a top-level navigation cannot send an Authorization header, so the URL carries a single-use, five-minute token that is spent in the database. teachingRecord additionally takes ?record=<uuid>, which is a selector and never a credential: the payload comes back only if that record belongs to whoever the token or the JWT identified.
The certificate's verification URL is a Vercel rewrite.www.oslr.co.uk/verify/:code proxies to verifyTeachingRecord in oslr-landing/vercel.json, so the link printed on a document handed to an appraiser is a brand rather than a project ref. A rewrite cannot read an environment variable, so that destination names production — which is why a certificate issued anywhere else prints its own project's function URL instead. Which host gets printed is deployment configuration, and comes from configuration in two matching places: the VERIFY_BASE_URL function secret drives the URL on the PDF (verifyBaseUrl() in supabase/functions/teachingRecord/index.ts), and the VITE_VERIFY_BASE_URL build variable drives the link shown beside an issued record on /record (verifyUrl() in oslr-web/src/lib/teachingRecord.ts). Set one without the other and the screen disagrees with the paper. Neither may be replaced by a hardcoded project ref — see Never write a Supabase project ref into a source file, above.
Required secrets. Repository-level (shared by both environments):
Secret
Source
SUPABASE_ACCESS_TOKEN
app.supabase.com/account/tokens
FCM_SERVICE_ACCOUNT_JSON
Firebase → Project settings → Service accounts
Per environment (Preview and Production, different values):
Secret
Value
SUPABASE_PROJECT_ID
that environment's project ref
SUPABASE_DB_PASSWORD
that project's database password
Manual deploy:
supabase login
supabase link --project-ref <project-ref>
supabase db push
supabase functions deploy <name> --use-api
Everyday workflow
Branch off dev, develop locally (npm run dev).
Open a PR into dev → the web quality gate runs; Vercel posts a Preview URL.
Merge to dev → deploys Preview web (dev DB) + the dev Supabase project, and Codemagic builds the release-candidate app and uploads it to TestFlight for App Review.
Release: once the app is reviewed and the change is signed off, open a PR dev → main. Merging deploys prod Supabase + Production web, and you release the reviewed app version in App Store Connect / Play Console at the same time.
A push to main can write to the live database. Keep a required-reviewer rule on the Production GitHub Environment so supabase/** changes pause for approval.
Verifying a deploy
Web
curl -I https://oslr3.vercel.app # expect 200
Check the deployment and logs in the Vercel dashboard.
Mobile
iOS: App Store Connect → TestFlight → check the new build processed.
Android: Play Console → Testing → Internal testing → check the versionCode.
Backend
GitHub → Actions → the "Deploy Supabase Changes" run is green.
Supabase dashboard → confirm the migration and function versions.
Rollback
Web — Vercel dashboard → Deployments → pick the last good one → Promote to Production (or vercel rollback).
Mobile — you can't truly roll back a shipped build; release a new build based on the previous good commit. On Android you can also halt/replace the internal-track rollout in the Play Console.
Backend — write a new "down" migration that reverses the change and supabase db push; never edit the original migration. For a function, git checkout <good-commit> -- supabase/functions/<name> and redeploy.
Troubleshooting
Symptom
Likely cause / fix
Web builds but white-screens
Missing VITE_SUPABASE_* in that Vercel scope — set both, redeploy
Supabase job runs on manual dispatch only
Push didn't touch supabase/** (path filter)
supabase db push fails
Migration error — test locally first with supabase db reset
Function deploy fails to bundle
Ensure the --use-api bundler is used (CI already does)
Android build can't read versionCode
Play Developer API access for the service account (Play Console → Setup → API access)
iOS signing fails
Check the App Store Connect integration + signing groups in Codemagic
Push notifications silent
Verify FCM_SERVICE_ACCOUNT_JSON set on the function's project
Backend deploy red on Verify the notification schedules
pg_cron is not enabled on that project — turn it on (Database → Extensions) and re-run the deploy. It takes down both clock-driven jobs at once, so expect the step to name session-reminders and review-reminders together
Nobody is being reminded of sessions
SELECT * FROM public.session_reminder_health(); — scheduled = false means the job is missing; SELECT public.ensure_session_reminder_schedule();
Attendance missing / /admin shows many sessions awaiting review
SELECT * FROM public.review_reminder_health(); — scheduled = false means no teacher is being chased; SELECT public.ensure_review_reminder_schedule();. sessions_awaiting_review is the platform-wide backlog, and stays non-zero even when the job is healthy — the chase is one nudge, not a campaign
Commands and URLs at a glance. Full detail in the Deployment guide.
Branch → environment
Branch
Web (Vercel)
Supabase project
main
Production
prod
dev + features
Preview
dev (jeibuakusgpcyybfnmos)
Everything deploys on push, and all work lands via PRs into dev. A push to dev builds the app (prod-backed) to TestFlight for review; merging dev → main deploys production web + backend and releases the reviewed app. Web follows the branch (dev DB on Preview, prod DB on Production); the native app is prod-backed on every track (codemagic.yaml bakes in the prod project — see Deployment § Mobile).
The marketing site (oslr-landing/) is a second Vercel project in the same dashboard — see Deployment § 1 for its settings.
Deploy commands
Deploys are automatic on push. Manual fallbacks:
# Web
cd oslr-web && npm run build && vercel --prod
# Supabase (link the right project first)
supabase link --project-ref <project-ref>
supabase db push # migrations
supabase functions deploy <name> --use-api # a function
Only calendar-feed and verifyTeachingRecord are deployed with --no-verify-jwt — both are read by clients that have no Oslr session (a calendar app; anyone holding a certificate). Everything else requires a JWT at the gateway and verifies the caller in its own code. Deploying by hand, pass the flag only for those two.
Mobile (local, for debugging)
cd oslr-web
npm run build
npx cap sync ios && npx cap open ios # or android
Store builds/uploads run in Codemagic on push to dev, not locally.
curl -I https://oslr3.vercel.app # web up?
vercel logs # web logs
supabase functions logs <name> --project-ref <ref> # function logs
gh run list --workflow=deploy-supabase.yml # backend deploys
Rollback
# Web: promote a previous deployment in the Vercel dashboard, or
vercel rollback
# Supabase: reverse with a NEW migration (never edit an old one)
supabase migration new revert_<name>
supabase db push
Common issues
Issue
Fix
Web white-screens
Set both VITE_SUPABASE_* in that Vercel scope, redeploy
Backend deploy skipped
Push must change supabase/**
Migration fails
Test locally: supabase db reset
Android versionCode error
Grant Play Developer API access to the service account
iOS signing fails
Check Codemagic App Store Connect integration + signing groups
03 · Operations
Production / dev environment split
One-time setup of the two backend environments. For how deploys work day to day see Deployment; for a cheat-sheet see Quick reference.
Two Supabase projects, selected by git branch:
Branch
Vercel target
GitHub Environment
Supabase project
main
Production
Production
prod (new, clean)
dev (+ any feature branch)
Preview
Preview
dev (existing, ref jeibuakusgpcyybfnmos)
Migrations/functions deploy via .github/workflows/deploy-supabase.yml. Frontend env vars live in Vercel. There is no hardcoded Supabase fallback in the app (oslr-web/src/lib/supabase.ts) — a missing var fails loudly.
1 · GitHub — secrets & Environments
Repository → Settings → Secrets and variables → Actions, and Repository → Settings → Environments.
1a. Repository-level secrets (shared by both environments)
Secret
Value
Where to get it
SUPABASE_ACCESS_TOKEN
account access token
app.supabase.com/account/tokens (one token manages all your projects)
FCM_SERVICE_ACCOUNT_JSON
full service-account JSON (single line)
Firebase console → Project settings → Service accounts → Generate new private key
Share FCM only if dev and prod use the same Firebase project. For separate push projects, move FCM_SERVICE_ACCOUNT_JSON down into each Environment instead (env secret overrides repo secret of the same name).
1b. Environment Preview secrets (→ dev project)
Secret
Value
SUPABASE_PROJECT_ID
jeibuakusgpcyybfnmos (existing dev project ref)
SUPABASE_DB_PASSWORD
dev project DB password (Project Settings → Database)
1c. Environment Production secrets (→ prod project)
Secret
Value
SUPABASE_PROJECT_ID
new prod project ref (Project Settings → General → Reference ID)
SUPABASE_DB_PASSWORD
prod project DB password
Then on the Production environment:
Deployment branches and tags → Selected → add main. This restricts the Production environment (and its secrets) to the main branch.
Required reviewers → yourself. A push to main that touches supabase/** then pauses for your approval before it writes to the live DB.
Put the per-project values only in the environments. Don't leave SUPABASE_PROJECT_ID / SUPABASE_DB_PASSWORD at repo level too — a Preview job with an empty environment would silently fall through to the repo value. Keep only SUPABASE_ACCESS_TOKEN (+ FCM_SERVICE_ACCOUNT_JSON) at repo level.
GitHub CLI equivalent
# Repo-level (shared)
gh secret set SUPABASE_ACCESS_TOKEN
gh secret set FCM_SERVICE_ACCOUNT_JSON < fcm-service-account.json
# Per environment
gh secret set SUPABASE_PROJECT_ID --env Preview --body "jeibuakusgpcyybfnmos"
gh secret set SUPABASE_DB_PASSWORD --env Preview
gh secret set SUPABASE_PROJECT_ID --env Production --body "<PROD_REF>"
gh secret set SUPABASE_DB_PASSWORD --env Production
Environments must exist before --env works; create them once in the UI (Settings → Environments → New environment) or they'll 404.
2 · Vercel — frontend env vars
Project → Settings → Environment Variables. Add each variable twice — once scoped to Production (prod values), once scoped to Preview + Development (dev values). Same name, different value per scope.
Variable
Scope: Production
Scope: Preview + Development
VITE_SUPABASE_URL
https://<PROD_REF>.supabase.co
https://jeibuakusgpcyybfnmos.supabase.co
VITE_SUPABASE_ANON_KEY
prod anon key
dev anon key
Anon key: Project Settings → API → Project API keys → anonpublic.
Result: main deploys (Production) hit prod; dev and any feature branch (Preview) hit dev. vite dev locally uses .env.local (unchanged).
After changing Vercel env vars, redeploy — they're baked in at build time.
Mobile (Codemagic) env vars
Unlike Vercel, the mobile build is prod-backed: codemagic.yamlvars: set VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY to the prod project (mxthnwdweubpkjostkbd) for both workflows, so the TestFlight/internal build that goes for App Review talks to prod. The anon value is the project's publishable key (sb_publishable_…).
Run Phase 0 first (verify migrations rebuild the schema from scratch) before any of this.
# Link to the NEW prod project and push schema
supabase link --project-ref <PROD_REF>
supabase db push # applies all migrations to empty prod DB# Prod seed = reference data (job titles, specialties, locations) + two App# Review logins (a teacher + a student, on example.com, no org membership).# Locations are HOSPITALS (Broomfield only for launch). Do NOT use seed.sql or# seed_demo.sql (they insert demo sessions/orgs). Idempotent — safe to re-run.# NOTE: the main->Production deploy now applies this automatically (needs the# SUPABASE_DB_URL Production secret); this manual run is just for first setup.
psql "<PROD_DB_CONNECTION_STRING>" -f supabase/seed_prod.sql
# Edge functions + their secrets (functions don't carry secrets across projects)
supabase functions deploy --project-ref <PROD_REF>
supabase secrets set FCM_SERVICE_ACCOUNT_JSON="$(cat fcm-service-account.json)" --project-ref <PROD_REF>
Dashboard settings to replicate on prod (config.toml only covers local)
Auth → URL Configuration: Site URL = real prod URL; add prod redirect allow-list; set Apple sign-in redirect.
Auth → SMTP: configure custom SMTP + email templates (don't ship real users on Supabase's default rate-limited mailer).
Storage: recreate any buckets + policies the app expects.
Database → Extensions → pg_cron: the migration creates it, so this is normally nothing to do — but if the project's role cannot, the backend deploy goes red on its last step (see Deployment §3) and the fix is to enable it here and re-run. Confirm any time with SELECT * FROM public.session_reminder_health(); and SELECT * FROM public.review_reminder_health(); — scheduled must be true on both, and each schedule must match its own SELECT public.session_reminder_schedule(); (every 5 minutes) / SELECT public.review_reminder_schedule(); (every 15).
4 · Go-live checklist
Phase 0: supabase db diff --linked against dev is clean (no drift)
GitHub repo secrets set (access token, FCM)
GitHub Preview + Production environments created with their secrets
Production environment restricted to main + required-reviewer protection
Vercel vars set for both scopes, prod redeployed
dev branch created and pushed; verify it deploys to dev project
Prod schema pushed; seed_prod.sql applied (reference data + two App Review logins), functions + secrets deployed
seed_prod.sql auto-applies on every prod deploy using the existing SUPABASE_PROJECT_ID + SUPABASE_DB_PASSWORD secrets (via the session pooler); the optional SUPABASE_DB_URL secret only overrides a non-standard pooler host
Prod auth/SMTP/storage/backups configured
Mobile: Codemagic env vars set to prod Supabase; a dev build submitted to TestFlight for App Review, released to the stores when merged to main
Backend deploy green including its Verify the notification schedules step — or both SELECT * FROM public.session_reminder_health(); and SELECT * FROM public.review_reminder_health(); reporting scheduled = true by hand
End-to-end smoke test on prod (signup, RLS, push, account deletion)
5 · Hosting this documentation (docs.oslr.co.uk)
The doc site is served by its own Vercel project, separate from the app — so it has its own domain and deployments and never touches the app's. Set it up once:
Vercel → Add New → Project → import oslr/oslr3 again; name it e.g. oslr-docs.
Settings → Environments → set the Production environment's branch to dev (this is where the docs live). Note: the production-branch control moved to the Environments page — it is no longer under Settings → Git.
Settings → Domains → add docs.oslr.co.uk. At the oslr.co.uk DNS host add a CNAME: docs → cname.vercel-dns.com. Vercel issues the TLS cert automatically.
(Optional) Settings → Git → Ignored Build Step: git diff --quiet HEAD^ HEAD -- docs/site — rebuilds only when the docs changed.
Because this project tracks dev, docs.oslr.co.uk updates as soon as doc changes merge to dev. The site is a single self-contained index.html, so there is no build to configure.
04 · Operations
Release credentials & incident response
Who owns what, how often secrets rotate, and how to respond when a release breaks.
Ownership matrix
Domain
Primary owner
Backup / reviewer
Escalation
Codemagic iOS signing (cert/profile + App Store Connect integration)
Platform DevOps Lead
Security QA Lead
CEO
Codemagic Android signing (keystore + Google Play service account)
Platform DevOps Lead
Security QA Lead
CEO
GitHub Actions secrets (Supabase deploy + web quality gate)
Platform DevOps Lead
Security QA Lead
CEO
Vercel and Supabase deployment secrets
Platform DevOps Lead
Security QA Lead
CEO
Credential storage policy
Store production credentials in the team's password manager and platform secret stores only.
Never commit credential files or plaintext secrets to git.
Keep local credential artifacts in the git-ignored credentials/ directory when needed for manual setup.
Rotation cadence
Rotate mobile signing/API credentials every 90 days or immediately after team membership changes.
Rotate deployment tokens/secrets every 90 days.
Rotate immediately after any suspected leak, CI log exposure, or compromised workstation.
Pre-release checks
Confirm npm ci, npm run build, and npm run lint are green on the release commit.
Confirm Codemagic ios_testflight and android_internal workflows are green for the same commit.
Confirm the iOS bundle ID is com.mercury.mercuryapp and the Android package ID is com.mercury.mercuryapp.
Confirm Google Play upload track is internal and TestFlight upload completed.
Incident severity & response
SEV-1 — Production outage or broken mobile release pipeline
Trigger: release artifact cannot be produced or published for iOS/Android, or a production deploy failure blocks users.
SLA: acknowledge within 15 minutes.
Owner: Platform DevOps Lead.
Escalate to CEO immediately and request Security QA Lead for validation.
SEV-2 — Degraded CI signal or non-blocking release issue
Escalate to Security QA Lead if risk reaches release cutoff.
Incident procedure
Stabilize — freeze new release attempts; capture failing run links, logs, and the exact commit SHA.
Triage — identify whether the failure is credentials, configuration, or a source change; validate signing assets and secret availability in Codemagic/GitHub.
Mitigate — roll forward with a targeted fix when possible; if blocked, roll back to the last known good release commit and republish.
Communicate — post status updates every 30 minutes until resolved; include impact, current mitigation, and next checkpoint.
Recover & prevent — document root cause and preventive action; rotate any credentials involved; open follow-up tasks for hardening and test-coverage gaps.
Escalation contacts
Platform DevOps Lead — primary on-call for release engineering.
Security QA Lead — release gate and validation support.
CEO — final escalation for staffing, external communication, and approval.
Source of truth for this page: docs/site/index.html (runbook section).
05 · The app
Demo videos
Narrated walkthroughs of every key workflow. Each one drives a real browser through a real user story with a voiceover and captions — the fastest way to see how a part of the app actually works. Because every step asserts its way through the flow, a demo that renders is also a passing acceptance test of that story.
The videos are rendered by demo-videos/, committed as the source of truth, and auto-published to the oslr-demo-videos S3 bucket by a GitHub Actions workflow on push to main (npm run publish does it manually). The "Watch" links here — and the help site on oslr.co.uk/help — both embed the same S3 files, so there's one canonical copy. Setup, the render pipeline, and the publish step are in demo-videos/README.md.
The full catalogue — including which script covers each story and suggested groupings for longer films — is in WORKFLOWS.md. (There's also a 00-smoke clip that just exercises the render harness itself.)
Render them yourself
From demo-videos/, against a local dev server seeded with seed_demo.sql:
cd demo-videos
npm install && npx playwright install chromium
npm run list # list the demos
node render.mjs --check # headless, assert-only — fast acceptance test
npm run demo # re-seed + fixtures + render all
node render.mjs 01-create-teaching-session # render one → out/01-create-teaching-session.mp4
Prerequisites (dev server on :5173, demo accounts from seed_demo.sql, ffmpeg, optional OpenAI TTS) and how to add a new demo are in demo-videos/README.md. Keep each demo's selectors aligned with the matching cypress/e2e/*.cy.ts spec so the demo and the test stay in step.