Photo by Campaign Creators on Unsplash

Flutter Android Deployment Workflow Done Right

Streamline and automate your distribution and app deployment process

Tomás Arias
Published in
11 min readMar 27, 2019

--

RReleases can be daunting, they can involve complex processes and demand a lot of time, the kind of time you could need elsewhere. It is imperative to have a defined workflow in place and automate most if not all of these processes.

What you’ll learn:

  • How to eliminate manual processes and automatically test, build and deploy your app.
  • How to create a continuous delivery pipeline.
  • How to implement CI/CD with Travis CI and fastlane.

Defining a development workflow

To establish a deployment workflow you first need to know what your development workflow is because it will be heavily influenced by it.

Determining a workflow for your project early on is key for focusing on your app development entirely, obviously as your project grows some necessities could change and you need to reflect those changes into your workflow, but it is much easier to work on a foundation than from scratch.

Even if your project has an amassment of lines of code or is brand new before you can establish a workflow, you need to think about your product and it’s objectives:

  • What are your current processes?
  • Which are essential and which aren’t?
  • How could you automate them?
  • Will you need more processes in the future?
  • If so, could you implement them easily?

Remember that you want to plan for the workflow you will need in the future, so maybe spending a little time now could save you hours in the future.

Allow yourself some time to reflect on these thoughts and continue later.

With that in mind, we can define a deployment workflow based on our development workflow.

Personally, I work with a general-purpose deployment workflow influenced by the GitOps principle “Git as a source of truth” but every project is unique and has different needs so this may or may not work for you, so take this as an example to implement what is right for your project.

I’ll start by separating the deployment workflow into three stages:

  1. Test
  2. Build
  3. Deploy

This is a standard workflow and while I will delve into the specifics of each stage later, first, we need to know where we want to release our app.

Possible tracks for releases on Google Play

The Google Play Store is my app marketplace of choice so I will tailor my workflow to match Play Console’s release structure.

Remember the “Git as a source of truth” from above? I will mimic the release tracks within my repo so if I push a release on Git it will reflect into Play Console.

So my repo will have five branches: master, internal, alpha, beta and prod.

The master branch will be used as the main development branch and the others should have the same release version as their release track counterpart.

So in an ideal scenario where a release goes through all release tracks (the solid path in the diagram above), the production track will have the lowest version release, the internal track the greatest version release and the branch history graph should look something like this:

As you can see in the diagram above, we never actually commit directly to the track branches, we only merge from a tagged commit, both directly (from the tag itself of a separate branch created to contain the tag) or indirectly (another track that was merged from a tagged commit), this is so because although we build all commits, we only keep the build artifacts from tagged commits (releases), this is important for two reasons:

  1. We can prepare a release but deploy later.
  2. We don’t need to worry about failed builds on a release already built.

So this frees us from having to oversee the build process each time you want to deploy and focus on the deployment only, ensuring you only deploy a build that passed all of your tests.

So our diagrammed deployment pipeline will be something like this:

App deployment pipeline

Note that the green path only advances if the process is successful.

Setting up a continuous integration environment

I will use Travis CI but you could easily use any CI provider but you will need to adjust accordingly.

Open or create .travis.yml in your project root dir and replace everything with this minimal Flutter project configuration:

language: generic
dist: xenial
addons:
apt:
packages:
- lib32stdc++6
env:
global:
- FLUTTER_CHANNEL=stable
install:
- git clone https://github.com/flutter/flutter.git -b $FLUTTER_CHANNEL
- export PATH="$PATH:`pwd`/flutter/bin/cache/dart-sdk/bin"
- export PATH="$PATH:`pwd`/flutter/bin"
- flutter doctor -v
- flutter packages get
cache:
directories:
- $HOME/.pub-cache

This basically installs Flutter and nothing else so we can run commands like flutter test.

Following our pipeline structure, we will add the three stages using build stages:

jobs:
include:
- stage: test
- stage: build
- stage: deploy

A stage is a group of jobs that are allowed to run in parallel. However, each one of the stages runs one after another, and will only proceed if all jobs in the previous stage have passed successfully. If one job fails in one stage, all other jobs on the same stage will still complete, but all jobs in subsequent stages will be canceled, and the build fails.

⚠️ Warning: Travis doesn’t currently support fast_finish on build stages.

This is ideal because this ensures that if something goes wrong we receive quick feedback to fix it.

Static analysis

Specifying linting rules is crucial to maintaining code quality and consistency.

Follow this guide to set up linting rules, your IDE will now ensure you and your team follow those rules.

Now we will set up the analyzer in the CI using YAML anchors:

static_analysis: &static_analysis
name: "Static analysis"
script: flutter analyze --no-current-package $TRAVIS_BUILD_DIR/lib

So now we can add a reference to this in the jobs section:

jobs:
include:
- <<: *static_analysis
- stage: build
- stage: deploy

Note that we replaced the stage name because the default stage is test so we don’t need to explicitly specify it.

And that’s it, every time a build is triggered it will look for errors and warnings in your project.

Tests

Following what we did above, add these to run the different kind of tests:

Unit tests

unit_tests: &unit_tests
name: "Unit tests"
script: flutter test test/unit_test.dart

If you use code coverage, you should generate and upload your reports here.

unit_tests: &unit_tests
name: "Unit tests"
script: flutter test --coverage test/unit_test.dart
after_script: bash <(curl -s https://codecov.io/bash) -f coverage/lcov.info

Remember to replace after_script: with your script to upload reports, or if you use Codecov, add your CODECOV_TOKEN as an environment variable.

Widget tests

widget_tests: &widget_tests
name: "Widget tests"
script: flutter test test/widget_test.dart

Same as above, add this if you use code coverage:

widget_tests: &widget_tests
name: "Widget tests"
script: flutter test --coverage test/widget_test.dart
after_script: bash <(curl -s https://codecov.io/bash) -f coverage/lcov.info

Integration tests

integration_tests: &integration_tests
name: "Integration tests"
script: flutter drive --target=test_driver/main.dart

⚠️ Warning: Currently flutter_driver doesn’t support collecting coverage information.

Finally, we add the references in the jobs section:

jobs:
include:
- <<: *static_analysis
- <<: *unit_tests
- <<: *widget_tests
- <<: *integration_tests
- stage: build
- stage: deploy

We could also want to run these tests against different versions of Flutter, if so add this:

jobs:
allow_failures:
- env: FLUTTER_CHANNEL=beta
include:
...
- <<: *static_analysis
env: FLUTTER_CHANNEL=beta
- <<: *unit_tests
env: FLUTTER_CHANNEL=beta
- <<: *widget_tests
env: FLUTTER_CHANNEL=beta
- <<: *integration_tests
env: FLUTTER_CHANNEL=beta
- stage: build
...

This runs all the tests using the stable channel and then again using the beta channel but if any of these tests fail it won’t compromise the build.

Build

When we build our app, we need to do different tasks such as incrementing the build version, generating screenshots, code signing, and finally compiling.

Build version

Every app has a version field in the pubspec.yaml file that defines the version and build number for your application.

In Android, the format for the version is version: versionName+versionCode
and you’ll need to increase the versionCode with each update of your app to differentiate it from previous builds.

In Flutter, we can override these values in flutter build by specifying the
--build-name and --build-number arguments.

⧸ «build-name is used as versionName while build-number is used as versionCode»

⚠️ Warning: 2100010000 is the greatest possible value for versionCode on the Play Console.

Read more about versioning here

Screenshots

While generating screenshots is beyond the scope of this article, so I won’t delve into it, you can read the article below and easily integrate into your process.

Signing

Android requires that all apps be digitally signed with a certificate before they can be installed.

  • Generate an upload key and keystore
    Follow these steps to create an upload key and keystore in Android Studio
    OR generate one using the command line:

Run flutter doctor -v and look for this line Java binary at: go or copy that path excluding java and execute the command below replacing $PATH with a complete path of your choice.

keytool -genkey -v -keystore $PATH/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

After generating the key, go to the aforementioned path and run:

base64 key.jks

OR this on Windows:

certutil -encode key.jks tmp.b64 && findstr /v /c:- tmp.b64 && del tmp.b64

Now you will add two environment variables in your CI.
First, copy the encoded key and add it with the name PLAY_STORE_UPLOAD_KEY.
⚠️ Warning: Use single quotes for it to parse correctly (Read more)
Then, add the password that you used to generate the key with the name UPLOAD_KEY_PASSWORD (if you used two different passwords add both)

Keep your signing key file secure and store it separately.

  • Configure the signing configurations for your release build type using Gradle build configurations

Add the signing configuration to android/app/build.gradle:

...
android {
...
defaultConfig {...}
signingConfigs {
release {
storeFile file("key.jks")
storePassword System.getenv("UPLOAD_KEY_PASSWORD")
keyAlias key
keyPassword System.getenv("UPLOAD_KEY_PASSWORD")
}
}
buildTypes {
release {
...
signingConfig signingConfigs.release
}
}
}

Now it is all set to automatically sign the app when building.

⭐️ Pro Tip: Use app signing by Google Play.

Read more about signing here

Building a release-ready APK

For building the APK we need a few more things installed like the Android SDK and Java 8 so we will create another anchor for this job and install all the required dependencies:

build: &build
name: "Build APK"
language: android
jdk:
- oraclejdk8
android:
components:
- tools
- tools # See (https://github.com/travis-ci/travis-ci/issues/6040#issuecomment-219367943)
- platform-tools
- build-tools-28.0.3
- android-27 # Breaks the build if not present (https://github.com/flutter/flutter/pull/26798#issuecomment-455758159)
- android-28

⚠️ Warning: Make sure that you install the same SDK platform version specified on compileSdkVersion in android/app/build.gradle.

To actually build the app with the correct build version we add the following:

build: &build
...
before_script:
- export BUILD_NAME=$TRAVIS_TAG
- export BUILD_NUMBER=$TRAVIS_BUILD_NUMBER
script:
- if [[ $TRAVIS_TAG == "" ]]; then flutter build apk; else flutter build apk --build-name $BUILD_NAME --build-number $BUILD_NUMBER; fi

So, if we push a tag it will use the tag name as the versionName and the build number of the CI as the versionCode, ensuring it will always be greater than the previous release. If it isn’t a tag these values don’t matter because the APK will be discarded when the build finishes.

Artifacts

To save the build artifacts from the step before we need to deploy them somewhere, I will deploy them as a release to GitHub but you can deploy to your provider of choice.

To do this you need to generate a new OAuth access token with repo scope and add it with the name GITHUB_TOKEN as an environment variable.

build: &build
...
deploy:
- provider: releases
api_key: $GITHUB_TOKEN
file: build/app/outputs/apk/release/app-release.apk
skip_cleanup: true
name: $TRAVIS_TAG
on:
tags: true

I like to create a branch with the same name as the tag to easily open a PR to merge to a track later, but you can skip this.

build: &build
...
deploy:
...
after_deploy:
- git branch $TRAVIS_TAG
- git push https://$GITHUB_TOKEN@github.com/$TRAVIS_REPO_SLUG.git $TRAVIS_TAG

And don’t forget to add the reference in the jobs section:

jobs:
...
- stage: build
<<: *build
- stage: deploy

Uploading & Publishing

Setting up fastlane 🚀

fastlane is the easiest way to automate deployments and releases for your apps

Install fastlane in your machine using gem install fastlane -NV then go to your project’s dir and run fastlane init to setup fastlane in your project, enter the package name exactly as your applicationId in your android/app/build.gradle file, when prompted to set up anything else just press ENTER and n.

Next, create a Gemfile in the android/ folder with:

source "https://rubygems.org"gem "fastlane"

Then, run bundle update and add both Gemfile and Gemfile.lock to Git.

Follow these instructions to allow fastlane to upload artifacts, create releases and publish to the Play Console automatically.

Once completed, copy the contents of the JSON file and add it as an environment variables in your CI with the name GOOGLE_CREDENTIALS.
⚠️ Warning: Use single quotes for it to parse correctly (Read more)

default_platform(:android)lane :release do
supply(
track: ENV["TRACK"],
apk: "../build/app/outputs/apk/release/app-release.apk",
json_key_data: ENV["GOOGLE_CREDENTIALS"]
)
end
lane :promote do
supply(
track: ENV["TRAVIS_PULL_REQUEST_BRANCH"],
track_promote_to: ENV["TRACK"],
apk: "../build/app/outputs/apk/release/app-release.apk",
json_key_data: ENV['GOOGLE_CREDENTIALS']
)
end

⭐️ Pro Tip: Use validate_only: true to only draft changes on the Play Console and not actually publish.

Release to Google Play

We’ll create our last anchor and install fastlane and all of its dependencies and if you set up automatic signing, export the keystore to a file:

google_play: &google_play
name: "Google Play"
install:
- bundle install --retry=3 --gemfile=android/Gemfile
- echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > $TRAVIS_BUILD_DIR/android/app/key.jks

Next, we need to get the build artifacts from the previous stage, if you use GitHub Releases we need a script to get the build artifacts from GitHub, in your project root dir create a file named get_build_artifacts.sh with:

#!/bin/bashcurl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/octet-stream" -LJO "$(curl https://api.github.com/repos/$TRAVIS_REPO_SLUG/releases/tags/$TRAVIS_PULL_REQUEST_BRANCH?access_token=$GITHUB_TOKEN | grep "assets/.*" | cut -d '"' -f 4)"

We could put this script in the .travis.yml file but apparently, the Travis parser doesn’t validate it.

So then comes all the logic from our pipeline discussed above but basically, it downloads the APK, puts it in the correct dir and then there are the conditionals for the tracks (see the first diagram) based on the base and head branches of the PR:

google_play: &google_play
...
before_script:
- chmod +x get_build_artifacts.sh # Make it executable
- export TRACK=$TRAVIS_BRANCH
- if [[ $TRAVIS_BRANCH == "prod" ]]; then export TRACK=production; fi
- mkdir -p "$TRAVIS_BUILD_DIR/build/app/outputs/apk/release" && cd "$_"
- $TRAVIS_BUILD_DIR/get_build_artifacts.sh
- cd $TRAVIS_BUILD_DIR/android
script:
- if [[ $TRAVIS_PULL_REQUEST_BRANCH == beta || $TRAVIS_PULL_REQUEST_BRANCH = alpha || $TRAVIS_PULL_REQUEST_BRANCH = internal ]]; then
bundle exec fastlane promote;
else
bundle exec fastlane release;
fi

Add it to the jobs sections and add a new section with rules for the stages execution so when we create a branch after we deploy to GitHub Releases or open a PR to merge to a track, that branch or PR doesn’t trigger a build and only trigger the deploy stage when the PR is merged.

jobs:
...
- stage: deploy
<<: *google_play
stages:
- name: test
if: (NOT branch =~ /^\d*\.\d*\.\d*$/) OR (NOT branch IN (internal, alpha, beta, prod))
- name: build
if: (NOT branch =~ /^\d*\.\d*\.\d*$/) OR (NOT branch IN (internal, alpha, beta, prod))
- name: deploy
if: (type = push) AND (branch IN (internal, alpha, beta, prod))

⭐️ Pro Tip: If you ever want to push a commit and skip triggering its pipeline, you can add [skip ci] or [ci skip] to the commit message.

You can view the complete .travis.yml here.

So if you want to deploy a release of your app you just need to merge to the desired branch and that’s all!

⧸ «The launch of the app is not a finish line but a start line of the app lifecycle»

Thanks for reading and please feel free to communicate your thoughts, suggestions or corrections.

--

--