Mobile DevOps: Automating App Builds and Deployments
Manual mobile app builds and deployments are slow, error-prone, and unsustainable. If your team is still building release candidates on a developer’s laptop and manually uploading to the App Store or Google Play, you are losing hours every release cycle and introducing unnecessary risk.
Mobile DevOps brings the same automation discipline that backend teams have enjoyed for years to the mobile development workflow. This guide covers how to set up automated builds, testing, and deployment for iOS and Android apps.
The Mobile CI/CD Pipeline
A mature mobile CI/CD pipeline handles five stages:
- Code quality: Linting, static analysis, and formatting checks
- Build: Compile the app for all target platforms
- Test: Run unit tests, UI tests, and integration tests
- Distribution: Deploy to internal testers (TestFlight, Firebase App Distribution)
- Release: Submit to the App Store and Google Play
Pipeline Tools
The tools we use and recommend:
- CI/CD platform: GitHub Actions, Bitrise, or CircleCI
- Build automation: Fastlane
- Code signing: Match (iOS) and Fastlane supply (Android)
- Testing: XCTest (iOS), Espresso (Android), Detox (React Native)
- Distribution: TestFlight, Firebase App Distribution
- Monitoring: Firebase Crashlytics, Sentry
Fastlane: The Foundat
ion
Fastlane is the industry standard for mobile build automation. It handles code signing, building, testing, and deployment through a Ruby-based DSL.
iOS Fastfile
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run unit tests"
lane :test do
scan(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
devices: ["iPhone 13"],
clean: true,
code_coverage: true
)
end
desc "Build and upload to TestFlight"
lane :beta do
setup_ci if ENV['CI']
match(
type: "appstore",
readonly: is_ci
)
increment_build_number(
build_number: ENV['BUILD_NUMBER'] || latest_testflight_build_number + 1
)
build_app(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
export_method: "app-store",
clean: true
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: changelog_from_git_commits(
commits_count: 10,
merge_commit_filtering: "exclude_merges"
)
)
slack(
message: "New iOS beta uploaded to TestFlight!",
channel: "#releases"
) if ENV['SLACK_URL']
end
desc "Submit to App Store"
lane :release do
build_app(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
export_method: "app-store"
)
upload_to_app_store(
submit_for_review: true,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false
)
end
end
Android Fastfile
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Run unit tests"
lane :test do
gradle(
task: "test",
build_type: "Debug"
)
end
desc "Build and upload to Firebase App Distribution"
lane :beta do
gradle(
task: "clean assembleRelease",
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'],
}
)
firebase_app_distribution(
app: ENV['FIREBASE_APP_ID'],
groups: "internal-testers",
release_notes: changelog_from_git_commits(
commits_count: 10,
merge_commit_filtering: "exclude_merges"
)
)
end
desc "Build and upload to Google Play (internal track)"
lane :release do
gradle(
task: "clean bundleRelease",
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],
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
GitHub Actions Workfl
ows
iOS Build and Test
# .github/workflows/ios.yml
name: iOS CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: macos-12
steps:
- uses: actions/checkout@v3
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_13.3.app
- name: Cache CocoaPods
uses: actions/cache@v3
with:
path: ios/Pods
key: pods-${{ hashFiles('ios/Podfile.lock') }}
- name: Install dependencies
run: cd ios && pod install
- name: Run tests
run: |
cd ios
bundle exec fastlane test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: ios/fastlane/test_output/
beta:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: macos-12
steps:
- uses: actions/checkout@v3
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_13.3.app
- name: Install dependencies
run: cd ios && pod install
- name: Deploy to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
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: ${{ secrets.ASC_API_KEY }}
BUILD_NUMBER: ${{ github.run_number }}
run: |
cd ios
bundle exec fastlane beta
Android Build and Test
# .github/workflows/android.yml
name: Android CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- name: Run tests
run: cd android && ./gradlew test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: android/app/build/reports/tests/
beta:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Build and deploy
env:
KEYSTORE_PATH: keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
run: |
cd android
bundle exec fastlane beta
iOS Code Signing with Ma
tch
Code signing is the most painful part of iOS CI/CD. Match solves this by storing certificates and provisioning profiles in a Git repository:
# ios/fastlane/Matchfile
git_url("https://github.com/your-org/certificates.git")
storage_mode("git")
type("appstore")
app_identifier("com.example.myapp")
username("[email protected]")
Initial setup (run once):
bundle exec fastlane match appstore
bundle exec fastlane match development
In CI, Match fetches certificates automatically. The MATCH_PASSWORD environment variable decrypts the repository.
Version Management
Automate version numbering to avoid conflicts and manual errors:
# Semantic versioning from Git tags
lane :bump_version do |options|
type = options[:type] || "patch"
case type
when "major"
increment_version_number(bump_type: "major")
when "minor"
increment_version_number(bump_type: "minor")
when "patch"
increment_version_number(bump_type: "patch")
end
version = get_version_number
commit_version_bump(message: "Bump version to #{version}")
add_git_tag(tag: "v#{version}")
push_to_git_remote
end
For build numbers, use the CI build number:
increment_build_number(
build_number: ENV['GITHUB_RUN_NUMBER'] || Time.now.strftime("%Y%m%d%H%M")
)
Environment Management
Separate Configurations
Maintain separate configurations for development, staging, and production:
# .env.development
API_BASE_URL=https://dev-api.example.com
ANALYTICS_ENABLED=false
LOG_LEVEL=debug
# .env.staging
API_BASE_URL=https://staging-api.example.com
ANALYTICS_ENABLED=true
LOG_LEVEL=info
# .env.production
API_BASE_URL=https://api.example.com
ANALYTICS_ENABLED=true
LOG_LEVEL=error
Secret Management
Never commit secrets to your repository. Use your CI platform’s secret management:
- GitHub Actions: Repository Secrets
- Bitrise: Secret Environment Variables
- CircleCI: Context and Environment Variables
Required secrets for a typical mobile CI/CD setup:
- App Store Connect API key
- Google Play service account JSON
- Code signing certificates and passwords
- Firebase configuration
- Slack webhook URL
Automated Testing in CI
Test Pyramid
Structure your test suite for CI efficiency:
Unit Tests (~70%) - Fast, run on every commit
Integration Tests (~20%) - Medium speed, run on PR merge
UI/E2E Tests (~10%) - Slow, run nightly or pre-release
Parallel Testing
Run tests in parallel to reduce pipeline duration:
# iOS parallel testing
- name: Run tests
run: |
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4
Monitoring and Notifications
Slack Notifications
# In your Fastfile
after_all do |lane|
slack(
message: "#{lane} completed successfully!",
success: true,
channel: "#mobile-releases",
payload: {
"Build Number" => get_build_number,
"Version" => get_version_number,
"Git Branch" => git_branch,
}
)
end
error do |lane, exception|
slack(
message: "#{lane} failed: #{exception.message}",
success: false,
channel: "#mobile-releases"
)
end
Practical Tips
-
Start with tests: Automate testing before automating deployment. A CI pipeline that only runs tests still saves significant time.
-
Cache aggressively: Cache CocoaPods, Gradle dependencies, and derived data. This can cut build times by 50 percent or more.
-
Use self-hosted runners for iOS: GitHub Actions macOS runners are expensive. A Mac Mini running as a self-hosted runner pays for itself in a few months.
-
Version your Fastfile: Treat your build automation code with the same care as application code. Review changes, write comments, and test in a branch before merging.
-
Monitor build times: Track how long each pipeline stage takes. When builds exceed 20 minutes, investigate and optimise.
Conclusion
Mobile DevOps transforms your release process from a stressful manual procedure into a reliable automated workflow. The upfront investment in setting up CI/CD pays dividends with every release: faster iteration, fewer mistakes, and the confidence to ship frequently.
Start with Fastlane for build automation, add GitHub Actions for CI/CD, and progressively automate more of your workflow. Within a few sprints, you will wonder how you ever shipped without it.
For help setting up mobile CI/CD for your team, contact eawesome. We build automated mobile delivery pipelines for Australian development teams.