Flutter + Fastlane (Part 1)

Flutter + Fastlane (Part 1)

Automate your flutter app deployments locally


Let's start by understanding what CI/CD means. CI/CD stands for Continuous Integration and Continuous Delivery/Deployment, which is a process in software development that automates testing, building, and deploying code changes.

Continuous Integration focuses on frequently merging new code or changes to existing code, ensuring that it doesn't break the software. This is typically done using automated tests.

Continuous Delivery/Deployment automates the process of deploying new code changes to production or a testing environment.

CI/CD enables teams to deliver software faster and with better quality, while also reducing the risk of errors or bugs. It is a crucial practice in software development that helps streamline the development process.

By the end of this article, we would have a working CI/CD pipeline that tests, builds and deploys our Flutter application to Google Play Store, Apple App Store and Firebase Hosting.

Checklist

Checklist is a simple to-do app built in Flutter and available on GitHub, Play Store, App Store and Web.

It is also important to note that for an app to have a Continuous Delivery/Deployment setup, it must already be available in the store. That's why we'll be using Checklist in this article.

Fastlane

Fastlane is an open-source automation tool used primarily for mobile app development. It simplifies the process of building and deploying applications by automating tasks that are otherwise time-consuming.

Some of the tasks that can be automated using Fastlane include building the app, running tests, managing code signing, distributing beta builds, and releasing them to the stores.

Setting up Fastlane would require Ruby and Bundler to be available on your system. If you are on a Mac then you already have Ruby and can verify that using the ruby --version command (a minimum of Ruby 2.5 is required). For more info on setting up Ruby env and Bundler visit the documentation on Fastlane.

On a Mac, Fastlane can be easily installed using homebrew by running brew install fastlane

Local Automation

Unit Test

Checklist already has unit tests and considering I already have Flutter set up locally, simply running the flutter test command will execute all unit/widget tests. This command will be used in a Makefile to set up a local automation command.

Fastlane would have to be set up separately for Android and iOS so let's move on. Starting with Android.

Android Set Up

To deploy Checklist to the Google Play Store using Fastlane, a new Google Service Account JSON Secret must be generated. I honestly can not provide a better guide to this process than what has been documented on code magic which provides a step-by-step guide with pictures!

Once this JSON secrete is available, continue by placing the file in the android directory and then navigating to the directory in your project cd android. Once in this directory run the following command fastlane init or sudo fastlane init if it gets stuck. This would prompt you for the package name for the project and the path to json secret file. In my case, this was ifeanyi.checklist.android and /Users/ifeanyi/Projects/checklist_app/android/play_store_secret.json respectively.

This JSON Secrete file should be added to .gitignore and not checked into git.

A new Fastlane folder and some files will be generated after the init process completes. The AppFile holds variables used by different Fastlane actions while the Fastfile holds the different lanes which are a distinct set of tasks that can be executed individually or as part of a larger automation process.

The Appfile should look something like this

json_key_file("/Users/ifeanyi/Projects/checklist_app/android/play_store_secret.json")
package_name("<PACKAGE NAME>")

Though it would be better to use relative paths so it would also work flawlessly with GitHub actions which would be covered in the next article. So the AppFile should look like this

json_key_file("./play_store_secret.json")
package_name("<PACKAGE NAME>")

Since this article concentrates on Continuous Delivery/Deployment, the Fastfile will only feature the deploy lane, and everything else will be removed. The gradle task in the deploy lane should also be changed from gradle(task: "clean assembleRelease") to gradle(task: "clean bundle") so that an aab is generated instead of an apk which is the recommended way to upload builds to the Play Store.

Below would be the contents of the Fastfile

default_platform(:android)

platform :android do

  desc "Deploy a new version to the Google Play"
  lane :deploy do
    gradle(task: "clean bundle")

    upload_to_play_store
  end
end

To test this deploy lane we just have to run bundle exec fastlane deploy. This would fail at the current state because certain properties have not been provided to the upload_to_play_store action.

To find all the properties that can be configured for any action simply run bundle exec fastlane action <action name>. In this case that would be bundle exec fastlane action upload_to_play_store.

To get this deployment working we simply need to set the path to the aab generated by gradle. Also since I want to deploy to testing track and not production I will be setting the track property which has the following available options:

  • production: Deploys to Production

  • beta: Deploys to Open testing

  • alpha: Deploys to Closed testing

  • internal: Deploys to Internal testing

The Appfile would look like this now

default_platform(:android)

platform :android do

  desc "Deploy a new version to the Google Play"
  lane :deploy do
    gradle(task: "clean bundle")

    upload_to_play_store(
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      track: "alpha"
    )
  end
end

With this setup, we will face an issue again when we run bundle exec fastlane deploy from within the android directory.

Google Api Error: Invalid request - APK specifies a version code that has already been used. This is because the local.properties file which contains the version used by gradle during the build process does not get updated after the version in pubspec.yaml has been updated. The version in the local.properties is usually automatically updated by Flutter when we run a command like flutter build appbundle but in this case, we would have to update the version directly in the app/build.gradle file after reading it from pubspec.yaml. This would also be beneficial when setting up GitHub action as the local.properties is not added to git.

To resolve this, we would create a new lane that reads the current version from the pubspec.yaml and updates the build.gradle. The newly created lane can then be called from within our deploy lane before the gradle build starts.

This new lane will make use of these community actions/plugins:

  • flutter_version: to read the version from the pubspec.yaml

  • versioning_android: to update the version name and version code

To make use of plugins, they have to be installed first. This can be done using the following command fastlane add_plugin <action_name> from within the appropriate directory (i.e android directory in this case)

Run the following in the command line from within the android directory to add these plugins to Fastlane.

bundle exec fastlane add_plugin flutter_version
bundle exec fastlane add_plugin versioning_android

Adding the first plugin would cause a prompt that asks Should fastlane modify the Gemfile at path ... for you? enter y and press enter. A new Pluginfile will be genrated in the Fatlane folder.

The Pluginfile would look like this

# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!

gem 'fastlane-plugin-flutter_version'
gem 'fastlane-plugin-versioning_android'

After adding the new lane set_full_version, the Fastfile would look like this

default_platform(:android)

platform :android do
  desc "Set Gradle Version and Build Number"
  lane :set_full_version do 
        version = flutter_version()

        android_set_version_name(version_name: version['version_name'])

        android_set_version_code(version_code: version['version_code'])
    end

  desc "Deploy a new version to the Google Play"
  lane :deploy do
    set_full_version()

    gradle(task: "clean bundle")

    upload_to_play_store(
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      track: "alpha"
    )
  end
end

In case you face build issues after the changes to set the version, remove the code that defines the flutterVersionCode and flutterVersionName in the app/build,gradle

// DELETE THESE

def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'
}

def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

And make sure the initial value of the versionName is in a String format like this

    defaultConfig {
        applicationId "<YOUR APPLICATION ID>"
        minSdkVersion flutter.minSdkVersion
        targetSdkVersion flutter.targetSdkVersion
        versionCode 3
        versionName "1.0.1" // Should be a String
    }

With this setup, a new build can be deployed to the Play Store with no issues by first making sure the version has been updated in the pubspec.yaml and then simply running bundle exec fastlane deploy from within the android directory.

Image of a successful android deployment

iOS Set Up

To deploy Checklist to the Apple App Store using Fastlane, first, navigate to the ios directory of the project cd ios. Once in this directory run the following command fastlane init or sudo fastlane init. This would ask What would you like to use fastlane for? and in my case, I want to Automate beta distribution to TestFlight which is option 2. So I enter 2 in the terminal and press enter to continue (This initial selection is needed to create a starting point, you can also create and edit the Fastfile ourselves).

The next step is to enter your Apple ID Username and the prompt then leads you to manually log in and select the Team and Developer portal that would be used.

Like the Android process, a new Fastlane folder and some files will be generated after the init process completes.

The Appfile would look something like this

app_identifier("<The bundle identifier of your app>")
apple_id("<Your Apple Developer Portal username>")

itc_team_id("<App Store Connect Team ID>")
team_id("<Developer Portal Team ID>")

While the Fastfile would have one lane. I changed the name of the lane from beta to deploy just to be consistent with what we have on Android.

default_platform(:ios)

platform :ios do
  desc "Push a new beta build to TestFlight"
  lane :deploy do
    build_app(workspace: "Runner.xcworkspace", scheme: "Runner")

    upload_to_testflight
  end
end

In other to be consistent with where the iOS build is stored after running a command like flutter build ipa, I will update the archive and output directory for the build_app action above. The location of the build would also be updated for the upload_to_testflight action now the contents of the Appfile would be

default_platform(:ios)

platform :ios do
  desc "Push a new beta build to TestFlight"
  lane :deploy do
    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      archive_path: "../build/ios/Runner.xcarchive",
      output_directory: "../build/ios/Runner",
    )

    upload_to_testflight(
      ipa: '../build/ios/Runner/Runner.ipa',
      skip_waiting_for_build_processing: true,
    )
  end
end

Running the current setup will quit with an error saying an app-specific password is needed to authenticate with Apple services.

There are different ways to authenticate with Apple and one option would be to go to appleid.apple.com, sign in, and generate a new App-Specific Password which would then be added to the actions that need authentication.

The recommended way however would be to create a new App Store Connect API Key just like we created a new Google Service Account JSON Secret for Android. And even though it is a relatively short and straightforward process, the code magic documentation still does it better.

There are different ways to integrate this key with Fastlane and they can be found here. In this case, I will use the API Key JSON file approach. After the Key (.p8 file) has been successfully downloaded, create a new JSON file app_store_connect.json inside the ios directory with the following.

This JSON file should also be added to .gitignore and not committed to git.

{
  "key_id": "<KEY-ID>",
  "issuer_id": "<ISSUER-ID>",
  "key": "<CONTENT-OF-THE-DOWNLOADED-.p8-FILE>",
  "duration": 1200,
  "in_house": false
}

It is important to note that the value of the key has to be properly formatted with a \n to match every new line from the .p8 file and should look something like this else you might encounter an error like invalid curve name

{
  ...
  "key": "-----BEGIN PRIVATE KEY-----\nFirstLine\nSecondLine\nThirdLine\nFourthLine\n-----END PRIVATE KEY-----",
  ...
}

We then make use of this file by adding the api_key_path parameter to the upload_to_testflight action.

default_platform(:ios)

platform :ios do
  desc "Push a new beta build to TestFlight"
  lane :deploy do
    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      archive_path: "../build/ios/Runner.xcarchive",
      output_directory: "../build/ios/Runner",
    )

    upload_to_testflight(
      api_key_path: "./app_store_connect.json",
      ipa: '../build/ios/Runner/Runner.ipa',
      skip_waiting_for_build_processing: true,
    )
  end
end

With this setup, we will face an issue again when we run bundle exec fastlane deploy from within the ios directory because of the version issue we faced with Android. We would also have to create a new lane to directly set the version name and build number in the info.plist.

For ios, we only need one community plugin flutter_version. So run the bundle exec fastlane add_plugin flutter_version from within the ios directory and follow the prompt if this is the first plugin added.

The newly created lane can then be called from within our deploy lane before the build_app starts. In the end, our Fastfile would look like this

default_platform(:ios)

platform :ios do
  desc "Set Info.plist Version and Build Number"
  lane :set_full_version do
    version = flutter_version()

    increment_version_number(version_number: version['version_name'])

    increment_build_number(build_number: version['version_code'])
  end

  desc "Push a new build to TestFlight"
  lane :deploy do
    set_full_version()

    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      archive_path: "../build/ios/Runner.xcarchive",
      output_directory: "../build/ios/Runner",
    )

    upload_to_testflight(
      api_key_path: "./app_store_connect.json",
      ipa: '../build/ios/Runner/Runner.ipa',
      skip_waiting_for_build_processing: true,
    )
  end
end

With this setup, a new build can be deployed to the App Store with no issues by first making sure the version has been updated in the pubspec.yaml and then simply running bundle exec fastlane deploy from within the ios directory.

Image of a successful ios deployment

Web

The web application is hosted with Firebase Hosting and to make a new deployment we only need two simple commands. flutter build web and firebase deploy. These commands will be used in the Makefile to deploy for the web.

Makefile

A Makefile is a special file used in software development that contains a set of instructions/rules that are used to build and maintain a software project.

When you run make in the command line, it reads the instructions in the Makefile and executes the necessary commands.

We are going to create a new file at the root of the project called Makefile and setup instructions that:

  • Runs test (unit or widget test)

  • Runs Fastlane deploy lane for android

  • Runs Fastlane deploy lane for iOS

  • Runs Firebase web deployment commands

  • Runs all the above instructions one after the other

The contents of the Makefile are as follows.

test:
    @echo "╠ Running test..."
    flutter test

deploy-android:
    @echo "╠ Sending Android Build to Closed Testing..."
    cd android/fastlane && bundle exec fastlane deploy

deploy-ios:
    @echo "╠ Sending iOS Build to TestFlight..."
    cd ios/fastlane && bundle exec fastlane deploy

deploy-web:
    @echo "╠ Sending Build to Firebase Hosting..."
    flutter build web
    firebase deploy

deploy: test deploy-android deploy-ios deploy-web

.PHONY: test deploy-android deploy-ios deploy-web

The instructions can be executed individually depending on what is needed by using the command make <instruction>.

The deploy instruction would serve as our local automation and by simply running make deploy we can sit back and watch how:

  • All tests(unit or widget) are run

  • A new Android build is deployed to the Closed Testing track

  • A new iOS build is deployed to TestFlight

  • A new web build is deployed to Firebase Hosting

.PHONY is used to prevent name clashes e.g flutter test executes a command named test and we have also defined an instruction named test. if we run make test without adding test to the .PHONY list, it would not work. So it is good practice to list out your commands in the .PHONY list to avoid naming issues.

Conclusion

In summary, using Fastlane in conjunction with Makefile is an efficient way to automate the process of building and deploying your Flutter applications. This allows you to focus on more productive tasks or at least look cool as the computer does the boring work.

In the next article, GitHub Actions would be set up to use the local Fastlane pipeline. That way you can build and deploy our application on a remote computer running in the cloud.

So stay tuned!