Mobile App CI/CD with EAS Build and GitHub Actions
GitHub Actions for checks, EAS for signed builds, OTA updates for everything else. A pipeline you can copy into any Expo project.
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.
React Native CI/CD is the discipline of turning that mess into a repeatable pipeline: every push runs lint and tests, every merge to main produces a signed build, and every release goes to TestFlight and Google Play with no one touching Xcode. In 2026, the cleanest way to build that pipeline is a combination of two tools: Expo Application Services (EAS) for the native heavy lifting, and GitHub Actions for everything else.
This guide walks through a pipeline you can actually copy: what belongs on each side, how to wire them together, how to handle secrets and code signing without leaking them, and how to add over-the-air (OTA) updates so most releases skip the app stores entirely. It also covers a shortcut most teams don't know exists: if your project was generated by RapidNative, the CI-ready scaffolding is already in the box (more on that near the end).
What "mobile app CI/CD" actually means
Mobile app CI/CD is the automated pipeline that takes a React Native or Expo commit and turns it into a signed, distributable build without a human running Xcode or Android Studio. It combines continuous integration (lint, tests, type checks on every push) with continuous delivery (signed builds and store submissions on every release), and adds an OTA update layer so JavaScript-only changes reach users in minutes instead of days.
That paragraph is the whole idea. The rest of the article is about how to implement it without spending your weekend debugging code-signing errors.
There are three moving parts in every serious pipeline:
CI checks: TypeScript, ESLint, unit tests, format checks. These are cheap, fast, and language-agnostic.
Native builds: compiling
.ipaand.aabbinaries. These need macOS runners (for iOS), signing keys, and a lot of setup.Distribution: uploading to TestFlight, Google Play internal testing, and pushing OTA updates.
The mistake most teams make is trying to do all three in the same place. GitHub Actions can technically compile an iOS build; it will also take 15–20 minutes per attempt, cost you macOS runner minutes at premium rates, and force you to manage certificates by hand. EAS was built to do that specific job well. The pragmatic split is: GitHub Actions owns the code, EAS owns the binary.
The split-brain problem, and why it's actually the right answer
The Expo team openly recommends using both. That felt wrong to me at first (why run two CI systems?) until I looked at what each is actually good at.
| Concern | GitHub Actions | EAS Build / Workflows |
|---|---|---|
| Lint, TypeScript, unit tests | Native fit; free tier is generous | Overkill |
iOS .ipa compilation |
Slow, expensive, hand-rolled signing | Purpose-built, fingerprint caching |
Android .aab compilation |
Workable but manual | One command |
| Code signing (iOS certs, Android keystore) | Store in Actions secrets, rotate manually | Managed credentials, auto-rotated |
| OTA updates (EAS Update) | Can trigger via CLI | First-class |
| PR preview builds | Complex | eas build --profile preview on PR |
| Cost model | Free-ish for JS jobs, painful for macOS | Paid tiers, but honest about it |
The rule I've settled on: if a step needs Xcode, Ruby, or a keychain, it belongs in EAS. Everything else (the fast feedback loop developers actually feel) belongs in GitHub Actions. That way a broken test blocks a PR in 90 seconds, and a full native build only runs when it actually needs to.
Photo by Luca Bravo on Unsplash
Step 1: Set up eas.json with real build profiles
eas.json is the config file EAS reads to decide how to build your app. It lives at the root of your Expo project alongside app.json and package.json. Most tutorials show a single production profile. That's a trap: you need at least three, because dev, QA, and prod are different environments with different bundle identifiers, API URLs, and signing setups.
Here's a working starting point:
{
"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"
}
}
}
}
A few non-obvious decisions in there:
appVersionSource: "remote"delegates the version number to EAS. This is the setting that finally kills the "who bumpedversionlast?" merge conflict. EAS tracks build numbers server-side and auto-increments them on production builds.channelbinds the build to an EAS Update channel. Apreview-channel build only receives OTA updates published to thepreviewchannel, so a QA release can't be accidentally overwritten by amainpush.ios.simulator: trueon development lets your CI produce a build that runs in the iOS simulator on any Mac. Great for design review, useless for App Store submission.
Full reference in the Expo docs on configuring eas.json.
Step 2: The GitHub Actions workflow
This is the file that ties everything together. It lives at .github/workflows/ci.yml. The design goal: every PR runs lint + tests + a preview EAS build; every merge to main runs a production EAS build and submits it.
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
Read that carefully. The shape matters more than the details.
qualityruns on Ubuntu, not macOS. JS-only jobs have no business on a macOS runner. Ubuntu is 10x cheaper on GitHub's billing.--no-waiton preview builds. The Actions job kicks off the EAS build and returns immediately. EAS runs the build on its own infrastructure, and Actions doesn't sit there burning minutes waiting for a 15-minute native compile.--auto-submiton production. EAS builds the binary, then hands it directly toeas submit. If the binary succeeds, it goes to TestFlight and Google Play internal testing on its own. No human touches App Store Connect.EXPO_TOKENis a personal access token created in your Expo dashboard. It authenticates the GitHub runner to your EAS account.
The Expo team publishes an official guide on triggering builds from CI that goes deeper into edge cases (monorepos, custom Docker images, submodules).
Step 3: Handling secrets and code signing without losing your mind
Code signing is where mobile CI/CD historically goes to die. iOS wants a distribution certificate, a provisioning profile, and a private key. Android wants a keystore, a key alias, and two passwords. Losing any of them means you can't ship an update to your existing users: you'd have to publish a new app.
EAS solves the iOS side almost entirely with managed credentials. On your first eas build, it offers to generate and store the certificates for you. Say yes. They live encrypted in EAS's infrastructure, are automatically rotated when they expire, and are the same across every developer on your team. If you have existing credentials (say, from a legacy Fastlane pipeline), you can upload them once and never think about them again.
For Android, the pattern is similar. EAS can generate a keystore or you can upload your existing one. Store the google-service-account.json (needed for eas submit) as an EAS secret, not in your repo:
eas secret:create --scope project --name GOOGLE_SERVICE_ACCOUNT_KEY --type file --value ./google-service-account.json
On the GitHub Actions side, the only secret you need is EXPO_TOKEN. All the app-signing material stays inside EAS, which means:
A compromised GitHub Actions run cannot leak your signing keys, because they aren't there.
A new developer joining the team gets access via Expo team membership, not by copying files around.
Certificate renewal is a background task EAS handles, not a fire drill three days before an OS release.
This is the single biggest reason to keep native builds off GitHub Actions. Managing an Apple distribution certificate inside ${{ secrets.IOS_P12 }} works until it doesn't, and then you find out on a Friday afternoon.
Photo by FLY:D on Unsplash
Step 4: Add OTA updates so most releases skip the stores
The best CI/CD pipeline is the one you don't have to run. Most of the changes shipped to a mature React Native app are JavaScript-only: a copy tweak, a style fix, a new screen using components that are already in the binary. These don't need a new native build. They just need to reach existing users' phones.
That's what EAS Update is for. Add one job to the workflow:
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 }}"
Now every merge to main publishes an OTA bundle in about 90 seconds. Users on the current binary get the change on their next app open. A full native build still runs in parallel (via the production-release job), so the binary and the OTA channel stay in sync, but you're not blocked on a long native compile to ship a bug fix.
One caveat that isn't in Expo's docs loud enough: native module changes are not OTA-updatable. If you add expo-camera this morning, it needs a new binary. The runtime version protects you here: EAS refuses to serve an OTA update to a binary whose runtime version doesn't match. Pair that with a fingerprint policy ("runtimeVersion": { "policy": "fingerprint" } in app.json) and the system auto-detects when a native rebuild is required.
Step 5: Cutting build time with fingerprint caching
EAS's biggest performance feature in 2026 is fingerprint-based build skipping. It hashes the parts of your project that affect the native binary (package.json, app.json, the native folders, plugin configs) and reuses a previously built binary if the fingerprint hasn't changed.
For a JS-only PR, this can drop an iOS build from roughly 15 minutes to a couple of minutes. It's on by default when you use eas build with a fingerprint runtime version. The practical implication: your preview builds on every PR go from painful to routine. If you were rate-limiting how often PRs got a native preview because builds were slow, you can stop doing that now.
Where RapidNative fits into all of this
The problem with every tutorial like this one (including this one) is that it assumes you already have a working Expo project. The wiring above is straightforward when you already have eas.json, an EXPO_TOKEN, and a project with sensible module boundaries. It's much less straightforward if you're starting from a boilerplate someone copy-pasted three years ago.
RapidNative generates production-ready Expo apps from natural-language prompts, and the export ships with the pieces that make this pipeline possible on day one:
eas.jsonwith dev, preview, and production profiles already scaffolded (the exact structure this article recommends).app.jsonwith runtime version set to fingerprint policy, so OTA updates auto-detect when a native rebuild is required.A monorepo layout (
mobile/for the React Native app,web/if you generated a web version) that matches what EAS expects and doesn't fightexpo/expo-github-action.A current Expo SDK configuration with matching React Native and TypeScript versions, so
eas builddoesn't reject your submission for using an unsupported SDK.
The reason this matters: we wrote about the ZIP-to-TestFlight path after watching too many teams get a great AI-generated app, then lose two days rebuilding the project's scaffolding into something CI could actually consume. The whole reason RapidNative uses Expo over bare React Native is that Expo is the shortest path from "code exists" to "binary is signed and on TestFlight." Skipping EAS in an AI-generated codebase would be leaving most of the automation on the table.
If you're not using RapidNative, none of this pipeline requires it. But if you are, the point of the export is that you can drop the workflow above into .github/workflows/ci.yml, add an EXPO_TOKEN, and push. That's the whole setup.
Photo by Redd Francisco on Unsplash
People also ask
How much does EAS Build cost compared to GitHub Actions?
EAS includes a free monthly build allowance, with paid plans scaling up for production teams. GitHub Actions is free for public repos and includes free minutes for private repos, but macOS runners bill at roughly 10x the Linux rate. For a team doing around 100 native builds a month, running them entirely on GitHub-hosted macOS runners typically costs more than an EAS paid plan, and EAS builds are faster because of fingerprint caching.
Can I use GitHub Actions without EAS for React Native?
Yes, technically. You'd need macOS runners with Xcode, a self-managed keychain, Fastlane for signing, and a lot of YAML. It's a valid choice for teams with existing native mobile expertise who don't want a vendor dependency. For most React Native teams (especially anyone using Expo modules) the operational cost of maintaining that pipeline exceeds the price of EAS by month three.
Do I need EAS Workflows if I already use GitHub Actions?
EAS Workflows is Expo's own CI/CD product, positioned as an alternative to GitHub Actions for mobile-heavy pipelines. If your project is 90% mobile and you want one dashboard, use Workflows. If you have a broader repo (backend services, web app, mobile app) and GitHub Actions is already the source of truth, keep it and use EAS just for build, submit, and update. Both approaches are supported.
What breaks first when a React Native CI pipeline goes wrong?
In order of frequency: expired iOS provisioning profiles, Android keystore password mismatches after a team member leaves, EXPO_TOKEN scoped to the wrong project, and native module additions that don't trigger a rebuild because runtime version is pinned instead of fingerprinted. Managed credentials in EAS eliminate the first two entirely; the last two are process problems solved by fingerprint policy and clear release ownership.
The point of all this
A mobile CI/CD pipeline is a boring, un-fun piece of infrastructure that has an outsized effect on how fast you can ship. When it works, no one notices. When it doesn't, every release is a two-day fire drill and your team stops shipping between store submissions.
The pipeline in this article is deliberately not the most sophisticated one possible. There's no matrix build across Node versions, no Slack-notified rollout gates, no per-branch environment promotion. Those are worth adding later, but only after the core loop (push → tested → signed → shipped) runs on its own.
If you're building the pipeline from scratch, start with the eas.json in step 1 and the workflow in step 2. If you're building it into an existing project, budget a day for the first successful production build (mostly for iOS signing) and another day for the OTA update wiring. If you're building it around a RapidNative-generated app, the scaffolding is already there; the export drops straight into this workflow with almost nothing to change.
Either way, the goal is the same: a git push that ends with your users getting the new build, and nobody touching Xcode along the way.

