Recently, I’ve worked on automating some internal processes for building and releasing Flutter applications. Part of this effort included utilizing Fastlane with a Continuous Integration/Delivery platform to build and deploy the app. This blog post will outline the process I followed to run the build on CircleCI after I had configured Fastlane to build and deploy the application from my local machine.
I chose to use CircleCI as a proof of concept platform because it integrates easily with Github, supports MacOS build images, provides a library to support Flutter, and because we were already using it for other projects. However, Fastlane integrates with various CI/CD platforms, and the general concepts for configuring the build covered here should apply across the board.
This blog is part 3 of a three-part series exploring automating Flutter CI/CD on CircleCI. Part 1 covers setting up Fastlane to build and deploy applications, part 2 outlines automating screenshot capture and test runs, and this post discusses configuring CircleCI to automate these processes.
CircleCI Terminology
CircleCI has extensive documentation, and their getting started guide is a good starting place. However, for the sake of coherency, I will summarize the basic terminology that I reference throughout this guide below.
Within a project, the CircleCI build is defined by a configuration YAML that is checked in with the project. The build configuration is composed of jobs and workflows.
A job defines an executor, which may be a virtual machine or a container, along with a list of steps to perform to complete a task. Reusable steps or configurations can be encapsulated in a component called an orb, which can be imported and referenced. CircleCI maintains some common orbs, but many additional orbs are available through the community.
Workflows orchestrate the execution of jobs. In particular, a workflow can define dependencies between jobs, schedule when jobs execute, and define dependencies between the jobs needed to fulfill a task.
Environment variables can be set on a single project and can also be defined once in a context. Then, they can be referenced across multiple projects. This can be used to manage secrets, following the CircleCI security recommendations.
Getting Started
To create my initial configuration, I followed the steps in the CircleCI “Getting Started Guide” to generate a new configuration file for my project, but I didn’t commit it immediately. The CircleCI workflow is triggered each time commits are pushed to the remote repository. I wanted to keep an eye on both my storage usage and build minutes to avoid unnecessary usage of my available credits.
CircleCI workflows support a special job type called approval. This type of job places the workflow in an On Hold
status until someone manually approves it to continue. While the workflow is On Hold
, it will not consume any build minutes.
I added an approval job named build-approval
. Then, I updated the say-hello
job to depend on build-approval
and committed my basic configuration file.
version: 2.1 jobs: say-hello: docker: - image: cimg/base:stable steps: - checkout - run: name: "Say hello" command: "echo Hello, World!" workflows: say-hello-workflow: jobs: - build-approval: type: approval - say-hello: requires: - build-approval
CircleCI picked up the configuration, validated the YAML, and set the workflow status to On Hold
. This verified the basic integration between Github and CircleCI without needing to approve the job.
I didn’t want to inadvertently leave multiple On Hold
workflows running, so I went to the project settings and set Auto-cancel redundant workflows
to true. This setting will cancel prior workflows on the branch when a new one is triggered. By design, it does not work on the default build branch, but it will work on all other project branches.
Now that I had the basic workflow setup in place, I was ready to add project-specific configuration. Our Flutter app runs on both Android and iOS devices, and I had already created two local Fastlane configuration files with a deployment lane.
The deployment lane runs tests, captures app screenshots, builds the application for release, then deploys the app to the App Store. These lanes can execute simultaneously, so I configured separate jobs for each app.
iOS Configuration
For iOS, I had locally configured Fastlane to run and upload to the app store, but the build was not optimized for running on CI. I needed to make a few changes first.
On the upload_to_testflight
command, I set skip_waiting_for_build_processing: true
. This change reduced the number of build minutes used by no longer waiting for the build processing to complete.
While I was experimenting with the configuration on CI, I did not want to inadvertently upload partial or invalid builds to TestFlight, so I temporarily commented out the call to upload_to_testflight
. I also did not want to impact the app store, so I set the verify_only: true
parameter on the upload_to_app_store
action. Once everything else was working, uploads to both were re-enabled.
Finally, I set a Fastlane action, which sets up the keychain and helps with CircleCI setup_circle_ci by adding a new before_all
lane in my iOS fastfile.
before_all do setup_circle_ci end
With these Fastlane tweaks, I was ready to move on to the CircleCI-specific configuration.
Deployment Job
To create the iOS job, the steps are fairly simple. I need a macOS executor with Xcode. Then, I need to install flutter and run the Fastlane deployment lane.
CircleCI has a macOS guide that provides in-depth explanations of the available options for macOS executors. I decided to start out with the simplest and latest configuration, macOS with 14.2.0. Then, I needed to install Flutter on the machine. CircleCI maintains a circleci/flutter orb to install the SDK and project dependencies.
Some of the commands, such as install_sdk_and_pub
use caching, which will use up storage space on the CircleCI plan. This is stated in the documentation, but I missed that initially and it surprised me when my storage credit usage suddenly went up by over 1 GB. I learned that it was important to pay attention to the cache settings when using orbs.
The integration tests and screenshot tests in my application also handle starting and stopping the simulators, so I did not need to configure them. The CircleCI/macOS orb can be used for controlling simulators if external configuration is needed.
I also wanted to make sure that things generally worked the way I expected by keeping the build logs and checking the snapshots were generated correctly. To do this, I need to persist them to the workspace. The store_artifacts
command from CircleCI can be used to specify a path and a destination to keep artifacts from the job.
To save the screenshots, I just had to add the path where they are stored. For the build output, the setup_circle_ci
action allows a directory to be specified for fastlane output by setting an environment variable FL_OUTPUT_DIR
which will define the path for the output. This can be set directly on the job since it is specific to this workflow.
At this point, I have a file that should be able to run the app tests through the Fastlane configuration, but I know the deployment will not work because I haven’t set the credentials yet. I created a temporary lane in Fastlane named all-tests
that ran every step that did not require credentials and plugged it into my config.
# .circleci/config.yml version: 2.1 orbs: flutter: circleci/[email protected] jobs: ios-deployment: macos: xcode: 14.2.0 environment: FL_OUTPUT_DIR: output steps: - checkout - flutter/install_sdk_and_pub: flutter_version: 3.3.10 - flutter/install_ios_gem - run: name: Fastlane ios deployment command: bundle exec fastlane all-tests working_directory: ios - store_artifacts: path: output - store_artifacts: path: ios/fastlane/screenshots/en-US workflows: deployment: jobs: - build-approval: type: approval - ios-deployment: requires: - build-approval context: - ios-fastlane
This let me check that the tests ran, including the snapshot generation, and served as a stable base to configure the build and deploy steps.
Credentials
With the basic test run in place, I needed to set credentials on CircleCI to sign the code and upload to the app store.
Code signing is configured through Match in Fastlane, and the certificates are in a git repository. To allow CircleCI to access the certificate repository, I needed to set up the ssh key for an authorized user. I followed the iOS codesigning guide to complete this step.
The app signing process also requires the password for Match. The fastlane continuous integration documentation indicates this should be set as the MATCH_PASSWORD
environment variable.
I wanted to use this password across multiple projects, so I set it in a context named ios-fastlane
. In CircleCI I navigated to Organization Settings -> Contexts -> Create Context. I named the context, then added the environment variable and value.
Now, I could sign the app, but I also needed to connect to the app store to upload it. The app store authorization uses the App Store Connect API as well as a 2FA (Two-Factor Authentication) enabled account, so I needed to set both types of access.
My Fastlane file uses the app_store_connect_api_key
action for App Store Connect, with the login information specified by the issuer_id
, the key_id
, and the key
.
To keep the key
secret, it needed to be set as an environment variable. I decided to also set the key_id
and issuer_id
the same way in the context, so that they can be easily reused across projects, although this wasn’t strictly necessary.
These variable names are required by the action to be:
APP_STORE_CONNECT_API_KEY_ISSUER_ID
APP_STORE_CONNECT_API_KEY_KEY
APP_STORE_CONNECT_API_KEY_KEY_ID
The appfile in the project contains the apple_id
for uploading to the app store. This must be a 2FA-enabled account, and it needs to be added as an environment variable named FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
.
These were all the variables that needed to be set for our application, but a few others may need to be set depending on the specific project configuration. Those are outlined in the Fastlane continuous integration documentation.
With all of this setup, I went back to the job configuration and switched the Fastlane command to bundle exec fastlane deployment
. After the build succeeded, I restored the code to deploy to TestFlight and switched verify_only
back to false
in the upload_to_app_store
.
I waited to build with a full release until I was also done with the Android configuration.
Android Configuration
The Android configuration is similar to the iOS configuration. Before beginning, I updated the fastfile to prevent inadvertent uploads by setting verify_only:true
in the upload_to_play_store
action.
The setup_circle_ci
setting only applies to iOS, so it does not need to be set in the Android fastfile.
Deployment Job
For building Android apps, I needed to have the ability to start and run emulators, run the integration tests, and collect screenshots. The Android machine utilizes nested virtualization, so they are able to support x86 emulators with reasonable performance. This image is built on Linux and comes pre-installed with the Android platform and sdk manager.
Next, I had to install Flutter, which I was able to do with the CircleCI/Flutter orb that I used for iOS. Note that since this is a different job, the dependencies will all be cached separately from the iOS job.
When I configured iOS, I let the integration tests handle starting and stopping the emulators. I tried this with Android as well, but the first time the application screenshots were collected, they included a notification that the system was unresponsive. Rather than adjusting the emulator startup logic in the tests to optimize it for running on CircleCI, I used the CircleCI/Android orb to create and start the emulators prior to the test run.
The android/start-emulator
command is designed to launch gradle
tests by default, so I set restore-gradle-cache-post-emulator-launch:false
and post-emulator-launch-assemble-command: ‘’
to override this behavior for Flutter. I also used a generic device with lower resource requirements.
The way that I implemented this worked for our application because I am only running one or two emulators at the most, but it required some experimentation to find the right balance for performance and run time. To troubleshoot this, I made sure to persist the screenshots the same way I had for iOS. When I had everything configured, the android configuration (without the iOS code) was:
# .circleci/config.yml version: 2.1 orbs: android: circleci/[email protected] flutter: circleci/[email protected] jobs: android-deployment: executor: name: android/android-machine resource-class: large tag: 2022.12.1 steps: - checkout - flutter/install_sdk_and_pub: flutter_version: 3.3.10 - flutter/install_android_gradle - flutter/install_android_gem - android/create-avd: avd-name: Phone_5.1 install: true system-image: system-images;android-31;default;x86_64 additional-args: -d 50 # This is a 5.1in WVGA device - android/start-emulator: avd-name: Phone_5.1 post-emulator-launch-assemble-command: '' restore-gradle-cache-post-emulator-launch: false - run: name: Fastlane - android deployment command: bundle exec fastlane deployment working_directory: android - android/kill-emulators - store_artifacts: path: android/fastlane/metadata/android/en-US/images/phoneScreenshots workflows: deployment: jobs: - build-approval: type: approval filters: branches: only: main - android-deployment: requires: - build-approval context: - android-fastlane
Credentials
To build the app for Android deployment, I needed to set up the credentials for app signing and deployment. Unlike the iOS configuration, the Android build tools don’t integrate directly with the environment variables. However, they can still be used to set sensitive information, and then it can be output to a file during the build, as long as it is not printed on the console.
I began by configuring app signing. The keystore and app reference to the keystore that were created during the app signing setup had to be added to CircleCI.
First, I had to convert both files to base64, per the CircleCI documentation. I did this by running base64 my_keystore.jks
and base64 key.properties
from a terminal. Then, I created a new context in CircleCI named android-fastlane
and added new environment variables PLAY_STORE_KEYSTORE
and PLAY_STORE_KEYSTORE_INFO
.
To deploy the app, I needed to configure the JSON file that was generated for Supply. I added this in a variable named SUPPLY_JSON_KEY_DATA
. This didn’t need to be encoded to base64.
Now that I had these variables, I would need to be able to get the values when the build ran. To do so, I can pipe them to the output files that are required by the Android build process and defined in the Appfile used by Supply. Because they are secrets, these files must not be persisted, and their contents must not be visible from the console.
I added the following steps to the job, prior to running the fastfile deployment:
- run: echo "$PLAY_STORE_KEYSTORE" | base64 --decode > android/app/key.jks - run: echo "$PLAY_STORE_KEYSTORE_INFO" | base64 --decode > android/key.properties - run: echo "$SUPPLY_JSON_KEY_DATA" > android/fastlane/play-store-credentials.json
With all of this setup, I went back to the job configuration and switched the Fastlane command to bundle exec fastlane deployment
. After the build succeeded, I switched verify_only
back to false
in the upload_to_play_store
.
Now I was done with both the Android configuration and the iOS configuration, I ran a full release. I validated that the app was updated in both stores and that screenshots were uploaded as well. I wasn’t quite done with the configuration, though. I needed to make one more update.
Workflow Filter
When I merge back to main, the CircleCI configuration will be included as other developers branch and merge. This workflow is specific to deployment and should not run on every branch of code.
We wanted to release directly from our main branch, so I updated the build-approval
job. I renamed it to deployment-approval
and set it to only run on the main branch.
- deployment-approval: type: approval filters: branches: only: main
Since the other jobs both require the deployment-approval
step, they are also not triggered from other branches. With this change in place, I was ready to take my code back to the main branch safely.
Conclusion
While working on the CircleCI configuration, I learned a lot about app signing, the app release process and store guidelines, and emulator configuration for both Android and iOS.
The configuration file in this blog post is a good starting point for running the full deployment, but it can and should be optimized for different processes and branch runs.
In particular, running integration tests and capturing screenshots is a complex topic that should be optimized to meet specific organizational needs. I look forward to spending more time tweaking the configuration and exploring CircleCI features to improve the build time and create more robust output.
Automating the release and deployment of our Flutter application was very helpful to us as a team. Prior to this, our builds were not running on CI, so just getting a build running with tests added value even before the release and deployment were added.
Failures from merges became more visible and having screenshots as a build artifact made layout issues easier to identify and test across a variety of devices. When the deployment steps were added, our release process became more robust. The secrets and keys for releasing did not need to be shared for someone new to complete a deployment.
Overall, it just saved us time. For our small application, running the entire deployment process for both app stores, including screenshot generation, takes under 15 minutes. A developer just needs to approve the release, then monitor the build.
For any team doing Flutter development, I recommend taking the time to configure an automated deployment process. The consistency and reliability it adds is worth the initial investment of time and effort.