Recently I was tasked with implementing app distribution as part of our CI/CD process. Being generally allergic to DevOps, I would have immediately reached for CodeMagic. Unfortunately, our client required the use of Azure DevOps 😞, but on the bright side, we were allowed to use Firebase App Distribution. Thus started the journey of integrating these moving pieces together in a way that is minimally invasive.

I will touch on Azure DevOps integration briefly, but this tutorial is meant to be as platform-agnostic as possible. We're Flutter developers after all - we don't believe in being tied down!

Prerequisites

This is an intermediate post, so I assume you have a basic knowledge of how to set up a Flutter application, how to create provisioning profiles and add users to them, and how to set up Firebase for your Android/iOS projects. If you do not, please refer to the links above. I apologize in advance for Apple's documentation resembling something from the early 2000s 😀.

1. Adding Fastlane for Android/iOS

Navigate to the Android directory of your flutter project and initialize fastlane.

fastlane init

You will be prompted to enter a package name and a secret JSON file location. Both of these are optional but we should add the package name from the AndroidManifest.xml.

The output of running the command will be an Appfile containing the package name and a Fastfile containing some default commands that we can run. We will replace those.

Run the same command in your iOS directory. You will be prompted for an app identifier and an Apple ID. For the app identifier, use a wildcard domain. Wildcard domains allow us to sign multiple applications with the same profiles, so they are great for distribution. If you plan on using Apple capabilities such as Apple Pay, you will want to use an explicit domain. For the Apple ID use the email address associated with your developer account.

NOTE: In an organization, you will probably want to create a generic account purely for distribution like distribution@example.com , but we will not cover that here.

The output of running this command will closely mirror that of Android.

2. Add Match to iOS

Because iOS is awesome, we must use provisioning profiles to distribute the application in test environments. Using match will save us from the headache of managing this manually so lets set it up. First create a private git repository named ios-certificates.git then run the following command

bundle exec fastlane match init

You will be prompted to choose a storage location for your profile. Choose git and input the previously created url in the next prompt. The end result will be a Matchfile created that tells fastlane how to manage your distribution profile. Add the following code to it

app_identifier([ "<<your wildcard domain>>" ]) username( "<<your apple id>>" )

Make sure to swap out the placeholders then run match again. Whenever a new iOS user is invited to the app, run match to update the distribution profile. The user should be able to install the application after a new build.

bundle exec fastlane match

You will be prompted for a password that match can use to encrypt the profile. It should be shared with your team members.

3. Add Firebase App Distribution plugin

Next, we need to add our fastlane plugin to distribute the application via Firebase App Distribution. Run the same command in both the android/ios directories.

fastlane add_plugin firebase_app_distribution

Now we can reference the plugin in our Fastfile commands.

3. Add App Distribution to Android

Add the following to your Fastfile

default_platform( :android ) APP_ID = ENV[ 'FIREBASE_ANDROID_APPID' ] FIREBASE_TOKEN = ENV[ 'FIREBASE_CI_TOKEN' ] BUILD_NUMBER = ENV[ "BUILD_NUMBER" ] platform :android do desc "Deploy a new beta" lane :distribute_beta do firebase_app_distribution( app: APP_ID, groups: "testers" , release_notes: BUILD_NUMBER, firebase_cli_path: "/usr/local/bin/firebase" , firebase_cli_token: FIREBASE_TOKEN, apk_path: "../build/app/outputs/apk/release/app-release.apk" ) end end

Let's break down each logical block.

default_platform( :android )

This block specifies that we are running fastlane for Android.

APP_ID = ENV[ 'FIREBASE_ANDROID_APPID' ] FIREBASE_TOKEN = ENV[ 'FIREBASE_CI_TOKEN' ] BUILD_NUMBER = ENV[ "BUILD_NUMBER" ]

Next we assign a few environment variables to local variables in the Fastfile.

APP_ID - The Android application ID that was created during the Firebase setup.

- The Android application ID that was created during the Firebase setup. FIREBASE_TOKEN - The Firebase token for CI usage.

- The Firebase token for CI usage. BUILD_NUMBER - The build number associated with this build (from our CI environment).

platform :android do desc "Deploy a new beta" lane :distribute_beta do firebase_app_distribution( app: APP_ID, groups: "testers" , release_notes: BUILD_NUMBER, firebase_cli_path: "/usr/local/bin/firebase" , firebase_cli_token: FIREBASE_TOKEN, apk_path: "../build/app/outputs/apk/release/app-release.apk" ) end end

Finally we create a lane (command) called distribute_beta that will call the plugin we installed in the previous step. We pass it our local variables, as well as some information about where to find our bundle artifacts & Firebase and who to distribute the application to.

4. Add App Distribution to iOS

Now the real fun begins. Add the following to your iOS Fastfile

default_platform( :ios ) TEMP_KEYCHAIN_NAME_DEFAULT = "fastlane_flutter" || ENV[ 'TEMP_KEYCHAIN_NAME' ] TEMP_KEYCHAN_PASSWORD_DEFAULT = "temppassword" || ENV[ 'TEMP_KEYCHAIN_PASSWORD' ] APP_ID = ENV[ 'FIREBASE_IOS_APPID' ] FIREBASE_TOKEN = ENV[ 'FIREBASE_CI_TOKEN' ] BUILD_NUMBER = ENV[ "BUILD_NUMBER" ] def delete_temp_keychain (name) delete_keychain( name: name ) if File.exist? File.expand_path( "~/Library/Keychains/ #{name} -db" ) end def create_temp_keychain (name, password) create_keychain( name: name, password: password, unlock: false , timeout: false ) end def ensure_temp_keychain (name, password) delete_temp_keychain(name) create_temp_keychain(name, password) end platform :ios do desc "Build & sign iOS app" lane :build_ios do |options| disable_automatic_code_signing( path: "./Runner.xcodeproj" , team_id: CredentialsManager: :AppfileConfig .try_fetch_value( :team_id ), profile_name: "match AdHoc #{ CredentialsManager: :AppfileConfig .try_fetch_value( :app_identifier )} " , code_sign_identity: "iPhone Distribution" ) keychain_name = TEMP_KEYCHAIN_NAME_DEFAULT keychain_password = TEMP_KEYCHAN_PASSWORD_DEFAULT ensure_temp_keychain(keychain_name, keychain_password) match( app_identifier: CredentialsManager: :AppfileConfig .try_fetch_value( :app_identifier ), type: "adhoc" , readonly: is_ci, keychain_name: keychain_name, keychain_password: keychain_password, git_url: "<<git url>>" ) build_ios_app( export_options: { method: "ad-hoc" }, output_directory: "./build/Runner" ) delete_temp_keychain(keychain_name) end desc "Deploy a new beta" lane :distribute_beta do |options| firebase_app_distribution( app: APP_ID, groups: "testers" , release_notes: BUILD_NUMBER, firebase_cli_path: "/usr/local/bin/firebase" , firebase_cli_token: FIREBASE_TOKEN, ipa_path: "./build/Runner/Runner.ipa" ) end end

There's a lot here so again let's dissect each block.

default_platform( :android )

This block specifies that we are running fastlane for Android.

TEMP_KEYCHAIN_NAME_DEFAULT = "fastlane_flutter" || ENV[ 'TEMP_KEYCHAIN_NAME' ] TEMP_KEYCHAN_PASSWORD_DEFAULT = "temppassword" || ENV[ 'TEMP_KEYCHAIN_PASSWORD' ] MATCH_GIT_URL = ENV[ 'MATCH_GIT_URL' ] APP_ID = ENV[ 'FIREBASE_IOS_APPID' ] FIREBASE_TOKEN = ENV[ 'FIREBASE_CI_TOKEN' ] BUILD_NUMBER = ENV[ "BUILD_NUMBER" ]

Along with local variables mirroring our Android Fastfile, we've defined a couple variables to help facilitate the installation of our provisioning profile. We also assign the git url necessary to download our provisioning profile using match.

def delete_temp_keychain (name) delete_keychain( name: name ) if File.exist? File.expand_path( "~/Library/Keychains/ #{name} -db" ) end def create_temp_keychain (name, password) create_keychain( name: name, password: password, unlock: false , timeout: false ) end def ensure_temp_keychain (name, password) delete_temp_keychain(name) create_temp_keychain(name, password) end

We then define a few utility functions to help manage the lifecycle of our temporary keychain user.

desc "Build & sign iOS app" lane :build_ios do |options| disable_automatic_code_signing( path: "./Runner.xcodeproj" , team_id: CredentialsManager: :AppfileConfig .try_fetch_value( :team_id ), profile_name: "match AdHoc #{ CredentialsManager: :AppfileConfig .try_fetch_value( :app_identifier )} " , code_sign_identity: "iPhone Distribution" ) keychain_name = ENV[ 'TEMP_KEYCHAIN_NAME' ] || TEMP_KEYCHAIN_NAME_DEFAULT keychain_password = ENV[ 'TEMP_KEYCHAIN_PASSWORD' ] || TEMP_KEYCHAN_PASSWORD_DEFAULT ensure_temp_keychain(keychain_name, keychain_password) match( app_identifier: CredentialsManager: :AppfileConfig .try_fetch_value( :app_identifier ), type: "adhoc" , readonly: is_ci, keychain_name: keychain_name, keychain_password: keychain_password, git_url: MATCH_GIT_URL ) build_ios_app( export_options: { method: "ad-hoc" }, output_directory: "./build/Runner" ) delete_temp_keychain(keychain_name) end

Dissecting the build_ios lane, we

Disable automatic code signing for our CI/CD environment

Create a temporary keychain and install the certificates & profiles into that keychain using match.

Build the iOS application.

Unfortunately, I have not yet figured out how to "just sign" a previously built iOS application using fastlane, so we must build the application again.

desc "Deploy a new beta" lane :distribute_beta do |options| firebase_app_distribution( app: APP_ID, groups: "testers" , release_notes: BUILD_NUMBER, firebase_cli_path: "/usr/local/bin/firebase" , firebase_cli_token: FIREBASE_TOKEN, ipa_path: "./build/Runner/Runner.ipa" ) end

Finally, we have reached the end, creating a lane that almost perfectly mirrors the one we used to distribute our Android application.

Extra Credit: Adding Azure DevOps for CI

Now let's reward ourselves with some Azure DevOps pipeline magic. Create a file in the project root and add the following code

variables: - group: <<your library group>> - name: projectDirectory value: $(System.DefaultWorkingDirectory) - name: FCI_BUILD_DIR value: . trigger: - master pr: - master jobs: - job: BuildAndDistribute pool: vmImage: 'macOS-10.14' steps: - script: | curl -L https://raw.githubusercontent.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py -o lcov_cobertura.py displayName: Install code coverage dependencies - task: NodeTool@0 inputs: versionSpec: '12.x' displayName: Install Node.js - task: UseRubyVersion@0 inputs: versionSpec: '>= 2.4' addToPath: true displayName: Install Ruby - script: | gem install bundler cd $(projectDirectory)/ios && bundle update --bundler bundle install --retry=2 --jobs=4 cd $(projectDirectory)/android && bundle update --bundler bundle install --retry=2 --jobs=4 displayName: Install Fastlane - task: FlutterInstall@0 displayName: Install Flutter - task: FlutterTest@0 inputs: projectDirectory: $(projectDirectory) displayName: Run tests - script: | $(FLUTTERTOOLPATH)/flutter test --coverage python lcov_cobertura.py coverage/lcov.info --output coverage/coverage.xml --demangle displayName: Assemble code coverage results - task: PublishCodeCoverageResults@1 inputs: codeCoverageTool: Cobertura summaryFileLocation: 'coverage/coverage.xml' displayName: Publish code coverage results - script: | echo $FCI_KEYSTORE_FILE | base64 --decode > $(projectDirectory)/android/app/keystore.jks displayName: Copy android keystore env: FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD) FCI_KEY_ALIAS: $(FCI_KEY_ALIAS) FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD) - task: FlutterBuild@0 inputs: target: aab projectDirectory: $(projectDirectory) displayName: Build android artifacts env: FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD) FCI_KEY_ALIAS: $(FCI_KEY_ALIAS) FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD) - task: FlutterBuild@0 inputs: target: apk projectDirectory: $(projectDirectory) displayName: Build android artifacts env: FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD) FCI_KEY_ALIAS: $(FCI_KEY_ALIAS) FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD) - task: FlutterBuild@0 inputs: target: ios projectDirectory: $(projectDirectory) iosCodesign: false displayName: Build ios artifacts - script: | cd ios bundle exec fastlane build_ios bundle exec fastlane distribute_beta displayName: Distribute iOS beta env: FIREBASE_IOS_APPID: $(FIREBASE_IOS_APPID) FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN) MATCH_PASSWORD: $(MATCH_PASSWORD) AZURE_TOKEN: $(AZURE_TOKEN) BUILD_NUMBER: $(Build.BuildNumber) - script: | cd android bundle exec fastlane distribute_beta displayName: Distribute android beta env: FIREBASE_ANDROID_APPID: $(FIREBASE_ANDROID_APPID) FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN) BUILD_NUMBER: $(Build.BuildNumber) - task: CopyFiles@2 inputs: contents: | **/release/**/*.aab **/release/**/*.apk **/*.ipa targetFolder: '$(build.artifactStagingDirectory)' displayName: Copy build artifacts - task: PublishBuildArtifacts@1 displayName: publish build artifacts

There's quite a lot going on here so let's again break down these blocks.

variables: - group: <<your library group>> - name: projectDirectory value: $(System.DefaultWorkingDirectory) trigger: - master pr: - master

Here we import variables from a previously variable group and define another variable pointing to the project root. We set up the pipeline to run on pushes and pull requests to master.

jobs: - job: BuildAndDistribute pool: vmImage: 'macOS-10.14'

Next we create a job called BuildAndDistribute that will run on macOS.

steps: - script: | curl -L https://raw.githubusercontent.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py -o lcov_cobertura.py - task: NodeTool@0 inputs: versionSpec: '12.x' displayName: Install Node.js - task: UseRubyVersion@0 inputs: versionSpec: '>= 2.4' addToPath: true displayName: Install Ruby - script: | npm install -g firebase-tools displayName: Install Firebase CLI - script: | gem install bundler cd $(projectDirectory)/ios && bundle update --bundler bundle install --retry=2 --jobs=4 cd $(projectDirectory)/android && bundle update --bundler bundle install --retry=2 --jobs=4 displayName: Install Fastlane - task: FlutterInstall@0 displayName: Install Flutter

Grouping all of the setup steps together, first we install a script necessary to export our lcov test coverage to something a bit more archaic that Azure DevOps understands. Then we install Node.js & Ruby using plugins, Firebase and Fastlane using scripts, and our Flutter dependencies again using plugins. Most CIs have a way of doing similar installations with plugins.

- script: | $(FLUTTERTOOLPATH)/flutter test --coverage python lcov_cobertura.py coverage/lcov.info --output coverage/coverage.xml --demangle displayName: Assemble code coverage results - task: PublishCodeCoverageResults@1 inputs: codeCoverageTool: Cobertura summaryFileLocation: 'coverage/coverage.xml' displayName: Publish code coverage results

Next, we run our tests, run the script to convert the results, and publish those results for the pipeline to display.

- script: | echo $FCI_KEYSTORE_FILE | base64 --decode > $(projectDirectory)/android/app/keystore.jks displayName: Copy android keystore env: FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD) FCI_KEY_ALIAS: $(FCI_KEY_ALIAS) FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD) - task: FlutterBuild@0 inputs: target: aab projectDirectory: $(projectDirectory) displayName: Build android artifacts env: FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD) FCI_KEY_ALIAS: $(FCI_KEY_ALIAS) FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD) ...

Here we download and decode keystore necessary to sign our application. We're using environment variables from the previously aforementioned variable group that we imported. We then build artifacts for Android (apk & aab) and iOS.

- script: | cd ios bundle exec fastlane build_ios bundle exec fastlane distribute_beta displayName: Distribute iOS beta env: FIREBASE_IOS_APPID: $(FIREBASE_IOS_APPID) FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN) MATCH_PASSWORD: $(MATCH_PASSWORD) AZURE_TOKEN: $(AZURE_TOKEN) BUILD_NUMBER: $(Build.BuildNumber) - script: | cd android bundle exec fastlane distribute_beta displayName: Distribute Android beta env: FIREBASE_ANDROID_APPID: $(FIREBASE_ANDROID_APPID) FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN) BUILD_NUMBER: $(Build.BuildNumber)

Now it's time to run our fastlane commands to distribute (and build for iOS) the application. Sweet Christmas! Again, we are passing in variables that were previously defined in our library and will be used in our Fastfile(s). There are two additions

MATCH_PASSWORD - this is the shared password used by match to encrypt the distribution profile.

- this is the shared password used by match to encrypt the distribution profile. AZURE_TOKEN - this is a personal access token that is used to download the match profile. Instead of using a normal git url, you will have to do something resembling

https: / / #{ENV[ 'AZURE_TOKEN' ]} @dev.azure.com/repo _url/ios-certificates.git

Don't blame me, I just work here.

- task: CopyFiles@2 inputs: contents: | **/release/**/*.aab **/release/**/*.apk **/*.ipa targetFolder: '$(build.artifactStagingDirectory)' displayName: Copy build artifacts - task: PublishBuildArtifacts@1 displayName: publish build artifacts

Finally, we copy all of our build artifacts to a staging area and publish them as part of the pipeline.

Recap

Congrats! We have automated the CI/CD for our Flutter application using Fastlane and Firebase App Distribution. And for extra credit, we threw a little Azure DevOps in there for free. Update that resume with some sweet DevOps skills 😎.

We can manage distribution locally or from CI/CD.

We can manage the update of our distribution profiles.

On every push and PR to master, all of our tests and builds will run.

On unsuccessful builds, the failure will be reported for the PR.

On successful builds, the app will be distributed on Firebase App Distribution.

Conclusion

I started on this journey with very little experience with fastlane and a whole lot of uncertainty concerning Azure DevOps. I pieced together articles that covered each unknown separately into this article documenting the process. I hope that this will aid you on your DevOps journey.