TL;DR
- GitHub Actions owns the code (lint, types, tests). EAS owns the binary (build, sign, submit).
- If a step needs Xcode, Ruby, or a keychain, it goes to EAS. Everything else stays on cheap Ubuntu runners.
- Use three
eas.jsonprofiles (development, preview, production) andappVersionSource: "remote". - The only GitHub secret you need is
EXPO_TOKEN. Signing material lives in EAS managed credentials. - Ship JS-only changes with
eas updatein ~90 seconds, and use fingerprint runtime versions so OTA never hits an incompatible binary.
Every React Native team eventually hits the same wall: the app works, the code is clean, and shipping is still a manual, terrifying ritual. Certificates expire on Fridays. Someone's laptop holds the only working keystore. A junior dev opens Xcode for an OTA-worthy typo fix.
This post is the pipeline I'd give my past self. It splits work across two systems on purpose - GitHub Actions for JS, EAS for native - and covers the exact eas.json, the exact workflow YAML, and the parts nobody warns you about.
Why not just do it all in GitHub Actions?
You can. You'll spend $50–200/month on macOS runner minutes, you'll rebuild your keychain in YAML every commit, and your iOS builds will take 15+ minutes because there's no caching layer that understands React Native.
EAS is a service built by the Expo team specifically for compiling and signing React Native binaries. It caches by fingerprint (JS-only PRs skip native rebuild), manages certificates for you, and submits to TestFlight/Play with one flag. It's not free, but neither is a macOS runner at $0.08/minute.
Rule of thumb: if a step needs Xcode, Ruby, or a keychain, EAS does it. Everything else stays on GitHub Actions.
The eas.json that actually works
Most tutorials show a one-profile eas.json. That's a trap. You want 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" }
},
"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"
}
}
}
}
The one setting worth calling out: appVersionSource: "remote". This kills version-bump merge conflicts forever because EAS owns the build number, not your repo.
The GitHub Actions workflow
Drop this into .github/workflows/ci.yml:
name: CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
quality:
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
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:
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
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 }}"
Notes worth reading before you copy-paste:
-
--no-waiton preview builds - Actions kicks off the EAS build and returns. Don't burn Ubuntu minutes waiting. -
--auto-submiton production - EAS builds AND submits to TestFlight/Play in one command. -
ota-updateruns in parallel withproduction-releaseso JS-only changes reach users in ~90 seconds even if the native binary takes 20 minutes.
Code signing without pain
Store EXPO_TOKEN as a GitHub Actions secret. That's the only secret you need.
Signing certs, provisioning profiles, keystore, service account JSON - all of it lives inside EAS as managed credentials. eas build prompts you once on the first run, generates or uploads them, and never asks again. Certificates rotate automatically before expiry.
The alternative - storing certs and keys in GitHub Actions secrets - technically works. Then a certificate expires and you're debugging Fastlane at 11pm on a Friday. Don't do it.
The OTA / native update boundary
Runtime version + fingerprint policy is the setting that keeps this system honest. In app.json:
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" }
}
}
EAS hashes the parts of your project that affect the native binary. If your PR only touches JS/TS, the fingerprint doesn't change and the OTA update reaches the existing binary. If you add expo-camera, the fingerprint changes, and EAS refuses to serve the OTA to the old binary - forcing a native rebuild. This is exactly the behavior you want; without it, an OTA update can crash users' apps in production.
What breaks first (from experience)
- Expired iOS provisioning profiles - solved by EAS managed credentials.
- Keystore password mismatch after a team member leaves - solved by EAS managed credentials.
-
EXPO_TOKENscoped to the wrong project - checkexpo whoamiand the token's project scope in your Expo dashboard. - Native module added but runtime version pinned - switch to fingerprint policy.
One shortcut: start from a CI-ready project
If you're generating your Expo project with an AI tool like RapidNative, the export already ships with eas.json, fingerprint runtime version, and the monorepo layout expo-github-action expects - so this workflow drops in without rewiring.
Wrapping up
Regardless of where the project came from: build the pipeline once, and every future release is a git push. That's the whole payoff.
What does your React Native release pipeline look like right now - all-in on EAS, Fastlane on macOS runners, or still building from someone's laptop? Drop a comment with what you're building and what broke last. I'll answer with code.
Top comments (0)