AgileSoftLabs Logo
MurugeshBy Murugesh
Published: July 2026|Updated: July 2026|Reading Time: 15 minutes

Share:

React Native CI/CD with GitHub Actions and EAS Build: Complete Setup Guide (2026)

Published: July 24, 2026 | Reading Time: 17 minutes 

About the Author

Murugesh R is an AWS DevOps Engineer at AgileSoftLabs, specializing in cloud infrastructure, automation, and continuous integration/deployment pipelines to deliver reliable and scalable solutions.

Key Takeaways

  • A production-grade React Native CI/CD pipeline handles five distinct concerns simultaneously: build profiles across environments, code signing for both platforms, environment-specific configuration, over-the-air updates, and store submission — all automated and repeatable from a single tag push.
  • The four-workflow file structure (PR checks, preview build, production build, OTA update) separates concerns cleanly — each workflow has a single trigger and a single responsibility, making failures immediately diagnosable and fixes narrowly scoped.
  • EAS managed credentials are the correct choice for code signingcredentialsSource: "remote" offloads provisioning profile rotation, certificate renewal, and keystore management entirely to EAS, eliminating the most error-prone manual step in mobile CI/CD.
  • The paths-ignore OTA trigger is the most important optimization in the pipeline — it distinguishes JavaScript-only changes (deployable as an instant OTA update) from native changes (requiring a full store build), eliminating unnecessary full builds on every main push.
  • eas build --json | jq -r '.id' captures the build ID for downstream submission steps — the pattern of building first and submitting with the build ID allows build and submission to be distinct, auditable steps rather than a single opaque command.
  • GitHub Environments with required reviewers is the correct mechanism to prevent accidental production deployments — the job waits for human approval before running, adding a mandatory gate without any custom approval logic to maintain.
  • EAS Update rollback is a single command: eas update --branch production --republish --group <previous-group-id> — making the OTA update strategy safe for production deployments because recovery is faster than any app store hotfix process.

Introduction

A React Native CI/CD pipeline that actually works in production needs to handle different build profiles for development, staging, and production; code signing for both iOS and Android; environment-specific configuration; over-the-air updates for JavaScript changes; and automated deployment to both stores — all automated, all repeatable.

Most tutorials cover the basics. This guide covers the full pipeline used in production at AgileSoftLabs, including the sharp edges around iOS code signing, Android keystore management, and OTA update deployment that production teams actually encounter.

Mobile App Development Services provides React Native DevOps consulting — EAS Build configuration, CI/CD pipeline setup, and code signing management — for teams building production mobile applications.

Pipeline Architecture

The complete CI/CD pipeline from PR to production:

PR Opened
    ↓
Lint + TypeScript Check (GitHub Actions, ubuntu-latest, ~2–3 min)
    ↓
Unit Tests (GitHub Actions, ubuntu-latest, ~3–5 min)
    ↓
expo-doctor validation (non-blocking warning)
    ↓
PR Merged to develop
    ↓
EAS Preview Build (iOS + Android, ~15 min)
    ↓
PR Merged to main
    ↓
OTA Update only (if no native file changes)
    ↓
Version tag pushed (v*.*.*)
    ↓
EAS Production Build (iOS + Android, ~20 min)
    ↓
EAS Submit → App Store Connect (TestFlight) + Play Store (internal track)
    ↓
Manual promotion → App Store + Play Store production

Feature branch strategy:

JavaScript-only changes on feature branches go straight to an OTA update after merging to main — no full native build required. Changes touching ios/, android/, app.json, or package.json require a full EAS build. This distinction is encoded in the paths-ignore trigger of the OTA workflow.

Prerequisites and Setup

Required accounts and tools:

  • Expo account with EAS access
  • Apple Developer account (for iOS builds and App Store submission)
  • Google Play Console account (for Android submission)
  • GitHub repository with Actions enabled

Install and authenticate EAS CLI:

npm install -g eas-cli
eas login

Initialize EAS in your project:

eas build:configure
# Generates eas.json in your project root

Configure eas.json with all three build profiles:

{
  "cli": {
    "version": ">= 7.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "android": {
        "buildType": "apk"
      },
      "ios": {
        "simulator": false
      },
      "env": {
        "EXPO_PUBLIC_API_URL": "https://dev-api.yourapp.com",
        "EXPO_PUBLIC_APP_ENV": "development"
      }
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview",
      "env": {
        "EXPO_PUBLIC_API_URL": "https://staging-api.yourapp.com",
        "EXPO_PUBLIC_APP_ENV": "staging"
      }
    },
    "production": {
      "autoIncrement": true,
      "channel": "production",
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.yourapp.com",
        "EXPO_PUBLIC_APP_ENV": "production"
      }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "your@email.com",
        "ascAppId": "1234567890",
        "appleTeamId": "YOURTEAMID"
      },
      "android": {
        "serviceAccountKeyPath": "./service-account-key.json",
        "track": "internal"
      }
    }
  }
}

autoIncrement: true in the production profile automatically bumps the build number on every EAS production build — no manual version management required and no duplicate build number rejections from App Store Connect.

Cloud Development Services configures the infrastructure layer that connects EAS Build to your deployment targets — App Store Connect API credentials, Google Play service account keys, and the Slack webhook endpoints that route build status notifications to your team channels.

GitHub Actions Workflow Structure

Four workflow files with separated responsibilities:

.github/
  workflows/
    pr-checks.yml        # Lint, TypeScript, tests on every PR
    preview-build.yml    # EAS preview build on push to develop
    production-build.yml # EAS production build on version tag
    ota-update.yml       # EAS OTA update on main push (JS changes only)

PR Checks: Lint, TypeScript, Tests

.github/workflows/pr-checks.yml

name: PR Checks

on:
  pull_request:
    branches: [main, develop]

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: TypeScript check
        run: npx tsc --noEmit

      - name: Run tests
        run: npm test -- --coverage --watchAll=false

  expo-doctor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run expo-doctor
        run: npx expo-doctor
        continue-on-error: true  # Warn but do not block the build

continue-on-error: true on the expo-doctor step is intentional — dependency incompatibilities flagged by expo-doctor are useful information but should not block a PR that is otherwise ready to merge. Treat the warning as input to a separate dependency maintenance task rather than a hard gate.

Preview Builds on Push to Develop

.github/workflows/preview-build.yml

name: Preview Build

on:
  push:
    branches: [develop]

jobs:
  build-preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Setup EAS
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Build preview (Android)
        run: eas build --platform android --profile preview --non-interactive

      - name: Build preview (iOS)
        run: eas build --platform ios --profile preview --non-interactive

Preview builds distribute to internal testers — QA teams and stakeholders — via Expo's internal distribution mechanism before any production build is triggered. Building both platforms on every develop push catches platform-specific issues (iOS signing, Android Gradle) before they reach the production workflow.

Production Build Workflow (iOS + Android)

.github/workflows/production-build.yml

name: Production Build and Submit

on:
  push:
    tags:
      - 'v*.*.*'  # Trigger on semantic version tags: v1.2.3

jobs:
  build-and-submit:
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval in GitHub Settings

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Setup EAS
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      # Capture build IDs for downstream submission steps
      - name: Build Android production
        id: android-build
        run: |
          BUILD_ID=$(eas build --platform android --profile production \
            --non-interactive --json | jq -r '.id')
          echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT

      - name: Build iOS production
        id: ios-build
        run: |
          BUILD_ID=$(eas build --platform ios --profile production \
            --non-interactive --json | jq -r '.id')
          echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT

      # Submit using captured build IDs — builds and submissions are separate steps
      - name: Submit to Play Store (internal track)
        run: |
          eas submit --platform android \
            --id ${{ steps.android-build.outputs.build_id }} \
            --non-interactive

      - name: Submit to App Store (TestFlight)
        run: |
          eas submit --platform ios \
            --id ${{ steps.ios-build.outputs.build_id }} \
            --non-interactive

      - name: Notify Slack
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          text: "Production build ${{ github.ref_name }} — ${{ job.status }}"
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

The environment: production line is the most important line in this workflow for organizations that have had an accidental production deployment. Paired with a required reviewer configured in GitHub Settings → Environments → production, every tag push that would trigger a production build pauses the job until an authorized reviewer manually approves. The approval gate requires zero custom logic to maintain.

The --json | jq -r '.id' pattern for capturing build IDs enables clean separation between build and submission steps — the build ID is an artifact reference that allows builds to be audited, submitted to multiple stores independently, or submitted in a separate manual step if the automated submission fails without triggering a rebuild.

CareSlot AI healthcare mobile platform and Loan Management Software fintech applications both use the tag-triggered production workflow with mandatory reviewer approval — regulated industry deployments require a documented human authorization step before any production binary reaches users.

Code Signing Automation

EAS managed credentials are the strongly recommended approach — they handle provisioning profile rotation, certificate renewal, and keystore management automatically:

# Set up iOS credentials — EAS handles certificates and provisioning profiles
eas credentials --platform ios

# Set up Android keystore — EAS stores it securely in their credential store
eas credentials --platform android

Reference managed credentials in eas.json:

{
  "build": {
    "production": {
      "ios": {
        "credentialsSource": "remote"
      },
      "android": {
        "credentialsSource": "remote"
      }
    }
  }
}

With credentialsSource: "remote", EAS handles all code signing automatically in CI — no GitHub secrets for certificates, no provisioning profile distribution headaches, and no manual renewal cycles that expire silently and break production builds on a Friday afternoon.

If you need to use your own Android keystore (some organizations require self-managed keystores for compliance reasons):

# Encode keystore to base64 and store as GitHub Secret
base64 -i release.keystore | pbcopy
# Paste to GitHub Secrets as ANDROID_KEYSTORE_BASE64

Then decode in the workflow before the build step:

- name: Decode keystore
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode \
      > android/app/release.keystore

OTA Updates with EAS Update

EAS Update delivers JavaScript bundle changes to users without going through the app store — no review cycle, updates applied within 24–48 hours of users opening the app.

Configure update channels in app.config.js:

export default {
  expo: {
    runtimeVersion: {
      policy: "sdkVersion"
    },
    updates: {
      url: "https://u.expo.dev/YOUR-PROJECT-ID"
    }
  }
};

.github/workflows/ota-update.yml:

name: OTA Update

on:
  push:
    branches: [main]
    paths-ignore:
      - 'ios/**'
      - 'android/**'
      - 'app.json'
      - 'package.json'  # Native dependencies may change

jobs:
  publish-update:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Setup EAS
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Publish OTA update
        run: |
          eas update \
            --branch production \
            --message "OTA: ${{ github.event.head_commit.message }}" \
            --non-interactive

The paths-ignore list is the critical design decision — it ensures that changes to native directories, app.json, or package.json (which may add native dependencies) do not trigger a JavaScript-only OTA update that would be incompatible with the current native binary running on user devices. Only changes to TypeScript/JavaScript source code, assets, and configuration that do not affect the native layer should reach the OTA workflow.

OTA rollout strategy:

# Stage to preview branch first for internal testing
eas update --branch preview --message "Testing new feature"

# After validation, promote to production
eas update --branch production --message "Rollout: new feature"

# Rollback if needed — single command, immediate effect
eas update --branch production --republish --group <previous-group-id>

The rollback command is what makes OTA updates safe for production. Recovery from a bad JavaScript bundle is faster than any app store hotfix review cycle — a critical consideration for AI Incident Management Software mobile deployments where response time to production issues directly affects service levels.

Environment Variables and Secrets

GitHub Secrets to configure:

Secret Name Value Purpose
EXPO_TOKEN From expo.dev → Access Tokens EAS Build authentication
SLACK_WEBHOOK Slack incoming webhook URL Build status notifications
GOOGLE_SERVICES_JSON Base64 encoded google-services.json Android FCM configuration
ANDROID_KEYSTORE_BASE64 Base64 encoded keystore Android signing (if self-managed)
SENTRY_AUTH_TOKEN From Sentry organization settings Source map upload

Environment-specific configuration pattern:

Keep public configuration (API URLs, feature flags, app environment identifier) in eas.json env blocks using the EXPO_PUBLIC_ prefix — these are embedded in the client bundle and safe for client-side access. Keep secrets (Sentry DSN, analytics keys, internal API keys) in EAS Secrets — these are available to build processes but never embedded in the client bundle:

# EAS Secrets — server-side build access only, never in client bundle
eas secret:create --scope project --name SENTRY_DSN --value "https://..."
eas secret:create --scope project --name AMPLITUDE_KEY --value "abc123"

Monitoring and Notifications

Sentry release tracking in the production workflow:

- name: Create Sentry release
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
  run: |
    npx @sentry/cli releases new ${{ github.ref_name }}
    npx @sentry/cli releases set-commits ${{ github.ref_name }} --auto
    npx @sentry/cli releases finalize ${{ github.ref_name }}

This creates a Sentry release tied to the git tag, enabling error grouping by release version and the ability to correlate crash rate changes to specific deployments.

Build URLs posted to PR comments:

- name: Comment PR with build URLs
  uses: actions/github-script@v7
  with:
    script: |
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: `## Preview Build Ready\n\n📱 [View build on EAS](https://expo.dev/builds)\n\n**Android:** ${{ steps.android-build.outputs.url }}\n**iOS:** ${{ steps.ios-build.outputs.url }}`
      })

Posting build download links directly to the PR comment removes the friction of testers navigating to the EAS dashboard — a small ergonomic improvement that meaningfully improves QA coverage speed on active development teams. Custom Software Development Services includes this PR comment automation as a standard component of the CI/CD pipeline setup, alongside branch protection rules and required status checks that enforce the quality gates before any merge.

Explore AgileSoftLabs case studies for production React Native CI/CD pipeline implementations across App Store and Google Play deployments with documented build frequency, submission success rates, and OTA update adoption metrics.

Ready to Set Up a Production-Grade React Native CI/CD Pipeline?

A well-structured pipeline transforms mobile deployment from a manual, error-prone process into a reliable, automated workflow where every version tag produces consistent, signed builds submitted to both stores — and every JavaScript-only change reaches users within 24 hours via OTA update without a review cycle.

AgileSoftLabs provides React Native DevOps consulting — EAS Build configuration, GitHub Actions pipeline setup, code signing management, and OTA update strategy. Explore the full mobile and technology services portfolio or contact our mobile team for a pipeline review and setup engagement.

Frequently Asked Questions

How long do EAS builds take in CI, and can Android and iOS build simultaneously? 

Android production builds run 10–18 minutes; iOS production builds run 12–22 minutes. EAS queues Android and iOS builds independently — when both are triggered in sequence in the workflow, EAS runs them in parallel on separate machines while the GitHub Actions runner waits sequentially. The total wall-clock time is determined by the slower platform (typically iOS) rather than the sum of both, though the GitHub Actions runner is billed for the sequential wait time. Preview builds on faster build machines typically run 8–15 minutes per platform.

Can EAS builds run on self-hosted GitHub Actions runners instead of EAS cloud? 

Yes, using eas build --local. This runs the complete EAS build process on the runner machine, requiring Xcode installed for iOS (necessitating a macOS runner) and the Android SDK for Android. For teams with their own macOS infrastructure, self-hosted runners with local builds provide full control over the build environment and no per-build EAS credit consumption. The trade-off is maintaining the macOS runner hardware and software stack. For most teams, EAS cloud builds are more cost-effective once runner maintenance time is factored in.

How do I prevent accidental production deployments without building custom approval tooling?

GitHub Environments with required reviewers handle this entirely within GitHub's native tooling. Create a production environment in your repository's Settings → Environments, add required reviewers (typically tech leads or release managers), and reference environment: production in your production workflow job. Every version tag push triggers the job but pauses it at the environment gate — execution only resumes after an authorized reviewer approves the deployment in the GitHub Actions UI. The audit trail of who approved each deployment is preserved automatically in the Actions run history.

What is the difference between EAS Build and EAS Update in a production context? 

EAS Build creates a new native binary — an .ipa for iOS and an .aab for Android — that must go through app store review before reaching users. The review cycle adds 1–3 days for iOS and typically minutes to hours for Android. EAS Update delivers a new JavaScript bundle to users with the existing native binary — no review cycle, updates applied within 24–48 hours of users opening the app after the update is published. Use EAS Build when native code changes (new libraries with native modules, Expo SDK upgrades, iOS/Android configuration changes). Use EAS Update for JavaScript-only changes: bug fixes in business logic, UI changes, new feature toggles, content updates.

How do I handle multiple environments — development, staging, and production — without duplicating workflow files? 

The combination of EAS Build profiles in eas.json and branch-based workflow triggers handles multi-environment configuration without workflow duplication. Map develop branch pushes to the preview profile, version tags to the production profile, and local development to the development profile. Each profile carries its own env block with the appropriate API URLs and configuration. Secrets that differ by environment (staging API keys, production analytics keys) belong in EAS Secrets or GitHub Environment secrets scoped to the relevant GitHub Environment — not hardcoded in any workflow file.

How do I roll back a bad OTA update that is causing crashes in production? 

EAS Update rollback is a single command: eas update --branch production --republish --group <previous-group-id>. Find the <previous-group-id> in the EAS dashboard under Updates, or by running eas update:list --branch production to see recent update groups with their IDs. The republish command marks the previous group as the active update for the branch — clients polling for updates will receive the previous bundle within their next check interval. The entire rollback process takes under two minutes from decision to active rollback, which is the key advantage of OTA updates over app store hotfixes for JavaScript-layer production incidents.

Stuck on a React Native performance issue?

Get a free 30-minute mobile audit — we’ll review your stack, perf metrics, and ship recommendations you can act on the same day.

React Native CI/CD with GitHub Actions and EAS Build: Complete Setup Guide (2026) - AgileSoftLabs Blog