Mobile CI/CD is harder than web CI/CD. There is no getting around it. Web applications deploy to servers you control. Mobile applications deploy to app stores you do not control, through review processes that take hours or days, with code signing requirements that will break your pipeline if a single certificate or provisioning profile is misconfigured. The build environments are complex -- Xcode versions, Gradle configurations, Android SDK levels, and CocoaPods or Swift Package Manager dependencies all have to align perfectly for a successful build.
This is exactly why automation matters so much for mobile. A manual release process for a mobile app typically involves a developer running a build on their local machine, manually managing certificates, uploading to TestFlight or the Play Console through a web interface, and updating screenshots and metadata by hand. This process takes hours, is error-prone, and creates a bottleneck where only one or two people on the team know how to ship a release.
Fastlane eliminates this bottleneck. It is an open-source platform that automates building, testing, and releasing iOS and Android apps. Originally created by Felix Krause and now maintained by Google, Fastlane has become the de facto standard for mobile deployment automation, used by companies from startups to Fortune 500 enterprises.
Fastlane Setup and Core Concepts
Fastlane is a Ruby-based tool that organizes automation into "lanes" -- sequences of "actions" that perform specific tasks. You define lanes in a Fastfile, and each lane can be invoked from the command line or from a CI system. Actions range from building your app (gym for iOS, gradle for Android) to uploading to stores (deliver for App Store, supply for Play Store) to managing code signing (match).
Install Fastlane and initialize it in your project:
# Install via Bundler (recommended for CI consistency)
gem install bundler
bundle init
# Add Fastlane to your Gemfile
echo 'gem "fastlane"' >> Gemfile
bundle install
# Initialize Fastlane in your iOS project
cd ios
bundle exec fastlane init
# Initialize Fastlane in your Android project
cd android
bundle exec fastlane init
Fastlane's init process creates a fastlane/ directory containing two key files: Appfile (which stores your app identifier and Apple ID or Google Play credentials) and Fastfile (which contains your lane definitions).
Here is a basic Appfile for iOS:
# fastlane/Appfile
app_identifier("com.example.myapp")
apple_id("developer@example.com")
team_id("ABC123DEF4")
itc_team_id("1234567890")
And for Android:
# fastlane/Appfile
json_key_file("path/to/google-play-key.json")
package_name("com.example.myapp")
Automating iOS Builds and TestFlight Uploads
The iOS build and deployment pipeline involves building the app, signing it with the correct certificate and provisioning profile, and uploading it to App Store Connect. Fastlane's gym action handles the build, and pilot (also aliased as upload_to_testflight) handles the TestFlight upload.
Here is a production-ready iOS Fastfile:
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run all tests"
lane :test do
scan(
scheme: "MyApp",
devices: ["iPhone 15"],
code_coverage: true,
output_directory: "./test_output",
result_bundle: true
)
end
desc "Build and upload to TestFlight"
lane :beta do
# Ensure we are on a clean git state
ensure_git_status_clean
# Increment the build number
increment_build_number(
build_number: ENV["CI_BUILD_NUMBER"] || latest_testflight_build_number + 1
)
# Sync code signing certificates
match(type: "appstore", readonly: is_ci)
# Build the app
gym(
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
output_directory: "./build",
output_name: "MyApp.ipa",
xcargs: "-allowProvisioningUpdates",
clean: true
)
# Upload to TestFlight
upload_to_testflight(
skip_waiting_for_build_processing: true,
distribute_external: false,
changelog: changelog_from_git_commits(
between: [last_git_tag, "HEAD"],
pretty: "- %s"
)
)
# Post to Slack
slack(
message: "New iOS beta build uploaded to TestFlight!",
success: true,
default_payloads: [:git_branch, :git_author, :last_git_commit]
)
end
desc "Deploy to App Store"
lane :release do
# Run tests first
test
# Sync certificates
match(type: "appstore", readonly: true)
# Build
gym(
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
clean: true
)
# Upload to App Store with metadata and screenshots
deliver(
submit_for_review: true,
automatic_release: false,
force: true,
skip_metadata: false,
skip_screenshots: false,
submission_information: {
add_id_info_uses_idfa: false,
},
precheck_include_in_app_purchases: false
)
end
end
The scan action runs your XCTest suite and produces structured output including code coverage reports. The gym action wraps xcodebuild with sensible defaults and handles the complexities of archive creation and IPA export. The deliver action manages the full App Store submission including metadata, screenshots, and review submission.
Code Signing Management with Match
Code signing is the single biggest source of CI/CD pain for iOS teams. Provisioning profiles expire, certificates get revoked, and new team members cannot build because they do not have the right signing identity. Fastlane's match action solves this by storing encrypted certificates and profiles in a private Git repository or cloud storage (Google Cloud Storage or Amazon S3).
Set up match:
bundle exec fastlane match init
This creates a Matchfile in your fastlane/ directory:
# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.example.myapp"])
username("developer@example.com")
team_id("ABC123DEF4")
Generate and store your certificates:
# Generate development certificates and profiles
bundle exec fastlane match development
# Generate App Store distribution certificates and profiles
bundle exec fastlane match appstore
Match encrypts all certificates and profiles with a passphrase before storing them. On CI, you provide this passphrase as an environment variable (MATCH_PASSWORD). The readonly: is_ci flag in the Fastfile ensures that CI environments never accidentally create new certificates -- they only read existing ones.
For teams that cannot use a Git repository for certificate storage, match also supports S3 and Google Cloud Storage backends:
# Matchfile with S3 storage
storage_mode("s3")
s3_bucket("my-app-certificates")
s3_region("us-east-1")
s3_access_key(ENV["AWS_ACCESS_KEY_ID"])
s3_secret_access_key(ENV["AWS_SECRET_ACCESS_KEY"])
Automating Android Builds and Play Store Deployment
Android builds are generally simpler than iOS from a signing perspective, but Fastlane still provides significant value in automation and consistency.
Here is a production Android Fastfile:
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Run all tests"
lane :test do
gradle(
task: "test",
build_type: "Debug"
)
end
desc "Build and deploy to internal testing track"
lane :beta do
# Increment version code
android_set_version_code(
version_code: ENV["CI_BUILD_NUMBER"] || Time.now.to_i
)
# Build the release AAB (Android App Bundle)
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 internal testing track
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
)
slack(
message: "New Android beta build uploaded to Play Store (internal track)!",
success: true,
default_payloads: [:git_branch, :git_author, :last_git_commit]
)
end
desc "Promote internal build to production"
lane :release do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
skip_upload_changelogs: false
)
end
end
Note the use of Android App Bundle (AAB) format instead of APK. Google Play requires AAB for new apps, and it enables dynamic delivery features like on-demand feature modules and optimized APK generation for each device configuration.
For Android signing, store your keystore file and credentials as CI secrets. Never commit the keystore to your source repository. On CI, decode the base64-encoded keystore from an environment variable:
# In your CI setup step
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > app/keystore.jks
Managing App Metadata and Screenshots
Fastlane's deliver (iOS) and supply (Android) actions can manage your entire store listing -- app descriptions, keywords, screenshots, and promotional graphics -- from version-controlled text files and image directories.
Set up the metadata directory structure for iOS:
bundle exec fastlane deliver init
This creates a directory structure like:
fastlane/metadata/en-US/
name.txt
subtitle.txt
description.txt
keywords.txt
release_notes.txt
privacy_url.txt
support_url.txt
marketing_url.txt
fastlane/screenshots/en-US/
iPhone 15 Pro Max-1.png
iPhone 15 Pro Max-2.png
iPad Pro 13-1.png
Fastlane's snapshot action can automate screenshot capture using Xcode UI tests, and frameit can add device frames to your screenshots. For Android, screengrab provides equivalent screenshot automation.
This metadata-as-code approach means your store listing goes through the same review process as your code -- pull requests, reviews, and version history. No more wondering who changed the app description or when.
Integrating with GitHub Actions
Running Fastlane on GitHub Actions requires a macOS runner for iOS builds (since Xcode only runs on macOS) and can use either macOS or Linux for Android builds. Here is a complete GitHub Actions workflow that builds and deploys both platforms:
# .github/workflows/mobile-deploy.yml
name: Mobile Deploy
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
platform:
description: 'Platform to deploy'
required: true
type: choice
options:
- ios
- android
- both
jobs:
ios:
if: >
github.event_name == 'push' ||
github.event.inputs.platform == 'ios' ||
github.event.inputs.platform == 'both'
runs-on: macos-14
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: ios
- name: Install CocoaPods
run: |
cd ios
bundle exec pod install
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: Run tests
run: |
cd ios
bundle exec fastlane test
- name: Deploy to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
CI_BUILD_NUMBER: ${{ github.run_number }}
run: |
cd ios
bundle exec fastlane beta
android:
if: >
github.event_name == 'push' ||
github.event.inputs.platform == 'android' ||
github.event.inputs.platform == 'both'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: android
- name: Decode keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/keystore.jks
- name: Run tests
run: |
cd android
bundle exec fastlane test
- name: Deploy to Play Store
env:
SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_PLAY_KEY_JSON }}
KEYSTORE_PATH: ${{ github.workspace }}/android/app/keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
CI_BUILD_NUMBER: ${{ github.run_number }}
run: |
cd android
bundle exec fastlane beta
Several important details in this workflow: the iOS job runs on macos-14 which provides Apple Silicon runners with significantly faster build times. The bundler-cache: true flag caches Ruby gems between runs. The Android keystore is decoded from a base64-encoded secret on each run and is never stored in the repository. The App Store Connect API key replaces the older Apple ID-based authentication, which required 2FA and was fragile on CI.
Running Tests in CI Before Deployment
Never ship a build that has not passed your test suite. Fastlane integrates testing into your lanes, but your CI workflow should also enforce test passage as a gate before deployment.
For iOS, scan runs your XCTest and XCUITest suites and produces JUnit-compatible output that GitHub Actions can display:
lane :test do
scan(
scheme: "MyApp",
devices: ["iPhone 15"],
result_bundle: true,
output_types: "junit",
output_directory: "./test_output"
)
end
For Android, run both unit tests and instrumented tests:
lane :test do
gradle(task: "testDebugUnitTest")
gradle(task: "lintDebug")
end
Publish test results as GitHub Actions artifacts so developers can diagnose failures without SSHing into a runner:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-ios
path: ios/test_output/
retention-days: 14
Build caching is critical for mobile CI performance. iOS builds can take 10-30 minutes without caching. Use ccache for C/C++ compilation caching and ensure CocoaPods or SPM dependencies are cached between runs. For Android, Gradle's build cache and the setup-java action's built-in Gradle caching significantly reduce build times.
Shipping Mobile Apps with Confidence
A well-automated mobile CI/CD pipeline transforms releases from stressful, error-prone events into routine operations. When any team member can trigger a release by pushing a tag, and the pipeline handles code signing, building, testing, and uploading without human intervention, your team ships faster and with fewer mistakes.
At Maranatha Technologies, we build mobile CI/CD pipelines that take the pain out of app store deployment. From Fastlane configuration and code signing management to GitHub Actions workflows and release automation, we help teams establish the infrastructure that makes frequent, reliable mobile releases possible. If you are spending too much time on manual builds and deployments, explore our mobile app development services or contact us to discuss your automation needs.