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 buildworks 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).
{
"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.
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.
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-interactiveA 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_idinput. 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 buildstep waits for EAS, so give the job a generoustimeout-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_requesttrigger. 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
concurrencyandtimeout-minutesso 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-notesThe 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.
- CI token access. If
eas buildfails 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. - Dependencies in the submit job.
eas submitreads the app config, which resolves your config plugins, so the submit job needsnpm citoo. Skipping it to save time will fail onexpo config. - 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 iosproduces a signed.ipa, and EAS manages the Apple signing credentials the way it does the Android keystore.eas submit --platform iosships 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.