We had a GitHub Actions workflow that built and submitted our React Native app to the Play Store. Adding iOS meant either:
- Running builds sequentially (slow)
- 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.