Automating Flutter Deployments with Fastlane and GitHub Actions - My Experience

Living life on the Fastlane!!

This is a documentation of my experience setting up a CI/CD flow including deployments to Google Playstore, App Store, and Huawei App Gallery with Fastlane. With every step, I will be discussing the issues I had and how I came about solving them.

Installation

You need Ruby installed on your local machine to get Fastlane to work. If you don't have Ruby installed, you can follow the setup instructions outlined in this link. In my case, it didn't go according to plan as my ruby version was outdated. So I had to use rvm. Rvm, much like Fvm is used to manage multiple versions of the same environment. In this case, it's ruby. Documentation for rvm can be found here.

After deciding to use rvm, I had to find a link to download the GPG file that worked, the links can be found here.

Snippets of the commands to do the above look like this:

//To install rvm, first get GPG keys
gpg2 --keyserver keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB

//Then download rvm
\curl -sSL https://get.rvm.io | bash -s stable

Now that you have your rvm installed, you can navigate to your Android or iOS folder in your Flutter project using a new version of Ruby. You can do that using the following commands.

//To list ruby versions that exist
rvm list known

//To download a particular ruby version, replace 3.2.2 with your version
rvm install 3.2.2

//To use that version in your current project, replace 3.2.2 with your version
rvm use 3.2.2

If your ruby version is updated i.e. >= 2.5 or rvm has been installed correctly, you can proceed to install bundler and then install Fastlane. The procedure to do that can be found here.

Setting up Fastlane

Setting up Fastlane was very straightforward, the documentation for both platforms can be found here and here. In essence, you initialize Fastlane using the command:

fastlane init

Afterward, you give all the needed information and then you're done! I didn't have any issues with this section. After setting up Fastlane successfully, you should have both Appfile and Fastfile files in a fastlane folder inside your android and ios directories. We will be heavily using .env files in this implementation. You'll need to add the below inside inside each Gemfile in your android and ios folder. We will be using an env file in our android and ios folders.

gem 'dotenv-rails'
💡
It is recommended to always add .env files to your gitignore file. They contain very sensitive information that should not be checked into version control. You can add it to your gitignore with *.env in your gitignore file.

Writing Lanes

In this section, we'll be looking at the peculiarities of my deployment to the platforms specified in the title of this article. I will assume you have the credentials listed below:

  • Your package name (com.company.app)

  • Your service account JSON file. You can get your service account JSON file by following the steps in the Collect your Google credentials section here.

  • Your credentials for deploying to the Huawei App Gallery; HUAWEI_CLIENT_ID , HUAWEI_CLIENT_SECRET , HUAWEI_APP_ID

  • The path to your release apk or aab

  • Your medium of delivering the above information. We used a .env file in the root directory of our android and ios folders.

Before we move on, it is important to specify what the AppFile looks like, we declare the app identifier i.e. package name as shown below.

# android/fastlane/Appfile
package_name("#{ENV['APP_IDENTIFIER']}")

Huawei

We used the huawei_appgallery_connect plugin to upload our release APK to the Huawei App Gallery. Our lane looked like this:

# android/fastlane/Fastfile
 desc "Release for Huawei App Gallery"
 lane :huawei_deploy do
    is_aab = true # This is hardcoded for now, can change for your case.

    if ENV['HUAWEI_CLIENT_ID'].nil? || ENV['HUAWEI_CLIENT_SECRET'].nil? || ENV['HUAWEI_APP_ID'].nil? || (is_aab && ENV['AAB_PATH'].nil?) || (!is_aab && ENV['APK_PATH'].nil?)
      UI.user_error!("You need to provide HUAWEI_CLIENT_ID, HUAWEI_CLIENT_SECRET, HUAWEI_APP_ID and #{is_aab ? 'AAB_PATH' : 'APK_PATH'} to upload to Huawei App Gallery Connect.}")
    end

    root_path = nil
    full_path= nil

    Dir.chdir("..") do
      Dir.chdir("..") do
        root_path = Dir.pwd
      end
    end

    full_path = root_path + '/' + (is_aab ? ENV['AAB_PATH'] : ENV['APK_PATH'])  
    if File.exist?(full_path)
      huawei_appgallery_connect(
        client_id: ENV['HUAWEI_CLIENT_ID'],
        client_secret: ENV['HUAWEI_CLIENT_SECRET'],
        app_id: ENV['HUAWEI_APP_ID'],
        apk_path: full_path,
        # OPTIONAL, Parameter beyond this are optional:-
        # if you're uploading aab instead of apk, specify is_aab to true and specify path to aab file on full_path
        is_aab: is_aab,
        submit_for_review: false,
        # If you are facing errors when submitting for review, increase the delay time before submitting the app for review using this option:
        # delay_before_submit_for_review: 20,
      )
    else
      UI.user_error!("No file found at path: #{full_path}")
      UI.user_error!("Make sure you have provided the correct path to the #{is_aab ? 'AAB' : 'APK'} file.")
    end
  end

Let's take a look at the code line by line. We declare the description of the lane as Release for Huawei App Gallery first. This ensures future developers and stakeholders can follow along. We name this lane huawei_deploy .

Moving on, we check for the existence of needed documents for this lane to run properly including HUAWEI_CLIENT_ID , HUAWEI_CLIENT_SECRET , HUAWEI_APP_ID , AAB_PATH or APK_PATH (depending on if you're uploading an APK or App bundle).

# android/fastlane/Fastfile
is_aab = true # This is hardcoded for now, can change for your case.
if ENV['HUAWEI_CLIENT_ID'].nil? || ENV['HUAWEI_CLIENT_SECRET'].nil? || ENV['HUAWEI_APP_ID'].nil? || (is_aab && ENV['AAB_PATH'].nil?) || (!is_aab && ENV['APK_PATH'].nil?)
  UI.user_error!("You need to provide HUAWEI_CLIENT_ID, HUAWEI_CLIENT_SECRET, HUAWEI_APP_ID and #{is_aab ? 'AAB_PATH' : 'APK_PATH'} to upload to Huawei App Gallery Connect.}")
end

is_abb is used to determine the kind of resource we're submitting to the Huawei App gallery; APK, or App bundle. You can get the HUAWEI credentials from your dashboard. Next, we find the full path of the apk. We do that using this snippet:

# android/fastlane/Fastfile
    root_path = nil
    full_path= nil

    Dir.chdir("..") do
      Dir.chdir("..") do
        root_path = Dir.pwd
      end
    end

    full_path = root_path + '/' + (is_aab ? ENV['AAB_PATH'] : ENV['APK_PATH'])

We use the full_path as an input to the plugin to upload the apk to the app gallery. We call the plugin in the following snippet:

    # android/fastlane/Fastfile
    if File.exist?(full_path)
      huawei_appgallery_connect(
        client_id: ENV['HUAWEI_CLIENT_ID'],
        client_secret: ENV['HUAWEI_CLIENT_SECRET'],
        app_id: ENV['HUAWEI_APP_ID'],
        apk_path: full_path,
        # OPTIONAL, Parameter beyond this are optional:-
        # if you're uploading aab instead of apk, specify is_aab to true and specify path to aab file on full_path
        is_aab: is_aab,
        submit_for_review: false,
        # If you are facing errors when submitting for review, increase the delay time before submitting the app for review using this option:
        # delay_before_submit_for_review: 20,
      )
    else
      UI.user_error!("No file found at path: #{full_path}")
      UI.user_error!("Make sure you have provided the correct path to the #{is_aab ? 'AAB' : 'APK'} file.")
    end

Here, we check if an apk or app bundle exists at the path we got before, and then we call the huawei_appgallery_connect plugin. The parameters of this call are especially straightforward. If no files exist for this path, we send error messages to the user to check the path.

To run this lane, the command below was used by me:

# android
bundle exec fastlane huawei_deploy

Playstore

Deploying to Playstore was quite straightforward, and I have a recommendation and advice upon completing the deployment.

  • The first recommendation would be to store the Base64 encoded version of your service account JSON file instead of the raw file. This improves security by adding a layer of encryption and making it easier to use in multiple CI/CD flows.

  • My advice is that you'll have to manually update the app version name and number in your pubspec.yaml when using this plugin. As of writing this article, the field for version number doesn't work.

Moving on, the lane for deploying to Playstore looks like this.

  # android/fastlane/Fastfile
  lane :playstore_deploy do
    is_aab = true

    if ENV['FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY'].nil? || (is_aab && ENV['AAB_PATH'].nil?) || (!is_aab && ENV['APK_PATH'].nil?)
      UI.user_error!("You need to provide FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY and #{is_aab ? 'AAB_PATH' : 'APK_PATH'} to upload to Google Playstore.}")
    end

    root_path = nil
    full_path= nil

    Dir.chdir("..") do
      Dir.chdir("..") do
        root_path = Dir.pwd
      end
    end

    full_path = root_path + '/' + (is_aab ? ENV['AAB_PATH'] : ENV['APK_PATH'])
    encoded_json_key = ENV['FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY']
    decoded_json_key = Base64.decode64(encoded_json_key)

    if File.exist?(full_path)
      upload_to_play_store(
        track: "beta", #should be production, can be alpha, beta
        package_name: ENV['APP_IDENTIFIER'],
        aab: is_aab ? full_path : nil,
        apk:  !is_aab ? full_path : nil,
        json_key_data: decoded_json_key,
        in_app_update_priority: 5, #This is to ensure highest priority update for in app updates
      )
    else
      UI.user_error!("No file found at path: #{full_path}")
      UI.user_error!("Make sure you have provided the correct path to the #{is_aab ? 'AAB' : 'APK'} file.")
    end
  end

We first check for the existence of the essential elements needed for this lane to work properly:FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY , AAB_PATH , APK_PATH . The next few lines of code are familiar as we've covered them before. Next, we decoded our service account file in the snippet below:

# android/fastlane/Fastfile
encoded_json_key = ENV['FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY']
decoded_json_key = Base64.decode64(encoded_json_key)

We then pass decoded_json_key to the upload_to_play_store plugin to upload your apk/aab to playstore.

    # android/fastlane/Fastfile
    if File.exist?(full_path)
      upload_to_play_store(
        track: "beta", #should be production, can be alpha, beta
        package_name: ENV['APP_IDENTIFIER'],
        aab: is_aab ? full_path : nil,
        apk:  !is_aab ? full_path : nil,
        json_key_data: decoded_json_key,
        in_app_update_priority: 5, #This is to ensure highest priority update for in app updates
      )
    else
      UI.user_error!("No file found at path: #{full_path}")
      UI.user_error!("Make sure you have provided the correct path to the #{is_aab ? 'AAB' : 'APK'} file.")
    end

If the file exists on the path generated, we call upload_to_play_store . We pass in the track , package_name , json_key_data , and in my case in_app_update_priority (to ensure users are always updated at all times).

To run this lane, I used the command here:

# android
bundle exec fastlane playstore_deploy

After getting Huawei and Playstore deployments to work correctly, my env file looked like this:

path: ./android/

APP_IDENTIFIER=co.company.app
HUAWEI_CLIENT_ID=***
HUAWEI_CLIENT_SECRET=***
HUAWEI_APP_ID=****
APK_PATH=build/app/outputs/apk/production/release/app-production-release.apk
AAB_PATH=build/app/outputs/bundle/release/app.aab
FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY=**** //your service account json in base64 format

App store

I had many issues deploying to Testflight with Fastlane as you might imagine. I will show my final lane and the fixes I had to make to get it working. Let's take a look at my Appfile .

# ios/fastlane/Appfile
app_identifier("#{ENV['APP_IDENTIFIER']}") # The bundle identifier of your app
apple_id("#{ENV['IOS_APPLE_ID']}") # Your Apple Developer Portal username

itc_team_id("#{ENV['IOS_ITC_TEAM_ID']}") # App Store Connect Team ID
team_id("#{ENV['IOS_TEAM_ID']}") # Developer Portal Team ID

My final lane Fastfile looks like this:

# ios/fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Push a new build to TestFlight"
  lane :ios_deploy do
    ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "120"
    build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
    upload_to_testflight(
      notify_external_testers: false
    )
  end
end

The first two lines of code declare that this lane runs on the ios platform. The name of the lane is ios_deploy.

The first fix I had to make was the next line of code:

# ./ios/fastlane/Fastfile
ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "120"

The line of code above was used to address the issue that can be found here. The remaining two lines of code are self-explanatory, they are; build the app and upload it to Testflight. You can choose to set the field notify_external_testers to true or false if your app-specific password has the authorization for that or not.

I had some other issues while trying to upload to Testflight. They are highlighted below:

  • An issue with authorization to get username and password, I had to create an app-specific password and add it to my env file with the key FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD . More info can be found in this issue here

  • Added this update source="$(readlink -f "${source}")" to the file /Pods/Target Support Files/Pods-APPNAME/Pods-APPNAME-frameworks.sh as described here.

  • I added these other ENV variables

    • IOS_APPLE_ID which is the email to your developer account.

    • FASTLANE_PASSWORD which is the password to said account

After I got the Testflight upload to work successfully, this is how my env file looked:

path: ./ios/

APP_IDENTIFIER=co.company.app
IOS_APPLE_ID=name@mail.co
IOS_ITC_TEAM_ID=*****
IOS_TEAM_ID=*****
FASTLANE_USER=name@mail.co
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=appe-spec-passw-orde

To run this lane, I used the command:

# ./ios/
bundle exec fastlane ios_deploy

Working with GitHub Actions

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. You can create workflows that build and test every pull request to your repository, or deploy merged pull requests to production. I will not be getting into the nitty-gritty of setting up GitHub actions; however, I will show how to make it work with the lanes we have written.

My final Github Workflow file looks like this:

# ./.github/workflow.yaml
env:
  keyAlias: ${{ secrets.KEYALIAS }}
  keyPassword: ${{ secrets.KEYPASSWORD }}
  storePassword: ${{ secrets.STOREPASSWORD }}
  HUAWEI_CLIENT_ID: ${{ secrets.HUAWEI_CLIENT_ID }}
  HUAWEI_CLIENT_SECRET: ${{ secrets.HUAWEI_CLIENT_SECRET }}
  HUAWEI_APP_ID: ${{ secrets.HUAWEI_APP_ID }}
  FLUTTER_VERSION: ${{secrets.FLUTTER_VERSION }}
  AAB_PATH: ${{ secrets.AAB_PATH }}
  APK_PATH: ${{ secrets.APK_PATH }}
  FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY }}

jobs:
  build_and_deploy_for_playstore_huawei:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Setup Java
        uses: actions/setup-java@v3.9.0
        with:
          distribution: "zulu"
          java-version: "11"
      - uses: subosito/flutter-action@v2.8.0
        with:
          channel: "stable"
          flutter-version: ${{ env.FLUTTER_VERSION }}
      - name: Build APK
        run: |
          flutter pub get
          flutter packages get
          flutter test
          flutter build appbundle --flavor production --release
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2.2'
          bundler-cache: true
      - name: Install dependencies
        run: |
          cd android
          gem install bundler
          bundle install
      - name: Deploy To Google Playstore
        uses: maierj/fastlane-action@v3.0.0
        with:
          lane: huawei_deploy
          subdirectory: android

First, notice how the env variables declared at the top match the keys of the variables in our local env file. This is essential for interoperability. To create secrets (essentially environment variables) for your repository, you can follow the documentation here.

  # ./.github/workflow.yaml
  HUAWEI_CLIENT_ID: ${{ secrets.HUAWEI_CLIENT_ID }}
  HUAWEI_CLIENT_SECRET: ${{ secrets.HUAWEI_CLIENT_SECRET }}
  HUAWEI_APP_ID: ${{ secrets.HUAWEI_APP_ID }}
  FLUTTER_VERSION: ${{secrets.FLUTTER_VERSION }}
  AAB_PATH: ${{ secrets.AAB_PATH }}
  APK_PATH: ${{ secrets.APK_PATH }}
  FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.FASTLANE_GOOGLE_SERVICE_ACCOUNT_KEY }}

Next, we use the ruby/setup-ruby@v1 action to install Ruby on our virtual machine. We install the dependencies needed to run Fastlane i.e bundler using gem install bundler .

      # ./.github/workflow.yaml
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2.2'
          bundler-cache: true
      - name: Install dependencies
        run: |
          cd android
          gem install bundler
          bundle install
      - name: Deploy To Huawei App Gallery
        uses: maierj/fastlane-action@v3.0.0
        with:
          lane: huawei_deploy
          subdirectory: android

We then call the lane of choice, this time huawei_deploy , to deploy to Huawei App Galley, we can follow this format for Playstore too. Next time, we will look at the GitHub Action for iOS.

Conclusion

I hope my experience with automating flutter deployments with Fastlane made for a good read. I certainly enjoyed my time researching and writing up the code, and I hope you do too. If you have any questions, please feel free to reach out to me on Twitter or via email.

Have a great day!