Continuous Integration and Continuous Deployment (CI/CD) automates building, testing, and deploying mobile apps. This guide covers setting up CI/CD pipelines for iOS, Android, React Native, and Flutter applications.
Why CI/CD for Mobile?
- Faster releases - Automate repetitive tasks
- Fewer bugs - Automated testing catches issues early
- Consistent builds - Same process every time
- Team efficiency - Developers focus on code, not deployment
CI/CD Pipeline Overview
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Code │───▶│ Build │───▶│ Test │───▶│ Deploy │───▶│ Release │
│ Push │ │ │ │ │ │ (Beta) │ │ (Store) │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Trigger Compile Unit Tests TestFlight App Store
Pipeline & Sign UI Tests Play Console Play Store
Popular CI/CD Platforms
| Platform | Best For | iOS | Android | Price |
|---|---|---|---|---|
| GitHub Actions | GitHub users | Yes | Yes | Free tier |
| Bitrise | Mobile-first | Yes | Yes | Free tier |
| Codemagic | Flutter | Yes | Yes | Free tier |
| CircleCI | Large teams | Yes | Yes | Free tier |
| Fastlane | Local/any CI | Yes | Yes | Free |
GitHub Actions Setup
React Native CI/CD
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run TypeScript check
run: npm run typecheck
- name: Run unit tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
build-android:
needs: lint-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install dependencies
run: npm ci
- name: Build Android Release
working-directory: android
run: ./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: android/app/build/outputs/apk/release/app-release.apk
build-ios:
needs: lint-and-test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install CocoaPods
working-directory: ios
run: pod install
- name: Build iOS
working-directory: ios
run: |
xcodebuild -workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 15' \
build
Android Deployment to Play Store
# .github/workflows/android-release.yml
name: Android Release
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install dependencies
run: npm ci
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
- name: Build Release Bundle
working-directory: android
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease
- name: Upload to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
packageName: com.myapp
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
track: internal
status: completed
iOS Deployment to TestFlight
# .github/workflows/ios-release.yml
name: iOS Release
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install CocoaPods
working-directory: ios
run: pod install
- name: Install Apple Certificate
uses: apple-actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_PASSWORD }}
- name: Install Provisioning Profile
uses: apple-actions/download-provisioning-profiles@v1
with:
bundle-id: com.myapp
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
- name: Build and Archive
working-directory: ios
run: |
xcodebuild -workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
archive
- name: Export IPA
working-directory: ios
run: |
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build \
-exportOptionsPlist ExportOptions.plist
- name: Upload to TestFlight
uses: apple-actions/upload-testflight-build@v1
with:
app-path: ios/build/MyApp.ipa
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
Flutter CI/CD
# .github/workflows/flutter-ci.yml
name: Flutter CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'
channel: 'stable'
cache: true
- name: Install dependencies
run: flutter pub get
- name: Analyze code
run: flutter analyze
- name: Run tests
run: flutter test --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
build-android:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'
channel: 'stable'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Build APK
run: flutter build apk --release
- name: Build App Bundle
run: flutter build appbundle --release
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: build/app/outputs/flutter-apk/app-release.apk
build-ios:
needs: test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Build iOS (no signing)
run: flutter build ios --release --no-codesign
Fastlane Configuration
Fastlane automates iOS and Android deployment locally or in CI.
iOS Fastfile
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run tests"
lane :test do
run_tests(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
devices: ["iPhone 15"]
)
end
desc "Build and upload to TestFlight"
lane :beta do
# Increment build number
increment_build_number(
build_number: ENV["BUILD_NUMBER"] || latest_testflight_build_number + 1
)
# Build app
build_app(
workspace: "MyApp.xcworkspace",
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
# Ensure we're on main branch
ensure_git_branch(branch: "main")
# Build app
build_app(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
export_method: "app-store"
)
# Upload to App Store
upload_to_app_store(
submit_for_review: true,
automatic_release: true,
force: true
)
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 and upload to Play Store Internal"
lane :beta do
# Increment version code
increment_version_code(
gradle_file_path: "app/build.gradle"
)
# Build release bundle
gradle(
task: "bundle",
build_type: "Release"
)
# Upload to Play Store
upload_to_play_store(
track: "internal",
aab: "app/build/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Promote Internal to Production"
lane :release do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
skip_upload_aab: true,
skip_upload_metadata: true
)
end
end
Environment Management
Using Environment Variables
# GitHub Actions secrets
env:
API_URL: ${{ secrets.API_URL }}
API_KEY: ${{ secrets.API_KEY }}
# Create .env file during build
- name: Create .env file
run: |
echo "API_URL=${{ secrets.API_URL }}" >> .env
echo "API_KEY=${{ secrets.API_KEY }}" >> .env
React Native Environment Config
// react-native-config
// .env.production
API_URL=https://api.production.com
API_KEY=prod_key_xxx
// .env.staging
API_URL=https://api.staging.com
API_KEY=staging_key_xxx
// Usage
import Config from 'react-native-config';
const apiUrl = Config.API_URL;
Flutter Environment Config
// Using --dart-define
// flutter build apk --dart-define=API_URL=https://api.production.com
class Environment {
static const String apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://api.dev.com',
);
}
Code Signing
iOS Code Signing with Match
# Matchfile
git_url("https://github.com/company/certificates")
storage_mode("git")
type("appstore") # or "development", "adhoc"
app_identifier(["com.myapp"])
# In Fastfile
lane :setup_signing do
match(
type: "appstore",
readonly: is_ci
)
end
Android Signing
// android/app/build.gradle
android {
signingConfigs {
release {
storeFile file(System.getenv("KEYSTORE_FILE") ?: "release.keystore")
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
Automated Version Management
Semantic Versioning
# .github/workflows/version.yml
name: Version Bump
on:
push:
branches: [main]
jobs:
version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Bump version and push tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: patch
- name: Create Release
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.tag_version.outputs.new_tag }}
name: Release ${{ steps.tag_version.outputs.new_tag }}
body: ${{ steps.tag_version.outputs.changelog }}
Auto-increment Build Number
# iOS - Fastlane
increment_build_number(
build_number: ENV["GITHUB_RUN_NUMBER"]
)
# Android - Gradle
def getBuildNumber() {
return System.getenv("GITHUB_RUN_NUMBER") ?: "1"
}
android {
defaultConfig {
versionCode getBuildNumber().toInteger()
}
}
Best Practices
1. Cache Dependencies
# Node modules
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
# Gradle
- uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
# CocoaPods
- uses: actions/cache@v3
with:
path: ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
2. Parallel Jobs
jobs:
lint:
runs-on: ubuntu-latest
# ...
test:
runs-on: ubuntu-latest
# ...
build-android:
needs: [lint, test] # Run after lint and test
runs-on: ubuntu-latest
# ...
build-ios:
needs: [lint, test] # Runs in parallel with build-android
runs-on: macos-latest
# ...
3. Fail Fast
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true # Stop all jobs if one fails
matrix:
node: [18, 20]
4. Branch Protection
Configure branch protection rules:
- Require status checks before merging
- Require branches to be up to date
- Require code review approval
Conclusion
CI/CD is essential for modern mobile development. Start with basic automated testing, then add automated deployment. Use Fastlane for complex workflows and caching for faster builds.
Need help setting up CI/CD? Contact Hevcode for professional mobile app development with automated deployment pipelines.