DEV Community

Famitha M A
Famitha M A

Posted on Originally published at fami-blog.hashnode.dev

Mobile App CI/CD with EAS Build and GitHub Actions (the split that actually works)

TL;DR

  • Run lint/tests/type checks on GitHub Actions (Ubuntu, cheap, fast)
  • Run native builds and code signing on EAS (purpose-built, managed credentials)
  • The rule: if a step needs Xcode, Ruby, or a keychain, it belongs in EAS
  • Add EAS Update so JS-only changes ship OTA in ~90 seconds, no store review
  • Fingerprint runtime policy auto-detects when a native rebuild is actually needed

The first time you ship a React Native app, the code is the easy part. The hard part is everything after git push: signing certificates that expire, provisioning profiles that don't match, a Fastlane script from a two-year-old blog post, a keystore.jks that lives on one laptop, and a build that succeeds locally on macOS but fails on Ubuntu because Xcode isn't there.

Here's the pipeline that fixed it for me: every push runs checks, every merge to main produces a signed build, every release lands on TestFlight and Google Play without anyone opening Xcode.

Why two CI systems (yes, really)

The Expo team openly recommends using both GitHub Actions and EAS. Felt wrong at first. Then I looked at what each is good at:

Concern GitHub Actions EAS
Lint, TS, unit tests Native fit, generous free tier Overkill
iOS .ipa compile Slow, expensive, hand-rolled signing Purpose-built, fingerprint caching
Android .aab compile Workable but manual One command
Code signing Secrets you rotate manually Managed, auto-rotated
OTA updates Trigger via CLI First-class
Cost Free-ish for JS, painful for macOS Paid tiers, honest about it

GitHub Actions can technically compile an iOS build. It'll also take 15–20 minutes per attempt and bill macOS minutes at 10x the Linux rate. So: GitHub Actions owns the code, EAS owns the binary.

Step 1: eas.json with real profiles

One production profile is a trap. You need three:

{
  "cli": {
    "version": ">= 12.0.0",
    "appVersionSource": "remote"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "env": { "APP_ENV": "development" },
      "ios": { "simulator": true }
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview",
      "env": { "APP_ENV": "preview" },
      "ios": { "simulator": false }
    },
    "production": {
      "channel": "production",
      "env": { "APP_ENV": "production" },
      "autoIncrement": true
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "you@example.com",
        "ascAppId": "1234567890",
        "appleTeamId": "ABCD123456"
      },
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json",
        "track": "internal"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Non-obvious bits:

  • appVersionSource: "remote" kills the "who bumped version last?" merge conflict forever. EAS tracks build numbers server-side.
  • channel binds each build to an EAS Update channel, so a QA release can't get clobbered by a main push.
  • ios.simulator: true on dev gives you simulator builds for design review on any Mac.

Step 2: the workflow

.github/workflows/ci.yml. Every PR: checks + preview build. Every merge to main: production build + submit.

name: CI/CD

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  quality:
    name: Lint, type-check, test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check
      - run: npm test -- --ci --coverage

  preview-build:
    name: EAS preview build
    needs: quality
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci
      - run: eas build --profile preview --platform all --non-interactive --no-wait

  production-release:
    name: EAS production build + submit
    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: 20, cache: npm }
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci
      - run: eas build --profile production --platform all --auto-submit --non-interactive
Enter fullscreen mode Exit fullscreen mode

The shape matters more than the details:

  • quality runs on Ubuntu, not macOS. JS jobs have no business on a macOS runner.
  • --no-wait on previews: Actions kicks off the EAS build and returns instead of burning minutes waiting on a 15-minute compile.
  • --auto-submit on production: binary succeeds → straight to TestFlight and Play internal testing. No human in App Store Connect.
  • EXPO_TOKEN (from your Expo dashboard) is the only secret GitHub needs.

Step 3: secrets and signing without losing your mind

Code signing is where mobile CI/CD historically goes to die. iOS: distribution cert + provisioning profile + private key. Android: keystore + alias + two passwords. Lose any of them and you can't update your existing app.

EAS managed credentials fix this. First eas build, it offers to generate and store everything. Say yes. Encrypted, auto-rotated, shared across the team.

Android submit key stays out of your repo:

eas secret:create --scope project --name GOOGLE_SERVICE_ACCOUNT_KEY --type file --value ./google-service-account.json
Enter fullscreen mode Exit fullscreen mode

Because signing material never touches GitHub:

  • A compromised Actions run can't leak your keys. They aren't there.
  • New teammates get access via Expo membership, not file-copying.
  • Cert renewal is a background task, not a Friday fire drill.

Stuffing an Apple cert into ${{ secrets.IOS_P12 }} works until it doesn't.

Step 4: OTA updates so most releases skip the stores

Most changes to a mature RN app are JS-only: copy tweaks, style fixes, new screens from existing components. They don't need a binary. They need to reach phones.

  ota-update:
    name: EAS OTA update
    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: 20, cache: npm }
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci
      - run: eas update --channel production --message "${{ github.event.head_commit.message }}"
Enter fullscreen mode Exit fullscreen mode

Every merge → OTA bundle live in ~90 seconds. Users get it on next app open. The native build still runs in parallel so binary and channel stay in sync.

The caveat Expo's docs don't shout: native module changes are not OTA-updatable. Added expo-camera this morning? New binary. Protect yourself with:

{
  "runtimeVersion": { "policy": "fingerprint" }
}
Enter fullscreen mode Exit fullscreen mode

in app.json. EAS refuses to serve an update to a binary whose runtime doesn't match, and fingerprinting auto-detects when a rebuild is required.

Step 5: fingerprint caching

EAS hashes everything that affects the native binary (package.json, app.json, native folders, plugin configs) and reuses a previously built binary when nothing changed. JS-only PR: iOS build drops from ~15 minutes to a couple. Preview builds on every PR go from painful to routine.

The shortcut nobody mentions

All of this assumes you already have a sane Expo project. If you're generating apps with RapidNative, the export already ships the scaffolding this article builds on: eas.json with dev/preview/production profiles, fingerprint runtime policy in app.json, and a monorepo layout that doesn't fight expo/expo-github-action. Drop the workflow above into .github/workflows/ci.yml, add an EXPO_TOKEN, push. Done.

Not using it? Nothing here requires it. The pipeline is the pipeline.

Wrap-up

Deliberately not the fanciest pipeline possible. No matrix builds, no Slack-gated rollouts. Get the core loop (push → tested → signed → shipped) running on its own first; budget a day for the first production build (iOS signing, mostly) and another for OTA wiring.

What broke first in your React Native pipeline? Expired provisioning profile? Keystore password nobody wrote down? Drop it in the comments. Misery loves company, and the fixes are usually reusable.

Top comments (0)