One Push, Two App Stores: Parallel iOS and Android Builds with GitHub Actions

We had a GitHub Actions workflow that built and submitted our React Native app to the Play Store. Adding iOS meant either:

  1. Running builds sequentially (slow)
  2. Running builds in parallel (fast)

We went with parallel. Here's the setup.

The Original Workflow

Our Android-only workflow was simple:

jobs:
  build-and-submit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - uses: expo/expo-github-action@v8
      - run: npm ci
      - run: npm test
      - run: npm version patch --no-git-tag-version
      - run: git commit && git push
      - run: eas build --platform android --auto-submit

One job. Linear execution. Works fine for one platform.

The Problem with Sequential

If we just added iOS to the same job:

- run: eas build --platform android --auto-submit
- run: eas build --platform ios --auto-submit

Build times:

  • Android: ~8 minutes
  • iOS: ~12 minutes
  • Total: ~20 minutes

But these builds are independent. They don't need to wait for each other.

The Parallel Solution

Split into separate jobs that run concurrently:

jobs:
  test-and-version:
    name: Test and Bump Version
    runs-on: ubuntu-latest
    outputs:
      new_version: ${{ steps.version.outputs.new_version }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
      - run: npm version patch --no-git-tag-version
      - run: git commit && git push

  build-android:
    name: Build Android
    needs: test-and-version
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: git pull origin main  # Get version bump
      - run: npm ci
      - run: eas build --platform android --auto-submit

  build-ios:
    name: Build iOS
    needs: test-and-version
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: git pull origin main  # Get version bump
      - run: npm ci
      - run: eas build --platform ios --auto-submit

  summary:
    name: Summary
    needs: [test-and-version, build-android, build-ios]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - run: echo "Done!"

Build times:

  • Test + Version: ~2 minutes
  • Android: ~8 minutes (parallel)
  • iOS: ~12 minutes (parallel)
  • Total: ~14 minutes

Saved: 6 minutes per deploy. Not huge, but it adds up.

The Key Patterns

1. Shared Setup, Separate Builds

The test-and-version job runs first and does everything that needs to happen once:

  • Run tests
  • Bump version
  • Commit and push

Both build jobs need this job, so they wait for it to complete.

2. Pull After Version Bump

Each build job checks out the repo, then immediately pulls:

- uses: actions/checkout@v4
  with:
    ref: main
- run: git pull origin main

This ensures both builds use the same version number that was just committed.

3. Output Variables

The version job exports the new version:

- id: version
  run: |
    NEW_VERSION=$(npm version patch --no-git-tag-version)
    echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT

The summary job can reference it:

${{ needs.test-and-version.outputs.new_version }}

4. Always-Run Summary

The summary job uses if: always() to run even if builds fail:

summary:
  needs: [test-and-version, build-android, build-ios]
  if: always()

This gives you a consolidated status report:

| Step | Status |
|------|--------|
| Tests & Version | ✅ |
| Android (Play Store) | ✅ |
| iOS (App Store) | ❌ |

The Full Workflow

Here's our complete Code-510-Apps.yml:

name: Code-510-Apps

on:
  workflow_dispatch:
  push:
    branches: [main]
    paths:
      - 'sweetmobile/**'
      - '.github/workflows/Code-510-Apps.yml'

permissions:
  contents: write

jobs:
  test-and-version:
    name: Test and Bump Version
    runs-on: ubuntu-latest
    outputs:
      new_version: ${{ steps.version.outputs.new_version }}
    steps:
      - uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
          cache-dependency-path: 'sweetmobile/package-lock.json'

      - run: cd sweetmobile && npm ci

      - run: cd sweetmobile && npm test

      - id: version
        run: |
          cd sweetmobile
          NEW_VERSION=$(npm version patch --no-git-tag-version)
          echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT

      - run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add sweetmobile/package.json sweetmobile/package-lock.json
          git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} [skip ci]"
          git push

  build-android:
    name: Build and Submit to Play Store
    needs: test-and-version
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: main
      - run: git pull origin main
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: cd sweetmobile && npm ci
      - run: cd sweetmobile && eas build --profile production --platform android --auto-submit --non-interactive
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

  build-ios:
    name: Build and Submit to App Store
    needs: test-and-version
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: main
      - run: git pull origin main
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: cd sweetmobile && npm ci
      - run: cd sweetmobile && eas build --profile production --platform ios --auto-submit --non-interactive
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

  summary:
    name: Deployment Summary
    needs: [test-and-version, build-android, build-ios]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - run: |
          echo "## App Store Deployments" >> $GITHUB_STEP_SUMMARY
          echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY
          echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
          echo "| Android | ${{ needs.build-android.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY
          echo "| iOS | ${{ needs.build-ios.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY

Gotchas

1. EAS builds are remote. The GitHub runner just triggers the build on Expo's servers. The "8 minute" Android build is mostly waiting for EAS, not using GitHub runner time.

2. Both platforms can fail independently. iOS might fail due to provisioning issues while Android succeeds. The summary job helps catch this.

3. Version must be committed before builds start. If builds use different versions, the App Store and Play Store will have mismatched version numbers.

4. [skip ci] is important. The version bump commit includes [skip ci] to prevent an infinite loop of workflows triggering themselves.

When to Use This

Parallel builds make sense when:

  • You deploy to multiple platforms
  • Builds are independent (no shared artifacts)
  • Time savings matter (CI minutes, deploy frequency)

For a single platform, keep it simple. For two or more, parallelism is worth the slightly more complex workflow.


What's your mobile CI/CD setup? Fastlane, EAS, something custom? Would love to hear what's working for others.

Building jo4.io - now shipping to both app stores with one git push.