CI/CD with Flutter flavors using Fastlane
By Charlin Joe · 30 September 2024
Fastlane is a Ruby toolchain that automates the repetitive parts of shipping mobile apps: signing, building, versioning, upload. Combined with Flutter flavors, it handles the complexity of shipping multiple app variants (dev, staging, production) to different distribution channels without manual steps.
Why Fastlane with Flutter
Flutter's CLI handles the build. Fastlane handles everything around it: fetching provisioning profiles, incrementing build numbers, uploading to TestFlight or Firebase App Distribution, and sending Slack notifications. Without it, each release is 15 manual steps that someone skips under deadline pressure.
Project setup
# From your Flutter project root
gem install bundler
bundle init
# Add to Gemfile:
# gem 'fastlane'
bundle install
cd ios && bundle exec fastlane init
cd ../android && bundle exec fastlane init
Structure your Fastfiles with shared lanes:
fastlane/
Fastfile # Shared across platforms
ios/fastlane/
Fastfile # iOS-specific
android/fastlane/
Fastfile # Android-specific
Android Fastfile
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
lane :deploy_production do
increment_version_code(gradle_file_path: 'app/build.gradle.kts')
gradle(
task: 'bundle',
build_type: 'Release',
flavor: 'production',
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: 'app/build/outputs/bundle/productionRelease/app-production-release.aab',
json_key: ENV['PLAY_STORE_JSON_KEY'],
)
slack(message: ":rocket: Android production bundle uploaded to Play Store internal track")
end
lane :deploy_firebase do |options|
flavor = options[:flavor] || 'staging'
gradle(
task: 'assemble',
build_type: 'Release',
flavor: flavor,
)
firebase_app_distribution(
app: ENV["FIREBASE_APP_ID_#{flavor.upcase}"],
groups: 'qa-team',
release_notes: "Branch: #{git_branch} | #{last_git_commit[:message]}",
)
end
end
iOS Fastfile
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
lane :deploy_testflight do |options|
flavor = options[:flavor] || 'production'
scheme = flavor.capitalize
setup_ci if ENV['CI']
app_store_connect_api_key(
key_id: ENV['ASC_KEY_ID'],
issuer_id: ENV['ASC_ISSUER_ID'],
key_content: ENV['ASC_KEY_CONTENT'],
)
match(
type: 'appstore',
app_identifier: app_identifier_for(flavor),
readonly: ENV['CI'] == 'true',
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
xcodeproj: 'Runner.xcodeproj',
)
build_app(
workspace: 'Runner.xcworkspace',
scheme: scheme,
export_method: 'app-store',
)
upload_to_testflight(skip_waiting_for_build_processing: true)
slack(message: ":apple: #{scheme} uploaded to TestFlight")
end
private_lane :app_identifier_for do |flavor|
case flavor
when 'dev' then 'com.example.myapp.dev'
when 'staging' then 'com.example.myapp.staging'
else 'com.example.myapp'
end
end
end
GitHub Actions integration
name: Release
on:
push:
tags: ['v*']
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.0'
- uses: ruby/setup-ruby@v1
with:
working-directory: android
bundler-cache: true
- run: flutter pub get
- name: Setup keystore
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > android/app/keystore.jks
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
- name: Deploy to Play Store
working-directory: android
run: bundle exec fastlane deploy_production
env:
KEYSTORE_PATH: app/keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- uses: ruby/setup-ruby@v1
with:
working-directory: ios
bundler-cache: true
- run: flutter pub get && cd ios && pod install
- name: Deploy to TestFlight
working-directory: ios
run: bundle exec fastlane deploy_testflight
env:
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
Managing build numbers automatically
Build numbers must increase monotonically. For Android:
# Reads from git tag count — always increasing
build_number = sh('git rev-list --count HEAD').strip.to_i
android_set_version_code(version_code: build_number)
For iOS, query TestFlight for the latest build number and increment:
current = latest_testflight_build_number(app_identifier: 'com.example.myapp')
increment_build_number(build_number: current + 1)
Shipping Flutter-specific build
Fastlane's gradle and build_app actions don't know about Flutter. Run flutter build first, then hand off to Fastlane:
# In the workflow:
- run: flutter build appbundle --flavor production --release
- run: bundle exec fastlane deploy_production
working-directory: android
Or call Flutter from inside Fastlane:
sh('flutter build appbundle --flavor production --release')
Common pitfalls
Hardcoding paths. app/build/outputs/bundle/productionRelease/ breaks when Gradle changes output structure. Use lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH] instead.
Not caching pods. CocoaPods resolution takes 2–4 minutes. Cache ~/.cocoapods keyed on Podfile.lock.
Uploading every commit. Add a tag-triggered workflow for releases. Use a separate branch push trigger for staging only.
Missing setup_ci. Without it, Fastlane tries to use the login Keychain on CI and fails silently or with a cryptic error.
Sign in to like, dislike, or report.