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
- Setting up Flutter Application
- Setting up iOS Provisioning Profiles
- Setting up Applications in Firebase
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>>") # Your Apple Developer Portal username
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.
- FIREBASE_TOKEN - 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)
# Default temporary keychain password and name, if not included from environment
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"]
# Remove the temporary keychain, if it exists
def delete_temp_keychain(name)
delete_keychain(
name: name
) if File.exist? File.expand_path("~/Library/Keychains/#{name}-db")
end
# Create the temporary keychain with name and password
def create_temp_keychain(name, password)
create_keychain(
name: name,
password: password,
unlock: false,
timeout: false
)
end
# Ensure we have a fresh, empty temporary keychain
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|
# Upload to test flight or AppStore depending on caller parameters
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|
# Upload to test flight or AppStore depending on caller parameters
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.
- 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.
Sup?! Iโm Ryan Edge. I am a Software Engineer at Superformula and a semi-pro open source contributor. If you liked this article, be sure to follow and spread the love! Happy trails