Introduction
Mobile app deployment is notoriously painful. Between code signing certificates, provisioning profiles, version bumping, and store submissions, a single release can eat up hours of manual work. Fastlane eliminates this friction by automating the entire release pipeline.
In this post, I'll walk through a production-ready Fastlane setup that handles:
- iOS: Automatic code signing with Match, build number incrementing, and TestFlight deployment
- Android: Google Play Store internal track releases with automatic version code management
- CI/CD: GitHub Actions workflow for fully automated releases on push
Architecture Overview
flowchart TB
subgraph "Developer Workflow"
A[Push to master/fastlane branch] --> B[GitHub Actions Triggered]
end
subgraph "GitHub Actions Runner"
B --> C[Checkout Code]
C --> D[Setup Environment]
D --> D1[Java 17]
D --> D2[Node.js 20]
D --> D3[Ruby 3.4.5]
D --> D4[Yarn]
D1 & D2 & D3 & D4 --> E[Install Dependencies]
E --> E1[yarn install]
E --> E2[bundle install]
E --> E3[gem install fastlane]
E1 & E2 & E3 --> F[Decode Secrets]
F --> F1[Service Account JSON]
F --> F2[Keystore File]
end
subgraph "Fastlane Android Deploy"
F1 & F2 --> G[fetch_and_increment_build_number]
G --> G1[Query Play Console for latest version code]
G1 --> G2[Increment version code in build.gradle]
G2 --> H[gradle clean bundleRelease]
H --> I[supply - Upload to Internal Track]
end
subgraph "Fastlane iOS Beta"
J[Push to iOS branch] --> K[setup_ci]
K --> L[match - Fetch Certificates]
L --> L1[Clone git repo with certs]
L1 --> L2[Install provisioning profiles]
L2 --> M[get_version_number]
M --> N[latest_testflight_build_number]
N --> O[increment_build_number]
O --> P[build_app]
P --> Q[pilot - Upload to TestFlight]
end
subgraph "Outputs"
I --> R[Google Play Internal Testing]
Q --> S[App Store TestFlight]
H --> T[Artifacts: APK + AAB]
end
Android Fastlane Configuration
Appfile
The Appfile defines your app's identity and authentication:
json_key_file("playstore-sa.json") # Google Play service account
package_name("com.acme.mobileapp")Fastfile
The Android Fastfile has two key lanes:
default_platform(:android)
platform :android do
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
lane :deploy do
fetch_and_increment_build_number
gradle(task: "clean bundleRelease")
supply(
track: "internal",
aab: "./app/build/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
release_status: "completed",
)
end
desc "Fetches the latest build number from Play Console and increments by 1"
lane :fetch_and_increment_build_number do
app_identifier = CredentialsManager::AppfileConfig.try_fetch_value(:package_name)
codes = google_play_track_version_codes(
package_name: app_identifier,
json_key: "./playstore-sa.json",
track: "internal"
)
next_version_code = (Array(codes).map(&:to_i).max || 0) + 1
increment_version_code(
gradle_file_path: "app/build.gradle",
version_code: next_version_code
)
end
endKey Concepts
| Action | Purpose |
|---|---|
google_play_track_version_codes |
Queries Play Console API for existing version codes |
increment_version_code |
Modifies build.gradle with new version code |
gradle |
Executes Gradle tasks (clean, bundleRelease) |
supply |
Uploads AAB to Google Play Store |
iOS Fastlane Configuration
Appfile
app_identifier("com.acme.mobileapp")
apple_id("dev@acme.com")
itc_team_id("123456789") # App Store Connect Team ID
team_id("ABCD1234XY") # Developer Portal Team IDMatchfile
Match stores certificates in a private Git repo for team sharing:
git_url("git@github.com:acme-org/fastlane-certificates.git")
storage_mode("git")
git_full_name("Your Name")
git_user_email("dev@acme.com")
type("appstore")Fastfile
default_platform(:ios)
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do
setup_ci if ENV["CI"]
match(
app_identifier: "com.acme.mobileapp",
type: "appstore",
git_private_key: "./fastlane/deploy_key",
readonly: true
)
version = get_version_number(
xcodeproj: "MyApp.xcodeproj",
target: "MyApp"
)
latest = latest_testflight_build_number(
app_identifier: "com.acme.mobileapp",
version: version
) || 0
increment_build_number(
xcodeproj: "MyApp.xcodeproj",
build_number: latest + 1
)
build_app(workspace: "MyApp.xcworkspace", scheme: "MyApp")
pilot(
api_key_path: "fastlane/store.json",
skip_waiting_for_build_processing: true
)
end
endiOS Flow Breakdown
sequenceDiagram
participant Dev as Developer
participant GH as GitHub Actions
participant Match as Match (Git Repo)
participant ASC as App Store Connect
participant TF as TestFlight
Dev->>GH: Push to branch
GH->>GH: setup_ci (keychain setup)
GH->>Match: Fetch certificates & profiles
Match-->>GH: Install to keychain
GH->>ASC: Query latest TestFlight build number
ASC-->>GH: Return build number (e.g., 42)
GH->>GH: increment_build_number (43)
GH->>GH: build_app (xcodebuild)
GH->>TF: pilot (upload IPA)
TF-->>Dev: Build available for testing
GitHub Actions Workflow
The CI/CD pipeline ties everything together:
name: Android APK Build and Deploy
on:
push:
branches:
- master
- fastlane
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Enable Corepack (Yarn)
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Yarn install
working-directory: android
run: yarn install
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.5
- name: Install Fastlane
run: gem install fastlane
- name: Install Ruby dependencies
working-directory: android
run: |
gem install bundler -N
bundle install --path ../vendor/bundle
- name: Decode Service Account Key
uses: timheuer/base64-to-file@v1
id: service_account_json_file
with:
fileName: "playstore-sa.json"
FileDir: "./android"
encodedString: ${{ secrets.GPLAY_SERVICE_ACCOUNT_KEY_JSON }}
- name: Validate Service Account Key
run: |
cd android
fastlane run validate_play_store_json_key json_key:./playstore-sa.json
- name: Decode Keystore File
uses: timheuer/base64-to-file@v1
id: android_keystore
with:
fileName: "my-upload-key.keystore"
fileDir: "./android/app"
encodedString: ${{ secrets.KEYSTORE_FILE }}
- name: Build & Deploy to Internal Testing
working-directory: android
run: fastlane android deploy
- name: Collect artifacts
uses: actions/upload-artifact@v4
with:
name: android-build-artifacts
path: |
android/app/build/outputs/apk/release/app-release.apk
android/app/build/outputs/bundle/release/app-release.aabSecrets Management
Store these as GitHub repository secrets:
| Secret | Description |
|---|---|
GPLAY_SERVICE_ACCOUNT_KEY_JSON |
Base64-encoded Google Play service account JSON |
KEYSTORE_FILE |
Base64-encoded Android signing keystore |
MATCH_PASSWORD |
Password for Match certificate encryption (iOS) |
APP_STORE_CONNECT_API_KEY |
Base64-encoded App Store Connect API key (iOS) |
Encoding secrets for GitHub:
# Encode service account JSON
base64 -i playstore-sa.json | pbcopy
# Encode keystore
base64 -i release-key.keystore | pbcopyComplete Release Flow
flowchart LR
subgraph "Trigger"
A[git push master]
end
subgraph "Build"
B[Install deps]
C[Decode secrets]
D[Build AAB/IPA]
end
subgraph "Version"
E[Query store API]
F[Increment build #]
end
subgraph "Deploy"
G[Upload to store]
H[Internal/TestFlight]
end
subgraph "Artifacts"
I[Save APK/AAB]
end
A --> B --> C --> E --> F --> D --> G --> H
D --> I
Best Practices
- Use
readonly: truefor Match in CI - Prevents accidental certificate regeneration - Skip metadata/screenshot uploads - Speeds up releases when only updating the binary
- Validate credentials before build - Fail fast with
validate_play_store_json_key - Use internal/TestFlight tracks - Test before promoting to production
- Store artifacts - Always save build outputs for debugging
Troubleshooting
Common Issues
"Version code already exists"
- The
fetch_and_increment_build_numberlane should prevent this - Ensure you're querying the correct track (internal vs production)
"Code signing failed"
- Verify Match repo access with SSH key
- Check
setup_ciis called in CI environment
"Service account permission denied"
- Ensure service account has "Release Manager" role in Play Console
- Verify JSON key is correctly base64-decoded
Conclusion
Fastlane transforms mobile releases from a multi-hour manual process into a single git push. The combination of:
- Match for iOS certificate management
- Supply for Android Play Store uploads
- GitHub Actions for CI/CD orchestration
Creates a robust, repeatable deployment pipeline that scales with your team. No more "works on my machine" certificate issues or manual version bumping—just push and ship.