| name | mobile-cicd-patterns |
| description | Mobile CI/CD patterns — iOS/Android builds in CI (GitHub Actions/Fastlane), code signing for TestFlight/App Store, automated versioning, screenshot testing, Firebase App Distribution, staged rollouts, and crash-rate gates. |
Mobile CI/CD Patterns
When to Activate
- Setting up automated iOS or Android builds in CI
- Configuring code signing for TestFlight or App Store submission
- Implementing beta distribution via TestFlight or Firebase App Distribution
- Managing certificates and provisioning profiles with Fastlane match
- Setting up OTA (Over-the-Air) updates with Expo EAS or CodePush
- Automating App Store / Google Play submission
- Configuring release versioning from Git tags
Code Signing — iOS
Why It's Complex
Apple requires every iOS binary to be signed with a certificate (proving identity) tied to a provisioning profile (proving the app is authorized for specific devices or the App Store). In CI, you need these without a GUI.
Fastlane match (recommended for teams)
match stores certificates and profiles in a Git repository (or S3/Google Cloud Storage), encrypted with a password. Any CI machine or developer can sync them with one command.
git_url("https://github.com/myorg/ios-certificates")
storage_mode("git")
type("appstore")
app_identifier(["com.myapp.ios"])
username("ci@myorg.com")
lane :sync_signing do
match(
type: "appstore",
readonly: is_ci,
keychain_name: "build.keychain",
keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"],
)
end
lane :build_ios do
sync_signing
build_app(
scheme: "MyApp",
export_method: "app-store",
output_directory: "./build",
output_name: "MyApp.ipa",
)
end
GitHub Actions: temporary keychain for CI
- name: Install certificates via match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -lut 21600 build.keychain
bundle exec fastlane sync_signing
Manual Certificate Install (simpler for small teams)
- uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
- uses: apple-actions/download-provisioning-profiles@v3
with:
bundle-id: com.myapp.ios
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
Code Signing — Android
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("ANDROID_KEYSTORE_PATH") ?: "debug.keystore")
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("ANDROID_KEY_ALIAS") ?: ""
keyPassword = System.getenv("ANDROID_KEY_PASSWORD") ?: ""
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
GitHub Actions: decode base64 keystore secret
- name: Decode Android keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore
env:
ANDROID_KEYSTORE_PATH: app/release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Build release AAB
run: ./gradlew bundleRelease
Play App Signing (recommended since 2021): Upload a .aab signed with your upload keystore. Google re-signs with the final distribution keystore. Protects against key loss — Google holds the distribution key.
Fastlane — Core Workflow
platform :ios do
lane :test do
run_tests(scheme: "MyAppTests", devices: ["iPhone 16"])
end
lane :beta do
test
sync_signing
increment_build_number(
build_number: number_of_commits,
)
build_app(scheme: "MyApp", export_method: "app-store")
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: changelog_from_git_commits(commits_count: 10),
)
slack(message: "iOS beta uploaded to TestFlight ✅", slack_url: ENV["SLACK_URL"])
end
lane :release do
beta
upload_to_app_store(
submit_for_review: false,
phased_release: true,
automatic_release: false,
)
end
end
platform :android
lane
gradle( , , )
lane
test
gradle( , , )
upload_to_play_store(
,
,
[],
)
lane
upload_to_play_store( , [])
lane
upload_to_play_store(
,
,
[],
)
Beta Distribution
TestFlight (iOS)
- Up to 10,000 external testers, 90-day expiry
- Internal testers (App Store Connect users): available immediately, no review
- External testers: requires brief Apple review (~24h first time, faster after)
- Groups: organize testers by role (QA, stakeholders, public beta)
upload_to_testflight(
api_key_path: "fastlane/api_key.json",
distribute_external: true,
groups: ["QA Team", "Beta Users"],
notify_external_testers: true,
changelog: "Bug fixes and performance improvements",
)
Firebase App Distribution (cross-platform)
firebase_app_distribution(
app: "1:123456:android:abcdef",
firebase_cli_token: ENV["FIREBASE_CLI_TOKEN"],
groups: "qa-team,stakeholders",
release_notes: changelog_from_git_commits(commits_count: 5),
android_artifact_type: "AAB",
android_artifact_path: "app/build/outputs/bundle/release/app-release.aab",
)
OTA (Over-the-Air) Updates
Expo EAS Update (React Native / Expo)
OTA updates push JavaScript bundle changes without going through App Store review. Only JS/asset changes — native code changes still require a full build.
npm install -g eas-cli
eas update:configure
eas update --branch production --message "Fix checkout bug"
eas update --branch production --rollout-percentage 20
{
"cli": { "version": ">= 5.0.0" },
"build": {
"production": {
"channel": "production",
"android": { "buildType": "app-bundle" },
"ios": { "simulator": false }
},
"preview": {
"channel": "preview",
"distribution": "internal"
}
},
"submit": {
"production": {
"ios"
CodePush (Microsoft, React Native)
import codePush from 'react-native-code-push';
const codePushOptions = {
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
installMode: codePush.InstallMode.ON_NEXT_RESTART,
minimumBackgroundDuration: 60,
};
export default codePush(codePushOptions)(App);
GitHub Actions — Full Mobile CI Matrix
name: Mobile CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
android-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('**/*.gradle.kts', '**/gradle-wrapper.properties') }}
- name: Run unit tests
run: ./gradlew testDebugUnitTest
-
Build Number Automation
Always derive build numbers from git — never manually edit them:
BUILD_NUMBER=$(git rev-list HEAD --count)
agvtool new-version -all $BUILD_NUMBER
increment_build_number(build_number: number_of_commits)
./gradlew bundleRelease -PversionCode=$BUILD_NUMBER
val buildNumber = System.getenv("BUILD_NUMBER")?.toIntOrNull()
?: ("git rev-list HEAD --count".runCommand()?.trim()?.toIntOrNull() ?: 1)
android {
defaultConfig {
versionCode = buildNumber
versionName = "2.1.0"
}
}
App Store Submission Checklist
Before submitting to App Store / Play Store:
iOS App Store:
- [ ] Marketing screenshots for all required device sizes (6.9", 6.5", 5.5" for iPhone; iPad Pro 12.9")
- [ ] App preview video (optional but increases conversion)
- [ ] Privacy manifest (PrivacyInfo.xcprivacy) updated
- [ ] Export compliance (uses encryption beyond HTTPS?)
- [ ] NSUserTrackingUsageDescription if using IDFA (App Tracking Transparency)
- [ ] All Info.plist usage descriptions present for requested permissions
- [ ] Age rating correctly set
- [ ] Phased release enabled (7 days, manual pause available)
Google Play Store:
- [ ] AAB format (not APK)
- [ ] Feature graphic (1024×500 banner)
- [ ] Screenshots for phone + 7" tablet + 10" tablet
- [ ] Content rating questionnaire completed
- [ ] Data safety section filled in
- [ ] Staged rollout: start at 10% for production releases
- [ ] Release notes in all supported locales
Reference
- Skill:
flutter-patterns — Flutter CI/CD with Fastlane + EAS
- Skill:
ci-cd-patterns — General GitHub Actions CI patterns
- Skill:
deployment-patterns — Release strategies (canary, blue-green)
- Skill:
android-patterns — Android build tooling (Gradle, Hilt, Room)
- Command:
mobile-release — Mobile release workflow command