CI/CD
React Native CI/CD — EAS Build, Fastlane, code signing, OTA updates, release pipeline
CI/CD
React Native CI/CD is harder than web CI/CD because you ship two native binaries (iOS and Android) that require code signing, platform-specific build toolchains, and store review. The pipeline must also handle a third artifact — the JavaScript bundle — which can sometimes be updated independently via OTA.
Get the pipeline right and deploys become a non-event. Get it wrong and you spend Fridays manually uploading IPAs from someone's MacBook.
Build Pipeline Overview
Stages
A production React Native pipeline moves through seven stages:
lint → typecheck → test → build → sign → distribute → monitor- Lint — ESLint + Prettier. Fast, catches style drift.
- Typecheck —
tsc --noEmit. Catches broken imports and type errors before building. - Test — Jest unit + integration tests. Detox or Maestro for E2E (run on a separate schedule if slow).
- Build — Metro bundle + native compile (Xcode / Gradle).
- Sign — Apply certificates (iOS) and keystore (Android).
- Distribute — Upload to TestFlight, Play Console internal track, or EAS distribution.
- Monitor — Crash-free rate, ANR rate, performance regressions post-release.
Platform Comparison
| Feature | EAS Build | GitHub Actions | Bitrise | App Center |
|---|---|---|---|---|
| macOS runners | Managed (included) | macos-latest (billed at 10x Linux) | Dedicated Mac VMs | Managed |
| Credential management | Built-in (eas credentials) | Manual secrets | Manual secrets | Manual |
| Caching | Automatic | Manual (actions/cache) | Built-in cache steps | Limited |
| Expo integration | Native | Via expo-github-action | Community steps | None |
| Cost model | Per-build minute (free tier) | Per-minute (free tier for public repos) | Per-minute (free tier) | Free tier + paid |
| OTA updates | EAS Update (integrated) | Separate step | Separate step | CodePush (built-in) |
| Self-hosted option | No | Yes (self-hosted runners) | No | No |
Expo Managed vs Bare Workflow
| Concern | Managed (Expo Go / dev client) | Bare (ejected / npx react-native init) |
|---|---|---|
| Build system | EAS Build (remote) | Xcode + Gradle (local or CI) |
| Code signing | eas credentials handles most of it | Manual or Fastlane match |
| Native dependencies | Expo config plugins | Direct Podfile / build.gradle edits |
| OTA updates | EAS Update | CodePush or custom |
| Recommended CI | EAS Build + EAS Submit | GitHub Actions + Fastlane |
Managed workflow does not mean you cannot customize native code. Expo config plugins let you modify Info.plist, AndroidManifest.xml, Gradle files, and Podfiles at prebuild time. You only need to eject when a config plugin cannot express the change you need.
EAS Build (Expo)
EAS Build compiles your app on Expo's cloud infrastructure. You push code, EAS installs dependencies, runs prebuild (generating native projects), builds, signs, and returns an artifact.
eas.json Configuration
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"env": {
"APP_VARIANT": "development"
},
"channel": "development"
},
"preview": {
"distribution": "internal",
"ios": {
"resourceClass": "m-medium"
},
"autoIncrement": true,
"env": {
"APP_VARIANT": "preview"
},
"channel": "preview"
},
"production": {
"ios": {
"resourceClass": "m-medium",
"autoIncrement": true
},
"android": {
"buildType": "app-bundle",
"autoIncrement": true
},
"env": {
"APP_VARIANT": "production"
},
"channel": "production"
}
},
"submit": {
"production": {
"ios": {
"appleId": "team@company.com",
"ascAppId": "1234567890",
"appleTeamId": "ABCDE12345"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
}EAS Build vs Local Builds
| Scenario | Recommendation |
|---|---|
| Team of 2+ developers | EAS Build — no "works on my machine" signing issues |
| CI for PRs (lint/test only) | GitHub Actions — faster, cheaper, no native build needed |
| Custom native module development | Local build — faster iteration, debugger attached |
| Production release | EAS Build — reproducible, auditable, credentials managed |
| E2E tests needing a simulator | Local or self-hosted runner — EAS does not run simulators |
Custom Build Steps
EAS supports lifecycle hooks in eas.json and custom build config files:
# .eas/build/pre-install.sh
#!/bin/bash
echo "Installing system dependencies..."
apt-get install -y libvips # example: image processing lib
# .eas/build/post-install.sh
#!/bin/bash
echo "Running codegen..."
npx graphql-codegenReference these in eas.json:
{
"build": {
"production": {
"config": "production.yml"
}
}
}EAS Submit
After a successful build, submit directly to stores:
# Submit the latest production build to App Store Connect
eas submit --platform ios --profile production --latest
# Submit a specific build to Google Play (internal track)
eas submit --platform android --profile production --latest
# Build and submit in one command
eas build --platform all --profile production --auto-submit--auto-submit is production-critical. It chains build and submission. If your build succeeds but submission fails (expired ASC session, wrong service account), the build artifact is still available — you can re-submit without rebuilding. Always check eas submit:status after auto-submit pipelines.
Code Signing
iOS Code Signing
iOS code signing requires three pieces: an Apple Developer certificate, a provisioning profile, and an entitlements file. Mismanaged, these become the single biggest source of CI breakage.
Certificate Types
| Certificate | Use | CI context |
|---|---|---|
| Development | Debug builds on registered devices | Dev client / simulator builds |
| Ad Hoc | Release builds on registered devices (up to 100) | Internal testing, QA |
| Enterprise | Unlimited internal distribution (requires Enterprise account) | Large organizations |
| App Store | Production distribution via App Store | Release builds |
Fastlane Match for Teams
Match stores certificates and profiles in a private Git repo (or S3/GCS), encrypted. Every team member and CI machine pulls the same credentials.
# fastlane/Matchfile
git_url("https://github.com/your-org/certificates.git")
storage_mode("git")
type("appstore") # or "adhoc", "development"
app_identifier(["com.company.app", "com.company.app.OneSignalNotificationServiceExtension"])
username("ci@company.com")
team_id("ABCDE12345")
# For CI: use API key instead of password
api_key_path("fastlane/AuthKey.json")# First-time setup (run locally, not in CI)
fastlane match development
fastlane match adhoc
fastlane match appstore
# CI pulls existing certs (readonly mode prevents accidental regeneration)
fastlane match appstore --readonlyAlways use --readonly in CI. Without it, match may revoke and regenerate certificates if it detects a mismatch, which invalidates every other developer's local setup and every other CI pipeline mid-flight.
EAS Credentials Management
EAS handles iOS credentials automatically or lets you manage them manually:
# Automatic: EAS generates and manages everything
eas credentials --platform ios
# Configure auto-managed credentials
eas build --platform ios --profile production
# EAS prompts: "Generate a new Apple Distribution Certificate?" → Yes
# Manual: bring your own
eas credentials --platform ios
# Select: "Upload a certificate (.p12)" and "Upload a provisioning profile"CI Keychain Setup (Bare Workflow)
When building iOS on CI without EAS, you must create a temporary keychain:
#!/bin/bash
# ci/setup-keychain.sh
KEYCHAIN_NAME="ci-build.keychain"
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -lut 21600 "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add to search list
security list-keychains -d user -s "$KEYCHAIN_NAME" $(security list-keychains -d user | tr -d '"')
# Import certificate
security import "$CERTIFICATE_PATH" \
-k "$KEYCHAIN_NAME" \
-P "$CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
# Allow codesign to access keychain without prompt
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"Android Code Signing
Keystore Generation
# Generate a release keystore (do this ONCE, store securely)
keytool -genkeypair \
-v \
-storetype PKCS12 \
-keystore release.keystore \
-alias release-key \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-dname "CN=Company Name, OU=Mobile, O=Company, L=Auckland, ST=Auckland, C=NZ"Never commit the keystore to version control. Store it as a base64-encoded CI secret. Losing the keystore means you cannot update the app (unless enrolled in Play App Signing).
Gradle Signing Config
// android/app/build.gradle
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('keystore.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
storeFile file(keystoreProperties['storeFile'] ?: '/dev/null')
storePassword keystoreProperties['storePassword'] ?: ''
keyAlias keystoreProperties['keyAlias'] ?: ''
keyPassword keystoreProperties['keyPassword'] ?: ''
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}# android/keystore.properties (git-ignored, injected by CI)
storeFile=../release.keystore
storePassword=your-store-password
keyAlias=release-key
keyPassword=your-key-passwordPlay App Signing
Enroll in Play App Signing so Google holds the real signing key. You sign uploads with an upload key — if the upload key is compromised or lost, Google can reset it without affecting end users.
# Export the upload certificate for Play Console
keytool -export \
-rfc \
-keystore upload.keystore \
-alias upload-key \
-file upload-cert.pemCI Secrets for Android
# In CI (GitHub Actions example): decode keystore from secret
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/release.keystore
# Write keystore.properties from secrets
cat > android/keystore.properties <<PROPS
storeFile=../release.keystore
storePassword=$ANDROID_STORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
PROPSFastlane Integration (Bare Workflow)
Fastlane automates the tedious parts: building, signing, uploading, and managing metadata. For bare-workflow React Native, it is the standard.
Complete Fastfile
# fastlane/Fastfile
default_platform(:ios)
before_all do
ensure_git_status_clean
end
platform :ios do
desc "Push a new build to TestFlight"
lane :beta do
setup_ci if ENV['CI']
# Pull certificates
match(type: "appstore", readonly: true)
# Increment build number from CI
increment_build_number(
build_number: ENV['BUILD_NUMBER'] || (latest_testflight_build_number + 1)
)
# Build
gym(
workspace: "ios/App.xcworkspace",
scheme: "App",
configuration: "Release",
export_method: "app-store",
output_directory: "./build",
output_name: "App.ipa",
xcargs: "-allowProvisioningUpdates"
)
# Upload to TestFlight
pilot(
ipa: "./build/App.ipa",
skip_waiting_for_build_processing: true,
apple_id: "1234567890",
distribute_external: false
)
# Notify
slack(
message: "iOS build #{lane_context[SharedValues::BUILD_NUMBER]} uploaded to TestFlight",
slack_url: ENV['SLACK_WEBHOOK_URL']
) if ENV['SLACK_WEBHOOK_URL']
end
desc "Push to App Store for review"
lane :release do
setup_ci if ENV['CI']
match(type: "appstore", readonly: true)
gym(
workspace: "ios/App.xcworkspace",
scheme: "App",
configuration: "Release",
export_method: "app-store"
)
deliver(
ipa: "./build/App.ipa",
submit_for_review: true,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false
)
end
end
platform :android do
desc "Build and upload to Play Store internal track"
lane :beta do
# Build AAB
gradle(
project_dir: "./android",
task: "bundle",
build_type: "Release",
properties: {
"android.injected.signing.store.file" => ENV['ANDROID_KEYSTORE_PATH'],
"android.injected.signing.store.password" => ENV['ANDROID_STORE_PASSWORD'],
"android.injected.signing.key.alias" => ENV['ANDROID_KEY_ALIAS'],
"android.injected.signing.key.password" => ENV['ANDROID_KEY_PASSWORD']
}
)
# Upload to Play Store
supply(
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
track: "internal",
json_key: ENV['GOOGLE_SERVICE_ACCOUNT_KEY_PATH'],
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Promote internal to production"
lane :release do
supply(
track: "internal",
track_promote_to: "production",
json_key: ENV['GOOGLE_SERVICE_ACCOUNT_KEY_PATH'],
rollout: "0.1" # 10% staged rollout
)
end
endsetup_ci is critical. It creates a temporary keychain and configures Fastlane to work in headless mode. Without it, builds hang waiting for keychain password prompts that never arrive.
GitHub Actions Workflow
Production-Ready Workflow
# .github/workflows/build.yml
name: Build & Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
JAVA_VERSION: '17'
jobs:
# ─── Quality Gate ───────────────────────────────────────────
quality:
name: Lint, Typecheck, Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Lint
run: yarn lint
- name: Typecheck
run: yarn tsc --noEmit
- name: Test
run: yarn test --ci --coverage --maxWorkers=2
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/lcov.info
# ─── Android Build ─────────────────────────────────────────
android:
name: Build Android
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: ${{ env.NODE_VERSION }}
cache: 'yarn'
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: ${{ env.JAVA_VERSION }}
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/release.keystore
- name: Create keystore.properties
run: |
cat > android/keystore.properties <<EOF
storeFile=../release.keystore
storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
- name: Build AAB
working-directory: android
run: ./gradlew bundleRelease --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: android-release
path: android/app/build/outputs/bundle/release/app-release.aab
# ─── iOS Build ─────────────────────────────────────────────
ios:
name: Build iOS
needs: quality
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Cache Pods
uses: actions/cache@v4
with:
path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: pods-${{ runner.os }}-
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Install Pods
working-directory: ios
run: bundle exec pod install
- name: Setup Fastlane
run: |
cd ios
bundle install
- name: Build and upload to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
run: bundle exec fastlane ios beta
# ─── EAS Build (alternative for Expo projects) ─────────────
eas:
name: EAS Build
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: ${{ env.NODE_VERSION }}
cache: 'yarn'
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build for both platforms
run: eas build --platform all --profile production --non-interactive --auto-submitmacOS runners are expensive. GitHub charges 10x the per-minute rate for macos-14 versus ubuntu-latest. Run lint, typecheck, and tests on Linux. Only use macOS for the actual iOS build step. Better yet, use EAS Build for Expo projects to avoid macOS runner costs entirely.
OTA Updates
OTA (Over The Air) updates push new JavaScript bundles to users without going through store review. This is the single biggest velocity advantage React Native has over fully native development.
When OTA Is Appropriate
| Change type | OTA safe? | Reasoning |
|---|---|---|
| Bug fix in JS/TS code | Yes | No native change |
| New screen (JS only) | Yes | No native change |
| Style / copy changes | Yes | No native change |
| New native module added | No | Binary must be rebuilt |
| Native dependency version bump | No | Binary must be rebuilt |
app.json changes (permissions, schemes) | No | Requires new binary |
| Expo SDK upgrade | No | Native runtime changes |
EAS Update
EAS Update replaces the JS bundle inside an already-installed binary. It uses update channels that map to build profiles, so a preview build only receives preview updates.
# Publish an update to the preview channel
eas update --branch preview --message "Fix cart total calculation"
# Publish to production
eas update --branch production --message "v2.3.1 hotfix: payment flow"
# Check update status
eas update:listRuntime Version Pinning
The runtime version ensures an OTA update is only applied to a binary built with a compatible native layer. Without it, a JS update referencing a new native module will crash on an old binary.
// app.config.ts
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'MyApp',
slug: 'my-app',
runtimeVersion: {
policy: 'appVersion', // ties runtime version to app version
},
updates: {
url: 'https://u.expo.dev/your-project-id',
fallbackToCacheTimeout: 3000,
checkAutomatically: 'ON_LOAD',
},
});| Policy | Behavior | Best for |
|---|---|---|
appVersion | Runtime version = version field | Simple apps, infrequent native changes |
nativeVersion | Runtime version = ios.buildNumber / android.versionCode | Apps with frequent native changes |
fingerprint | Hash of all native dependencies | Maximum safety, automatic detection |
| Custom string | You manage it manually | Full control |
Use fingerprint policy for maximum safety. It hashes all native config and dependencies. If anything native changes, the runtime version changes automatically, preventing mismatched updates. The tradeoff is that some benign native changes (e.g., a comment in Podfile) will also invalidate the runtime version.
CodePush (Bare Workflow)
CodePush (now part of Microsoft App Center, with community forks like react-native-code-push) provides OTA for bare-workflow apps:
// App.tsx — CodePush wrapper
import codePush from 'react-native-code-push';
const codePushOptions = {
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
installMode: codePush.InstallMode.ON_NEXT_RESTART,
mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
minimumBackgroundDuration: 60 * 5, // 5 minutes
};
function App() {
return (
<NavigationContainer>
<RootStack />
</NavigationContainer>
);
}
export default codePush(codePushOptions)(App);# Release an update via CodePush CLI
appcenter codepush release-react \
-a "Company/MyApp-iOS" \
-d "Production" \
--target-binary-version "~2.3.0" \
--mandatory false \
--description "Fix cart total calculation"
# Rollback the latest release
appcenter codepush rollback -a "Company/MyApp-iOS" -d "Production"App Center is being retired. Microsoft has announced App Center retirement. If you are starting a new project, use EAS Update (Expo) or evaluate community alternatives like @callstack/repack with a custom update server. If you are already on CodePush, plan your migration.
Multi-Environment Setup
Expo: app.config.ts with Variants
// app.config.ts
import { ConfigContext, ExpoConfig } from 'expo/config';
const APP_VARIANT = process.env.APP_VARIANT ?? 'development';
const variantConfig = {
development: {
name: 'MyApp (Dev)',
bundleId: 'com.company.app.dev',
icon: './assets/icon-dev.png',
apiUrl: 'https://api-dev.company.com',
},
preview: {
name: 'MyApp (Preview)',
bundleId: 'com.company.app.preview',
icon: './assets/icon-preview.png',
apiUrl: 'https://api-staging.company.com',
},
production: {
name: 'MyApp',
bundleId: 'com.company.app',
icon: './assets/icon.png',
apiUrl: 'https://api.company.com',
},
} as const;
const variant = variantConfig[APP_VARIANT as keyof typeof variantConfig];
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: variant.name,
slug: 'my-app',
version: '2.3.1',
icon: variant.icon,
ios: {
bundleIdentifier: variant.bundleId,
supportsTablet: true,
},
android: {
package: variant.bundleId,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
},
},
extra: {
apiUrl: variant.apiUrl,
appVariant: APP_VARIANT,
},
});Bare Workflow: react-native-config
# .env.development
API_URL=https://api-dev.company.com
SENTRY_DSN=https://dev-key@sentry.io/123
FEATURE_FLAG_ENDPOINT=https://flags-dev.company.com
# .env.production
API_URL=https://api.company.com
SENTRY_DSN=https://prod-key@sentry.io/456
FEATURE_FLAG_ENDPOINT=https://flags.company.com// src/core/config.ts
import Config from 'react-native-config';
export const config = {
apiUrl: Config.API_URL!,
sentryDsn: Config.SENTRY_DSN!,
featureFlagEndpoint: Config.FEATURE_FLAG_ENDPOINT!,
} as const;Build with the correct env file:
# iOS
ENVFILE=.env.production npx react-native run-ios --configuration Release
# Android
ENVFILE=.env.production npx react-native run-android --variant=releaseBuild Variants and Schemes
For bare-workflow iOS, create separate Xcode schemes (App-Dev, App-Staging, App-Release) each with its own build configuration. For Android, use Gradle product flavors:
// android/app/build.gradle
android {
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
resValue "string", "app_name", "MyApp Dev"
}
staging {
dimension "environment"
applicationIdSuffix ".staging"
resValue "string", "app_name", "MyApp Staging"
}
prod {
dimension "environment"
resValue "string", "app_name", "MyApp"
}
}
}Version Management
Semantic Versioning for Mobile
Mobile versioning has two dimensions:
| Field | Purpose | Example |
|---|---|---|
Version string (CFBundleShortVersionString / versionName) | User-visible, follows semver | 2.3.1 |
Build number (CFBundleVersion / versionCode) | Monotonically increasing integer, store-enforced | 147 |
- Bump major for breaking UX changes or major redesigns.
- Bump minor for new features.
- Bump patch for bug fixes and hotfixes.
- Build number increments on every build submitted to stores.
Auto-Incrementing in CI
EAS Auto-Versioning
{
"build": {
"production": {
"autoIncrement": true
}
},
"cli": {
"appVersionSource": "remote"
}
}EAS tracks the build number server-side. Each build increments automatically. No merge conflicts over version bumps.
Manual Auto-Increment (Bare Workflow)
#!/bin/bash
# ci/bump-build-number.sh
# iOS: read current, increment, write back
PLIST_PATH="ios/App/Info.plist"
CURRENT=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$PLIST_PATH")
NEXT=$((CURRENT + 1))
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $NEXT" "$PLIST_PATH"
echo "iOS build number: $NEXT"
# Android: use CI build number
# In build.gradle, read versionCode from environment:
# versionCode System.getenv("BUILD_NUMBER")?.toInteger() ?: 1Changelog Generation
Use conventional commits and automate changelogs:
# Generate changelog from conventional commits
npx standard-version --release-as minor
# Or with Changesets for monorepos
npx changeset version
npx changeset publishRelease Pipeline
Release Stages
Internal Testing → Closed Beta → Open Beta → Production (staged)| Stage | iOS | Android | Audience |
|---|---|---|---|
| Internal testing | TestFlight (internal group) | Play Console internal track | Team only (25 max on Play) |
| Closed beta | TestFlight (external group) | Closed testing track | Selected testers |
| Open beta | TestFlight (public link) | Open testing track | Anyone with link |
| Production | App Store release | Production track | All users |
Staged Rollouts
Google Play supports percentage-based rollouts. Apple does not offer staged rollouts for new versions (only phased release for updates).
# fastlane/Fastfile — staged rollout to production
lane :staged_release do
supply(
track: "production",
rollout: "0.01", # Start at 1%
json_key: ENV['GOOGLE_SERVICE_ACCOUNT_KEY_PATH']
)
end
lane :increase_rollout do |options|
supply(
track: "production",
rollout: options[:percentage], # e.g., "0.1", "0.5", "1.0"
json_key: ENV['GOOGLE_SERVICE_ACCOUNT_KEY_PATH']
)
end# Increase rollout to 10%
fastlane increase_rollout percentage:0.1
# Full rollout
fastlane increase_rollout percentage:1.0Rollback Strategy
| Scenario | Strategy | Speed |
|---|---|---|
| JS-only bug discovered | OTA update with fix (or rollback to previous OTA) | Minutes |
| Native crash in new binary | Halt staged rollout, push hotfix binary | Hours to days (store review) |
| Critical security issue | Pull app from store + OTA disable feature flag | Hours |
| Slow degradation noticed | OTA feature flag to disable, then fix at normal pace | Minutes for flag, days for fix |
# EAS Update: rollback by re-publishing the last known good update
eas update:republish --group <previous-update-group-id> --branch production
# CodePush: built-in rollback
appcenter codepush rollback -a "Company/MyApp-iOS" -d "Production"
# Google Play: halt a staged rollout
fastlane supply --track production --rollout 0 --json_key key.jsonBundle Size Management
Metro Bundle Analysis
# Install the visualizer
yarn add --dev react-native-bundle-visualizer
# Generate a report
npx react-native-bundle-visualizer
# Or use Metro directly
npx react-native bundle \
--platform ios \
--dev false \
--entry-file index.js \
--bundle-output /tmp/bundle.js \
--sourcemap-output /tmp/bundle.js.map
# Check raw size
ls -lh /tmp/bundle.js
# Check gzipped size (what actually transfers for OTA)
gzip -c /tmp/bundle.js | wc -cHermes Bytecode vs JSC
| Metric | Hermes bytecode | JSC (JavaScriptCore) |
|---|---|---|
| Bundle format | Precompiled bytecode (.hbc) | Raw JS (minified) |
| Bundle size | ~15-20% larger on disk | Smaller source |
| Parse time | Near-zero (already compiled) | Must parse and compile at startup |
| TTI impact | Significantly faster | Slower cold start |
| OTA size | Larger download per update | Smaller download |
Hermes is the right default. The larger bundle size is offset by dramatically faster startup. For OTA, the update diff (not the full bundle) is what transfers, so the size difference is negligible in practice.
Tree Shaking Limitations
Metro does not tree-shake. Every import pulls in the entire module.
// Bad: imports all of lodash (~70 KB minified)
import { debounce } from 'lodash';
// Good: import only the function you need (~1 KB)
import debounce from 'lodash/debounce';
// Better: use a tree-shakeable alternative
import { debounce } from 'lodash-es'; // still no Metro tree shaking
// Best: write it yourself — debounce is 10 linesSize Budget Enforcement in CI
#!/bin/bash
# ci/check-bundle-size.sh
MAX_SIZE_KB=2048 # 2 MB budget
npx react-native bundle \
--platform ios \
--dev false \
--entry-file index.js \
--bundle-output /tmp/bundle.js \
--sourcemap-output /tmp/bundle.js.map \
2>/dev/null
SIZE_KB=$(( $(wc -c < /tmp/bundle.js) / 1024 ))
echo "Bundle size: ${SIZE_KB} KB (budget: ${MAX_SIZE_KB} KB)"
if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then
echo "FAIL: Bundle exceeds size budget by $(( SIZE_KB - MAX_SIZE_KB )) KB"
echo "Run 'npx react-native-bundle-visualizer' to identify heavy modules"
exit 1
fi
echo "PASS: Bundle within budget"Image Asset Optimization
# Optimize PNGs (lossless)
find assets/ -name "*.png" -exec pngquant --quality=65-80 --skip-if-larger --ext .png --force {} \;
# Convert large PNGs to WebP (supported on Android; iOS 14+)
find assets/ -name "*.png" -size +50k -exec cwebp -q 80 {} -o {}.webp \;
# Use @expo/image-utils for Expo managed workflow
# It automatically optimizes icons and splash screens during prebuildMonitoring Post-Release
Crash-Free Rate Targets
| Metric | Target | Action threshold |
|---|---|---|
| Crash-free users | > 99.5% | Investigate below 99%, halt rollout below 98% |
| Crash-free sessions | > 99.9% | Investigate below 99.5% |
| ANR rate (Android) | < 0.5% | Google Play flags above 0.47% |
| Startup crash rate | < 0.1% | Immediate hotfix if exceeded |
Sentry Setup
// src/core/monitoring/sentry.ts
import * as Sentry from '@sentry/react-native';
export function initSentry() {
Sentry.init({
dsn: Config.SENTRY_DSN,
environment: Config.APP_VARIANT,
release: `${Application.applicationId}@${Application.nativeApplicationVersion}+${Application.nativeBuildVersion}`,
dist: Application.nativeBuildVersion,
tracesSampleRate: __DEV__ ? 1.0 : 0.2,
profilesSampleRate: 0.1,
enableAutoSessionTracking: true,
sessionTrackingIntervalMillis: 30000,
// Filter noisy errors
beforeSend(event) {
// Ignore network errors in development
if (__DEV__ && event.exception?.values?.[0]?.type === 'TypeError') {
return null;
}
return event;
},
integrations: [
Sentry.reactNativeTracingIntegration({
routingInstrumentation:
Sentry.reactNavigationIntegration({
enableTimeToInitialDisplay: true,
}),
}),
],
});
}# Upload source maps to Sentry (bare workflow)
npx sentry-cli react-native appcenter \
--org your-org \
--project your-project \
--release "com.company.app@2.3.1+147" \
--dist "147" \
--sourcemap /tmp/bundle.js.map \
--bundle /tmp/bundle.js
# For Expo / EAS, use the Sentry Expo plugin — it uploads automatically
# In app.json or app.config.ts:
# plugins: [
# ["@sentry/react-native/expo", {
# organization: "your-org",
# project: "your-project",
# }]
# ]Firebase Crashlytics (Alternative)
// src/core/monitoring/crashlytics.ts
import crashlytics from '@react-native-firebase/crashlytics';
export function initCrashlytics() {
// Identify user for crash reports
crashlytics().setUserId(userId);
crashlytics().setAttribute('environment', Config.APP_VARIANT);
// Log non-fatal errors
crashlytics().recordError(new Error('Payment declined'), 'PaymentFlow');
// Breadcrumbs
crashlytics().log('User tapped checkout button');
}
// Global error handler
const defaultHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
crashlytics().recordError(error, isFatal ? 'FatalJS' : 'NonFatalJS');
defaultHandler(error, isFatal);
});Performance Monitoring
Track key user flows:
// Measure screen render time
import { PerformanceObserver, performance } from 'react-native-performance';
export function useScreenPerformance(screenName: string) {
useEffect(() => {
const mark = `${screenName}_mount`;
performance.mark(mark);
return () => {
performance.measure(`${screenName}_visible`, mark);
const entries = performance.getEntriesByName(`${screenName}_visible`);
const duration = entries[entries.length - 1]?.duration;
if (duration && duration > 1000) {
Sentry.captureMessage(`Slow screen: ${screenName} took ${duration}ms`, {
level: 'warning',
extra: { duration, screenName },
});
}
};
}, [screenName]);
}Pipeline Checklist
A complete CI/CD checklist for React Native projects:
| Category | Item | Priority |
|---|---|---|
| Quality | ESLint + Prettier on every PR | Must have |
| Quality | TypeScript strict mode, tsc --noEmit in CI | Must have |
| Quality | Jest unit tests with coverage threshold | Must have |
| Quality | E2E tests (Detox or Maestro) on key flows | Should have |
| Build | Reproducible builds (lockfile, pinned Node version) | Must have |
| Build | Separate build profiles (dev, preview, production) | Must have |
| Build | Caching (node_modules, Pods, Gradle) | Must have |
| Build | Bundle size budget enforced in CI | Should have |
| Signing | Credentials stored in CI secrets (never in repo) | Must have |
| Signing | Fastlane match or EAS credentials for iOS | Must have |
| Signing | Play App Signing enrolled | Should have |
| Signing | Keystore backup in secure vault | Must have |
| Distribution | Internal testing track configured | Must have |
| Distribution | TestFlight + Play Console internal distribution | Must have |
| Distribution | Automated store submission (EAS Submit or Fastlane) | Should have |
| Distribution | Staged rollout for production releases | Should have |
| OTA | EAS Update or CodePush configured | Should have |
| OTA | Runtime version pinning enabled | Must have (if using OTA) |
| OTA | OTA rollback procedure documented and tested | Must have (if using OTA) |
| Versioning | Auto-incrementing build numbers | Should have |
| Versioning | Semantic version bumps tied to release process | Should have |
| Versioning | Changelog generation from conventional commits | Nice to have |
| Monitoring | Crash reporting (Sentry / Crashlytics) | Must have |
| Monitoring | Source map upload automated in CI | Must have |
| Monitoring | ANR rate monitoring (Android) | Should have |
| Monitoring | Performance monitoring on key flows | Should have |
| Monitoring | Alerts for crash-free rate dropping below threshold | Must have |
| Environments | Separate bundle IDs per environment | Should have |
| Environments | Visual differentiation (icon, app name) per environment | Should have |
| Environments | Environment-specific API endpoints | Must have |
| Security | Secrets never logged in CI output | Must have |
| Security | CI secrets rotated on schedule | Should have |
| Security | Dependency audit (yarn audit) in CI | Should have |