CI/CD Pipelines for Mobile: From Code to App Store
Build robust continuous integration and deployment pipelines for mobile applications — from automated testing to store submission.
The Mobile CI/CD Challenge
Mobile CI/CD is fundamentally different from web deployment. You can't just push to production — you need to:
- Build platform-specific binaries
- Sign with certificates and provisioning profiles
- Run tests on real devices and simulators
- Navigate app store review processes
- Manage multiple environments and configurations
This guide covers the patterns that scale from solo developer to enterprise team.
Pipeline Architecture
The Four Stages
Branching Strategy
| Branch | Trigger | Actions |
|---|---|---|
| feature/* | Push | Build, Unit Tests, Lint |
| develop | Merge | Build, All Tests, Deploy to Dev |
| release/* | Create | Build, All Tests, Deploy to Staging |
| main | Merge | Build, All Tests, Deploy to Production |
iOS Pipeline
Fastlane Configuration
# Fastfile
default_platform(:ios)
platform :ios do
before_all do
setup_ci if ENV['CI']
end
desc "Run unit tests"
lane :test do
run_tests(
scheme: "MyApp",
devices: ["iPhone 15 Pro"],
code_coverage: true,
output_directory: "./test_results"
)
end
desc "Build and upload to TestFlight"
lane :beta do
sync_code_signing(type: "appstore")
increment_build_number(
build_number: ENV['BUILD_NUMBER'] || latest_testflight_build_number + 1
)
build_app(
scheme: "MyApp",
export_method: "app-store",
output_directory: "./build"
)
upload_to_testflight(
skip_waiting_for_build_processing: true
)
end
desc "Deploy to App Store"
lane :release do
sync_code_signing(type: "appstore")
build_app(
scheme: "MyApp-Release",
export_method: "app-store"
)
upload_to_app_store(
submit_for_review: false,
automatic_release: false,
precheck_include_in_app_purchases: false
)
end
end
Code Signing
Use match for team-based code signing:
# Matchfile
git_url("git@github.com:company/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.company.app", "com.company.app.widget"])
username("ci@company.com")
GitHub Actions Workflow
# .github/workflows/ios.yml
name: iOS CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.2.app
- name: Install dependencies
run: bundle install
- name: Run tests
run: bundle exec fastlane test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./test_results/coverage.lcov
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: bundle install
- name: Setup certificates
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
run: bundle exec fastlane match appstore --readonly
- name: Deploy to TestFlight
env:
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
run: bundle exec fastlane beta
Android Pipeline
Gradle Configuration
// build.gradle
android {
buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
}
staging {
initWith debug
applicationIdSuffix ".staging"
versionNameSuffix "-staging"
signingConfig signingConfigs.staging
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
flavorDimensions "brand"
productFlavors {
brandA {
dimension "brand"
applicationId "com.company.branda"
}
brandB {
dimension "brand"
applicationId "com.company.brandb"
}
}
}
Fastlane for Android
# Fastfile
default_platform(:android)
platform :android do
desc "Run unit tests"
lane :test do
gradle(task: "test")
end
desc "Build and upload to Play Store internal track"
lane :internal do
gradle(
task: "bundle",
build_type: "Release",
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
desc "Promote internal to production"
lane :promote_to_production do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
Testing in CI
Test Pyramid
Test Layer Guidelines:
- E2E Tests (10%) — Real devices, critical paths only
- Integration Tests (20%) — API contracts, database operations
- Unit Tests (70%) — Business logic, fast and numerous
Device Testing
Use cloud device farms for real device testing:
# Firebase Test Lab
- name: Run instrumented tests
run: |
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel6,version=33 \
--device model=Pixel4,version=30
Visual Regression Testing
Catch UI regressions before they ship:
// Snapshot testing
func testLoginScreen() {
let vc = LoginViewController()
assertSnapshot(matching: vc, as: .image(on: .iPhone13Pro))
}
Environment Management
Configuration per Environment
# config/environments.yml
development:
api_base_url: "https://dev-api.example.com"
analytics_enabled: false
debug_logging: true
staging:
api_base_url: "https://staging-api.example.com"
analytics_enabled: true
debug_logging: true
production:
api_base_url: "https://api.example.com"
analytics_enabled: true
debug_logging: false
Secrets Management
Never commit secrets. Use CI/CD secret management:
# GitHub Actions
env:
API_KEY: ${{ secrets.API_KEY }}
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
# GitLab CI
variables:
API_KEY: $API_KEY # From CI/CD settings
Release Automation
Versioning Strategy
Semantic versioning with build numbers:
Version: MAJOR.MINOR.PATCH (1.2.3)
Build: Incrementing integer (456)
Full: 1.2.3 (456)
Automate version bumping:
# Fastlane
lane :bump_version do |options|
type = options[:type] || "patch"
increment_version_number(
bump_type: type
)
commit_version_bump(
message: "Bump version: #{type}"
)
add_git_tag
push_git_tags
end
Changelog Generation
Generate changelogs from commits:
# .github/workflows/release.yml
- name: Generate changelog
uses: TriPSs/conventional-changelog-action@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
output-file: "CHANGELOG.md"
Phased Rollouts
Reduce risk with gradual releases:
# iOS phased release
upload_to_app_store(
phased_release: true # 7-day rollout
)
# Android staged rollout
upload_to_play_store(
track: "production",
rollout: "0.1" # 10% of users
)
Pipeline Optimization
Build Caching
Cache dependencies and derived data:
# GitHub Actions
- name: Cache CocoaPods
uses: actions/cache@v3
with:
path: Pods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
- name: Cache Gradle
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
Parallel Execution
Run independent jobs concurrently:
jobs:
test-ios:
runs-on: macos-14
# iOS tests...
test-android:
runs-on: ubuntu-latest
# Android tests...
lint:
runs-on: ubuntu-latest
# Lint checks...
deploy:
needs: [test-ios, test-android, lint]
# Only after all pass...
Build Time Monitoring
Track and improve build times:
# Fastlane build time tracking
before_all do
@start_time = Time.now
end
after_all do
duration = Time.now - @start_time
# Send to metrics service
post_build_metrics(
duration: duration,
lane: lane_context[SharedValues::LANE_NAME]
)
end
Monitoring & Alerting
Build Status Dashboard
Visibility into pipeline health:
- Build success rate
- Average build duration
- Test flakiness
- Deployment frequency
Failure Notifications
# Slack notification on failure
- name: Notify Slack on failure
if: failure()
uses: 8398a7/action-slack@v3
with:
status: failure
fields: repo,message,commit,author
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Checklist
CI Pipeline
- Automated builds on every commit
- Unit tests with coverage thresholds
- Linting and static analysis
- Dependency vulnerability scanning
- Build caching configured
CD Pipeline
- Automated deployment to test environments
- Code signing automated
- Environment configuration managed
- Secrets properly secured
- Rollback procedures documented
Release Process
- Versioning automated
- Changelog generation
- Phased rollouts configured
- Store metadata automation
- Post-release monitoring
Conclusion
A well-designed mobile CI/CD pipeline is the foundation of shipping quality software quickly. The investment in automation pays dividends in:
- Developer velocity — Focus on features, not deployments
- Quality — Catch issues before users do
- Confidence — Know exactly what's in each release
- Recovery — Roll back quickly when needed
Start simple, automate incrementally, and continuously improve.
Pipeline patterns refined through years of enterprise mobile development with dozens of releases per week.
