How to build flutter apps on CI with Fastlane and reuse some code
Tags: flutter, ios, fastlane, android
Published: 2021-05-27 00:11:19

We use `fastlane` for our flutter apps, for ios and android builds, it give us a common language to define the setup and reuse code for:
- Generating a changelog based on our changelog style
- Validate code style, formatting and lint 
- Unit testing
- Verify that the project autogenerated code is been built fine.
- Verify that the project builds fine for each platform.
- Deploy to firebase app distribution for testing
- Deploying to each app store

There are some tricks to be able to do it:
- Create a parent `fastfile` where you have the reusable logic and one for each platform `ios/fastfile` and `android/fastfile`.
- Have an easy way to run tasks from the root of the project, our idea was to create a lane `sh_on_root` to run all flutter commands from each platform `fastfile`.
- Create a `Gemfile` for each platform and define `.ruby-version` file to have a defined environment to run, helping us to avoid problems on CI server requiring some specific ruby version to run fastlane in there.

Another thing we noticed is that using `--no-pub --suppress-analytics` params from flutter commands saved us some time on CI if you want to try that's a good start.

I will show an example of some usefull lanes and some workflows for them, you should think about your needs and call the lanes you need for each step but try to reuse for each platform some tasks having the parent `fastfile` file.

# Show me the setup

1. Create the `Gemfile` for each platform
- `android/Gemfile`
- `ios/Gemfile`

Contents:
```ruby
source "https://rubygems.org"

gem 'cocoapods'
gem "fastlane"

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
```

2. Create the `.ruby-version` so the CI (in our case Bitrise) have a clue of which ruby version should be used
- `ios/.ruby-version`
- `android/.ruby-version`

Contents:
```ruby
2.6.5
```

3. Create 3 fastlane files, 1 to be reused by each platform, and 1 for each platform (check that from each platform fastlane we import the parent one with `import "../../scripts/Fastfile"`)

- `fastfile`:
```ruby
opt_out_usage

# Have an easy way to get the root of the project
def root_path
  Dir.pwd.sub(/.*\Kfastlane/, '').sub(/.*\Kandroid/, '').sub(/.*\Kios/, '').sub(/.*\K\/\//, '')
end

# Have an easy way to run flutter tasks on the root of the project
lane :sh_on_root do |options|
  command = options[:command]
  sh("cd #{root_path} && #{command}")
end

# Tasks to be reused on each platform flow
lane :fetch_dependencies do
  sh_on_root(command: "flutter pub get --suppress-analytics")
end

# Tasks to be reused on each platform flow
lane :build_autogenerated_code do
  sh_on_root(command: "flutter pub run intl_utils:generate && flutter pub run build_runner build --delete-conflicting-outputs")
end

# Tasks to be reused on each platform flow
lane :lint do
  sh_on_root(command: "flutter format --suppress-analytics --set-exit-if-changed -n lib/main.dart lib/src/ test/")
end

# Tasks to be reused on each platform flow
lane :test do |options|
  sh_on_root(command: "flutter test --no-pub --coverage --suppress-analytics")
end
```

- `ios/fastfile`:
```ruby
import "../../scripts/Fastfile"

default_platform(:ios)

platform :ios do
  # Updates XCode project settings to use a different code signing based on method
  private_lane :archive do |options|
    method = options[:method]

    profile_name = method == 'ad-hoc' ? "Distribution - Staging (adhoc)" : "Production"
    update_code_signing_settings(profile_name: profile_name)

    build_app(export_method: method)
  end

  private_lane :authenticate_apple_store do
    p8_path = path_for_secret("your .p8 file path")

    app_store_connect_api_key(
      key_id: "your key id",
      issuer_id: "your used id",
      key_filepath: p8_path,
      duration: 1200, # optional
      in_house: false,
    )
  end

  lane :build do |options|
    # Reuse parent fastfile tasks
    fetch_dependencies
    build_autogenerated_code


    sign_enabled = options[:sign_enabled] || false
    sign_param = sign_enabled ? '' : '--no-codesign'

    config_only = options[:config_only] || false
    config_param = config_only ? '--config-only' : ''

    sh_on_root(command: "flutter build ios --no-pub --suppress-analytics --release #{sign_param} #{config_param}")
  end

  lane :deploy_staging do
    build(sign_enabled: true)
    archive(method: "ad-hoc")

    upload_symbols_to_crashlytics(dsym_path: "your.app.dSYM.zip")

    firebase_app_distribution(
      app: "yours app id from firebase",
      ipa_path: "your.ipa",
      groups: "developers, staging",
      release_notes: changelog
    )
  end

  lane :deploy_staging_testflight do
    build(sign_enabled: true)
    archive(method: "app-store")

    authenticate_apple_store

    # Reuse parent fastfile tasks
    test

    upload_to_testflight(
      ipa: "your.ipa",
      reject_build_waiting_for_review: true,
      skip_waiting_for_build_processing: false,
      distribute_external: true,
      notify_external_testers: true,
      groups: "Your testers"
    )
  end

  lane :deploy_production do
    sh_on_root(command: "sh scripts/cp_env.sh")
    
    # All certificates and .p8 file should be fine on runnning machine
    build(sign_enabled: true, config_only: true)
    archive(method: "app-store")
    authenticate_apple_store

    # Reuse parent fastfile tasks
    test

    deliver(
      ipa: "your.ipa",
      skip_metadata: true,
      skip_screenshots: true,
      submit_for_review: false,
      force: false,
      automatic_release: false,
      submission_information: {
        add_id_info_serves_ads: false,
        add_id_info_uses_idfa: false,
        export_compliance_uses_encryption: false,
      },
      precheck_include_in_app_purchases: false,
    )
  end
end
```

- `android/fastfile`
```ruby
import "../../scripts/Fastfile"

default_platform(:android)

platform :android do
  private_lane :build_apk do
    # Reuse parent fastfile tasks
    fetch_dependencies
    build_autogenerated_code

    sh_on_root(command: "flutter build apk --release")
  end

  lane :build do
    # Reuse parent fastfile tasks
    fetch_dependencies
    build_autogenerated_code

    sh_on_root(command: "flutter build appbundle --no-pub --release --suppress-analytics")
  end

  lane :deploy_staging do
    build_apk

    # Reuse parent fastfile tasks
    test

    firebase_app_distribution(
      app: "your app id",
      groups: "your testers",
      android_artifact_type: "APK",
      android_artifact_path: "#{root_path}/build/app/outputs/flutter-apk/app-release.apk",
      firebase_cli_path: "/usr/local/bin/firebase"
    )
  end

  lane :deploy_production do
    build
    
    # Reuse parent fastfile tasks
    test

    supply(
      track: 'beta',
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      json_key: path_for_secret("your play store.json"),
      skip_upload_apk: true, # Upload the aab instead of apk
      skip_upload_metadata: true,
      skip_upload_changelogs: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end
```