Steven's Knowledge

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
  1. Lint — ESLint + Prettier. Fast, catches style drift.
  2. Typechecktsc --noEmit. Catches broken imports and type errors before building.
  3. Test — Jest unit + integration tests. Detox or Maestro for E2E (run on a separate schedule if slow).
  4. Build — Metro bundle + native compile (Xcode / Gradle).
  5. Sign — Apply certificates (iOS) and keystore (Android).
  6. Distribute — Upload to TestFlight, Play Console internal track, or EAS distribution.
  7. Monitor — Crash-free rate, ANR rate, performance regressions post-release.

Platform Comparison

FeatureEAS BuildGitHub ActionsBitriseApp Center
macOS runnersManaged (included)macos-latest (billed at 10x Linux)Dedicated Mac VMsManaged
Credential managementBuilt-in (eas credentials)Manual secretsManual secretsManual
CachingAutomaticManual (actions/cache)Built-in cache stepsLimited
Expo integrationNativeVia expo-github-actionCommunity stepsNone
Cost modelPer-build minute (free tier)Per-minute (free tier for public repos)Per-minute (free tier)Free tier + paid
OTA updatesEAS Update (integrated)Separate stepSeparate stepCodePush (built-in)
Self-hosted optionNoYes (self-hosted runners)NoNo

Expo Managed vs Bare Workflow

ConcernManaged (Expo Go / dev client)Bare (ejected / npx react-native init)
Build systemEAS Build (remote)Xcode + Gradle (local or CI)
Code signingeas credentials handles most of itManual or Fastlane match
Native dependenciesExpo config pluginsDirect Podfile / build.gradle edits
OTA updatesEAS UpdateCodePush or custom
Recommended CIEAS Build + EAS SubmitGitHub 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

ScenarioRecommendation
Team of 2+ developersEAS 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 developmentLocal build — faster iteration, debugger attached
Production releaseEAS Build — reproducible, auditable, credentials managed
E2E tests needing a simulatorLocal 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-codegen

Reference 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

CertificateUseCI context
DevelopmentDebug builds on registered devicesDev client / simulator builds
Ad HocRelease builds on registered devices (up to 100)Internal testing, QA
EnterpriseUnlimited internal distribution (requires Enterprise account)Large organizations
App StoreProduction distribution via App StoreRelease 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 --readonly

Always 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-password

Play 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.pem

CI 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
PROPS

Fastlane 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
end

setup_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-submit

macOS 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 typeOTA safe?Reasoning
Bug fix in JS/TS codeYesNo native change
New screen (JS only)YesNo native change
Style / copy changesYesNo native change
New native module addedNoBinary must be rebuilt
Native dependency version bumpNoBinary must be rebuilt
app.json changes (permissions, schemes)NoRequires new binary
Expo SDK upgradeNoNative 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:list

Runtime 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',
  },
});
PolicyBehaviorBest for
appVersionRuntime version = version fieldSimple apps, infrequent native changes
nativeVersionRuntime version = ios.buildNumber / android.versionCodeApps with frequent native changes
fingerprintHash of all native dependenciesMaximum safety, automatic detection
Custom stringYou manage it manuallyFull 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=release

Build 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:

FieldPurposeExample
Version string (CFBundleShortVersionString / versionName)User-visible, follows semver2.3.1
Build number (CFBundleVersion / versionCode)Monotonically increasing integer, store-enforced147
  • 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() ?: 1

Changelog 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 publish

Release Pipeline

Release Stages

Internal Testing → Closed Beta → Open Beta → Production (staged)
StageiOSAndroidAudience
Internal testingTestFlight (internal group)Play Console internal trackTeam only (25 max on Play)
Closed betaTestFlight (external group)Closed testing trackSelected testers
Open betaTestFlight (public link)Open testing trackAnyone with link
ProductionApp Store releaseProduction trackAll 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.0

Rollback Strategy

ScenarioStrategySpeed
JS-only bug discoveredOTA update with fix (or rollback to previous OTA)Minutes
Native crash in new binaryHalt staged rollout, push hotfix binaryHours to days (store review)
Critical security issuePull app from store + OTA disable feature flagHours
Slow degradation noticedOTA feature flag to disable, then fix at normal paceMinutes 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.json

Bundle 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 -c

Hermes Bytecode vs JSC

MetricHermes bytecodeJSC (JavaScriptCore)
Bundle formatPrecompiled bytecode (.hbc)Raw JS (minified)
Bundle size~15-20% larger on diskSmaller source
Parse timeNear-zero (already compiled)Must parse and compile at startup
TTI impactSignificantly fasterSlower cold start
OTA sizeLarger download per updateSmaller 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 lines

Size 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 prebuild

Monitoring Post-Release

Crash-Free Rate Targets

MetricTargetAction 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:

CategoryItemPriority
QualityESLint + Prettier on every PRMust have
QualityTypeScript strict mode, tsc --noEmit in CIMust have
QualityJest unit tests with coverage thresholdMust have
QualityE2E tests (Detox or Maestro) on key flowsShould have
BuildReproducible builds (lockfile, pinned Node version)Must have
BuildSeparate build profiles (dev, preview, production)Must have
BuildCaching (node_modules, Pods, Gradle)Must have
BuildBundle size budget enforced in CIShould have
SigningCredentials stored in CI secrets (never in repo)Must have
SigningFastlane match or EAS credentials for iOSMust have
SigningPlay App Signing enrolledShould have
SigningKeystore backup in secure vaultMust have
DistributionInternal testing track configuredMust have
DistributionTestFlight + Play Console internal distributionMust have
DistributionAutomated store submission (EAS Submit or Fastlane)Should have
DistributionStaged rollout for production releasesShould have
OTAEAS Update or CodePush configuredShould have
OTARuntime version pinning enabledMust have (if using OTA)
OTAOTA rollback procedure documented and testedMust have (if using OTA)
VersioningAuto-incrementing build numbersShould have
VersioningSemantic version bumps tied to release processShould have
VersioningChangelog generation from conventional commitsNice to have
MonitoringCrash reporting (Sentry / Crashlytics)Must have
MonitoringSource map upload automated in CIMust have
MonitoringANR rate monitoring (Android)Should have
MonitoringPerformance monitoring on key flowsShould have
MonitoringAlerts for crash-free rate dropping below thresholdMust have
EnvironmentsSeparate bundle IDs per environmentShould have
EnvironmentsVisual differentiation (icon, app name) per environmentShould have
EnvironmentsEnvironment-specific API endpointsMust have
SecuritySecrets never logged in CI outputMust have
SecurityCI secrets rotated on scheduleShould have
SecurityDependency audit (yarn audit) in CIShould have

On this page