TL;DR
- Run lint/tests/type checks on GitHub Actions (Ubuntu, cheap, fast)
- Run native builds and code signing on EAS (purpose-built, managed credentials)
- The rule: if a step needs Xcode, Ruby, or a keychain, it belongs in EAS
- Add EAS Update so JS-only changes ship OTA in ~90 seconds, no store review
- Fingerprint runtime policy auto-detects when a native rebuild is actually needed
The first time you ship a React Native app, the code is the easy part. The hard part is everything after git push: signing certificates that expire, provisioning profiles that don't match, a Fastlane script from a two-year-old blog post, a keystore.jks that lives on one laptop, and a build that succeeds locally on macOS but fails on Ubuntu because Xcode isn't there.
Here's the pipeline that fixed it for me: every push runs checks, every merge to main produces a signed build, every release lands on TestFlight and Google Play without anyone opening Xcode.
Why two CI systems (yes, really)
The Expo team openly recommends using both GitHub Actions and EAS. Felt wrong at first. Then I looked at what each is good at:
| Concern | GitHub Actions | EAS |
|---|---|---|
| Lint, TS, unit tests | Native fit, generous free tier | Overkill |
iOS .ipa compile |
Slow, expensive, hand-rolled signing | Purpose-built, fingerprint caching |
Android .aab compile |
Workable but manual | One command |
| Code signing | Secrets you rotate manually | Managed, auto-rotated |
| OTA updates | Trigger via CLI | First-class |
| Cost | Free-ish for JS, painful for macOS | Paid tiers, honest about it |
GitHub Actions can technically compile an iOS build. It'll also take 15–20 minutes per attempt and bill macOS minutes at 10x the Linux rate. So: GitHub Actions owns the code, EAS owns the binary.
Step 1: eas.json with real profiles
One production profile is a trap. You need three:
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": { "APP_ENV": "development" },
"ios": { "simulator": true }
},
"preview": {
"distribution": "internal",
"channel": "preview",
"env": { "APP_ENV": "preview" },
"ios": { "simulator": false }
},
"production": {
"channel": "production",
"env": { "APP_ENV": "production" },
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "you@example.com",
"ascAppId": "1234567890",
"appleTeamId": "ABCD123456"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
}
Non-obvious bits:
-
appVersionSource: "remote"kills the "who bumpedversionlast?" merge conflict forever. EAS tracks build numbers server-side. -
channelbinds each build to an EAS Update channel, so a QA release can't get clobbered by amainpush. -
ios.simulator: trueon dev gives you simulator builds for design review on any Mac.
Step 2: the workflow
.github/workflows/ci.yml. Every PR: checks + preview build. Every merge to main: production build + submit.
name: CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
quality:
name: Lint, type-check, test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test -- --ci --coverage
preview-build:
name: EAS preview build
needs: quality
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: eas build --profile preview --platform all --non-interactive --no-wait
production-release:
name: EAS production build + submit
needs: quality
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: eas build --profile production --platform all --auto-submit --non-interactive
The shape matters more than the details:
-
qualityruns on Ubuntu, not macOS. JS jobs have no business on a macOS runner. -
--no-waiton previews: Actions kicks off the EAS build and returns instead of burning minutes waiting on a 15-minute compile. -
--auto-submiton production: binary succeeds → straight to TestFlight and Play internal testing. No human in App Store Connect. -
EXPO_TOKEN(from your Expo dashboard) is the only secret GitHub needs.
Step 3: secrets and signing without losing your mind
Code signing is where mobile CI/CD historically goes to die. iOS: distribution cert + provisioning profile + private key. Android: keystore + alias + two passwords. Lose any of them and you can't update your existing app.
EAS managed credentials fix this. First eas build, it offers to generate and store everything. Say yes. Encrypted, auto-rotated, shared across the team.
Android submit key stays out of your repo:
eas secret:create --scope project --name GOOGLE_SERVICE_ACCOUNT_KEY --type file --value ./google-service-account.json
Because signing material never touches GitHub:
- A compromised Actions run can't leak your keys. They aren't there.
- New teammates get access via Expo membership, not file-copying.
- Cert renewal is a background task, not a Friday fire drill.
Stuffing an Apple cert into ${{ secrets.IOS_P12 }} works until it doesn't.
Step 4: OTA updates so most releases skip the stores
Most changes to a mature RN app are JS-only: copy tweaks, style fixes, new screens from existing components. They don't need a binary. They need to reach phones.
ota-update:
name: EAS OTA update
needs: quality
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: eas update --channel production --message "${{ github.event.head_commit.message }}"
Every merge → OTA bundle live in ~90 seconds. Users get it on next app open. The native build still runs in parallel so binary and channel stay in sync.
The caveat Expo's docs don't shout: native module changes are not OTA-updatable. Added expo-camera this morning? New binary. Protect yourself with:
{
"runtimeVersion": { "policy": "fingerprint" }
}
in app.json. EAS refuses to serve an update to a binary whose runtime doesn't match, and fingerprinting auto-detects when a rebuild is required.
Step 5: fingerprint caching
EAS hashes everything that affects the native binary (package.json, app.json, native folders, plugin configs) and reuses a previously built binary when nothing changed. JS-only PR: iOS build drops from ~15 minutes to a couple. Preview builds on every PR go from painful to routine.
The shortcut nobody mentions
All of this assumes you already have a sane Expo project. If you're generating apps with RapidNative, the export already ships the scaffolding this article builds on: eas.json with dev/preview/production profiles, fingerprint runtime policy in app.json, and a monorepo layout that doesn't fight expo/expo-github-action. Drop the workflow above into .github/workflows/ci.yml, add an EXPO_TOKEN, push. Done.
Not using it? Nothing here requires it. The pipeline is the pipeline.
Wrap-up
Deliberately not the fanciest pipeline possible. No matrix builds, no Slack-gated rollouts. Get the core loop (push → tested → signed → shipped) running on its own first; budget a day for the first production build (iOS signing, mostly) and another for OTA wiring.
What broke first in your React Native pipeline? Expired provisioning profile? Keystore password nobody wrote down? Drop it in the comments. Misery loves company, and the fixes are usually reusable.
Top comments (0)