Manual app releases are slow, error-prone, and don't scale. Teams with CI/CD ship 10x faster with 90% fewer errors. Automated pipelines turn hours of manual work into minutes of automated reliability. This guide shows you how to build bulletproof CI/CD pipelines for iOS and Android that test, build, and deploy automatically.
Why CI/CD Matters
Benefits of Automation
- Speed: Deploy in minutes, not hours
- Reliability: Eliminate human error
- Consistency: Same process every time
- Confidence: Automated tests catch issues
- Frequency: Ship multiple times per day
- Documentation: Pipeline is living documentation
- Feedback: Fast feedback on every commit
CI/CD Pipeline Overview
Typical mobile CI/CD pipeline:
1. Code Push
↓
2. Automated Tests
- Unit tests
- Integration tests
- UI tests
- Lint/Format checks
↓
3. Build App
- Debug build (PR)
- Release build (main)
- Increment version
↓
4. Beta Distribution
- TestFlight (iOS)
- Internal Testing (Android)
- Firebase App Distribution
↓
5. Production Release
- App Store submission
- Google Play release
- Staged rollout
↓
6. Monitor & Rollback
- Crash monitoring
- Analytics
- Rollback if issues
CI (Continuous Integration):
- Run on every commit/PR
- Test code changes
- Build app
- Fast feedback (<10 min)
CD (Continuous Deployment):
- Run on main/release branch
- Deploy to TestFlight/Beta
- Submit to stores
- Automated releases
Tool Selection
Platform Comparison
GitHub Actions:
✓ Free for public repos
✓ 2000 min/month free (private)
✓ Native GitHub integration
✓ Large marketplace
✓ Easy setup with YAML
✗ macOS minutes expensive ($0.08/min)
Best for: Most projects, GitHub users
Bitrise:
✓ Mobile-focused
✓ Pre-built workflows
✓ Great iOS/Android support
✓ Free tier available
✗ Expensive at scale
✗ Proprietary platform
Best for: Mobile-first teams
CircleCI:
✓ Fast builds
✓ macOS support
✓ Docker-based
✓ Free tier available
✗ Complex pricing
✗ Configuration complex
Best for: Large teams
GitLab CI:
✓ Self-hosted option
✓ Integrated with GitLab
✓ Unlimited minutes (self-hosted)
✗ No native macOS support
✗ Setup complexity
Best for: Self-hosted needs
Codemagic:
✓ Flutter-focused
✓ Easy setup
✓ Good free tier
✗ Expensive for high usage
Best for: Flutter apps
Fastlane:
✓ Free, open source
✓ Powerful automation
✓ Works with any CI
✗ Ruby-based (learning curve)
✗ Requires separate CI platform
Best for: Complex automation
Recommendation:
GitHub Actions + Fastlane
- Best combination
- Free for most use cases
- Flexible and powerful
Fastlane Setup
Installation
# Install Fastlane
brew install fastlane
# Or with RubyGems
sudo gem install fastlane -NV
# Initialize in your project
cd your-app-directory
fastlane init
# iOS: Choose option (1-4)
# Android: Confirm package name
This creates:
fastlane/
Fastfile # Main configuration
Appfile # App identifiers
Pluginfile # Plugins (if any)
iOS Fastlane Configuration
# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("[email protected]")
team_id("TEAMID123")
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
# Lane for running tests
lane :test do
run_tests(
scheme: "YourApp",
devices: ["iPhone 14", "iPhone 14 Pro"],
clean: true
)
end
# Lane for beta deployment
lane :beta do
# Increment build number
increment_build_number(xcodeproj: "YourApp.xcodeproj")
# Build app
build_app(
scheme: "YourApp",
export_method: "app-store",
export_options: {
provisioningProfiles: {
"com.yourcompany.yourapp" => "YourApp AppStore"
}
}
)
# Upload to TestFlight
upload_to_testflight(
skip_waiting_for_build_processing: true,
notify_external_testers: false
)
# Send Slack notification (optional)
slack(
message: "New beta build uploaded to TestFlight!",
success: true
)
end
# Lane for App Store release
lane :release do
# Capture screenshots
capture_screenshots
# Increment build number
increment_build_number(xcodeproj: "YourApp.xcodeproj")
# Build app
build_app(
scheme: "YourApp",
export_method: "app-store"
)
# Upload to App Store
upload_to_app_store(
submit_for_review: false, # Manual review submission
automatic_release: false,
skip_metadata: false,
skip_screenshots: false
)
# Commit version bump
commit_version_bump(
message: "Version bump",
xcodeproj: "YourApp.xcodeproj"
)
# Tag release
add_git_tag(
tag: "ios-#{get_version_number}-#{get_build_number}"
)
push_to_git_remote
slack(
message: "New version submitted to App Store!",
success: true
)
end
# Lane for screenshots
lane :screenshots do
capture_screenshots(
scheme: "YourAppUITests",
devices: [
"iPhone 14 Pro Max",
"iPhone 14 Pro",
"iPhone 14",
"iPhone SE (3rd generation)",
"iPad Pro (12.9-inch) (6th generation)"
],
languages: ["en-US"],
output_directory: "./fastlane/screenshots"
)
end
end
Android Fastlane Configuration
# fastlane/Appfile
json_key_file("path/to/your/google-play-key.json")
package_name("com.yourcompany.yourapp")
# fastlane/Fastfile
default_platform(:android)
platform :android do
# Lane for running tests
lane :test do
gradle(
task: "test",
build_type: "Debug"
)
end
# Lane for beta deployment
lane :beta do
# Increment version code
increment_version_code(
gradle_file_path: "app/build.gradle"
)
# Build release APK/AAB
gradle(
task: "bundle", # or "assemble" for APK
build_type: "Release"
)
# Upload to internal testing
upload_to_play_store(
track: "internal",
release_status: "draft",
skip_upload_apk: true,
aab: "app/build/outputs/bundle/release/app-release.aab"
)
slack(
message: "New beta uploaded to Play Console!",
success: true
)
end
# Lane for production release
lane :release do
# Increment version code
increment_version_code(
gradle_file_path: "app/build.gradle"
)
# Build release AAB
gradle(
task: "bundle",
build_type: "Release"
)
# Upload to production
upload_to_play_store(
track: "production",
release_status: "draft", # or "completed" for immediate release
skip_upload_apk: true,
aab: "app/build/outputs/bundle/release/app-release.aab",
rollout: "0.1" # 10% staged rollout
)
# Commit version bump
git_commit(
path: ["./app/build.gradle"],
message: "Version bump"
)
# Tag release
add_git_tag(
tag: "android-#{get_version_name}-#{get_version_code}"
)
push_to_git_remote
slack(
message: "New version released to Play Store!",
success: true
)
end
# Lane for screenshots
lane :screenshots do
gradle(
task: "assemble",
build_type: "Debug"
)
capture_android_screenshots(
locales: ["en-US"],
clear_previous_screenshots: true
)
end
end
GitHub Actions CI/CD
iOS Pipeline
# .github/workflows/ios.yml
name: iOS CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
DEVELOPER_DIR: /Applications/Xcode_15.1.app/Contents/Developer
jobs:
test:
name: Test
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Install dependencies
run: |
bundle install
pod install
- name: Run tests
run: bundle exec fastlane test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: fastlane/test_output/
beta:
name: Deploy to TestFlight
runs-on: macos-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for version bumps
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Install dependencies
run: |
bundle install
pod install
- name: Setup App Store Connect API Key
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APP_STORE_CONNECT_API_KEY }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_KEY_ID }}.p8
- name: Import certificates
env:
CERTIFICATE_P12: ${{ secrets.CERTIFICATE_P12 }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create keychain
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 -t 3600 -u build.keychain
# Import certificate
echo "$CERTIFICATE_P12" | base64 --decode > certificate.p12
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
rm certificate.p12
- name: Download provisioning profiles
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: bundle exec fastlane match appstore --readonly
- name: Build and deploy to TestFlight
env:
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_PATH: ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_KEY_ID }}.p8
run: bundle exec fastlane beta
- name: Upload build artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: ios-build
path: |
*.ipa
fastlane/builds/
# Setup secrets in GitHub repo settings:
# APP_STORE_CONNECT_API_KEY - API key content
# APP_STORE_CONNECT_KEY_ID - Key ID
# APP_STORE_CONNECT_ISSUER_ID - Issuer ID
# CERTIFICATE_P12 - Base64 encoded .p12 certificate
# CERTIFICATE_PASSWORD - Certificate password
# KEYCHAIN_PASSWORD - Temporary keychain password
# MATCH_PASSWORD - Fastlane match password
# MATCH_GIT_BASIC_AUTHORIZATION - Git credentials for match repo
# FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD - App-specific password
Android Pipeline
# .github/workflows/android.yml
name: Android CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew test --stacktrace
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
target: google_apis
arch: x86_64
script: ./gradlew connectedCheck
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
app/build/reports/
app/build/test-results/
beta:
name: Deploy to Play Store Beta
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > app/release.keystore
- name: Decode Google Play API key
env:
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
run: |
echo "$PLAY_STORE_JSON_KEY" > play-store-key.json
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and deploy to Play Store
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
bundle install
bundle exec fastlane beta
- name: Upload build artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: android-build
path: |
app/build/outputs/bundle/release/
app/build/outputs/apk/release/
# Setup secrets in GitHub repo settings:
# KEYSTORE_BASE64 - Base64 encoded release keystore
# KEYSTORE_PASSWORD - Keystore password
# KEY_ALIAS - Key alias
# KEY_PASSWORD - Key password
# PLAY_STORE_JSON_KEY - Google Play service account JSON
Best Practices
Security
Never commit:
✗ API keys
✗ Signing certificates
✗ Keystore files
✗ Provisioning profiles
✗ Service account JSONs
✗ Passwords
Use secrets:
✓ GitHub Secrets (encrypted)
✓ Environment variables
✓ Fastlane match (encrypted git)
✓ Encrypted artifacts
✓ Vault/secret managers
Rotate credentials:
- Every 90 days minimum
- After team member leaves
- If potentially exposed
- Store securely (1Password, etc.)
Performance
Speed up CI/CD:
Caching:
✓ Dependencies (gems, pods, gradle)
✓ Build artifacts
✓ Docker layers
Parallelization:
✓ Run tests in parallel
✓ Split iOS/Android jobs
✓ Matrix builds
Optimization:
✓ Only run relevant tests
✓ Incremental builds
✓ Skip unnecessary steps
✓ Use faster machines
Target times:
- PR checks: < 10 minutes
- Beta deployment: < 20 minutes
- Full release: < 30 minutes
Conclusion
CI/CD transforms mobile development from manual, error-prone releases to automated, reliable deployments. With GitHub Actions and Fastlane, you can build a professional pipeline that tests, builds, and ships your app automatically. The initial setup investment pays massive dividends in speed, reliability, and developer happiness. Ship faster, ship safer, ship more often.
Automated deployments ensure consistent quality, but users still need great support. Our support URL generator creates the professional support pages required for App Store and Google Play submissions, complementing your automated release pipeline with automated support infrastructure.