Manual deployments waste 4-8 hours per release. CI/CD automates building, testing, and deploying, reducing time to 15 minutes and eliminating human error. This guide covers complete setup.
What is CI/CD?
CI (Continuous Integration):
- Automatically build code on every commit
- Run tests automatically
- Catch issues early
- Ensure code quality
CD (Continuous Deployment/Delivery):
- Automatically deploy to TestFlight/Play Console
- Release to production automatically
- Zero-downtime deployments
- Rollback capability
Benefits:
- 80% faster deployments
- 90% fewer deployment errors
- More frequent releases
- Better code quality
CI/CD Pipeline Overview
Typical pipeline:
1. Code Push
↓
2. Trigger CI/CD (GitHub Actions, CircleCI, etc.)
↓
3. Install Dependencies
↓
4. Run Linter (code quality)
↓
5. Run Unit Tests
↓
6. Run Integration Tests
↓
7. Build App (Debug/Release)
↓
8. Run UI Tests (optional)
↓
9. Upload to TestFlight/Play Console (Beta)
↓
10. Deploy to Production (after approval)
Fastlane Setup
What is Fastlane?
Fastlane automates mobile app deployment tasks:
- Build and sign apps
- Screenshot generation
- Beta deployment
- App Store submission
- Code signing management
Installation
# Install Fastlane
sudo gem install fastlane
# Initialize in project
cd ios
fastlane init
cd ../android
fastlane init
iOS Fastfile
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run tests"
lane :test do
run_tests(
scheme: "MyApp",
devices: ["iPhone 14"]
)
end
desc "Build app"
lane :build do
gym(
scheme: "MyApp",
export_method: "app-store"
)
end
desc "Deploy to TestFlight"
lane :beta do
# Increment build number
increment_build_number(
xcodeproj: "MyApp.xcodeproj"
)
# Build
gym(
scheme: "MyApp",
export_method: "app-store"
)
# Upload to TestFlight
upload_to_testflight(
skip_waiting_for_build_processing: true
)
end
desc "Deploy to App Store"
lane :release do
# Increment version
increment_version_number(
bump_type: "patch"
)
# Build
gym(
scheme: "MyApp",
export_method: "app-store"
)
# Upload to App Store
upload_to_app_store(
submit_for_review: false,
automatic_release: false
)
end
end
Android Fastfile
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Run tests"
lane :test do
gradle(task: "test")
end
desc "Build app"
lane :build do
gradle(
task: "bundle",
build_type: "Release"
)
end
desc "Deploy to Play Console (Internal)"
lane :beta do
# Increment version code
increment_version_code(
gradle_file_path: "app/build.gradle"
)
# Build AAB
gradle(
task: "bundle",
build_type: "Release"
)
# Upload to Play Console
upload_to_play_store(
track: "internal",
aab: "app/build/outputs/bundle/release/app-release.aab"
)
end
desc "Deploy to Play Store"
lane :release do
# Build
gradle(
task: "bundle",
build_type: "Release"
)
# Upload to production
upload_to_play_store(
track: "production",
aab: "app/build/outputs/bundle/release/app-release.aab"
)
end
end
GitHub Actions CI/CD
iOS Workflow
# .github/workflows/ios.yml
name: iOS CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: |
cd ios
pod install
- name: Run tests
run: |
cd ios
fastlane test
deploy-beta:
needs: test
runs-on: macos-latest
if: github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v3
- name: Install Fastlane
run: sudo gem install fastlane
- name: Install dependencies
run: |
cd ios
pod install
- name: Setup signing
env:
CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }}
CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }}
PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }}
run: |
# Import certificate
echo $CERTIFICATES_P12 | base64 --decode > certificate.p12
security create-keychain -p "" build.keychain
security import certificate.p12 -k build.keychain -P $CERTIFICATES_PASSWORD -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
# Install provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo $PROVISIONING_PROFILE | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision
- name: Deploy to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
run: |
cd ios
fastlane beta
deploy-production:
needs: test
runs-on: macos-latest
if: github.ref == 'refs/heads/main'
steps:
# Similar to beta but uses `fastlane release`
- uses: actions/checkout@v3
- name: Deploy to App Store
run: |
cd ios
fastlane release
Android Workflow
# .github/workflows/android.yml
name: Android CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '17'
- name: Run tests
run: |
cd android
./gradlew test
- name: Run lint
run: |
cd android
./gradlew lint
deploy-beta:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '17'
- name: Setup signing
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
echo $KEYSTORE_BASE64 | base64 --decode > android/app/keystore.jks
echo "storeFile=keystore.jks" >> android/key.properties
echo "storePassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "keyAlias=$KEY_ALIAS" >> android/key.properties
echo "keyPassword=$KEY_PASSWORD" >> android/key.properties
- name: Install Fastlane
run: sudo gem install fastlane
- name: Deploy to Play Console
env:
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: |
echo $GOOGLE_PLAY_JSON_KEY | base64 --decode > android/google-play-key.json
cd android
fastlane beta
deploy-production:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
# Similar to beta but uses `fastlane release`
- uses: actions/checkout@v3
- name: Deploy to Play Store
run: |
cd android
fastlane release
Code Signing
iOS Code Signing
Options:
1. Match (Fastlane):
# Setup
fastlane match init
# Create certificates
fastlane match appstore
fastlane match development
# Use in Fastlane
match(type: "appstore")
2. Manual:
- Export certificates from Keychain
- Convert to base64
- Store in GitHub Secrets
- Import in CI
# Export
security export -t identities -f p12 -k ~/Library/Keychains/login.keychain-db -P "password" > cert.p12
base64 cert.p12 | pbcopy
# In CI
echo $CERT_BASE64 | base64 --decode > cert.p12
security import cert.p12 -k build.keychain -P $PASSWORD
Android Code Signing
# Create keystore
keytool -genkey -v -keystore my-release-key.keystore -alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000
# Convert to base64
base64 my-release-key.keystore | pbcopy
# Store in GitHub Secrets:
KEYSTORE_BASE64: [base64 string]
KEYSTORE_PASSWORD: [password]
KEY_ALIAS: [alias]
KEY_PASSWORD: [key password]
# In CI
echo $KEYSTORE_BASE64 | base64 --decode > app/keystore.jks
# build.gradle
signingConfigs {
release {
storeFile file('keystore.jks')
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")
}
}
Automated Testing
Unit Tests
iOS (XCTest):
- name: Run unit tests
run: xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 14'
Android (JUnit):
- name: Run unit tests
run: ./gradlew testDebugUnitTest
UI Tests
iOS (XCUITest):
- name: Run UI tests
run: xcodebuild test -scheme MyAppUITests -destination 'platform=iOS Simulator,name=iPhone 14'
Android (Espresso):
- name: Run UI tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: ./gradlew connectedAndroidTest
Code Coverage
iOS:
- name: Generate coverage
run: |
xcodebuild test -scheme MyApp -enableCodeCoverage YES
xcrun llvm-cov export -format="lcov" > coverage.lcov
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.lcov
Android:
- name: Generate coverage
run: ./gradlew jacocoTestReport
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./app/build/reports/jacoco/jacoco.xml
Environment Variables
Managing Secrets
GitHub Secrets (Settings → Secrets):
- APP_STORE_CONNECT_API_KEY_ID
- APP_STORE_CONNECT_API_ISSUER_ID
- APP_STORE_CONNECT_API_KEY_CONTENT
- CERTIFICATES_P12
- CERTIFICATES_PASSWORD
- PROVISIONING_PROFILE
- KEYSTORE_BASE64
- KEYSTORE_PASSWORD
- KEY_ALIAS
- KEY_PASSWORD
- GOOGLE_PLAY_JSON_KEY
Access in workflow:
env:
API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
Environment-Specific Builds
iOS (Schemes):
- Development
- Staging
- Production
Build specific:
gym(scheme: "MyApp-Production")
Android (Build Variants):
android {
flavorDimensions "environment"
productFlavors {
dev {
applicationIdSuffix ".dev"
}
staging {
applicationIdSuffix ".staging"
}
prod {}
}
}
Build specific:
./gradlew assembleProdRelease
Versioning Strategy
Semantic Versioning
Format: MAJOR.MINOR.PATCH
Example: 2.5.3
MAJOR: Breaking changes (2.0.0)
MINOR: New features (2.1.0)
PATCH: Bug fixes (2.0.1)
iOS:
- Version (CFBundleShortVersionString): 2.5.3
- Build number (CFBundleVersion): 145
Android:
- versionName: 2.5.3
- versionCode: 145
Auto-increment:
iOS: increment_build_number
Android: increment_version_code
Git Tagging
# Tag release
git tag -a v2.5.3 -m "Release version 2.5.3"
git push origin v2.5.3
# Auto-tag in CI
- name: Create tag
run: |
VERSION=$(cat version.txt)
git tag -a v$VERSION -m "Release $VERSION"
git push origin v$VERSION
Notifications
Slack Integration
- name: Notify Slack
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'iOS build deployed to TestFlight'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
if: always()
Email Notifications
GitHub Actions sends emails by default on failure.
Custom:
- name: Send email
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.EMAIL_USERNAME }}
password: ${{ secrets.EMAIL_PASSWORD }}
subject: Build ${{ job.status }}
body: Build completed with status ${{ job.status }}
Rollback Strategy
TestFlight Rollback
# Remove build from TestFlight
fastlane pilot remove -b [build_number]
# Or mark build as expired in App Store Connect
Play Console Rollback
# Halt release
- Go to Play Console
- Production → Releases
- Stop rollout or update percentage
# Via Fastlane
upload_to_play_store(
track: "production",
rollout: "0.1" # 10% rollout
)
Alternative CI/CD Platforms
CircleCI
# .circleci/config.yml
version: 2.1
jobs:
test:
macos:
xcode: "14.2.0"
steps:
- checkout
- run: cd ios && pod install
- run: cd ios && fastlane test
deploy:
macos:
xcode: "14.2.0"
steps:
- checkout
- run: cd ios && fastlane beta
workflows:
test-and-deploy:
jobs:
- test
- deploy:
requires:
- test
filters:
branches:
only: main
Bitrise
- Mobile-focused CI/CD
- Visual workflow builder
- Pre-built steps for mobile
- Easy setup for iOS/Android
Codemagic
- Flutter/React Native focused
- Simple configuration
- Generous free tier
- Fast builds
Best Practices
Pipeline Optimization
Speed improvements:
1. Cache dependencies:
- name: Cache CocoaPods
uses: actions/cache@v3
with:
path: ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
2. Parallel jobs:
jobs:
test-ios:
runs-on: macos-latest
test-android:
runs-on: ubuntu-latest
3. Fail fast:
strategy:
fail-fast: true
4. Skip unnecessary steps:
if: github.event_name == 'push'
Security
- Never commit secrets to git
- Use GitHub Secrets or environment variables
- Rotate API keys regularly
- Use least-privilege permissions
- Enable branch protection
Testing Strategy
Test pyramid:
- Unit tests (fast, many): 70%
- Integration tests (medium): 20%
- UI tests (slow, few): 10%
Run on every commit:
- Linting
- Unit tests
Run on develop branch:
- Integration tests
- Beta deployment
Run on main branch:
- Full test suite
- Production deployment
Monitoring
Build Metrics
- Build success rate
- Build duration
- Test pass rate
- Deployment frequency
- Time to production
Crash Monitoring
Integrate crash reporting:
- Firebase Crashlytics
- Sentry
- Bugsnag
Track deployment:
# Send release to Sentry
- name: Create Sentry release
run: |
sentry-cli releases new $VERSION
sentry-cli releases set-commits $VERSION --auto
sentry-cli releases finalize $VERSION
Conclusion
CI/CD transforms mobile app development by automating builds, testing, and deployments. Start with Fastlane for deployment automation, add GitHub Actions for CI, and gradually expand testing coverage. The time invested pays off in faster releases and fewer bugs.