CI overview
Two systems run the pipeline (PLAN.md decision 1). GitHub Actions is the JS gate: everything that needs no simulator, native toolchain or EAS credits, wired as required checks on main. EAS Workflows is the native lane and the delivery ladder: fingerprint-keyed builds, Maestro on iOS and Android, OTA updates, web hosting, approvals and store submission. This page is the map of both — every workflow, what triggers it, what it guards, what it needs — and where to go when one of them is red. The README's pipeline diagram is the picture; this is the legend.
pull request ──► GitHub Actions CI (required checks) ─┐
──► GitHub Actions PR title (required) ├─► squash-merge to main
──► EAS E2E (native) (Maestro iOS + Android, PR comment) ─┘
──► EAS Preview web (pr-<n> alias + PR comment; `web-preview` label, behind HOSTING)
push to main ──► EAS Deploy staging (build on fingerprint miss → OTA staging → web staging alias → Slack)
──► GitHub Actions Release please (keeps the release PR current; its merge pushes the vX.Y.Z tag)
──► GitHub Actions Docs (VitePress build → GitHub Pages)
manual ──► EAS Promote (approval → fingerprint gate → republish to uat | production → web alias)
──► EAS Rollout (approval → ramp a production rollout group 25 | 50 | 75 | 100 %)
──► EAS Backport (approval → cherry-pick a main fix onto older release tags → OTA production)
vX.Y.Z tag ──► GitHub Actions Release (production Environment reviewer) ──► EAS Release (store builds → TestFlight / Play internal)GitHub Actions (.github/workflows/)
Jobs are named exactly as branch protection matches them (REQUIRED_CHECKS in scripts/repo-settings.js); the full per-check table with durations and artifacts is JS gate → Checks.
| Workflow / job | Trigger | Required | Guards | Config lives in |
|---|---|---|---|---|
CI / Lint | PR, push to main | yes | oxlint && expo lint: oxlint defaults as a fast front pass, then ESLint for expo config, import sort, unused imports, RN a11y, the local require-testid rule (ADR-0004) | .oxlintrc.json, eslint.config.js, eslint-rules/ |
CI / Typecheck | PR, push to main | yes | tsc --noEmit (TypeScript strict, typed routes) | tsconfig.json, scripts/ensure-expo-env.js |
CI / Format | PR, push to main | yes | Prettier on everything not in .prettierignore (markdown included) | .prettierrc, .prettierignore |
CI / Knip | PR, push to main | yes | Dead code, unused exports and dependencies | knip.jsonc |
CI / Env check | PR, push to main | yes | EXPO_PUBLIC_* against the Zod schema | src/lib/env.schema.ts, scripts/env-check.ts, .env.example |
CI / i18n check | PR, push to main | yes | t() keys in code ↔ src/i18n/locales/*/common.json | i18next-parser.config.js |
CI / Unit tests | PR, push to main | yes | Jest + jest-expo + RNTL with coverage; JUnit + lcov artifacts | jest.config.js, jest.setup.ts (Testing) |
CI / Commitlint | PR, push to main | yes | Every commit in the range is a Conventional Commit | commitlint.config.js |
CI / Secret scan | PR, push to main | yes | gitleaks over the commit range | .gitleaks.toml |
CI / Bundle budget (web | ios | android) | PR, push to main | yes | JS-only expo export per platform, gzip size under budget; the web leg is its own job (bundle-budget-web) and its export feeds Maestro web | bundle-budget.json, scripts/bundle-budget.js |
CI / Maestro web | PR, push to main | yes | Maestro web-tagged flows against the served static export | .maestro/ (flows/web/*), scripts/serve-web.js |
CI / Docs | PR, push to main | yes | bun run docs:build: the VitePress site over docs/ builds and has no dead relative link (Conventions → Docs) | docs/.vitepress/config.mts, docs/index.md |
CI / Template init | PR, push to main | yes | bun run template:e2e: headless bun run init on a copy, then the gate on the generated project (Template init) | scripts/template-e2e.js, scripts/init.js |
CI / Perf (Reassure) | PR only | no | Render-time compare of PR base vs head on the same runner; red on a significant slowdown (Render-perf tests) | reassure.setup.ts, scripts/reassure-gate.js, src/__perf__/ |
CI / Fingerprint drift | PR only | no | Production-variant @expo/fingerprint of base vs head; one upserted PR comment + fingerprint-drift label on drift, never red | scripts/fingerprint.js (Release ladder) |
PR title / PR title | PR opened / edited / synchronize | yes | The PR title (= squash commit subject) passes commitlint | commitlint.config.js |
Release please / Release PR / tag | push to main | n/a | Keeps the release PR (next version in package.json + CHANGELOG.md) current; on its merge pushes the vX.Y.Z tag (ADR-0002) | release-please-config.json, .release-please-manifest.json, RELEASE_PLEASE_TOKEN (repo secret) |
Release / Trigger EAS release | push of a v* tag | n/a | Waits for the production GitHub Environment reviewer, then dispatches .eas/workflows/release.yml with tag=<tag> | scripts/repo-settings.js (environments), EXPO_TOKEN (repo secret) |
Docs / Build → Deploy | push to main (docs, README, PLAN, package.json, bun.lock), manual | n/a | Builds the VitePress site and publishes it to GitHub Pages (github-pages environment); needs Pages → Source: GitHub Actions (bun run repo:settings:apply --only pages) | .github/workflows/docs.yml, docs/.vitepress/config.mts |
All CI jobs share .github/actions/setup (Bun from .bun-version, install cache, bun install --frozen-lockfile) and start in parallel except Maestro web, which waits for Bundle budget (web) alone — that leg is a standalone job (bundle-budget-web), the ios/android legs a two-entry matrix, because needs: on a matrix job waits for every entry. CI cancels the previous run on a new push to the same ref only on a pull request (cancel-in-progress is the pull_request predicate): every push to main keeps its own run, so back-to-back merges each end with a green status of their own. Permissions are contents: read everywhere except Fingerprint drift (pull-requests: write for its comment and label).
What the gate needs from outside the repo: nothing but GITHUB_TOKEN, except Release, which needs the EXPO_TOKEN repository secret and a reviewer on the production Environment (Owner checklist → GitHub Environments).
EAS Workflows (.eas/workflows/)
One file per workflow, validated with bun run eas workflow:validate <file> (16 KiB cap per file) and run by hand with bun run eas workflow:run <file> [-F input=value]. pull_request / push triggers only fire once the Expo GitHub App is linked to the repository, and never for PRs from forks (JS gate → How EAS checks appear on the PR).
| File | Name | Trigger | Jobs | Gates and approvals | Constants | Runbook |
|---|---|---|---|---|---|---|
e2e.yml | E2E (native) | PR to main; PR labelled e2e:ios; workflow_dispatch (push off by default) | fingerprint → per platform get_build → build (miss) | repack (hit) → maestro → comment (PR comment) | Reports as a PR check once the GitHub App is linked; not in REQUIRED_CHECKS until its context string is copied there | IOS_MODE, FLASHLIGHT | Native E2E → Workflow |
e2e-quarantine.yml | E2E (quarantine) | workflow_dispatch (weekly schedule commented out until a flow is tagged) | Same shape as e2e.yml with include_tags: [quarantine], retries: 0, no comment | Non-blocking | — | Native E2E → Flake budget |
e2e-cloud.yml | E2E (Maestro Cloud) | PR labelled e2e:cloud; workflow_dispatch (maestro_cloud=enabled) | fingerprint → per platform get_build → repack (hit only) → maestro-cloud (pre-packaged job, both platforms); refuse notice on a miss | Off until MAESTRO_CLOUD is flipped and the project id replaces proj_REPLACE_ME; refuses to build on a fingerprint miss; needs MAESTRO_CLOUD_API_KEY on development (owner checklist). Unverified until the first run | MAESTRO_CLOUD | Native E2E → Maestro Cloud |
preview-web.yml | Preview web | PR labelled web-preview; workflow_dispatch | deploy_web (export + eas deploy to the pr-<number> alias) → comment | Opt-in by label, so no permanent "skipped" entry on every PR's checks list; both jobs skipped while HOSTING is disabled | HOSTING | Release ladder → PR previews |
deploy-staging.yml | Deploy staging | push to main; workflow_dispatch (ios_builds, hosting, critical=yes publishes a forced update) | fingerprint → get_build / build (staging, both platforms) → update (staging channel) → observe (informational) → deploy_web → slack | update runs whenever the fingerprint job succeeded, even if a build failed; build_ios behind IOS_BUILDS, deploy_web behind HOSTING, slack skips itself without SLACK_WEBHOOK_URL | IOS_BUILDS, HOSTING | Release ladder → Staging |
promote.yml | Promote | workflow_dispatch (target=uat | production, optional update_group_id, rollout_percentage for production, critical) | resolve → approve (require-approval) → fingerprint_<target> + get_build → gate → uat build on a miss → republish → promote_web_* → slack | expo.dev approval; the fingerprint gate cuts a uat build on a miss and refuses production on a miss (go through the store release) | IOS_BUILDS, HOSTING | Release ladder → UAT and production |
release.yml | Release | workflow_dispatch only — dispatched by .github/workflows/release.yml after the production Environment reviewer | version_check (tag = app.config.ts version) → fingerprint → check_<p> (store build with this fingerprint?) → gate → build → testflight_ios / submit_android → notify | Skipped green when the fingerprint is unchanged since the last store build (force=yes overrides); iOS build + TestFlight behind IOS_RELEASE, Play submit behind PLAY_SUBMIT | IOS_RELEASE, PLAY_SUBMIT | Release ladder → Store release |
rollout.yml | Rollout | workflow_dispatch only (update_group_id, rollout_percentage = 25 | 50 | 75 | 100) | resolve (group on production, in-progress rollout, up only) → approve (require-approval) → ramp_<n> (update-rollout, one job per choice) → slack | expo.dev approval; other percentages via eas update:edit. Unverified until the first staged rollout | — | Release ladder → Staged rollouts |
backport.yml | Backport | workflow_dispatch only (tags=v1.2.0,v1.1.0 + fix=<sha>, or one tag + ref=backport/<tag>; rollout_percentage, platforms, message) | resolve (tags → clean-tag fingerprints + store builds, fix subject) → approve (require-approval) → backport (per tag: checkout + cherry-pick → fingerprint gate → eas update --channel production) | expo.dev approval; per tag and platform the fingerprint of tag + fix must equal the tag's and have a store build, else refused (native fix → fix release); a conflict prints the local recipe. Straight to production — the one sanctioned ladder skip (ADR-0008). Unverified until the first store release | — | Release ladder → Backports |
observe-check.yml | Observe check | workflow_dispatch (platform, days, version, strict); cron commented out | observe (bun run observe:check) | Informational unless strict=on; meant to run after a staging soak, before a promotion | — | EAS Observe → Gating on TTI |
register-device.yml | Register test device | workflow_dispatch (apple_team_id, note) | register (apple-device-registration-request: QR code on the run page, then a team member approves) | Needs the App Store Connect API key on EAS | — | Device onboarding |
Where the job logic lives
A workflow file is a wiring diagram, not a program: anything longer than a couple of lines lives in scripts/eas/ and the YAML calls it with node scripts/eas/<x>.js. That keeps every file under the 16 KiB cap, stops the same shell block from being copy-pasted across workflows, and makes the decisions unit-testable (scripts/__tests__/eas-scripts.test.ts) — these workflows gate real releases and cannot be run locally.
| Script | Used by | Does |
|---|---|---|
promote-resolve.js | promote.yml resolve | Which staging update group is promoted, and is it critical (ADR-0003) |
rollout-resolve.js | rollout.yml resolve | The group's current rollout, and the three refusals that keep a ramp going up |
update-view.js | both resolvers | The one eas update:view call (injected as run in the tests) |
fingerprint-gate.js --mode <m> | promote.yml, release.yml gate | Per platform: reuse the build, cut one, refuse, or skip green |
backport.js | backport.yml backport | The per-tag cherry-pick → fingerprint gate → eas update loop, with a timeout |
slack-compose.js <workflow> | the four slack / notify jobs | The Slack mrkdwn, from the job's env: block |
Two rules these scripts follow. They read a dependency's results through after.<job>.outputs.* (never needs.*) — see the job env: blocks — because the jobs that report depend via after: so they still run on a red run. And they use Node built-ins only, enforced by scripts/__tests__/builtins-only.test.ts: a job may run them before eas/install_node_modules.
Repo constants
EAS workflows have no top-level env, and inputs.* are empty on any run that is not a workflow_dispatch, so each owner-dependent job is guarded by a literal in the YAML — a repo-level constant — mirrored by a dispatch input whose default equals it. Flipping one means changing the literal and the input default in the same PR (grep the constant's name; the comment above each if: names it). Until flipped, the job is skipped and the run stays green with none of the owner-owed secrets in place.
| Constant | Files | Default | Enable after |
|---|---|---|---|
IOS_MODE | e2e.yml | always | Nothing to enable — it tiers iOS down (main-only | label); see Tiered mode |
FLASHLIGHT | e2e.yml | disabled | Nothing to enable — flip it (or dispatch -F flashlight=enabled) to profile CPU / RAM / FPS after the Android Maestro step, informational (Performance → Flashlight) |
MAESTRO_CLOUD | e2e-cloud.yml | disabled | A Maestro Cloud plan: MAESTRO_CLOUD_API_KEY on the development EAS environment and the project id in the file (Native E2E → Maestro Cloud) |
HOSTING | preview-web.yml, deploy-staging.yml, promote.yml | disabled | The first manual eas deploy claims the project's dev-domain (then consider swapping preview-web.yml back to a pull_request trigger so every PR gets a preview) (Owner checklist → EAS Hosting) |
IOS_BUILDS | deploy-staging.yml, promote.yml | disabled | iOS ad hoc credentials for staging / uat exist (iOS runbook) |
IOS_RELEASE | release.yml | disabled | App Store credentials, the App Store Connect API key and submit.production.ios.ascAppId exist (iOS runbook) |
PLAY_SUBMIT | release.yml | disabled | The Play service-account key is on EAS (Google Play runbook) |
Four more literals are owner-owned but not enabled / disabled switches, so they are not in the table above. This is the full list — grep the name in .eas/workflows/ and the comment above it says what to change:
| Literal | Files | Ships as | The owner changes it when |
|---|---|---|---|
MAESTRO_CLOUD_PROJECT_ID | e2e-cloud.yml | proj_REPLACE_ME (both jobs) | A Maestro Cloud project exists — replace it in the same PR that flips MAESTRO_CLOUD (Native E2E → Maestro Cloud) |
TESTFLIGHT_GROUP | release.yml | internal_groups: ['Internal'] | The App Store Connect internal group has another name; it must not be auto-distributed, since the testflight_ios job adds the build to it (Release ladder) |
upload_sentry_sourcemaps | deploy-staging.yml | commented out (best-effort) | SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKEN are on EAS — uncomment it so a broken upload fails the run instead of shipping unsymbolicated errors (Owner checklist → Sentry) |
schedule: crons (two) | e2e-quarantine.yml (0 6 * * 1), observe-check.yml (0 7 * * 1-5) | commented out | The first flow is quarantined / the first staging build reports to Observe. maestro test exits 1 when no flow matches the tag, and Observe has no data before then, so both are off until there is something to run (Flake budget, EAS Observe) |
What each EAS workflow needs
| Need | Used by | Where it lives |
|---|---|---|
| Expo GitHub App linked to the repo | Every pull_request / push trigger, the github-comment jobs, PR check status | expo.dev → project → GitHub settings (Native E2E → Human prerequisites) |
EXPO_PUBLIC_* values per environment (development / preview / production) | Every job with environment:; fingerprint jobs must use the same one as the build profile they match | EAS environment variables (Environments and secrets → Environment variables) |
| Signing credentials (Android keystores, iOS certificates / profiles / ASC key) | build, submit, register-device | EAS credentials, never the repo (Environments and secrets → Credentials) |
SLACK_WEBHOOK_URL | slack / notify jobs (their eas/send_slack_message step is skipped while unset) | EAS secret in preview / production (Build sharing → Slack channel) |
MAESTRO_CLOUD_API_KEY | maestro-cloud jobs of e2e-cloud.yml (off until MAESTRO_CLOUD is flipped) | EAS secret in development (Native E2E → Maestro Cloud) |
SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKEN | update job source-map upload (best-effort until set) | EAS environment variables (Owner checklist → Sentry) |
| Robot access token | Custom jobs that call eas themselves (promote, observe, release) | ${ eas.job.secrets.robotAccessToken }, provided by the run; no stored secret |
EXPO_TOKEN | Only the GitHub side: .github/workflows/release.yml dispatching the EAS release | GitHub repository secret |
RELEASE_PLEASE_TOKEN | .github/workflows/release-please.yml (release PR + tag; GITHUB_TOKEN events trigger nothing) | GitHub repository secret (Owner checklist) |
From PR to merge
- Open a PR from a same-repo branch with a Conventional Commit title.
CIandPR titlestart;E2E (native)starts on EAS once the GitHub App is linked;Preview webonly when the PR is labelledweb-preview. - Branch protection on
mainrequires everyCIjob exceptPerf (Reassure)andFingerprint drift, plusPR title. The EAS check joins the required set only when its context string is added toREQUIRED_CHECKSandbun run repo:settings:applyis re-run. - Merge is squash-only; the PR title becomes the commit subject and the PR body the commit body (keep
Closes #nin the body). Renovate PRs auto-merge on green. - The merge commit triggers
Deploy staging; the staging OTA lands within the same run. From there the ladder is manual: Release ladder.
Details — merge settings, strict off, why no required reviews, how to change the required set — are in JS gate → How merging works and Changing the required set.
When something is red
| Red | First look | Doc |
|---|---|---|
Lint, Typecheck, Format, Knip | Run the same script locally; lefthook should have caught most of it at pre-commit / pre-push | JS gate → Running the gate locally, Conventions |
Env check / i18n check | bun run env:check / bun run i18n:extract then commit the catalog | Environments and secrets → Environment variables, Conventions → i18n |
Unit tests | bun run test; the JUnit and coverage artifacts on the run | Testing → Unit and component tests |
Commitlint / PR title | Reword the commit (git commit --amend) or edit the PR title; subject must be lowercase | Conventions → Commits and PRs |
Secret scan | The job summary names the finding; rotate the secret, then allowlist only true placeholders in .gitleaks.toml | Environments and secrets |
Bundle budget (<platform>) | bun run export:<platform> && bun run budget --platform <platform>; find the cause with Atlas before raising a limit | Performance → Bundle budgets, Expo Atlas |
Maestro web | Download the maestro-web artifact (JUnit + debug output); reproduce with bun run e2e:web | Testing → E2E, Native E2E → Reading Maestro's debug output |
Template init | bun run template:e2e --keep; usually a moved identifier or a leftover template token in a new file | Template init → End-to-end test |
Perf (Reassure) (informational) | Read the step summary; a real slowdown gets a fix or a justification in the PR, a noisy one a re-run | Render-perf tests → What perf:gate fails on |
Fingerprint drift comment | Expected on native changes: merging means new staging builds and a store release before production | Release ladder → Fingerprint drift on PRs |
E2E (native) | The PR comment names the failed flows and links the run; artifacts hold recordings, JUnit, device logs | Native E2E → A flow failed on the PR |
E2E (native) never appears on the PR | GitHub App not linked, fork PR, or a [skip eas] marker in a commit | JS gate → How EAS checks appear on the PR |
Deploy staging | The Slack post (or run page) says which job: a build usually means credentials, an update a fingerprint mismatch | Release ladder → Staging |
Promote refused | Fingerprint gate: production has no store build for the group's runtime, or the checkout is not the group's commit | Release ladder → UAT and production |
Release (GitHub) fails at EXPO_TOKEN | Add the repository secret | Owner checklist → repository secrets |
Release (EAS) skipped or red | Skipped = fingerprint unchanged (by design); red at version_check = tag does not match app.config.ts version | Release ladder → Store release |
| A required check shows "Expected — waiting" forever | A job was renamed without updating REQUIRED_CHECKS; run bun run repo:settings:check | JS gate → Changing the required set |
| Rolling back what a run shipped | Per channel: update:rollback / update:republish / roll back to embedded | Release ladder → Rollback |