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:



dist: xenial

addons:

apt:

packages:

- lib32stdc++6

env:

global:

- FLUTTER_CHANNEL=stable

install:

- git clone

- 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 language: genericdist: xenialaddons:apt:packages:- lib32stdc++6env:global:- FLUTTER_CHANNEL=stableinstall:- 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 getcache: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.



name: "Unit tests"

script: flutter test --coverage test/unit_test.dart

after_script: bash <(curl -s unit_tests: &unit_testsname: "Unit tests"script: flutter test --coverage test/unit_test.dartafter_script: bash 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:



name: "Widget tests"

script: flutter test --coverage test/widget_test.dart

after_script: bash <(curl -s widget_tests: &widget_testsname: "Widget tests"script: flutter test --coverage test/widget_test.dartafter_script: bash 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.



...

deploy:

...

after_deploy:

- git branch $TRAVIS_TAG

- git push 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/bash curl -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!