This guide shows you how to build a fully automated CI/CD pipeline for a React Native app using GitHub Actions and Expo Application Services (EAS). Follow all eight steps and you'll have automated testing, builds, and over-the-air updates shipping to the App Store and Google Play — expect about 90 minutes to complete the initial setup.

What You'll Build

  • A GitHub Actions workflow that runs lint and Jest tests on every pull request
  • An EAS Build configuration that compiles iOS and Android binaries automatically on merge
  • An over-the-air (OTA) update channel via EAS Update that pushes JS bundle changes without a store review
  • Environment-specific secrets management so staging and production never share credentials
  • A Slack notification step that posts build status to your team channel in real time

Prerequisites

  • Node.js 20+ and pnpm 9 (or npm 10) installed locally
  • An existing React Native project using Expo SDK 51 or later (bare or managed workflow)
  • An Expo account — free tier is sufficient to start
  • Apple Developer Program membership and a Google Play Console account for store distribution
  • A GitHub repository with Actions enabled
  • Basic familiarity with YAML syntax

Step 1: Install and Configure the EAS CLI

EAS CLI is Expo's official build and submit tool. It handles code signing, credentials storage, and cloud builds — eliminating the need to maintain Mac or Linux build machines locally.

npm install -g eas-cli
eas login
eas build:configure

The eas build:configure command generates an eas.json file in your project root. Open it and define three build profiles: development, preview, and production.

{
  "cli": {
    "version": ">= 10.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview"
    },
    "production": {
      "channel": "production",
      "autoIncrement": true
    }
  },
  "submit": {
    "production": {}
  }
}

Common pitfall: Skipping eas build:configure and writing eas.json manually often causes schema validation errors. Always run the command first, then edit the output.

Step 2: Store Credentials as GitHub Secrets

Never hardcode API keys or signing credentials in your repository. GitHub Actions reads secrets from your repository's Settings → Secrets and variables → Actions panel at runtime.

Add the following secrets:

  • EXPO_TOKEN — generate this from your Expo account dashboard under Access Tokens
  • SLACK_WEBHOOK_URL — your Slack Incoming Webhook URL (created in your Slack app settings)

Pro tip: Use GitHub Environments (not just plain secrets) to gate production deployments behind a required reviewer. This is a best practice aligned with supply chain security recommendations from the SLSA framework, widely adopted as of 2026.

Step 3: Write the Pull Request CI Workflow

Create .github/workflows/ci.yml in your repository. This workflow runs on every pull request targeting main or develop.

name: CI

on:
  pull_request:
    branches: [main, develop]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run ESLint
        run: pnpm lint

      - name: Run Jest tests
        run: pnpm test --ci --coverage

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

Why does --frozen-lockfile matter? It prevents pnpm from silently updating your lockfile during CI, ensuring the exact dependency tree you tested locally is what runs in the pipeline. This has caught broken transitive dependency upgrades in many production React Native apps.

Step 4: Write the EAS Build and Deploy Workflow

Create .github/workflows/deploy.yml. This workflow triggers when code merges to main.

name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-submit:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

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

      - name: Build iOS and Android
        run: eas build --platform all --profile production --non-interactive

      - name: Submit to App Store and Play Store
        run: eas submit --platform all --latest --non-interactive

Common pitfall: The --non-interactive flag is essential. Without it, EAS CLI prompts for user input and the workflow hangs indefinitely until it times out.

What if the EAS build fails with a credentials error?

Run eas credentials locally to verify your signing certificates and provisioning profiles are stored in Expo's credentials service. EAS manages credentials remotely by default, but the initial setup must happen on a machine that can authenticate with your Apple and Google accounts.

Step 5: Add EAS Update for Over-the-Air Deployments

OTA updates let you ship JavaScript and asset changes to users without waiting for an App Store review — typically bypassing a 24–72 hour review cycle. EAS Update supports this natively with Expo SDK 51+.

Add an update step to deploy.yml between the dependency install and the EAS build step:

      - name: Publish OTA update
        run: eas update --branch production --message "Release from ${{ github.sha }}"

Configure your app.json to enable updates:

{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/YOUR_PROJECT_ID",
      "enabled": true,
      "fallbackToCacheTimeout": 0,
      "checkAutomatically": "ON_LOAD"
    },
    "runtimeVersion": {
      "policy": "sdkVersion"
    }
  }
}

Pro tip: Set fallbackToCacheTimeout: 0 in production. It forces the app to use a cached bundle immediately rather than waiting for a network fetch on launch — this measurably improves perceived startup time on slow connections common in regional markets across Australia and Southeast Asia.

Step 6: Separate Preview Builds for Staging

Your QA team and stakeholders should never test against production credentials. Add a second deploy job that triggers on pushes to develop and uses the preview EAS profile.

  build-preview:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - name: Build preview binary
        run: eas build --platform all --profile preview --non-interactive
      - name: Publish preview OTA update
        run: eas update --branch preview --message "Preview from ${{ github.sha }}"

Distribute preview builds to testers using EAS's internal distribution — share the install link directly rather than going through TestFlight or the Play Console internal track.

Step 7: Add Slack Notifications for Build Status

Real-time notifications reduce the feedback loop from hours to minutes. Add this as a final step in both workflow files.

      - name: Notify Slack
        if: always()
        uses: slackapi/[email protected]
        with:
          payload: |
            {
              "text": "Build ${{ job.status }} for ${{ github.repository }} on branch ${{ github.ref_name }} — commit ${{ github.sha }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

The if: always() condition ensures the notification fires whether the job succeeded, failed, or was cancelled — so your team is never left wondering why a build went quiet.

Step 8: Validate the Full Pipeline End to End

With all files committed, verify the pipeline works before announcing it to your team.

  1. Open a new branch, make a trivial change (e.g. update a comment), and raise a pull request against main. The ci.yml workflow should trigger automatically and complete in under three minutes for a typical React Native project.
  2. Merge the pull request. The deploy.yml workflow should trigger, run the EAS build (cloud build time is typically 10–20 minutes for both platforms), and post a Slack notification.
  3. In your Expo dashboard, confirm the new build appears under Builds and the OTA update appears under Updates → production branch.
  4. Install the app from EAS's distribution link and verify the OTA update loads on launch.

Common pitfall: If the EAS submit step fails with a "missing App Store Connect API key" error, you need to add your App Store Connect API key to EAS credentials. Run eas credentials --platform ios locally and follow the prompts to upload the key.

Frequently Asked Questions

Does this pipeline work with Expo bare workflow projects?

Yes. EAS Build supports both managed and bare workflow projects as of Expo SDK 51. Bare workflow projects may require additional native configuration steps, but the GitHub Actions YAML shown here works without modification.

How is EAS Build different from building locally with Xcode or Android Studio?

EAS Build runs on Expo's cloud infrastructure, so you don't need a macOS machine to produce iOS binaries. It also manages code signing credentials centrally, which eliminates the "works on my machine" certificate issues that slow down most small teams.

What if the Jest tests pass locally but fail in CI?

The most common cause is a missing environment variable. Add a .env.test file to your repository (non-secret values only) or inject test-specific variables as GitHub Actions environment variables using the env: key on the test step.

Can I use this setup with Bitbucket Pipelines or GitLab CI instead of GitHub Actions?

The EAS CLI commands are identical regardless of CI provider. Replace the GitHub Actions YAML syntax with your provider's equivalent — the eas build, eas submit, and eas update commands remain unchanged.

How much does this pipeline cost to run?

GitHub Actions provides 2,000 free minutes per month on the free tier, and the Linux runners used here consume those minutes at 1× the rate. EAS Build offers a free tier with 30 build credits per month — enough for a small team shipping weekly. Production-scale teams typically pay $29–$99 USD per month for additional EAS credits as of mid-2026.

Next Steps

You now have a complete CI/CD pipeline that tests every PR, builds production binaries automatically, and pushes OTA updates without manual intervention. A few directions worth exploring next:

  • Add Detox end-to-end tests to your CI workflow to catch UI regressions before they reach users
  • Configure EAS Insights to track crash rates and ANR (App Not Responding) events by build version
  • Set up branch protection rules in GitHub to require CI to pass before any merge — this prevents a single broken commit from blocking your entire team

If you're building a React Native app for your business and want a production-ready architecture from day one, the team at Lenka Studio works with SMBs across Australia, Singapore, Canada, and the US to deliver apps with CI/CD, design systems, and scalable backends already in place. Get in touch to talk through your project — we're happy to review your current setup and suggest what to prioritise first.