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.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
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.
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.
# Matchfile
git_url("https://github.com/myorg/ios-certificates")
storage_mode("git")
type("appstore") # or "development", "adhoc"
app_identifier(["com.myapp.ios"])
username("ci@myorg.com")
# Fastfile
lane :sync_signing do
match(
type: "appstore",
readonly: is_ci, # CI: read-only; developers: can update
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
# GitHub Actions: using apple-actions/import-codesign-certs
- 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 }}
// app/build.gradle.kts — release signing from environment variables
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.
# Fastfile — shared lanes for both platforms
# ──── iOS Lanes ────────────────────────────────────────────────────────────────
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, # auto-increment from git
)
build_app(scheme: "MyApp", export_method: "app-store")
upload_to_testflight(
skip_waiting_for_build_processing: true, # faster CI
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, # manual review trigger
phased_release: true, # 7-day phased rollout
automatic_release: false,
)
end
end
# ──── Android Lanes ────────────────────────────────────────────────────────────
platform :android do
lane :test do
gradle(task: "test", flavor: "staging", build_type: "Debug")
end
lane :beta do
test
gradle(task: "bundle", flavor: "production", build_type: "Release")
upload_to_play_store(
track: "internal",
aab: "app/build/outputs/bundle/productionRelease/app-production-release.aab",
json_key: ENV["PLAY_STORE_SERVICE_ACCOUNT_JSON"],
)
end
lane :promote_to_beta do
upload_to_play_store(track_promote_to: "beta", version_code: ENV["VERSION_CODE"])
end
lane :release do
upload_to_play_store(
track: "production",
rollout: "0.1", # 10% staged rollout
aab: ENV["AAB_PATH"],
)
end
end
# Fastlane pilot (TestFlight)
upload_to_testflight(
api_key_path: "fastlane/api_key.json", # App Store Connect API key (not password)
distribute_external: true,
groups: ["QA Team", "Beta Users"],
notify_external_testers: true,
changelog: "Bug fixes and performance improvements",
)
# Fastlane plugin: fastlane-plugin-firebase_app_distribution
firebase_app_distribution(
app: "1:123456:android:abcdef", # Firebase App ID
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 updates push JavaScript bundle changes without going through App Store review. Only JS/asset changes — native code changes still require a full build.
# Install EAS CLI
npm install -g eas-cli
# Configure
eas update:configure
# Push OTA update to production branch
eas update --branch production --message "Fix checkout bug"
# Staged rollout: 20% of users first
eas update --branch production --rollout-percentage 20
// eas.json — build and update profiles
{
"cli": { "version": ">= 5.0.0" },
"build": {
"production": {
"channel": "production",
"android": { "buildType": "app-bundle" },
"ios": { "simulator": false }
},
"preview": {
"channel": "preview",
"distribution": "internal"
}
},
"submit": {
"production": {
"ios": { "appleId": "dev@myorg.com", "ascAppId": "1234567890" },
"android": { "serviceAccountKeyPath": "./service-account.json", "track": "internal" }
}
}
}
// App.tsx — check for updates on launch
import codePush from 'react-native-code-push';
const codePushOptions = {
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
installMode: codePush.InstallMode.ON_NEXT_RESTART,
minimumBackgroundDuration: 60, // Only install if app was in background > 1 min
};
export default codePush(codePushOptions)(App);
# .github/workflows/mobile-ci.yml
name: Mobile CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# ──── Android ────────────────────────────────────────────────────────────────
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
- name: Run Paparazzi screenshot tests
run: ./gradlew verifyPaparazziRelease
- name: Build release AAB
if: github.ref == 'refs/heads/main'
run: ./gradlew bundleRelease
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# ──── iOS (requires macOS runner) ──────────────────────────────────────────
ios-test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: pods-${{ hashFiles('Podfile.lock') }}
- name: Install dependencies
run: cd ios && pod install
- name: Run tests
run: bundle exec fastlane ios test
- name: Upload to TestFlight
if: github.ref == 'refs/heads/main'
run: bundle exec fastlane ios beta
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
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 }}
Always derive build numbers from git — never manually edit them:
# Build number = total git commit count (monotonically increasing)
BUILD_NUMBER=$(git rev-list HEAD --count)
# iOS (using agvtool or fastlane)
agvtool new-version -all $BUILD_NUMBER
# or in Fastfile:
increment_build_number(build_number: number_of_commits)
# Android: pass to Gradle
./gradlew bundleRelease -PversionCode=$BUILD_NUMBER
// app/build.gradle.kts
val buildNumber = System.getenv("BUILD_NUMBER")?.toIntOrNull()
?: ("git rev-list HEAD --count".runCommand()?.trim()?.toIntOrNull() ?: 1)
android {
defaultConfig {
versionCode = buildNumber
versionName = "2.1.0" // update manually on significant releases
}
}
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
flutter-patterns — Flutter CI/CD with Fastlane + EASci-cd-patterns — General GitHub Actions CI patternsdeployment-patterns — Release strategies (canary, blue-green)android-patterns — Android build tooling (Gradle, Hilt, Room)mobile-release — Mobile release workflow command