Publish your flutter app with GitLab CI automatically

Publishing your app by hand every time is quite a tedious task. It is also error prone, not fun and this leads to you not doing it as often as you should. Because small, frequent releases are good.

We will change this now by creating a GitLab CI pipeline that lets you deliver your flutter app – or any android app really – automatically to Google Play.

I assume you already have the following:

  • A successfully building flutter app you want to publish
  • A Google Play developer account
  • Your code is hosted on an instance of GitLab
  • Gradle 6.7.+
  • Android Gradle plugin 4.1.+

Signing config

First, you have to sign your app. There are two ways to sign your app.

With a signing key, or with an upload key. Read more about that here. We will use the upload key and leave the actual signing to Google, as it has some benefits.

Create a new keystore with this command. This is going to be your upload key that is used to verify to Google that the artifact really comes from you. I’m using linux so your windows paths will look differently. The keytool comes with your JDK.

keytool -genkey -v -keystore play-upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

The play-upload-keystore.jks goes into the android folder of your flutter project. But make sure to never commit it! I added this to my .gitignore file.

After that we have three values we need to remember.

  • KEYSTORE_PASSWORD – pretty self explanatory
  • KEY_ALIAS – this is specified in the command above: „key“
  • KEY_PASSWORD – the password for the key inside the keystore

Now we need these values in our gradle scripts. Open android/app/build.gradle and add a release signingConfig in the android block.

signingConfigs {
    release {
        storeFile file("../play-upload-keystore.jks")
        storePassword KEYSTORE_PASSWORD
        keyAlias KEY_ALIAS
        keyPassword KEY_PASSWORD
    }
}

Now make sure that this config is used when you build your app, by setting it in your release buildType.

buildTypes {
    release {
        signingConfig signingConfigs.release
    }
}

The last part to make this work is setting the three variables we remembered earlier.
We don’t want to store them in the workspace and accidentally commit them to out repository, so we create a file gradle.properties in a directory called .gradle in our user’s home directory and fill it with our values. Gradle reads this file by default and makes all the values available to our scripts.

KEYSTORE_PASSWORD=<your keystore password>
KEY_ALIAS=key
KEY_PASSWORD=<your key password>

Note: The official docs say, you should place such a file in your project directory, but then you have to add extra code to your gradle script to load this file which I don’t like. Also since we won’t be doing the signing on our own machine from now on anymore, it won’t conflict with your other projects.

The signing is now working. Run the following to create a signed appbundle:

flutter build appbundle

Publish your flutter appbundle

Open android/app/build.gradle again and include the Gradle Play Publisher as a plugin at the top of the file, like so:

plugins {
    id 'com.github.triplet.play' version '3.0.0'
}

This plugin needs a bit of configuration. You can read about all of the available options in the documentation of the GPP.

play {
    serviceAccountCredentials.set(file("../play-api-private-key.json"))
    track.set("beta") // internal/alpha/beta/production
    userFraction.set(1d)
    artifactDir.set(file("../../build/app/outputs/bundle/release"))
}

How to get the file play-api-private-key.json that you have to put into your android folder is also described in their documentation, so I will not write this here again. Also make sure to never commit this file!

Now you could publish your appbundle by executing the gradle task publishBundle. But we’re not here to do this manually, are we?

Automate with a GitLab CI pipeline

Just copy this in a file called .gitlab-ci.yml in the root of your flutter app and you’re done.

image: cirrusci/flutter:1.22.4

stages:
- build

before_script:
- export GRADLE_USER_HOME=$(pwd)/android/.gradle
- mkdir -p ${GRADLE_USER_HOME}
- echo ${GRADLE_PROPS} | base64 -d > ${GRADLE_USER_HOME}/gradle.properties
- chmod +x android/gradlew

after_script:
- rm -f ${GRADLE_USER_HOME}/gradle.properties

build-debug:
  stage: build
  only:
    - branches
  script:
    - flutter build appbundle --debug

publish-play:
  stage: build
  only:
    - /^play-\d+$/
  script:
    - cd android
    - export BUILD_NUMBER=`echo ${CI_COMMIT_REF_NAME} | grep -oP "(?<=play-)(\d+)"`
    - echo ${PLAY_KEY} > play-api-private-key.json
    - echo ${KEYSTORE} | base64 -d > play-upload-keystore.jks
    - flutter build appbundle --build-number ${BUILD_NUMBER} --build-name ${CI_COMMIT_SHORT_SHA}
    - cp ../build/app/outputs/mapping/release/mapping.txt ../build/app/outputs/bundle/release/
    - ./gradlew publishBundle
  after_script:
    - rm -f android/play-api-private-key.json android/play-upload-keystore.jks
  artifacts:
    paths:
      - build/app/outputs/bundle/release/*

Not quite.
We have to somehow get our secret values (passwords, keystore etc.) into out pipeline.
We do this by adding them as variables in GitLab > Settings > CI / CD > Variables.

GitLab now has the ability to save variables as files in a temporary directory, but I do it the old fashioned way, it has no disadvantages in this scenario.

So take your play-api-private-key.json and store it in PLAY_KEY. For the play-upload-keystore.jks and gradle.properties we have to encode them as base64 before storing it in KEYSTORE and GRADLE_PROPS because the keystore it is a binary file and the line breaks in the properties file are important and gitlab will mess with them if we just put it into a variable as plain text.

Encoding these files in base64 can be done as follows:

cat ~/.gradle/gradle.properties | base64 -w0

The parameter -w0 prevents line wrapping in the base64 output, which is important. Otherwise it can not be decoded correctly in the pipeline.

Lastly you have to commit the file gradlew in your android directory. This file is on your .gitignore list as per flutter default. There are mixed opinions as to whether to commit this file to your repository or not. In this case it makes sense and the task at hand easier, because we don’t have to worry to provide an installation of gradle in the dockerimage we use.

You can force to add ignored files to your git stage with the --force parameter.

git add -f android/gradlew

Executing the pipeline

The way this pipeline works is it runs flutter build appbundle --debug on every push to every branch to ensure the app can be built successfully.

To create and publish a release to Google Play, you have to push a git tag pointing to the commit you want to be built and published.

The tag has to be in the format play-<versionCode> (eg. play-123). The versionCode will be built into the app and has to be an integer that is incremented for each new release and can not get smaller as it will otherwise be rejected by Google Play.

Congratulations, upon running your pipeline you should see the following output:

Starting App Bundle upload: /builds/uberflieger.media/android/vanfinder/build/app/outputs/bundle/release/app-release.aab
Uploading App Bundle: 55% complete
App Bundle upload complete
Starting mapping file upload: /builds/uberflieger.media/android/vanfinder/build/app/outputs/bundle/release/mapping.txt
Mapping file upload complete
Updating [completed] release (de.vanfinder:[2]) in track 'beta'

Additional resources