Skip to content

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.

text
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 / jobTriggerRequiredGuardsConfig lives in
CI / LintPR, push to mainyesoxlint && 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 / TypecheckPR, push to mainyestsc --noEmit (TypeScript strict, typed routes)tsconfig.json, scripts/ensure-expo-env.js
CI / FormatPR, push to mainyesPrettier on everything not in .prettierignore (markdown included).prettierrc, .prettierignore
CI / KnipPR, push to mainyesDead code, unused exports and dependenciesknip.jsonc
CI / Env checkPR, push to mainyesEXPO_PUBLIC_* against the Zod schemasrc/lib/env.schema.ts, scripts/env-check.ts, .env.example
CI / i18n checkPR, push to mainyest() keys in code ↔ src/i18n/locales/*/common.jsoni18next-parser.config.js
CI / Unit testsPR, push to mainyesJest + jest-expo + RNTL with coverage; JUnit + lcov artifactsjest.config.js, jest.setup.ts (Testing)
CI / CommitlintPR, push to mainyesEvery commit in the range is a Conventional Commitcommitlint.config.js
CI / Secret scanPR, push to mainyesgitleaks over the commit range.gitleaks.toml
CI / Bundle budget (web | ios | android)PR, push to mainyesJS-only expo export per platform, gzip size under budget; the web leg is its own job (bundle-budget-web) and its export feeds Maestro webbundle-budget.json, scripts/bundle-budget.js
CI / Maestro webPR, push to mainyesMaestro web-tagged flows against the served static export.maestro/ (flows/web/*), scripts/serve-web.js
CI / DocsPR, push to mainyesbun 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 initPR, push to mainyesbun 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 onlynoRender-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 driftPR onlynoProduction-variant @expo/fingerprint of base vs head; one upserted PR comment + fingerprint-drift label on drift, never redscripts/fingerprint.js (Release ladder)
PR title / PR titlePR opened / edited / synchronizeyesThe PR title (= squash commit subject) passes commitlintcommitlint.config.js
Release please / Release PR / tagpush to mainn/aKeeps 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 releasepush of a v* tagn/aWaits 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 / BuildDeploypush to main (docs, README, PLAN, package.json, bun.lock), manualn/aBuilds 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).

FileNameTriggerJobsGates and approvalsConstantsRunbook
e2e.ymlE2E (native)PR to main; PR labelled e2e:ios; workflow_dispatch (push off by default)fingerprint → per platform get_buildbuild (miss) | repack (hit) → maestrocomment (PR comment)Reports as a PR check once the GitHub App is linked; not in REQUIRED_CHECKS until its context string is copied thereIOS_MODE, FLASHLIGHTNative E2E → Workflow
e2e-quarantine.ymlE2E (quarantine)workflow_dispatch (weekly schedule commented out until a flow is tagged)Same shape as e2e.yml with include_tags: [quarantine], retries: 0, no commentNon-blockingNative E2E → Flake budget
e2e-cloud.ymlE2E (Maestro Cloud)PR labelled e2e:cloud; workflow_dispatch (maestro_cloud=enabled)fingerprint → per platform get_buildrepack (hit only) → maestro-cloud (pre-packaged job, both platforms); refuse notice on a missOff 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 runMAESTRO_CLOUDNative E2E → Maestro Cloud
preview-web.ymlPreview webPR labelled web-preview; workflow_dispatchdeploy_web (export + eas deploy to the pr-<number> alias) → commentOpt-in by label, so no permanent "skipped" entry on every PR's checks list; both jobs skipped while HOSTING is disabledHOSTINGRelease ladder → PR previews
deploy-staging.ymlDeploy stagingpush to main; workflow_dispatch (ios_builds, hosting, critical=yes publishes a forced update)fingerprintget_build / build (staging, both platforms) → update (staging channel) → observe (informational) → deploy_webslackupdate 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_URLIOS_BUILDS, HOSTINGRelease ladder → Staging
promote.ymlPromoteworkflow_dispatch (target=uat | production, optional update_group_id, rollout_percentage for production, critical)resolveapprove (require-approval) → fingerprint_<target> + get_buildgate → uat build on a miss → republishpromote_web_*slackexpo.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, HOSTINGRelease ladder → UAT and production
release.ymlReleaseworkflow_dispatch only — dispatched by .github/workflows/release.yml after the production Environment reviewerversion_check (tag = app.config.ts version) → fingerprintcheck_<p> (store build with this fingerprint?) → gatebuildtestflight_ios / submit_androidnotifySkipped green when the fingerprint is unchanged since the last store build (force=yes overrides); iOS build + TestFlight behind IOS_RELEASE, Play submit behind PLAY_SUBMITIOS_RELEASE, PLAY_SUBMITRelease ladder → Store release
rollout.ymlRolloutworkflow_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) → slackexpo.dev approval; other percentages via eas update:edit. Unverified until the first staged rolloutRelease ladder → Staged rollouts
backport.ymlBackportworkflow_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 releaseRelease ladder → Backports
observe-check.ymlObserve checkworkflow_dispatch (platform, days, version, strict); cron commented outobserve (bun run observe:check)Informational unless strict=on; meant to run after a staging soak, before a promotionEAS Observe → Gating on TTI
register-device.ymlRegister test deviceworkflow_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 EASDevice 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.

ScriptUsed byDoes
promote-resolve.jspromote.yml resolveWhich staging update group is promoted, and is it critical (ADR-0003)
rollout-resolve.jsrollout.yml resolveThe group's current rollout, and the three refusals that keep a ramp going up
update-view.jsboth resolversThe one eas update:view call (injected as run in the tests)
fingerprint-gate.js --mode <m>promote.yml, release.yml gatePer platform: reuse the build, cut one, refuse, or skip green
backport.jsbackport.yml backportThe per-tag cherry-pick → fingerprint gate → eas update loop, with a timeout
slack-compose.js <workflow>the four slack / notify jobsThe 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.

ConstantFilesDefaultEnable after
IOS_MODEe2e.ymlalwaysNothing to enable — it tiers iOS down (main-only | label); see Tiered mode
FLASHLIGHTe2e.ymldisabledNothing to enable — flip it (or dispatch -F flashlight=enabled) to profile CPU / RAM / FPS after the Android Maestro step, informational (Performance → Flashlight)
MAESTRO_CLOUDe2e-cloud.ymldisabledA Maestro Cloud plan: MAESTRO_CLOUD_API_KEY on the development EAS environment and the project id in the file (Native E2E → Maestro Cloud)
HOSTINGpreview-web.yml, deploy-staging.yml, promote.ymldisabledThe 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_BUILDSdeploy-staging.yml, promote.ymldisablediOS ad hoc credentials for staging / uat exist (iOS runbook)
IOS_RELEASErelease.ymldisabledApp Store credentials, the App Store Connect API key and submit.production.ios.ascAppId exist (iOS runbook)
PLAY_SUBMITrelease.ymldisabledThe 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:

LiteralFilesShips asThe owner changes it when
MAESTRO_CLOUD_PROJECT_IDe2e-cloud.ymlproj_REPLACE_ME (both jobs)A Maestro Cloud project exists — replace it in the same PR that flips MAESTRO_CLOUD (Native E2E → Maestro Cloud)
TESTFLIGHT_GROUPrelease.ymlinternal_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_sourcemapsdeploy-staging.ymlcommented 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 outThe 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

NeedUsed byWhere it lives
Expo GitHub App linked to the repoEvery pull_request / push trigger, the github-comment jobs, PR check statusexpo.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 matchEAS environment variables (Environments and secrets → Environment variables)
Signing credentials (Android keystores, iOS certificates / profiles / ASC key)build, submit, register-deviceEAS credentials, never the repo (Environments and secrets → Credentials)
SLACK_WEBHOOK_URLslack / notify jobs (their eas/send_slack_message step is skipped while unset)EAS secret in preview / production (Build sharing → Slack channel)
MAESTRO_CLOUD_API_KEYmaestro-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_TOKENupdate job source-map upload (best-effort until set)EAS environment variables (Owner checklist → Sentry)
Robot access tokenCustom jobs that call eas themselves (promote, observe, release)${ eas.job.secrets.robotAccessToken }, provided by the run; no stored secret
EXPO_TOKENOnly the GitHub side: .github/workflows/release.yml dispatching the EAS releaseGitHub 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

  1. Open a PR from a same-repo branch with a Conventional Commit title. CI and PR title start; E2E (native) starts on EAS once the GitHub App is linked; Preview web only when the PR is labelled web-preview.
  2. Branch protection on main requires every CI job except Perf (Reassure) and Fingerprint drift, plus PR title. The EAS check joins the required set only when its context string is added to REQUIRED_CHECKS and bun run repo:settings:apply is re-run.
  3. Merge is squash-only; the PR title becomes the commit subject and the PR body the commit body (keep Closes #n in the body). Renovate PRs auto-merge on green.
  4. 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

RedFirst lookDoc
Lint, Typecheck, Format, KnipRun the same script locally; lefthook should have caught most of it at pre-commit / pre-pushJS gate → Running the gate locally, Conventions
Env check / i18n checkbun run env:check / bun run i18n:extract then commit the catalogEnvironments and secrets → Environment variables, Conventions → i18n
Unit testsbun run test; the JUnit and coverage artifacts on the runTesting → Unit and component tests
Commitlint / PR titleReword the commit (git commit --amend) or edit the PR title; subject must be lowercaseConventions → Commits and PRs
Secret scanThe job summary names the finding; rotate the secret, then allowlist only true placeholders in .gitleaks.tomlEnvironments and secrets
Bundle budget (<platform>)bun run export:<platform> && bun run budget --platform <platform>; find the cause with Atlas before raising a limitPerformance → Bundle budgets, Expo Atlas
Maestro webDownload the maestro-web artifact (JUnit + debug output); reproduce with bun run e2e:webTesting → E2E, Native E2E → Reading Maestro's debug output
Template initbun run template:e2e --keep; usually a moved identifier or a leftover template token in a new fileTemplate 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-runRender-perf tests → What perf:gate fails on
Fingerprint drift commentExpected on native changes: merging means new staging builds and a store release before productionRelease ladder → Fingerprint drift on PRs
E2E (native)The PR comment names the failed flows and links the run; artifacts hold recordings, JUnit, device logsNative E2E → A flow failed on the PR
E2E (native) never appears on the PRGitHub App not linked, fork PR, or a [skip eas] marker in a commitJS gate → How EAS checks appear on the PR
Deploy stagingThe Slack post (or run page) says which job: a build usually means credentials, an update a fingerprint mismatchRelease ladder → Staging
Promote refusedFingerprint gate: production has no store build for the group's runtime, or the checkout is not the group's commitRelease ladder → UAT and production
Release (GitHub) fails at EXPO_TOKENAdd the repository secretOwner checklist → repository secrets
Release (EAS) skipped or redSkipped = fingerprint unchanged (by design); red at version_check = tag does not match app.config.ts versionRelease ladder → Store release
A required check shows "Expected — waiting" foreverA job was renamed without updating REQUIRED_CHECKS; run bun run repo:settings:checkJS gate → Changing the required set
Rolling back what a run shippedPer channel: update:rollback / update:republish / roll back to embeddedRelease ladder → Rollback