← Articles

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.

CI/CD with Flutter flavors using Fastlane — ANN Tech