CI/CD for Mobile

A pipeline that gates on tests, then builds, submits and publishes updates, with EAS Workflows or GitHub Actions.

Syntaxeas workflow:run .eas/workflows/production.yml

Mobile 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

Example · tsx
// 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 null

When 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.

Example · yaml
# .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: production

GitHub 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.

Example · yaml
# .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-interactive

Setting 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.

Example · bash
# 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

  • Be the first to comment on this lesson.