Deploying Flutter applications to Google Play using Github actions
Published on September 9, 22'
github
flutter
devops

Table of contents

Introduction

After or during the development of a Flutter application, usually you'd want to see how it works on real devices and distribute it to other people.

The de facto distribution service for Android apps is Google Play and deploying to it will be covered in this post using GitHub Actions which are very flexible and customizable.

Preparations

Before even considering to deploy an app to Google Play, you need to generate an upload keystore; a repository of certificates and private keys used to verify your application.

To do this, run the following command:

  • On Windows: keytool -genkey -v -keystore %userprofile%\upload-keystore.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias upload

  • On Mac/Linux: keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload

If you want to change the destination of the generated keystore, just change the keystore parameter. Remember the password used for generating the keystore as it will be needed later.

However, keep the file private and do not check it into source control.

Afterwards, create a file called key.properties in your android folder with the following contents:

storePassword=#{STORE_PASSWORD}#
keyPassword=#{KEY_PASSWORD}#
keyAlias=uploadkey
storeFile=./upload-keystore.jks

Also keep the file private and do not check it into source control.

Subsequently, the STORE_PASSWORD and KEY_PASSWORD text will be replaced by secrets located in your Github secrets tab.

To make Gradle automatically use your upload key while building in release mode, a few changes need to be made in the android/app/build.gradle file.

Place the following before the android block:

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
   keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

android {
	 ...
}

This'll be used to load in the keystore information later.

Additionally, signing configuration info must be added before the buildTypes block:

signingConfigs {
   release {
	   keyAlias keystoreProperties['keyAlias']
	   keyPassword keystoreProperties['keyPassword']
	   storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
	   storePassword keystoreProperties['storePassword']
   }
}

buildTypes {
	 ...
}

The buildTypes block has to be changed as well, however:

buildTypes {
   release {
	   signingConfig signingConfigs.release
   }
}

The app will now be automatically signed each time it is assembled into an APK or AAB.

Flutter uses auto versioning and updates the Android and iOS settings based on the version entry in the pubspec.yaml file.

In order to create a dynamic version number, we'll have to edit the version number to something we can change later.

name: app
description: Description.
publish_to: "none"
version: 99.99.99+99

Setting up the workflow

To create a workflow using GitHub actions, create a file with the name of your choosing in your root directory: .github/workflows/<fileName>.yaml

Give the workflow a name and define which branch you want to use to trigger the action:

name: Flutter CI

on:
  push:
    branches: [master]

Creating a version number

The first step of the whole process is creating a version number using generated git tags.

Since we'll need to access some info on the repository, add your personal GitHub token to the repository secrets.

You can generate your GitHub token at this link.

The steps are defined as such:

jobs:
  version:
    name: Create version number
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Fetch all history for all tags and branches
        run: |
          git config remote.origin.url https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
          git fetch --prune --depth=10000
      - name: Install GitVersion
        uses: gittools/actions/gitversion/setup@v0.9.7
        with:
          versionSpec: "5.x"
      - name: Use GitVersion
        id: gitversion
        uses: gittools/actions/gitversion/execute@v0.9.7
      - name: Create version.txt with nuGetVersion
        run: echo ${{ steps.gitversion.outputs.nuGetVersion  }} > version.txt
      - name: Upload version.txt
        uses: actions/upload-artifact@v2
        with:
          name: gitversion
          path: version.txt

To get any info about the repository we need to use the actions/checkout action and furthermore the additional GitVersion actions.

GitVersion is not available by default and needs to be installed beforehand if we want to use the features it offers. The action is available here.

We create the version.txt file with the content being the output of the GitVersion execute command and then upload it to the artifacts section of the repository using the upload-artifact action.

This file will be used later in order to replace the version number inside the pubspec.yaml file.

Building the app

Moving on from the previous step, now we attempt to change the version number and build the application.

jobs:
  build:
    name: Build APK and Create release
    needs: [version]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Get version.txt
        uses: actions/download-artifact@v2
        with:
          name: gitversion
      - name: Create new file without newline char from version.txt
        run: tr -d '\n' < version.txt > version1.txt
      - name: Read version
        id: version
        uses: juliangruber/read-file-action@v1
        with:
          path: version1.txt
      - name: Update version in YAML
        run: sed -i 's/99.99.99+99/${{ steps.version.outputs.content }}+${{ github.run_number }}/g' pubspec.yaml
      - name: Update KeyStore password in gradle properties
        run: sed -i 's/#{KEYSTORE_PASS}#/${{ secrets.KEYSTORE_PASS }}/g' android/key.properties
      - name: Update KeyStore key password in gradle properties
        run: sed -i 's/#{KEYSTORE_KEY_PASS}#/${{ secrets.KEYSTORE_KEY_PASS }}/g' android/key.properties
      - uses: actions/setup-java@v1
        with:
          java-version: "12.x"
      - uses: subosito/flutter-action@v1
        with:
          channel: "beta"
      - run: flutter clean
      - run: flutter pub get
      - run: flutter build apk --release --split-per-abi
      - run: flutter build appbundle --release
      - name: Create a Release in GitHub
        uses: ncipollo/release-action@v1
        with:
          artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/app-release.aab"
          token: ${{ secrets.GH_TOKEN }}
          tag: ${{ steps.version.outputs.content }}
          commit: ${{ github.sha }}
      - name: Upload app bundle
        uses: actions/upload-artifact@v2
        with:
          name: appbundle
          path: build/app/outputs/bundle/release/app-release.aab

Using read-file-action we read the contents of the previously generated version.txt file and, using the sed command, we replace the contents of the pubspec.yaml file (the app version) and the contents of the key.properties file with the secrets we entered before.

To build flutter applications though, we need an additional action called flutter-action - available here.

Specify the version of Flutter you want to use; in this case I use the beta channel because of the experimental features that are needed in my application.

Firstly we clean the project using the flutter clean command, then get the required packages with flutter pub get, build the apk using flutter build apk --release --split-per-abi and finally finish it off with a bundle generation: flutter build appbundle --release.

After all of that is done we create a release using the release-action action, located here and upload the app bundle we just generated as it will be needed in the next workflow step.

Google Play deploy preparations

Before reviewing the final step, a Google service account on the Google Cloud platform needs to be set up in order to deploy to the Google Play Console.

Create a new project and create a service account:

After making an account, click on the three dots on the right side and select Manage keys to generate a new access key which will be used to deploy the app.

When making the key, the JSON option is encouraged:

Copy the contents of the JSON file to your GitHub secrets tab under the name PLAYSTORE_ACCOUNT_KEY.

After creating the service account, enable API access on your Google Play Console project and link it with the Google Cloud Platform project.

Service accounts should automatically be displayed under the Service accounts section.

Deploying to Google Play and creating a release

⚠️ Important note: You may need to create an initial release by manually uploading an Android bundle and filling out the rest of the required info

The final step in the workflow is deploying the generated AAB file to the Play Store onto a track of your choosing and then creating a release on GitHub and Google Play.

jobs:
  release:
    name: Release app to internal track
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Get appbundle from artifacts
        uses: actions/download-artifact@v2
        with:
          name: appbundle
      - name: Release app to internal track
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
          packageName: com.app.package_name
          releaseFiles: app-release.aab
          track: alpha
          status: completed
		  

Using the checkout action we get the AAB from the artifacts section and afterwards use the upload-google-play action to upload the bundle to the Google Play Console.

The upload-google-play action is located here. It offers quite a few parameters used to release the app, the most important being:

  • serviceAccountJsonPlainText - The JSON record fetched from the Google Cloud Platform service account
  • packageName - The package name generated from your pubspec.yaml file, you can change this at any point before an initial release, but it needs to stay the same on every subsequent release
  • releaseFiles - Generated files from the previous step, located in the Artifacts section
  • track - The track you want to release the file to, in this instance the Closed testing track
  • status - An additional parameter indicating the state of the application

The whole workflow file

name: Flutter CI

on:
  push:
    branches: [master]
jobs:
  version:
    name: Create version number
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Fetch all history for all tags and branches
        run: |
          git config remote.origin.url https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
          git fetch --prune --depth=10000
      - name: Install GitVersion
        uses: gittools/actions/gitversion/setup@v0.9.7
        with:
          versionSpec: "5.x"
      - name: Use GitVersion
        id: gitversion
        uses: gittools/actions/gitversion/execute@v0.9.7
      - name: Create version.txt with nuGetVersion
        run: echo ${{ steps.gitversion.outputs.nuGetVersion  }} > version.txt
      - name: Upload version.txt
        uses: actions/upload-artifact@v2
        with:
          name: gitversion
          path: version.txt
  build:
    name: Build APK and Create release
    needs: [version]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Get version.txt
        uses: actions/download-artifact@v2
        with:
          name: gitversion
      - name: Create new file without newline char from version.txt
        run: tr -d '\n' < version.txt > version1.txt
      - name: Read version
        id: version
        uses: juliangruber/read-file-action@v1
        with:
          path: version1.txt
      - name: Update version in YAML
        run: sed -i 's/99.99.99+99/${{ steps.version.outputs.content }}+${{ github.run_number }}/g' pubspec.yaml
      - name: Update KeyStore password in gradle properties
        run: sed -i 's/#{KEYSTORE_PASS}#/${{ secrets.KEYSTORE_PASS }}/g' android/key.properties
      - name: Update KeyStore key password in gradle properties
        run: sed -i 's/#{KEYSTORE_KEY_PASS}#/${{ secrets.KEYSTORE_KEY_PASS }}/g' android/key.properties
      - uses: actions/setup-java@v1
        with:
          java-version: "12.x"
      - uses: subosito/flutter-action@v1
        with:
          channel: "beta"
      - run: flutter clean
      - run: flutter pub get
      - run: flutter build apk --release --split-per-abi
      - run: flutter build appbundle --release
      - name: Create a Release in GitHub
        uses: ncipollo/release-action@v1
        with:
          artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/app-release.aab"
          token: ${{ secrets.GH_TOKEN }}
          tag: ${{ steps.version.outputs.content }}
          commit: ${{ github.sha }}
      - name: Upload app bundle
        uses: actions/upload-artifact@v2
        with:
          name: appbundle
          path: build/app/outputs/bundle/release/app-release.aab
  release:
    name: Release app to internal track
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Get appbundle from artifacts
        uses: actions/download-artifact@v2
        with:
          name: appbundle
      - name: Release app to internal track
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
          packageName: com.app.package_name
          releaseFiles: app-release.aab
          track: alpha
          status: completed

Conclusion

Even though setting up automatic deployments is a chore, it is extremely useful in the long run because of the lack of additional work needed to upload apps to external sources.

©   Matija Novosel 2024