Android application CI/CD with Flutter

⏱️5 min read
Share:

CI/CD pipeline is essential part of software development. This article explains how to construct CI/CD pipeline to build Android application using Flutter. We'll construct pipeline based on GitHub Actions and not use fastlane.

1. Build locally

Before creating a pipeline on GitHub, let's build an app locally. You can prepare build environment with the official web site - Build and release an Android app. We'll explain some of the contents here.

1-1. key.jks and key.properties

Building Android app requires the key to sign. key.jks contains the key and key.properties contains the password. key.jks can be generated by the below commands.

Windows

bash
1keytool -genkey -v -keystore c:\Users\USER_NAME\key.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias key

Mac/Linux

bash
1keytool -genkey -v -keystore ~/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

When you use these commands, you need key password and store password. Record these passwords in key.properties file.

properties
1storePassword=<your password>
2keyPassword=<your password>
3keyAlias=key
4storeFile=C:/Users/USER_NAME/key.jks // depends on your environment

Then, modify Android gradle files to use these files to sign your app.

1-2. app/bundle.gradle

Fix bundle.gradle file. Note that there are two bundle.gradle files in the project. We use the file under app directory.

gradle
1def keystoreProperties = new Properties()
2def keystorePropertiesFile = rootProject.file('key.properties')
3if (keystorePropertiesFile.exists()) {
4 keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
5}
6
7android {
8 signingConfigs {
9 release {
10 keyAlias keystoreProperties['keyAlias'] ? keystoreProperties['keyAlias'] : "key"
11 keyPassword keystoreProperties['keyPassword'] ? keystoreProperties['keyPassword'] : "$System.env.KEY_PASSWORD"
12 storeFile file("../key.jks")
13 storePassword keystoreProperties['storePassword'] ? keystoreProperties['storePassword'] : "$System.env.STORE_PASSWORD"
14 }
15 }
16
17 buildTypes {
18 release {
19 signingConfig signingConfigs.release
20 }
21 }
22}

First block enables the app to read key.properties file. rootProject.file('key.properties') means to read key.properties under the project root directory. If you want ot change file name or directory, you can modify this expression.

Next we can see two blocks under android block.

signingConfigs literally describes the configuration for signing. You can retrieve the contents of key.properties here. For example, keystoreProperties['keyPassword'] provides keyPassword value from key.properties file. $System.env.KEY_PASSWORD means you can get the value from system environment. file("../key.jks") means you can directly load key data from the file. You can choose whatever expression you like.

Next, let's build the app.

bash
1flutter build apk --release

You can generate apk file by this command. The command may warn you to use app bundle or split apk to avoid fat apk, but please ignore them right now. I'll explain it later.

2. Register secrets on GitHub

Register necessary secrets on GitHub. We need at least below secrets. Please see Encrypted Secrets to learn how to register secrets on GitHub.

  • key password
  • store password
  • key.jks

When you register binary files like key.jks, you need base64 encoding to generate secret string. Don't forget to decode it when use it in the pipeline.

You can also use other secret management services such as Azure KeyVault and GCP SecretManager. Anyway, you must separate the key.jks and any other secrets from source code repository and keep them secure.

3. Add workflow

Add workflow using yaml file. Create yaml file like .github/workflows/xxx.yaml.

yaml
1name: CICD
2
3on:
4 pull_request:
5 branches: [master]
6 workflow_dispatch:
7
8jobs:
9 dev:
10 runs-on: ubuntu-latest
11
12 steps:
13 # checkout source code
14 - uses: actions/checkout@v2
15 # setup java
16 - name: set up JDK 1.8
17 uses: actions/setup-java@v1
18 with:
19 java-version: 1.8
20 # setup flutter
21 - name: Setup flutter
22 uses: subosito/flutter-action@v1
23 with:
24 flutter-version: "2.0.1"
25 # pub get
26 - run: flutter pub get
27 # analyze
28 - run: flutter analyze
29 # test
30 - run: flutter test
31 # build
32 - name: build dev
33 env:
34 KEY_JKS: ${{ secrets.KEY_JKS }}
35 KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
36 STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
37 GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
38 run: echo $KEY_JKS | base64 --decode --ignore-garbage > android/key.jks && flutter build apk --release

I'll skip explanation about Java setup, Flutter setup, pub get, analyze, test. Build task consists of a few commands connected with &&. Let's see one by one.

bash
1echo $KEY_JKS | base64 --decode --ignore-garbage > android/key.jks

This command means to generate key.jks file from secrets. As I explained, key.jks is registered as base64 encoded string, so here we need to decode it and re-generate key.jks file. By generating the file, the app can load key information from the file as we configure store file to be file("../key.jks").

In build task, I didn't generate key.properties file. This is because we configure keyPassword and storePassword as $System.env.KEY_PASSWORD. We have set environment as below, so the app can load password from system environment. If you want to read password from key.properties, you can modify bundle.gradle and generate key.properties file here in build task.

yaml
1- name: build dev
2 env:
3 KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
4 STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}

Next, let's check build command.

bash
1flutter build apk --release

This is completely the same as local build. So you'll see warnings which tells you that you should use app bundle or split apk to avoid fat apk. According to the official site Building the app for release, app bundle is recommended. So when you release the app, it is better to build app bundle, however when you test the app on your device or use App Distribute of Firebase, you need apk.

So I recommend to choose the way to build your app according to the purpose. For example, as daily CI build, you can generate apk which allows you to easily test on your device, and as release build, you can generate app bundle and register the app to Google Play Store. Below examples provide with both types of build.

Register an app with App Distribution

yaml
1name: CICD
2
3on:
4 pull_request:
5 branches: [master]
6 workflow_dispatch:
7
8jobs:
9 dev:
10 runs-on: ubuntu-latest
11
12 steps:
13 # checkout source code
14 - uses: actions/checkout@v2
15 # setup java
16 - name: set up JDK 1.8
17 uses: actions/setup-java@v1
18 with:
19 java-version: 1.8
20 # setup flutter
21 - name: Setup flutter
22 uses: subosito/flutter-action@v1
23 with:
24 flutter-version: "2.0.1"
25 # pub get
26 - run: flutter pub get
27 # analyze
28 - run: flutter analyze
29 # test
30 - run: flutter test
31 # build
32 - name: build
33 env:
34 KEY_JKS: ${{ secrets.KEY_JKS }}
35 KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
36 STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
37 GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
38 run: echo $KEY_JKS | base64 --decode --ignore-garbage > android/key.jks && echo $GOOGLE_SERVICES > android/app/google-services.json && flutter build apk --release
39 # app distribution
40 - name: Firebase App Distribution
41 uses: wzieba/Firebase-Distribution-Github-Action@v1.2.2
42 with:
43 appId: ${{secrets.FIREBASE_APP_ID}}
44 token: ${{secrets.FIREBASE_APP_TOKEN}}
45 file: build/app/outputs/apk/release/app-release.apk
46 groups: Tester

Register an app with Google Play Store

yaml
1name: Release
2
3on:
4 workflow_dispatch:
5
6jobs:
7 dev:
8 runs-on: ubuntu-latest
9
10 steps:
11 # checkout source code
12 - uses: actions/checkout@v2
13 # setup java
14 - name: set up JDK 1.8
15 uses: actions/setup-java@v1
16 with:
17 java-version: 1.8
18 # setup flutter
19 - name: Setup flutter
20 uses: subosito/flutter-action@v1
21 with:
22 flutter-version: "2.0.1"
23 # pub get
24 - run: flutter pub get
25 # analyze
26 - run: flutter analyze
27 # test
28 - run: flutter test
29 # build
30 - name: build
31 env:
32 KEY_JKS: ${{ secrets.KEY_JKS }}
33 KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
34 STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
35 GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
36 run: echo $KEY_JKS | base64 --decode --ignore-garbage > android/key.jks && flutter build appbundle --obfuscate --split-debug-info=build/app/outputs/symbols --release
37 # google play
38 - name: Create service account json
39 env:
40 SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON}}
41 run: echo $SERVICE_ACCOUNT_JSON > android/app/service-account.json
42 - uses: r0adkll/upload-google-play@v1
43 with:
44 serviceAccountJson: android/app/service-account.json
45 packageName: your app id
46 releaseFiles: build/app/outputs/bundle/release/app-release.aab
47 track: alpha
48 whatsNewDirectory: release-notes
49 mappingFile: build/app/outputs/mapping/release/mapping.txt

4. Other options

4-1. Firebase google-services.json

bash
1echo $GOOGLE_SERVICES > android/app/google-services.json

In order to connect with Firebase, you need google-services.json. You can generate the json by above command. For more details, see FlutterFire - android installation.

4-2. App Distribution

App Distribution is one of the features that Firebase provides. You can deliver the app to testers by this feature. If you want to use this feature, you just add the below task to the pipeline.

yaml
1- name: Firebase App Distribution
2 uses: wzieba/Firebase-Distribution-Github-Action@v1.2.2
3 with:
4 appId: your app id
5 token: ${{secrets.FIREBASE_APP_TOKEN}}
6 file: build/app/outputs/apk/release/app-release.apk
7 groups: Tester

See Firebase Distribution Github Action, for more information. Note that only apk file is allowed for App Distribution.

4-3. Product Flavor

When you use product flavor with Flutter, specify --flavor <flavor name> in build command. See build variants for product flavor

bash
1flutter build appbundle --obfuscate --split-debug-info=build/app/outputs/symbols --release --flavor jp

With flavor, you can generate different app id with the same code.

gradle
1android {
2
3 defaultConfig {
4 applicationId "xxx.yyy.zzz"
5 ...
6 }
7
8 flavorDimensions "targetArea"
9 productFlavors {
10 jp {
11 applicationIdSuffix ".jp"
12 dimension "targetArea"
13 }
14 eu {
15 applicationIdSuffix ".eu"
16 dimension "targetArea"
17 }
18 }

With flavor, the app id will be the combination of applicationId and applicationIdSuffix. For example, if you specify --flavor jp, the app id will be xxx.yyy.zzz.jp.

You can also modify manifest file with flavor. Suppose you will use Google AdMob, then you need to add applicationId to the manifest file. You can do this by setting manifest files like below.

txt
1android/app/src
2 |- main/AndroidManifest.xml
3 |- jp/AndroidManifest.xml
4 |- eu/AndroidManifest.xml

Put base manifest file at android/app/src/main, then put each flavor dependent manifest file at android/app/src/<flavor name>. Then built app will contain both main manifest and flavor manifest.

xml
1<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2 package="xxx.yyy.zzz">
3 <uses-permission android:name="android.permission.INTERNET" />
4 <application>
5 <meta-data
6 android:name="com.google.android.gms.ads.APPLICATION_ID"
7 android:value="your admob app id"/>
8 </application>
9</manifest>

4-4. --dart-define

This option enables you to pass environment variables to the app.

bash
1flutter build appbundle --obfuscate --split-debug-info=build/app/outputs/symbols --release --flavor jp --dart-define=REGION=asia-northeast1

This commands generates environment named REGION which describes the region of the backend server, for example. The app can read the environment by below code.

dart
1String region = String.fromEnvironment('REGION'); // get 'asia-northeast1'

4-5. Register an app to Google Play Store

When you register your app to Google Play Store, use this task.

yaml
1- name: Create service account json
2 env:
3 SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON}}
4 run: echo $SERVICE_ACCOUNT_JSON > android/app/service-account.json
5- uses: r0adkll/upload-google-play@v1
6 with:
7 serviceAccountJson: android/app/service-account.json
8 packageName: your app id
9 releaseFiles: build/app/outputs/bundle/release/app-release.aab
10 track: alpha
11 whatsNewDirectory: release-notes
12 mappingFile: build/app/outputs/mapping/release/mapping.txt

First task generates service-account.json which is necessary to access Play Store from CI server. Then specify this file in the second task. Please see upload google play for more information.

References

Build and release an Android app

Encrypted Secrets

Building the app for release

FlutterFire - android installation

Firebase CLI

Firebase Distribution Github Action

build variants

upload google play

Share:

Related Articles

How to localize app in Flutter
Guides

How to localize app in Flutter

Learn how to localize your Flutter app using arb files. This article is based on Flutter 2.0.1.

mark241
Describe Azure resources as ARM Template
Guides

Describe Azure resources as ARM Template

ARM Template is a json file that defines Azure resources. Learn how to create ARM Templates efficiently for deploying new resources.

mark241