How to set up a CI/CD pipeline for a Flutter project on GitHub

Why Automate?
As a developer, it's very tempting to automate repetitively boring tasks. But there is always that little voice inside you that keeps telling you that you're overcomplicating stuff and you shouldn't automate and stick with the manual approach. I like to argue that the amount of time spent on automating a task is worth the time and effort. I'm saying this from my personal experience. I had to publish an app made using Flutter to the Google Play Store. I used a manual approach in the beginning but it was problematic. I followed the wrong steps, or I choose the wrong target or forgot to update the build number. That's when I decided to push myself to the limits and come up with a way to automate the task. I had to figure out a lot of stuff as I couldn't find a guide that was related to my situation. But It was definitely worth it.
In this article, I will show you how to set up a CI/CD pipeline for your Flutter project using GitHub Actions. It's mostly based on a nice article written by Emanuel Moecklin. This is just a minor modification for a Flutter project.
What we will be doing?
- Configure a GitHub Action that will be triggered when making changes to a specific branch (probably your default branch). The action will:
- Run the
flutter analyzecommand to check for lint errors. If you leave your print statements that you used for debugging in your codebase this should catch it for you. - Run the
flutter testcommand to run your automated tests. If the code you are merging breaks anything this step should catch it for you. - Build and publish your Flutter app to the Google Play Store.
- Run the
Requirements
There are a couple of things that you need:
- You need API access to the Google Play Developer Publishing API. In order to get API access, you need to be an owner of the Google Play developer account you're using to publish your app.
- A Flutter project that is hosted on GitHub.
- Gradle Play Publisher Plugin.
- You need to manually build and publish your app for the very first time. After that, it can be automated.
Step 1: Get API access to the Google Play Developer Publishing API
The steps for this task are perfectly described in the article I mentioned above:
- Go to your Google Play Developer Console as the owner (you have to be an owner, unfortunately) of the account and open the API access page:

- Accept the Terms of Service (if not done yet)
- Create a new Google Cloud project if you haven’t created one already (otherwise link an existing one).
- Under Service Accounts click on “Create new service account” and open the link that leads to the Google Cloud Platform:

- In Google Cloud Platform click on “CREATE SERVICE ACCOUNT”:

- Pick a meaningful name and description before hitting the “CREATE” button:

- The account needs the role “Service Account User”:

- You don’t need to grant user access to the new service account, Google Cloud adds the required users automatically with the correct permissions (a Google Play service and your own user) so just hit “DONE”:

- Next you need to create an API key for the account. Open the actions menu (the three dots) and select “Manage keys”:

- Under “ADD Key” select “Create new key”:

- Create a JSON key:

- After hitting the “CREATE” button, the key file will be downloaded to your computer. I recommend renaming the file to make its purpose more obvious:

- Now you’re done in Google Cloud Platform and you can go back to the Google Play Console (to the API access screen). The newly created account should appear under “Service accounts” (hit the “Refresh service account” button). Click on “Grant access”:

- Click on “Add app” and select all apps you want to manage with this service account:

- The Account permissions are already set correctly so that the service can manage all release-related activities (create releases including publication to production, management of metadata, etc.).
- Click on “Invite user” and you’re done. We will use the Gradle Play Publisher plugin to validate the API key setup later on.
Step 2: Create an upload keystore
To publish on the Play Store, you need to give your app a digital signature. On Android, there are two signing keys: deployment and upload. The end-users download the .apk signed with the ‘deployment key’. An ‘upload key’ is used to authenticate the .aab / .apk uploaded by developers onto the Play Store and is re-signed with the deployment key once in the Play Store.
Create the keystone using the following command line:
On Mac/Linux, use the following command:
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
On Windows, use the following command:
keytool -genkey -v -keystore c:\Users\USER_NAME\upload-keystore.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias upload
When you run the command it will ask you for a password. I recommend storing the password somewhere safe for future use. This command stores the upload-keystore.jks file in your home directory. If you want to store it elsewhere, change the argument you pass to the -keystore parameter. However, keep the keystore file private; don’t check it into public source control!
Reference the keystore from the app
Create a file named [project]/android/key.properties that contains a reference to your keystore:
storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=upload
storeFile=<location of the key store file, such as /Users/<user name>/upload-keystore.jks>
Warning: Make sure to add the key.properties file to your .gitignore file so that you don’t check it into public source control.
Here's how I defined it for my flutter project:

Configure signing in gradle
Configure gradle to use your upload key when building your app in release mode by editing the [project]/android/app/build.gradle file:
Add the keystore information from your properties file before the
androidblock:def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } android { ... }Find the
buildTypesblock:buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, // so `flutter run --release` works. signingConfig signingConfigs.debug } }And replace it with the following signing configuration info:
signingConfigs { release { keyAlias keystoreProperties['keyAlias'] keyPassword keystoreProperties['keyPassword'] storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null storePassword keystoreProperties['storePassword'] } } buildTypes { release { signingConfig signingConfigs.release } }Release builds of your app will now be signed automatically.
- Build the app for release from your command line:
flutter build appbundle
The release bundle for your app is created at [project]/build/app/outputs/bundle/release/app.aab. You can now publish the release bundle to the Google Play Store manually, see the Google Play launch documentation.
Step 3: Gradle Play Publisher
While we are now able to build the app and create a signed bundle (or apk), we still need to configure Gradle Play Publisher to publish the signed app to Google Play.
Setup the Gradle Play Publisher plugin:
- Add the plugin to the
[project]/android/app/build.gradlefile:apply plugin: 'com.github.triplet.play' - Add classpath to the dependencies section of the
[project]/android/build.gradlefile:classpath 'com.github.triplet.gradle:play-publisher:3.7.0-SNAPSHOT' - Add maven URL to the repositories section of the
[project]/android/build.gradlefile:maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } - Add a new
playblock to your[project]/android/app/build.gradlefile:
android {...}
play {
artifactDir.set(file("../../build/app/outputs/bundle/release"))
track.set("internal")
}
At this point try to run the app on your emulator/device and if it works you're good to go. But for me I had to tweak certain things for the plugin to work:
- Change the
ext.kotlin_versionvalue in the buildscript section in the[project]/android/build.gradlefile:ext.kotlin_version = '1.6.10' - Change the gradle version in the dependencies section in the
[project]/android/build.gradlefile:classpath 'com.android.tools.build:gradle:7.0.3' - Change the
distributionUrlvalue in the[project]/android/gradle/wrapper/gradle-wrapper.propertiesfile:distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip - I also had to change the
compileSdkVersionto 31 in the[project]/android/app/build.gradlefile:
Step 4: GitHub Actions
First we need to define secrets that will be needed for building our app. We need to define the following secrets:
- GOOGLE_PLAY_API_KEY
- KEYSTORE_FILE
- KEYSTORE_PASSWORD
- KEY_ALIAS
- KEY_PASSWORD
It’s easy to define the three values for KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD since they are just text values. To do so, go to the “Repository settings” and select “Secrets / Actions”. Enter all three variables with the correct values:

To store the KEYSTORE_FILE and the GOOGLE_PLAY_API_KEY as a repository secret variables, we base64 encode the files. The build pipeline will decode it and recreate the original files (see below).
Run the following commands to encode the two files:
base64 -w 0 google-play-api-key.json > google-play-api-key.json.base64
base64 -w 0 playstore.keystore > playstore.keystore.base64
We use the -w flag of the base64 command to disable line wrapping.
Copy the base64 strings and create repository secrets in GitHub. You should have something like this now:

GitHub Action
We have the secrets, now it’s time to create the GitHub Action.
- In your GitHub repository, open the
Actionsmenu:

- Click on the
New workflowbutton:

- Click
configureon the theDartoption because Flutter is based on dart. - Now put the following in the editor:
name: Flutter CI/CD Action
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v1
with:
java-version: '12.x'
- uses: subosito/flutter-action@v1
with:
channel: 'stable' # or: 'beta', 'dev' or 'master'
flutter-version: '2.5.1'
With the above script our GitHub Action will be triggered when code is pushed to the main branch.
- Now we need to run linting and run our unit tests by adding the following to the GitHub Action we just created above (the
nameblock should start as the level as theusesblock):
# Run linting and run our unit tests
- name: Lint and test code
run: cd hamsa_lomi && flutter pub get && flutter analyze && flutter test
- The keystore file needs to be extracted from the secrets and written to a file since our Gradle build takes a file path/name as a parameter. We will use this action for this: https://github.com/timheuer/base64-to-file
# Decode the keystore file containing the signing key
- name: Decode Keystore
id: decode_keystore
uses: timheuer/base64-to-file@v1.1
with:
fileDir: './secrets'
fileName: 'my.keystore'
encodedString: ${{ secrets.KEYSTORE_FILE }}
The Google Play API key file is extracted in the same way:
# Decode the Google Play api key file
- name: Decode Google Play API key
id: decode_api_key
uses: timheuer/base64-to-file@v1.1
with:
fileDir: './secrets'
fileName: 'google-play-api-key.json'
encodedString: ${{ secrets.GOOGLE_PLAY_API_KEY }}
- Now all that’s left to do is build and publish the app:
# Build app with a new build number and publish to play store
- name: Build and publish app
run: |
cd hamsa_lomi
echo storePassword=${{ secrets.KEYSTORE_PASSWORD }} >> ./android/key.properties
echo keyPassword=${{ secrets.KEY_PASSWORD }} >> ./android/key.properties
echo keyAlias=${{ secrets.KEY_ALIAS }} >> ./android/key.properties
echo storeFile="${{ steps.decode_keystore.outputs.filePath }}" >> ./android/key.properties
flutter pub get
flutter build appbundle --build-number $GITHUB_RUN_NUMBER
cd android
export ANDROID_PUBLISHER_CREDENTIALS=$(cat "${{ steps.decode_api_key.outputs.filePath }}")
./gradlew publishBundle
To explain few important points about this part:
We create the
key.propertiesfile before building the app. Remember we didn't commit this file to our source repository so we have to create it everytime before building the app.When we build the app using
flutter build appbundlewe set also set the build number using theGITHUB_RUN_NUMBERGitHub environment variable. This is unique number for each run of a particular workflow in a repository. The build number must be different for every deployment.
To see the entire step, take a look at this GitHub gist.
Disclaimer
I would like to thank Nasrallah Hassan for setting up the Google Play developer account. If you've any questions, feel free to write them on the comment section.