All posts

50+ Releases Without a Manual Step: GitHub Actions + Expo EAS

The CI/CD setup I built for a production React Native app — EAS build profiles, GitHub Actions triggers, store submission, and the small decisions that kept 50+ releases boring.

4 min read
React NativeExpoEASCI/CDGitHub Actions

At HeavyTask I own the release process for UTS, a React Native platform that ships to both the App Store and Google Play. Before automation, a release meant one developer losing half a day to Xcode archives, keystore juggling, and store console uploads — and every manual step was a chance to ship the wrong build. After moving to GitHub Actions + Expo EAS, we've shipped 50+ production releases where the human input is a git tag.

This is the shape of that setup, and the decisions that mattered more than I expected.

Three build profiles, three purposes

Everything hangs off eas.json. We settled on three profiles and never needed more:

{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview"
    },
    "production": {
      "channel": "production",
      "autoIncrement": true
    }
  },
  "submit": {
    "production": {
      "ios": { "appleTeamId": "XXXXXXXXXX" },
      "android": { "track": "production" }
    }
  }
}
  • development builds carry the dev client and are installed once per native change, not per release.
  • preview builds go to internal testers via a QR code — no TestFlight review wait for day-to-day QA.
  • production builds auto-increment the build number (autoIncrement: true), which removed an entire category of "version code already used" submission failures.

Trigger on tags, not on merges

Early on, we built on every merge to main. That burned build minutes and, worse, made "what's in this build?" a research project. Switching to tag-triggered releases fixed both:

name: release
on:
  push:
    tags: ["v*"]

jobs:
  build-and-submit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci
      - run: eas build --platform all --profile production --non-interactive --no-wait
      - run: eas submit --platform all --profile production --non-interactive --no-wait

Two flags do the heavy lifting. --non-interactive makes EAS fail loudly instead of waiting for input that will never come. --no-wait returns immediately after queueing — the GitHub Actions job finishes in about two minutes, and EAS notifies the team channel when builds finish. Holding a CI runner open for a 25-minute native build is paying twice for the same wait.

Merges to main still get a cheap workflow: typecheck, lint, tests, and an eas update to the preview channel so QA always has the latest JS.

OTA updates are a release channel, not a loophole

EAS Update lets you push JavaScript changes without a store review. It's tempting to treat that as "free deploys forever." We use it under two rules:

  1. OTA for fixes, stores for features. A crash fix goes out over the air the same hour. Anything that changes behavior meaningfully ships as a store build, so store listings, review notes, and the runtime never drift apart.
  2. Runtime version discipline. Every native dependency change bumps runtimeVersion, and updates only ever target one runtime. Sending new JS to an old native binary is the fastest way to a crash you can't reproduce locally.

The unglamorous parts that saved us

  • Secrets live in exactly two places. Store credentials (App Store Connect API key, Android service account, keystore) live in EAS; workflow tokens live in GitHub. When credentials expire — and Apple's expire yearly — there is one place to look.
  • A release is a checklist artifact. The tag message is the changelog. A short script turns it into store release notes, so "what changed" never depends on someone's memory at 6 p.m.
  • Zero critical post-deploy bugs is a process outcome, not luck. Every release rode through the preview channel for at least a day with real QA before the tag was cut. Automation didn't remove the human judgment — it removed the human typing.

If you're still building release binaries on a laptop, the pitch is simple: the setup above took roughly two days to stand up, and it pays that back on the first release where nothing goes wrong at all.