4 min readEngineering
How I automated iOS and Android store releases for a React Native app
A merge to main ships the Unlok app to TestFlight and Google Play. How the GitHub Actions and fastlane pipeline works, and when EAS Build fits better.
For the Unlok app, every merge to main that touches the app builds both platforms in GitHub Actions with fastlane, sends the iOS build to TestFlight and the Android build to Google Play, and testers can pull a Firebase build whenever they need one. Nobody builds a release on a laptop. Here is how a pipeline like that fits together, and when Expo’s EAS Build is the better choice.
What happens on a merge
- Only when the app changedA path filter means a backend-only merge doesn’t start a 30-minute mobile build.
- Two jobs at onceiOS on a macOS runner, Android on Linux, side by side.
- Signing without a personCertificates and keys come from secrets, so no one has to be at a Mac with the right keychain.
- Upload to both storesiOS to TestFlight, Android to an internal testing track on Google Play.
- Builds for testers on demandA manual run of the same workflow sends a build to Firebase App Distribution.
The workflow
A trimmed example of the shape, not Unlok's exact file:
name: Release app
on:
push:
branches: [main]
paths: ["apps/mobile/**"]
workflow_dispatch:
jobs:
ios:
runs-on: macos-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true, working-directory: apps/mobile }
- run: cd ios && pod install
- run: bundle exec fastlane ios beta
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY: ${{ secrets.ASC_KEY }}
android:
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- run: npm ci
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true, working-directory: apps/mobile }
- run: echo "${{ secrets.UPLOAD_KEYSTORE }}" | base64 --decode > android/app/upload.keystore
- run: bundle exec fastlane android beta
env:
PLAY_JSON_KEY: ${{ secrets.PLAY_JSON_KEY }}
UPLOAD_STORE_PASSWORD: ${{ secrets.UPLOAD_STORE_PASSWORD }}The fastlane lanes
platform :ios do
lane :beta do
setup_ci
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY"],
)
match(type: "appstore", readonly: true)
increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"], xcodeproj: "ios/App.xcodeproj")
build_app(workspace: "ios/App.xcworkspace", scheme: "App", export_method: "app-store")
upload_to_testflight(skip_waiting_for_build_processing: true)
end
end
platform :android do
lane :beta do
gradle(task: "bundle", build_type: "Release", project_dir: "android")
upload_to_play_store(
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
json_key_data: ENV["PLAY_JSON_KEY"],
)
end
endAndroid needs a rising version code too. Have android/app/build.gradle read it from an environment variable such as the run number, the same way the iOS lane sets its build number.
Secrets, and where they live
- An App Store Connect API key, so the pipeline never needs a person to pass a two-factor prompt.
- Certificates and profiles encrypted in a private repository by fastlane match, unlocked with one password.
- The Android upload keystore, stored as a base64 secret and written to disk only during the build.
- A Google Play service account key with access to just this app.
- None of it in the app repository. Rotate any key that ever was.
The parts that take longest
- Signing, the first timeGetting certificates, profiles and the keystore right once is most of the work. After that it is invisible.
- Google Play’s first uploadA new app's first build has to be uploaded by hand in the Play Console before the API will accept uploads.
- macOS minutesmacOS runners cost more than Linux ones on GitHub, so cache dependencies and only build iOS when the app changed.
- Build numbersBoth stores reject a build number they have seen before. Take it from the CI run, never from a file someone edits.
fastlane or EAS Build?
EAS Build is Expo's hosted build service. It handles signing for you, builds on Expo's machines, and EAS Submit sends the result to both stores. It works with bare React Native projects as well as Expo ones.
- EAS fits whenThe app uses Expo, you don't want to maintain macOS runners or signing yourself, and the build minutes on Expo's plans suit your release rhythm.
- fastlane on your own CI fits whenYou already run everything in GitHub Actions, want every step of signing and upload in your own hands, or need native steps a hosted service makes awkward.
For Unlok I used fastlane on GitHub Actions. For a new Expo app I'd look at EAS first. Either way, the goal is the same: a release is a merge, not an afternoon.
If your team is still building releases by hand, setting up a pipeline like this is a few days of work, and part of what I do when I build a product.
Questions people ask
- Can GitHub Actions build iOS apps?
- Yes, on GitHub's macOS runners. With an App Store Connect API key and fastlane match for signing, the whole build and TestFlight upload runs without a person or a Mac of your own.
- Should I use fastlane or EAS Build for React Native?
- EAS if your app uses Expo and you would rather not maintain signing and macOS runners. fastlane on your own CI if you want full control of every step, or already run everything in GitHub Actions.
- How do testers get a build before it reaches the store?
- TestFlight for iOS and an internal testing track on Google Play cover most teams. Firebase App Distribution is useful for sending a particular build to particular testers on demand.