Automation turns Expo builds and over-the-air (OTA) updates into a predictable, low-friction flow that runs on every commit. This guide walks you through a safe, staged CI/CD path for Expo projects using branch-based channels, validation gates, and practical rollback procedures. You will learn how to wire GitHub Actions or GitLab CI to EAS Build and EAS Update, verify what shipped, and recover quickly when something breaks.
The fastest way to succeed is to start small. Pilot a single Android preview pipeline that is easy to inspect, then extend to production and iOS once it proves stable.
Version and Environment Inventory
Before wiring any pipeline, lock down versions, topology, credentials, and roles.
Supported Tooling
- Node.js: LTS (for example, 18.x or 20.x)
- Package manager: npm or yarn
- Expo SDK: align with your app (for example, SDK 50 or 51)
- EAS CLI: latest (install via npm)
- Git provider: any (examples use GitHub Actions and GitLab CI)
Prerequisites
- Expo account and a personal access token (EXPO_TOKEN)
- Android keystore and iOS signing set up in EAS (you can store and manage these in EAS)
- Project using Expo Updates (managed or bare), with a runtimeVersion strategy defined
- Protected branches defined in your repo (for example, main for production, develop for preview)
Example Repository Topology (Simple Case)
- Single app repository
- Branches:
- main: production
- develop: preview
Secrets and Variables
Store secrets in your CI system. Do not commit them to the repository.
| Name | Example Value | Purpose |
|---|---|---|
| EXPO_TOKEN | expo1...redacted | Non-interactive auth for EAS CLI |
| NODE_VERSION | 20 | Pin Node for consistent builds |
Minimal eas.json Profiles
Place this file at the project root to describe build and submit profiles. Adjust to your app and SDK.
{
"cli": {
"version": ">= 3.16.0"
},
"build": {
"preview": {
"channel": "preview",
"android": {
"gradleCommand": ":app:bundleRelease"
},
"ios": {
"image": "latest"
}
},
"production": {
"channel": "production",
"android": {
"gradleCommand": ":app:bundleRelease"
},
"ios": {
"image": "latest"
}
}
},
"submit": {
"production": {
"android": {
"track": "internal"
},
"ios": {
"ascAppId": "YOUR_ASC_APP_ID"
}
}
}
}
Notes:
- The
channelfield sets the default Expo Updates channel for the binary produced by that profile. - For a first pilot, target Android preview builds and OTA updates only.
Safe Configuration Path
This path implements a narrow, measurable pilot you can expand later.
1) Authenticate and Initialize
Run these locally once to ensure EAS knows your project.
npm i -g eas-cli
expo login # or: eas login
# Initialize EAS project if not yet linked
cd your-app
EAS_NO_VCS=1 eas init --id YOUR_PROJECT_ID
Expected result: EAS CLI confirms the linked project and can read eas.json.
2) Create Channels and Confirm Mapping
Create channels that match your branch strategy.
eas channel:create preview --non-interactive
eas channel:create production --non-interactive
# Inspect channels
eas channel:list
Expected result: preview and production channels exist and appear in the list.
3) Add Validation Gates
Gate builds and updates behind fast checks:
expo doctororexpo-doctorto catch misconfigurations.- Unit tests and type checks where applicable.
# Example local checks
npx expo-doctor
npm test --silent || yarn test --ci
Expected result: No errors in expo-doctor output. Tests pass.
4) CI Example: GitHub Actions (Android Preview + Production)
This workflow:
- Runs on pushes to main and develop.
- Installs Node and dependencies.
- Validates with expo-doctor and tests.
- On develop: sends an OTA update to the preview channel.
- On main: kicks off a cloud build with the production profile.
name: expo-ci
on:
push:
branches: [main, develop]
jobs:
expo:
runs-on: ubuntu-latest
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
NODE_VERSION: '20'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install dependencies
run: |
yarn install --frozen-lockfile || npm ci
- name: Install EAS CLI
run: npm i -g eas-cli
- name: Validate with expo-doctor
run: npx expo-doctor
- name: Run tests
run: |
if [ -f package.json ]; then
(yarn test --ci || npm test) || exit 1
fi
- name: Authenticate to EAS
run: |
eas whoami || eas account:login --token "$EXPO_TOKEN"
- name: OTA update to preview on develop
if: github.ref == 'refs/heads/develop'
run: |
eas update \
--branch preview \
--message "CI: ${GITHUB_SHA}"
- name: Build production binary on main
if: github.ref == 'refs/heads/main'
run: |
eas build \
--platform android \
--profile production \
--non-interactive
Expected result:
- develop: an OTA update appears on the preview channel.
- main: a new Android production build starts on EAS Build.
Notes:
- EAS Build runs in the cloud; your workflow does not need Android SDK or Xcode locally.
- Add a second job or extend the build step to include iOS once the pilot is stable.
5) CI Example: GitLab CI (Minimal)
This minimal pipeline uses the same steps for a preview OTA update.
stages:
- validate
- update
variables:
NODE_VERSION: '20'
validate:
image: node:20
stage: validate
script:
- yarn install --frozen-lockfile || npm ci
- npx expo-doctor
- yarn test --ci || npm test
preview_update:
image: node:20
stage: update
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
script:
- npm i -g eas-cli
- eas account:login --token "$EXPO_TOKEN"
- eas update --branch preview --message "CI: $CI_COMMIT_SHORT_SHA"
Expected result: On develop branch commits, a preview OTA update is published.
6) Submitting to Stores (After Build)
When production builds are stable, add a submit step. You can trigger submission using the latest completed build.
# Android (example: internal track)
eas submit -p android --latest --profile production --non-interactive
# iOS (once configured)
eas submit -p ios --latest --profile production --non-interactive
Expected result: Submissions start in their respective stores using the latest EAS build artifacts.
Verification and Diagnostics
Focus on observable signals so you know exactly what shipped and where.
Verify an OTA Update
List updates on the target branch:
eas update:list --branch preview
Expected result: The latest update group shows your commit message and runtime version.
Confirm the channel points to the correct update group:
eas channel:list
Expected result: preview and production channels exist and point to expected update groups.
Optional in-app logging (constructed example):
// App.tsx (constructed example only)
import * as Updates from 'expo-updates';
console.log('Expo channel:', (Updates as any).channel || 'unknown');
console.log('Update ID:', (Updates as any).updateId || 'unknown');
Expected result: The app logs the channel and update ID matching your deployment when launched.
Verify a Build
From CI logs, capture the EAS Build URL printed by the CLI, or list builds:
eas build:list --limit 5
Expected result: The most recent entry matches your branch and profile (for example, production, android).
Environment Diagnostics
When builds act inconsistently, capture tool versions in CI and locally:
eas --version
node --version
yarn --version || npm --version
npx expo-doctor
Expected result: Versions are pinned and consistent with your documented inventory; expo-doctor reports no errors.
Failure Modes and Recovery
Even with guardrails, things break. Prepare recovery steps in advance.
Common Pipeline Failures and Quick Fixes
| Failure | Symptom | Fast Fix |
|---|---|---|
| Missing EXPO_TOKEN | EAS CLI prompts for login or exits | Add EXPO_TOKEN secret and login with --token |
| Broken expo-doctor | Validation step fails | Fix peer deps, SDK versions, app.json/app.config issues |
| Android build fails at Gradle | Build error in EAS cloud logs | Check gradleCommand, memory in EAS logs, native modules compatibility |
| iOS signing issues | EAS Build fails with signing errors | Re-provision credentials in EAS, ensure bundle ID and ASC app id match |
| Update not visible | Device does not fetch new OTA | Confirm channel mapping, runtimeVersion compatibility, app reinstalled with correct channel |
| Submit failure | Store rejects artifact | Verify package name/bundle id, versionCode/buildNumber increment, track permissions |
OTA Rollback
If a preview or production OTA causes problems:
- Identify the last known good update group:
eas update:list --branch production
- Point the channel back to that group by moving the channel to the branch containing the known good update (constructed example using a branch named prod-stable):
eas channel:edit production --branch prod-stable
- Ask affected users to relaunch the app, or wait for the next foreground fetch interval.
Expected result: Devices on the production channel receive the previous update.
Notes:
- OTA rollback only works when the runtimeVersion matches between the binary and the update. If you changed runtimeVersion, you need a new binary.
Binary Rollback
If the faulty change is in native code or runtimeVersion changed:
- Revert the offending commit(s) and bump versionCode/buildNumber as required by stores.
- Trigger a new production build with the production profile:
eas build --platform android --profile production --non-interactive
# and/or iOS
- Submit the new build to the same track you previously used.
Expected result: Stores receive a reverted binary. Users update via the store.
Recovering from Broken Credentials
- For Android: rotate keystore only as a last resort. Prefer re-uploading the correct keystore to EAS if it has been misplaced locally.
- For iOS: regenerate provisioning profiles and re-authenticate with the correct Apple account. Confirm the bundle identifier matches the one in the certificates.
Operations Checklist
Use this as a quick runbook for daily operations.
Daily
- Check that the latest runs on develop and main completed successfully.
- Confirm the preview channel shows the most recent update group.
- Skim expo-doctor and test results for regressions.
Before Merging to Main
- Ensure the app compiles and runs locally on at least one device or emulator.
- Verify runtimeVersion has not changed unexpectedly.
- Confirm any native module changes are reflected in the build profiles.
After Production Build
- Verify EAS Build status is finished and artifacts exist.
- Submit to the intended track with the latest artifacts.
- Monitor crash and error telemetry for the first hours after rollout (for example, watch error logs or in-app reports).
If Something Goes Wrong
- For OTA: repoint the channel to the last known good update.
- For binary: revert and rebuild; resubmit with incremented versioning.
- Document the failure and the fix so the next incident is faster.
Conclusion
You now have a safe, observable path to automate Expo builds and updates. The pipeline starts with a narrow pilot that gates changes using expo-doctor and tests, then uses branch-based channels for preview and production. Clear verification steps confirm exactly what shipped and where, while OTA and binary rollback procedures let you recover quickly when issues appear. A practical checklist covers daily operations, pre-merge validation, post-release monitoring, and incident response. Extend this foundation gradually: add iOS, expand test coverage, introduce environment-specific configuration, and harden credential management. By growing the pipeline in small, measurable steps, you retain speed while reducing risk.