CI/CD and previews
Four workflows. The interesting one is preview.yml, which puts a testable app on your phone for
every pull request.
What happens on a pull request
The preview stack
| Component | Command | Isolation |
|---|---|---|
| API | wrangler versions upload --env preview | A per-version URL. Production traffic is never touched |
| Database | wrangler d1 migrations apply --env preview --remote | Shared crag-topo-db-preview |
| iOS app | eas update --branch pr-<n> | A per-PR EAS branch |
| Docs | wrangler pages deploy --branch pr-<n> | A per-PR Pages URL |
Why versions upload and not deploy
wrangler versions upload publishes a version and hands back a preview URL without shifting any
production traffic. There is no path by which a preview becomes production because someone
passed the wrong flag.
The bit that makes it actually useful
env:
EXPO_PUBLIC_API_URL: ${{ steps.worker.outputs.url }}
The app update has this PR's Worker URL compiled into it. Scanning the QR code gives you a phone already talking to the backend built from the same commit — no tunnel, no LAN address, no editing a URL in a settings screen.
Where each piece is actually hosted
Worth being explicit, because it is not all one account:
| Hosted by | |
|---|---|
| Worker API, preview D1, docs site | Cloudflare — your account |
| The app's JS bundle (EAS Update) | Expo — u.expo.dev/<projectId> |
| Building the app binary | Expo (EAS Build), or your Mac |
| Running the workflows | GitHub Actions — hosts nothing itself |
Expo is a third infrastructure dependency alongside Cloudflare, not something running inside it.
The health gate
Before commenting, the workflow polls /health up to ten times and asserts that the revision it
reports equals the PR head SHA. A green comment therefore means the preview is genuinely up and
serving the right commit — not that a deploy command exited zero.
If the health check never passes, the job fails and there is no comment. Do not merge a PR whose preview never came up.
Testing a PR from your phone
The comment on each PR carries a QR code. Point the camera at it and the app opens on that branch, already talking to that branch's API. Check the Profile tab if you are unsure — it shows the API URL and the build commit the running app is using, so a stale cached update is obvious.
The one-off you need first
Expo Go cannot do this. EAS Update needs expo-updates in the binary, and runtimeVersion in
app.config.ts stops the app launching in Expo Go at all. You need a development build on the
phone.
The build is a shell that can load any JS bundle you point it at; the QR code is a link to a bundle. Apple governs the shell, not what it loads — so QR codes work identically however the build was signed.
| Free Apple ID (what we do) | Developer Program (£79/yr) | |
|---|---|---|
| Build it | On your Mac, over a cable | Your Mac, or eas build in the cloud |
| Shell lifetime | 7 days | About a year |
| Scanning PR QR codes | ✅ | ✅ |
| Handing a build to someone else | ❌ | ✅ |
The workflow: plug in once a week, rebuild, then scan PR QR codes from anywhere until it expires.
pnpm --filter @crag-topo/mobile exec expo run:ios --device --configuration Release
A couple of minutes, and pull requests rarely outlive a seven-day window anyway, so the expiry does not strand you mid-review. Paying gets rid of the weekly rebuild and lets you build without a Mac; neither is needed to review a PR from the sofa.
A new native module needs a new build, not just an update. Say so in the PR description when you add one.
What happens on main
deploy.yml, in a deliberate order:
Migrations before the Worker, so new code never meets an old schema. Worker before the app update, so the app never meets an API that has not shipped. Health check between them, so a failed deploy does not get an app update pointed at it.
concurrency: cancel-in-progress: false — two deploys queue rather than one killing the other
mid-migration.
Forked pull requests
preview.yml is skipped:
if: github.event.pull_request.head.repo.full_name == github.repository
Forks cannot read secrets, and deploying them would let anyone with a fork write to the preview
database. Forked PRs still get the full ci.yml.
The docs freshness check
docs-check.yml compares changed paths. If anything under apps/api, apps/mobile, packages/
or .github/workflows/ changed but nothing under docs/ did, it leaves a comment.
It comments; it does not fail. The author knows better than a path glob whether a change is
genuinely documentation-neutral. If it is — a refactor, a dependency bump, a test — say so and
carry on. The comment deletes itself once docs/ is touched.
Setup required before any of this works
Until the secrets exist, preview.yml fails on every PR. Full list and how to create them:
Environments.
Quick version:
| Kind | Name | Used by |
|---|---|---|
| Secret | CLOUDFLARE_API_TOKEN | preview, deploy |
| Secret | CLOUDFLARE_ACCOUNT_ID | preview, deploy |
| Secret | EXPO_TOKEN | preview, deploy |
| Variable | EAS_PROJECT_ID | preview, deploy |
| Variable | PRODUCTION_API_URL | deploy |
Plus, once, by hand:
wrangler d1 create crag-topo-db
wrangler d1 create crag-topo-db-preview
# put the returned ids into apps/api/wrangler.jsonc
wrangler pages project create crag-topo-docs
Known gaps
- The preview database is shared across PRs. Two branches with conflicting migrations will fight. Per-PR databases are the fix if it becomes a problem — see ADR-0006.
- Nothing is cleaned up. EAS branches and Worker versions accumulate after merge.
- An EAS Update cannot deliver a native module change. If a PR adds one, reviewers need a new dev build, and there is no workflow for that — say it in the PR description.
- Actions are pinned to major tags, not SHAs.
expo/expo-github-actionandmarocchino/sticky-pull-request-commentrun in a job that holds deploy credentials.