CI/CD for Mobile
A pipeline that gates on tests, then builds, submits and publishes updates, with EAS Workflows or GitHub Actions.
eas workflow:run .eas/workflows/production.ymlMobile releases go wrong when they live on one laptop: one set of credentials, one person's uncommitted changes, one memory of the right order. A pipeline makes the sequence a file in the repository.
Two ways to write it
EAS Workflows are YAML files in .eas/workflows/. A job declares a type such as build, submit or update, and needs wires one job's output into the next, so a submit job can take build_id from the build that produced it. Run one with eas workflow:run .eas/workflows/production.yml or on a push.
GitHub Actions suits teams whose other pipelines already live there. expo/expo-github-action@v8 installs the EAS CLI with eas-version and authenticates with token, read from a repository secret.
Gating
Nothing builds until typechecking and tests pass. A failed build costs minutes of queue time; a failed release costs a review cycle.
The decision the pipeline makes
If the change is JavaScript-only, publish an update. If anything native changed, build and submit. Fingerprint runtime versions let you detect which case you are in instead of guessing.
Authentication
Every command needs --non-interactive and a robot access token, created in your Expo account settings. A token tied to a person breaks the day their access changes.
Example
// scripts/planRelease.ts - what CI should do with this commit
type Commit = {
branch: string;
// True when the fingerprint runtime version differs from the live build's.
nativeFingerprintChanged: boolean;
};
type Plan = {
action: "build" | "update" | "none";
channel: string | null;
};
export function planRelease({ branch, nativeFingerprintChanged }: Commit): Plan {
const channels: Record<string, string> = { main: "preview", release: "production" };
const channel = channels[branch];
// Feature branches get tests and nothing else.
if (!channel) {
return { action: "none", channel: null };
}
// No existing binary can run JS that needs new native code, so build instead.
return { action: nativeFingerprintChanged ? "build" : "update", channel };
}
const a = planRelease({ branch: "main", nativeFingerprintChanged: false });
console.log(a.action, a.channel);
// Output: update preview
const b = planRelease({ branch: "release", nativeFingerprintChanged: true });
console.log(b.action, b.channel);
// Output: build production
const c = planRelease({ branch: "feat/maps", nativeFingerprintChanged: false });
console.log(c.action, c.channel);
// Output: none nullWhen to use it
- Publish a preview update automatically on every merge to main.
- Build and submit both platforms from a tagged release branch.
- Refuse to build when typecheck or tests fail.
- Remove the one teammate whose laptop holds the release credentials from the critical path.
More examples
An EAS Workflow that builds and submits
The two builds run in parallel and each submission waits only on its own build, so an Android failure does not hold up the iOS upload. Reach for EAS Workflows when the pipeline is mostly EAS commands: credentials, environments and build ids are already in scope, with no secrets to copy.
# .eas/workflows/production.yml
name: Production release
on:
push:
branches: ['release']
jobs:
build_ios:
type: build
params:
platform: ios
profile: production
build_android:
type: build
params:
platform: android
profile: production
submit_ios:
type: submit
needs: [build_ios]
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
submit_android:
type: submit
needs: [build_android]
params:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: productionGitHub Actions: tests gate an update
Steps run in order and the job stops at the first failure, which is the whole gate. EXPO_TOKEN is a robot token stored as a repository secret. Use this shape when the rest of your CI already lives in GitHub, or when you want to run checks that are not EAS commands alongside the release.
# .github/workflows/update.yml
name: Publish update
on:
push:
branches: [main]
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: npx tsc --noEmit
- run: npm test -- --ci
# Only reached if typecheck and tests passed. --environment is required
# for eas update from SDK 55 on.
- run: eas update --channel preview --environment preview --message "${{ github.event.head_commit.message }}" --non-interactiveSetting the pipeline up
Run the workflow manually first: a pipeline that fails only on push to the release branch is the worst place to debug credentials. The EXPO_TOKEN check is worth doing from your own terminal, because an authentication failure in CI looks identical to a misconfigured project.
# Scaffold a workflow file from a template
eas workflow:create --template build
# Run it by hand before trusting a trigger
eas workflow:run .eas/workflows/production.yml
# Verify the token works the way CI will use it
EXPO_TOKEN=<robot-token> eas build:list --non-interactive --limit 1
# Everything a pipeline runs takes this flag, and eas update also needs
# --environment from SDK 55 on
eas update --channel preview --environment preview --message "ci" --non-interactive
Discussion