Skip to main content
Back to writing
CI/CD

Build a Mobile Release Pipeline with GitHub Actions and EAS

A step-by-step guide to automating React Native (Expo) releases: build on EAS, submit to the app store on every GitHub Release, with tag-driven versioning, security, and the gotchas to avoid.

June 30, 20266 min read
ci-cdgithub-actionseasexpomobiledevops

This guide walks through setting up a CI/CD pipeline for a React Native (Expo) app. By the end, publishing a GitHub Release will build a signed app on EAS and submit it to the store's internal track, with the version driven by your release tag. Promotion to production stays a deliberate step in the store console.

The runner never builds the app itself. It triggers an EAS cloud build, which holds your signing credentials and manages the build number. That keeps signing keys off CI entirely.

Prerequisites

  • An Expo project with EAS configured (eas build works locally).
  • A GitHub repository for the app.
  • A Google Play developer account with the app already created, and a service account JSON key with permission to release to testing tracks.
  • An Expo access token for CI.

Step 1: Configure the submit profile

In eas.json, add a submit profile that targets the internal track, and make sure the build profile lets EAS manage the build number (appVersionSource: remote with autoIncrement).

eas.json
{
  "cli": { "appVersionSource": "remote" },
  "build": {
    "production-android": {
      "autoIncrement": true,
      "env": { "NPM_CONFIG_LEGACY_PEER_DEPS": "true" }
    }
  },
  "submit": {
    "production": {
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json",
        "track": "internal"
      }
    }
  }
}

Step 2: Drive the version from the git tag

Add a dynamic Expo config so the user-facing version comes from an environment variable, falling back to app.json for local builds. EAS still owns the build number; this only sets the version name.

app.config.js
module.exports = ({ config }) => ({
  ...config,
  version: process.env.APP_VERSION || config.version,
});

The workflow will set APP_VERSION from the release tag in a later step.

Step 3: Add the CI secrets

In the repository, go to Settings > Secrets and variables > Actions and add two secrets:

  • EXPO_TOKEN: an Expo access token. Use a robot account that is a member of your Expo org, with a build and submit role.
  • GOOGLE_SERVICE_ACCOUNT_KEY: the full JSON of your Play service account key.

The workflow writes the JSON to a git-ignored file at runtime, so the key never gets committed.

Step 4: Create the workflow

Create .github/workflows/release.yml. It has two jobs: build produces the app and outputs its build id, and submit uploads that exact build by id.

.github/workflows/release.yml
on:
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      build_id:
        description: "Existing build id to submit (blank = build a new one)"
        default: ""
 
permissions:
  contents: read
 
concurrency:
  group: mobile-release
  cancel-in-progress: false
 
jobs:
  build:
    if: ${{ github.event_name == 'release' || github.event.inputs.build_id == '' }}
    runs-on: ubuntu-latest
    timeout-minutes: 120
    outputs:
      build_id: ${{ steps.build.outputs.build_id }}
    steps:
      - uses: actions/checkout@<sha> # pin to a commit SHA
      - uses: actions/setup-node@<sha>
        with: { node-version: 20, cache: npm }
      - uses: expo/expo-github-action@<sha>
        with: { eas-version: 16.18.0, token: ${{ secrets.EXPO_TOKEN }} }
      - if: startsWith(github.ref, 'refs/tags/v')
        run: echo "APP_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
      - run: npm ci --legacy-peer-deps
      - id: build
        run: |
          eas build --platform android --profile production-android --non-interactive --json > build.json
          echo "build_id=$(node -e "console.log(require('./build.json')[0].id)")" >> "$GITHUB_OUTPUT"
 
  submit:
    needs: build
    if: ${{ !cancelled() && needs.build.result != 'failure' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha>
      - uses: actions/setup-node@<sha>
        with: { node-version: 20, cache: npm }
      - uses: expo/expo-github-action@<sha>
        with: { eas-version: 16.18.0, token: ${{ secrets.EXPO_TOKEN }} }
      - run: npm ci --legacy-peer-deps
      - run: printf '%s' "$SERVICE_ACCOUNT_KEY" > google-service-account.json
        env:
          SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
      - run: eas submit --platform android --profile production --id ${{ github.event.inputs.build_id || needs.build.outputs.build_id }} --non-interactive

A few choices worth calling out:

  • Submit by build id, not --latest. Passing the id from the build job avoids a race if another build finishes in between.
  • The build_id input. Trigger the workflow manually with an existing build id to re-submit it without paying for another build.
  • The build runs in the cloud. The eas build step waits for EAS, so give the job a generous timeout-minutes.

Step 5: Lock it down

Treat the pipeline like production, because it can push code to every user's device.

  • Set permissions: contents: read. The workflow never writes to the repo.
  • Do not add a pull_request trigger. That keeps secrets away from fork PRs, a common leak path.
  • Pin every action to a commit SHA, not a moving tag, so a hijacked tag cannot read your secrets.
  • Scope the service account to the one permission it needs, on a single app.
  • Add concurrency and timeout-minutes so releases do not overlap or hang.

Step 6: Cut a release

Create a GitHub Release. The tag drives the version, and publishing it triggers the workflow.

gh release create v1.0.1 --generate-notes

The workflow builds once and submits to the internal track. When you are ready to ship, open the store console and promote the internal release to production, where you can set a staged rollout (for example 10% to 100%). Keeping production a manual promotion avoids shipping a bad build to everyone at once.

Gotchas to avoid

Three things tend to fail the first time. Knowing them up front saves an afternoon.

  1. CI token access. If eas build fails with an authorization error, the token's account is not a member of the Expo org, or lacks a build role. Add it with the right role.
  2. Dependencies in the submit job. eas submit reads the app config, which resolves your config plugins, so the submit job needs npm ci too. Skipping it to save time will fail on expo config.
  3. Version codes are single-use. A version code can be uploaded only once across the whole app. Do not try to upload the same build to two tracks. Upload once, then promote to other tracks in the console.

Extending to iOS

The same pipeline covers iOS. EAS builds and submits it the same way, so you mostly swap the platform flag.

  • eas build --platform ios produces a signed .ipa, and EAS manages the Apple signing credentials the way it does the Android keystore.
  • eas submit --platform ios ships to App Store Connect, authenticated with an App Store Connect API key instead of a Google service account.
  • TestFlight is the iOS equivalent of the internal track, and promotion to the App Store is the human gate.
  • iOS build numbers are also single-use per upload, so the upload-once rule still applies.

Wrap up

With this in place, a single git push of a release tag produces a signed, auditable build in the store. The build service owns signing and versioning, secrets stay scoped and off the runner, and production stays a deliberate promotion rather than an automatic one.

Share