Manual app releases are a tax on your team's time and a source of human error. Forgetting to bump a version number, uploading the wrong build to TestFlight, or shipping a debug build to production — I've seen all of these happen on teams without automation. A well-configured CI/CD pipeline eliminates the entire category. Here's the setup I use across multiple React Native projects.
Why CI/CD Matters for Mobile
Mobile CI/CD solves three distinct problems: consistency (every build is produced the same way), speed (builds run in parallel without tying up a developer's machine), and auditability (every production build traces back to a specific commit and pull request).
Project Structure
my-app/
├── .github/
│ └── workflows/
│ ├── ios-deploy.yml
│ └── android-deploy.yml
├── fastlane/
│ ├── Fastfile
│ ├── Appfile
│ └── Matchfile
├── ios/
└── android/
Setting Up Fastlane
gem install fastlane
cd ios && fastlane init
cd ../android && fastlane init
Configure fastlane/Appfile:
# fastlane/Appfile
app_identifier("com.mycompany.myapp")
apple_id("dev@mycompany.com")
team_id("XXXXXXXXXX")
iOS Fastlane Lane with Match
Use match to manage certificates and provisioning profiles in a private Git repo. This solves the certificate chaos that plagues iOS teams:
# fastlane/Matchfile
git_url("https://github.com/myorg/certificates")
storage_mode("git")
type("appstore")
app_identifier(["com.mycompany.myapp"])
# fastlane/Fastfile
platform :ios do
desc "Build and upload to TestFlight"
lane :beta do
increment_build_number(xcodeproj: "ios/MyApp.xcodeproj")
match(type: "appstore", readonly: is_ci)
build_app(
workspace: "ios/MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
output_directory: "./build",
output_name: "MyApp.ipa",
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: last_git_commit[:message],
)
end
end
Android Fastlane Lane
platform :android do
desc "Build and upload to Google Play internal track"
lane :beta do
gradle(
task: "bundle",
build_type: "Release",
project_dir: "android/",
properties: {
"android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["KEY_PASSWORD"],
}
)
upload_to_play_store(
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
)
end
end
GitHub Actions — iOS Workflow
# .github/workflows/ios-deploy.yml
name: iOS Deploy to TestFlight
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Install CocoaPods
run: cd ios && pod install
- name: Run Fastlane
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_PRIVATE_KEY }}
run: bundle exec fastlane ios beta
GitHub Actions — Android Workflow
# .github/workflows/android-deploy.yml
name: Android Deploy to Play Store
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/release.keystore
- name: Run Fastlane
env:
KEYSTORE_PATH: android/app/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
SUPPLY_JSON_KEY_DATA: ${{ secrets.PLAY_STORE_JSON_KEY }}
run: bundle exec fastlane android beta
Managing Secrets
Store all sensitive values in GitHub repository secrets (Settings > Secrets and variables > Actions). Never commit keystores, certificates, or API keys to your repository. For the Android keystore, Base64-encode it and store that string as a secret:
# Encode your keystore locally
base64 -i android/app/release.keystore | pbcopy
# Paste into GitHub secret: KEYSTORE_BASE64
Triggering Deployments
The workflow above runs on every push to main. For a more controlled flow, add a version tag trigger:
on:
push:
tags:
- 'v*.*.*' # triggers on v1.2.3, v2.0.0, etc.
A tag-based trigger means your team controls exactly when a release is sent to TestFlight or the Play Store — no accidental deploys from routine merges to main.

