Himanshu Sharma Computer science student pursuing his Bachelor's and working as an SWE Intern. For the past 2 years, has been developing mobile and web apps using Flutter SDK. Open-source enthusiast and co-organizer of Flutter India.

Flutter CI/CD using GitHub Actions

9 min read 2738

Flutter Logo

We are in a state where companies are releasing software and solutions within minutes, and they are doing so by following the Continuous integration (CI) and continuous delivery (CD) set of operating principles.

A CI/CD pipeline makes the automatic delivery of your software more frequent, reliable, and secure. It focuses on higher code quality, and that’s why it is vital for a mobile developer or team.

In this tutorial, you’ll learn how to deploy your Flutter app following CI/CD principles with GitHub Actions as a tool.

This tutorial requires a Google service account, which will be used in GitHub Actions to publish the Android build to the Play Store, so to create a project in GCP, create a service account and select your created project.

N.B., this tutorial assumes you have some prior knowledge of Flutter. If you are new to Flutter, please go through the official documentation to learn about it.

What is GitHub Actions?

GitHub Actions is a CI/CD tool that helps you build, test, and deploy your changes on production directly from your repository. You can use it to set up app releases on certain events like committing a tag in a certain branch of your repository.

Additionally, one doesn’t have to create a workflow for common things across the projects as GitHub has a marketplace from which you can use existing workflows developed by others.

The GitHub Actions workflow uses .yml (“YAML Ain’t Markup Language”) files, which will be stored in the .github directory at the root of your project.

Additionally, GitHub Actions supports different environments and containers like Linux, macOS, Windows, and even VMS.

Getting started

Follow the below steps for initial setup:

  1. Set up a new Flutter project using your favourite IDE or using the Flutter command-line tool
  2. Initialize Git in your new project on your machine and create a new repository associated with your GitHub account
  3. Create the config directory in the root of your flutter project .github and a new directory called workflows. The workflows here will contain all your CI/CD workflows as .yml files

Use a basic Flutter action to build an Android release

Now, you will create a basic Android workflow to help you understand how GitHub Actions works in building your Flutter app.

Create a .yml file, android-release.yml, inside workflows with the following code:

name: Android Release

# 1
on:
  # 2
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

  # 3
  workflow_dispatch:

# 4
jobs:
  # 5
  build:
    # 6
    runs-on: ubuntu-latest

    # 7
    steps:
      # 8
      - uses: actions/[email protected]
      # 9
      - uses: actions/[email protected]
        with:
          distribution: 'zulu'
          java-version: "12.x"
      # 10   
      - uses: subosito/[email protected]
        with:
          # 11
          flutter-version: "3.0.0"
          channel: 'stable'
      # 12
      - name: Get dependencies
        run: flutter pub get

      # Runs a set of commands using the runners shell
      - name: Start release build
        run: flutter build appbundle

The above workflow:

  1. Controls when the workflow will run
  2. Triggers the workflow on push or pull request events for the "master" branch; you can change it according to your requirement
  3. Allows you to run this workflow manually from the Actions tab from your GitHub repo (a workflow run is made up of one or more jobs that can run sequentially or in paralle)l
  4. Contains a single job called build
  5. Contains the type of runner that the job will run on
  6. Uses steps to represent a sequence of tasks that will be executed as part of job
  7. Readies your repository under $GITHUB_WORKSPACE, so your job can access it
  8. Sets up Java so your job can use it for the Flutter app build
  9. Sets up Flutter using the subosito Flutter workflow
  10. Adjusts to the Flutter version you are working with
  11. Runs a single command using the runner’s shell

Problems with this basic action

The problem with this basic workflow is that whenever you push changes in the master branch, this workflow will trigger and start setting up the Java SDK and Flutter SDK every time. So eventually, it will lead to the latency of building your application as you have to set up services every time.



How can you make your workflow faster?

You can make your Flutter workflow faster by caching the Java and Flutter SDKs so that on the next run, it won’t fetch the SDK directly before checking for the existence of the SDKs.

In your main.yml file, make the following changes:

      - uses: actions/[email protected]
        with:
          distribution: 'zulu'
          java-version: "12.x"
          cache: 'gradle' // 1
         
      - uses: subosito/[email protected]
        with:
          flutter-version: "3.0.0"
          channel: 'stable'
          cache: true // 2

You have updated the SDK setup by providing Gradle to be cached with respect to the Java SDK (1) and enabling caching for the Flutter SDK (2).

Next time you run the job post, save the above changes and observe the time spent; you will see a time difference from the basic flow.

Prepare for the Play Store release

Now you will be expanding your workflow to create an Android Play Store release.

Generate a version number

For any new release, you should have a new release version number, so before the build, you need to create a version number using the below job:

# 1
version:
    name: Create version number
    # The type of runner that the job will run on
    runs-on: ubuntu-latest
    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      - uses: actions/[email protected]
      # 2
      - name: Install GitVersion
        uses: gittools/actions/gitversion/[email protected]
        with:
          versionSpec: "5.x"
      - name: Use GitVersion
        id: gitversion
        uses: gittools/actions/gitversion/[email protected]
      # 3
      - name: Create version.txt with nuGetVersion
        run: echo ${{ steps.gitversion.outputs.nuGetVersion  }} > version.txt
      # 4
      - name: Upload version.txt
        uses: actions/[email protected]
        with:
          name: gitversion
          path: version.txt

In the above code, we did the following:

  1. Created a new job version that will be executed before the build job
  2. Installed the GitVersion, a tool used for versioning by looking at your Git history
  3. Posted using GitVersion, placing the version in a version.text file
  4. Uploaded the version.text file as an artifact for the actions system with a name gitversion to be used later in the build job

Sign the app

To publish the app to Play Store, you need to give your app a digital signature using a keystore. Follow this official Flutter Doc on how to do that depending upon your machine:

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

This will store a file with a .jks extension in your home directory or whatever path you provided.

N.B., make sure to add the store password, key password, and key alias in your GitHub repository secrets (from GitHub repository > Secrets > Actions)

Repository Secrets

Facing issues while running the keytool?

If you are facing “’keytool’ is not recognized as an internal or external command” issue, then add the path of the JDK bin to use environment variables, or else install JDK and repeat the path addition to environment variables.

Next, create a new file key.properties under the Android directory of your app and provide the reference to your keystore generated before:

storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=upload
storeFile=<location of the key store file, such as /Users/<user name>/your-keystore-file.jks>

To use this key when building your app in release mode, update your Android-level build.gradle file as below:

  1. Define the keyProperties variable to refer to the key.properties file from the filesystem:
       def keystoreProperties = new Properties()
       def keystorePropertiesFile = rootProject.file('key.properties')
       if (keystorePropertiesFile.exists()) {
           keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
       }
  2. Update the buildTypes and add the signingConfigs as below:
       signingConfigs {
           release {
               keyAlias keystoreProperties['keyAlias']
               keyPassword keystoreProperties['keyPassword']
               storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
               storePassword keystoreProperties['storePassword']
           }
       }
       buildTypes {
           release {
               signingConfig signingConfigs.release
           }
       }

After this, your new build will be created in release mode using the key.


More great articles from LogRocket:


N.B., don’t commit the keystore key and the key.properties file and let them be private.

So you might be wondering, how can our job know whether this key exists in the filesystem and use it as a reference in the key.properties file?

To resolve this, do the following:

  1. Base64 encode your keystore file in your machine using Git Bash or Bash:
    base64 <your-keystore-file.jks>
  2. Create a new secret ANDROID_KEYSTORE_BASE64 in your GitHub repository
  3. Copy the output and paste it as ANDROID_KEYSTORE_BASE64 in your GitHub repository; it’ll remain safe there

Now update the build job in your android-release.yml file:

build:
    name: Create Android Build
    # 1
    needs: version
    runs-on: ubuntu-latest
    steps:
      - uses: actions/[email protected]
      # 2   
      - name: Get version.txt
        uses: actions/[email protected]
        with:
          name: gitversion
      # 3
      - name: Create new file without newline char from version.txt
        run: tr -d '\n' < version.txt > version1.txt
      # 4
      - name: Read version
        id: version
        uses: juliangruber/[email protected]
        with:
          path: version1.txt
      # 5
      - name: Update version in YAML
        run: sed -i 's/99.99.99+99/${{ steps.version.outputs.content }}+${{ github.run_number }}/g' pubspec.yaml
      # 6
      - name: Download Android keystore
        id: android_keystore
        uses: timheuer/[email protected]
        with:
          fileName: upload-keystore.jks
          encodedString: ${{ secrets.KEYSTORE_BASE64 }}
      # 7
      - name: Create key.properties
        run: |
          echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties
          echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/key.properties
          echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
          echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
      - uses: actions/[email protected]
        with:
          distribution: 'zulu'
          java-version: "12.x"
          cache: gradle
      - uses: subosito/[email protected]
        with:
          flutter-version: "3.0.0"
          channel: 'stable'
          cache: true
     
      - name: Get dependencies
        run: flutter pub get

      - name: Start Android Release Build
        run: flutter build appbundle
      # 8
      - name: Upload Android Release
        uses: actions/[email protected]
        with:
          name: android-release
          path: build/app/outputs/bundle/release/app-release.aab

In the above code, you performed the following:

  1. Added dependency on the version job to run this one sequentially
  2. Downloaded the version file uploaded in the first job using the name gitversion
  3. Created a new file without newline char from version.txt
  4. Read the updated version from version1.txt file
  5. Updated the pubspec.yml file with the version ID having the version in it
  6. Decoded the base64 encoded keystore value saved as a secret to ID android_keystore
  7. Created key.properties using the secrets and android_keystore
  8. Uploaded the Android release bundle as an artifact to be used in the next job

Deploy the app

Now, you need to use the bundle and send it to Play Store. Before that, it is time to make use of the service account that you created at the start of this tutorial. If the service account is created, copy the key for that account and store it in secrets as PLAYSTORE_ACCOUNT_KEY.

Next, in your Google Play Console > Users & Permissions, invite the user and add the service account user email here.

If you are not seeing your app in App permissions, make sure that the Google Play Developer API in GCP is enabled for your project.

Next, update the permission of the user so that it has the access to release the app like the admin role.

Now, add a new job deploy in your android-release flow:

deploy:
    name: Deploy Android Build
    # 1
    needs: build
    runs-on: ubuntu-latest

    steps:
    - uses: actions/[email protected]
      # 2
    - name: Get Android Build from artifacts
      uses: actions/[email protected]
      with:
        name: android-release
      # 3
    - name: Release Build to internal track
      uses: r0adkll/[email protected]
      with:
        serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
        packageName: <YOUR_PACKAGE_NAME>
        releaseFiles: app-release.aab
        track: alpha
        status: completed

Here, you did the following:

  1. Added a dependency to run this job sequentially
  2. Downloaded the Android build from artificats using the name android-release
  3. Used the [email protected] workflow with the PLAYSTORE_ACCOUNT_KEY secret, your app package name, the track in which you want to upload the build and its status

After this, push your changes to GitHub and see the workflow deploy your app to the Play Store.

Here’s the complete workflow:

name: Android Release

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

  workflow_dispatch:

jobs:
  version:
    name: Create version number
    runs-on: ubuntu-latest
    steps:
      - uses: actions/[email protected]
      - name: Install GitVersion
        uses: gittools/actions/gitversion/[email protected]
        with:
          versionSpec: "5.x"
      - name: Use GitVersion
        id: gitversion
        uses: gittools/actions/gitversion/[email protected]
      - name: Create version.txt with nuGetVersion
        run: echo ${{ steps.gitversion.outputs.nuGetVersion  }} > version.txt
      - name: Upload version.txt
        uses: actions/[email protected]
        with:
          name: gitversion
          path: version.txt

  build:
    name: Create Android Build
    needs: version
    runs-on: ubuntu-latest
    steps:
      - uses: actions/[email protected]
      - name: Get version.txt
        uses: actions/[email protected]
        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/[email protected]
        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: Download Android keystore
        id: android_keystore
        uses: timheuer/[email protected]
        with:
          fileName: upload-keystore.jks
          encodedString: ${{ secrets.KEYSTORE_BASE64 }}
      - name: Create key.properties
        run: |
          echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties
          echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/key.properties
          echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
          echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
      - uses: actions/[email protected]
        with:
          distribution: 'zulu'
          java-version: "12.x"
          cache: gradle
      - uses: subosito/[email protected]
        with:
          flutter-version: "3.0.0"
          channel: 'stable'
          cache: true
      
      - name: Get dependencies
        run: flutter pub get

      - name: Start Android Release Build
        run: flutter build appbundle

      - name: Upload Android Release
        uses: actions/[email protected]
        with:
          name: android-release
          path: build/app/outputs/bundle/release/app-release.aab

  deploy:
    name: Deploy Android Build
    needs: build
    runs-on: ubuntu-latest
    steps:
    - uses: actions/[email protected]
    - name: Get Android Build from artifacts
      uses: actions/[email protected]
      with:
        name: android-release
    - name: Release Build to internal track
      uses: r0adkll/[email protected]
      with:
        serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
        packageName: <YOUR_PACKAGE_NAME>
        releaseFiles: app-release.aab
        track: alpha
        status: completed

Note:

  • You can combine all these jobs in a single and only job so sharing files between jobs won’t require artifacts publishing, which consumes a free usage limit
  • There is a known issue that sometimes the app is not published on the initial run. So, upload an APKor appbundle built from this pipeline and roll it out for internal users. After that, this workflow will be able to release apps without any issues
  • If you are still facing issues during deployment, make sure all config and permissions are correct, or check this issues page

Flutter web release to GitHub pages

Now create a new web-release.yml workflow and paste the following code:

name: Web Release

on:
  push:
    branches:  [ "master" ]

  pull_request:
    branches: [ "master" ]

  workflow_dispatch:

jobs:
  build:
    name: Create Web Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/[email protected]
      - uses: actions/[email protected]
        with:
          distribution: 'zulu'
          java-version: "12.x"
          cache: gradle
      - uses: subosito/[email protected]
        with:
          flutter-version: "3.0.0"
          channel: 'stable'
          cache: true
      
      - name: Get dependencies
        run: flutter pub get

      - name: Start Web Release Build
        run: flutter build web --release
     
      - name: Upload Web Build Files
        uses: actions/[email protected]
        with:
          name: web-release
          path: ./build/web

  deploy:
    name: Deploy Web Build
    needs: build
    runs-on: ubuntu-latest

    steps:
    - name: Download Web Release
      uses: actions/[email protected]
      with:
        name: web-release

    - name: Deploy to gh-pages
      uses: peaceiris/[email protected]
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: ./

The above workflow is quite similar to the Android workflow, but here you are using the Flutter web build command and later using the peaceiris/[email protected] workflow to deploy the web build to GitHub Pages.

Note:

  • The GITHUB_TOKEN is not a personal access token. It gets automatically created to authenticate in your workflow
  • Make sure the branch in the GitHub Pages section in Settings is set to gh-pages

Conclusion

In this tutorial, you learned about how to set up a GitHub Actions workflow to deploy your Flutter app across the Web and Android. For the next step, you can copy and modify the workflow to directly release the app to the app store or learn about other alternatives of GitHub Actions like CircleCI, GitLab CI, Jenkins, and more.

We hope you enjoyed this tutorial. Feel free to reach out to us if you have any queries. Thank you!

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Himanshu Sharma Computer science student pursuing his Bachelor's and working as an SWE Intern. For the past 2 years, has been developing mobile and web apps using Flutter SDK. Open-source enthusiast and co-organizer of Flutter India.

Leave a Reply