Either you’re a team or a sole developer, your goal is always to deliver a feature-rich app, but also stable and thoroughly tested. We need to assure both code quality and speed of delivery. In this article, we’re going to explore how you can leverage powerful CI & CD pipelines to achieve your goal.
While many of you may be familiar with the term Continuous Integration (CI), I’m going to leave a short explainer; CI is a practice where developers merge their changes to the master branch as often as possible, preferably in the form of Pull Requests (PR). Before a PR is merged to the master branch, the branch is being built on a build server. Then, the build is tested by fully automated tests. When, and only when, the tests are successful; the PR is ready to be merged. This way, we can assure no broken builds and commits are being merged to the master branch. For the sake of simplicity, I’m only committing straight to the master branch in this walkthrough.
While there are many platforms on which you can run your CI pipeline, we chose Travis for my startup’s last project, and I will use it for this demonstration. Other alternatives include the likes of Circle CI, Cirrus, GitLab and others. The build configuration is very similar for most of them, so you should be able to follow along, regardless of your choice or preference.
Getting started with Travis 👷♂️
Let’s start by creating a new Flutter project.
Now we can push this to a Git repo and connect it to Travis. All public repositories on Travis are eligible for unlimited free builds.
Your repository is connected to Travis. By default, Travis will build your repository on every commit, however, it will not build anything unless we include a Travis configuration. Let’s fix that right now.
A Travis build server is by default a virtual Linux server. Before we configure our CI pipeline, let’s sketch out what we need our pipeline to do for us at this point.
- Install Flutter 🚀
- Test the app ✅
Alright, that’s pretty straight forward. First, Travis needs to know what language we want to use. We’re installing Flutter manually and don’t need any other languages installed, so we’re going with generic.
I’m not going to go into detail with Travis configuration, so I encourage you to check out their documentation and learn yourself. What we’re doing here is cloning the stable branch from the Flutter repo, and adding the binary to our PATH. This way, we can use the flutter command anytime we like. Let’s push this and see what happens!
Now grab a cup of coffee while the build runs, and you should return to all of your tests passing.
Great! We’ve now set up a CI pipeline that autonomously tests every commit to all of your branches. Some of you might think;
Why should I spend time doing this when i can just test locally?
And that’s a fair point, but have you ever had your tests pass on your machine but fail on another? The build server is running a crisp, clean Linux image. This can reduce side effects and weird complications you might get in your local environment. Also, if the tests pass in the CI pipeline, you can tell everyone that fails them locally that they’re wrong. This practice makes it a lot easier to spot broken builds and fix them straight away.
If you’re still unsure a CI pipeline is necessary for you and your team, look up the pros and cons and consider yourself.
Continuous Deployment 🚀
Now that we’ve set up automated tests in a CI pipeline, let’s move on to Continuous Deployment (CD). When your app passes all your tests, you want to push it to a staging environment where your developers or QA team can review the changes in a production-like environment, before it’s pushed to production and the end-user. The practice of automating the deployment of your app to different environments is called Continuous Deployment.
Our CI pipeline is very simple, as we have used it exclusively for testing. Now we also need to build our apps, so that we can deploy them to staging- or production environments. Let’s sketch out the additional steps.
- Previous CI steps
- Build the iOS app
- Build the Android app
We need to build both the Android and the iOS app separately. This introduces some complexity to our pipeline, as iOS apps require a macOS environment with Xcode tooling, and Android apps require Android tooling. Luckily, Travis makes it easy to split our builds to separate jobs. There are several ways of doing this. Here, I’m using Travis’ matrix system to include two separate builds; one for Android running on Linux, and one for iOS running on macOS.
There’s a lot of new things here, but our script section only has one more line — for building the app. Let’s look into this first. While flutter build apk does not require signing by default, flutter build ios does. We’ll get into signing later, but for now, we don’t want our builds to be signed. That’s why we add the — no-codesign flag.
Edit: flutter build ios creates a .app file and not a .ipa file. This results in the need for building the iOS app twice; once with Flutter to get dependencies and other links straight, and once with Xcode to archive the app for the App Store. There’s an open issue on this, but if you need to have control of the Xcode export, you can use the following commands in the /ios folder:
iosxcodebuild -workspace Runner.xcworkspace -scheme Runner -sdk iphoneos -configuration Release archive -archivePath $PWD/build/Runner.xcarchivexcodebuild -exportArchive -archivePath $PWD/build/Runner.xcarchive -exportOptionsPlist exportOptions.plist -exportPath $PWD/build/Runner.ipa
Note that you will also need a exportOptions.plist file containing the configuration for the export:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
Android build 🤖
You might notice that we’ve added android as the pre-installed language for our Android build. This includes packages and binaries required to build Android apps, e.g. sdkmanager. The reason why we also set the JDK version to 8 (which is now deprecated), is that Android tools like sdkmanager does not support later JDK versions. We also specify what version of the Android build tools we want to use, and the Android SDK we want to target. Here, you can go creative with environment variables to fully customize your build.
iOS build 🍏
We added os: osx in our iOS build, which sets the virtual machine to run macOS instead of Linux. We also specify what Xcode version we want to use before we continue as usual.
Great, but this is not deploying anything?
Fastlane for deployment 🏎
We now build our apps and produce binaries that can be manually uploaded to Google Play and App Store. How can we automate the deployment of our app? Let me introduce fastlane, a tool that builds your application and automates the submission Google Play and App Store, as well as their respective testing programs such as TestFlight and Google Play test tracks. There’s a ton of extra automation and integrations, like Slack messaging, documentation generation, version incrementing, etc.
We’re going to assume that we have created the apps with the corresponding bundle identifier/package name on both the Apple Developer portal as well as in the Google Play Console, as well as necessary information and metadata to release our apps straight away; this is out of scope for this article.
To use fastlane, you first need these gems;
Then, we initialize fastlane in both our /android and /ios folders.
In your iOS project, you’ll be prompted to select what you’re using fastlane for. Let’s select #4, manual setup. In your Android project, you’ll be prompted to enter your package name, a path to a JSON secret file (leave this empty for now) and to upload metadata (select no for now).
Android Setup 🤖🔧
Before we can use fastlane, there are some prerequisites. For us to autonomously upload builds, we need some kind of credentials so that the app stores can verify that it’s us. For Google Play, we need to set up a service account as detailed in the fastlane docs. Then, we download the JSON credentials and place them somewhere in our /android folder. This way, fastlane can find and read our credentials. I’ve chosen to rename it to serviceAccount.json and placed it inside my /android/app folder. DO NOT check this file into source control. Then, we edit our Appfile in our /android/fastlane folder, and we’re good to go!
iOS Setup 🍏🔧
For the App Store, fastlane provides us with a tool called match. This allows us to create only one code signing identity that can be shared across your team, which is stored in a private git repo. That’s great news as if you’re familiar with App Store deployment, every time a new device is added or a certificate is expired, we need to generate new ones. This results in a lot of certificates and profiles, where many are duplicates. With match, these are automatically renewed.
First, create a private git repository for your certificates to be stored in. Navigate to your /ios folder, and voilà;
You’ll be prompted to enter the URL to your private git repo, then a Matchfile will be created in /ios/fastlane/. Then we need to create a certificate;
You will be prompted to log in to your Apple Developer account (at our startup, Wobb, we use a separate CI account), to enter the bundle identifier and to set a passphrase to the repo. This passphrase needs to be stored securely. Your git repo should be updated with your certificates, and you should end up with something like this:
To be able to create certificates autonomously, fastlane needs most of this information to be written to the Matchfile, which is used for signing.
We can also provide more information in the Appfile in our /ios/fastlane folder, which is used for building.
Notice how the Apple Developer account password is not set anywhere. You often don’t want to check in any secrets to your git repo. We’ll set this up later, along with the Google Play Service Account for our Android app. The last step is to disable automatic signing in our Xcode project.
That should be it. For now, we’re happy with our configuration!
Right now, fastlane does absolutely nothing. You can happily push the previous changes to your git repo without Travis even caring. Right now our CI pipeline does the following:
- Test the app
- Build the iOS app
- Build the Android app
We want fastlane to bridge the gap between our apps being built and our apps being signed and delivered to Google Play and App Store. With fastlane, we define an automated task as a lane. These lanes are defined in the Fastfile of our respective fastlane folders. For example, a release lane would do the following:
- Sign the built iOS/Android app
- Publish each respective binary to Google Play and App Store 🎉
Android Lane 🚗
Now that we know what our lane to do, let’s get straight to it, starting with Android. While we’ve set up match for our iOS app, we’ll do some additional setup for our Android app. When you create your app in the Google Play Developer Console, you should have enabled something called App Signing by Google Play. This enables us to use a single upload key to sign all of our releases and provides us with a public certificate we can use to register with API providers like Firebase. The upload key we use will be the key that our first release is signed with. It’s crucial to keep this key backed up and locked away. Since we haven’t uploaded any releases to Google Play yet, we can create a new release key which will become our important upload key:
keytool -genkey -v -keystore upload.keystore
-alias upload -keyalg RSA -keysize 2048 -validity 10000
Keep a backup or two of this file, as well as the alias and password. Place in your /android/app folder but do not check in to source control. By default, our app is being signed by your local debug key. We still want this for our debug builds, however, we want to sign our release builds with our newly created keystore so that we can release it to Google Play. To keep your secrets safe and separate from our checked-in build.gradle file, we store the keystore credentials in a key.properties file inside your /android folder. Do not check this into source control.
I hope you’re better at creating passwords than this.
We need to define our release config in our /android/app/build.gradle file. Here, we read our key.properties file to retrieve our keystore and credentials.
Now we’re ready to roll on the Android side of things! When you build your release binary, it’s now signed with your release key. Let’s create a lane for releasing our app to the internal track on Google Play.
We’re using the supply action, where we pass the release track we want to release on, the path of the built apk, and our service account JSON. We could also add other actions, like slack, to perform additional tasks. We can now try to build our app locally, then run our lane to upload the binary to Google Play. Note that you could create an identical lane named e.g. publish, change the track from internal to release, and the app would be submitted to the public. Since our binary has already been built by flutter, fastlane is taking us around 10 seconds to upload to Google Play in a large project.
Be aware that Google Play does not allow uploading another binary with the same build version and build number as an existing release. I will touch on this later. For now, we’re satisfied with our existing lane. Great!
iOS Lane 🚙
As we’ve already set up match, there are no additional steps needed before we create our lane. Where we uploaded our app to the internal track of Google Play earlier, we’ll upload our iOS app to TestFlight. Let’s jump in.
On iOS, we’re using two actions; gym and pilot. Gym builds a .ipa binary, while pilot uploads it to TestFlight. We don’t want to submit it for review, and we only want to distribute it internally. To distribute, however, we need to create a distribution certificate. Thanks to match, this is almost too easy;
Now we’re ready to rock. You can run this lane without running flutter build, as fastlane is building the .ipa binary we need.
Now both of our apps are successfully being distributed using fastlane locally. Perfect! We just need to tell Travis to run these lanes, and our CI/CD pipeline is complete. Since we’re building our binary with fastlane, these builds average us about 3 minutes on a large project with several pods. However, there are a few things we need to set up first. When we’ve been running things locally, all the secrets and credentials we needed has been stored locally. We do not want these secrets in our source control, so we’ll need to somehow get them on our build server without exposing them. Let’s get started with the Travis CLI and environment variables:
After installing, run travis login to connect to your build servers. The Travis CLI enables us to encrypt files that can be decrypted on the build server without exposing our secrets. Right now we have a few secret files:
- other secrets we may have, e.g. Firebase’s google-services.json, API keys, etc.
As well as some variables that we need to set:
- The passphrase for our match repository
- The Apple Developer account username/password
Let’s start with our files. When encrypting multiple files, the easiest way to do it is by packaging all the files into an archive, encrypting the archive, then decrypting and unpacking the archive on the build server:
tar cvf secrets.tar android/key.properties android/app/serviceAccount.json android/app/upload.keystore
This will package all the files into a single secrets.tar file. When we then encrypt this file, Travis will create an encrypted secrets.tar.enc file and automatically add decryption keys as environment variables to our build server. Be sure to not check in the .tar file, but check in the .tar.enc file.
We can then retrieve our secrets.tar file on our build server by adding the above command output to our .travis.yml config. While we’re at it, we might as well add the step where fastlane publishes our app to Google Play and App Store;
We also need to install bundler and run bundle install to retrieve fastlane on our build server. Since we don’t have any sensitive file in our iOS project, we don’t need to add the secrets.tar file to our iOS build. However, as mentioned earlier, we need to add our Apple Developer username & password, as well as our match repository passphrase. We can do this in the settings of our Travis build.
Just like that, all of our secrets and sensitive files are being protected from the public and clumsy developers. We can continue to develop and debug as usual in our local environment, but as soon as we push our commits, Travis will test our branch, and, if successful; build our binaries and deploy them to Google Play and App Store with the help of fastlane. Just a quick note on the iOS build, if you haven’t set a development team for the Runner project in Xcode, the fastlane build will fail; make sure you’ve set the team and the correct certificates (match certificates) in the Xcode project, and disable automatic signing.
Customising our builds 🚚
We’ve now walked through how a simple CI/CD pipeline can be set up using Flutter, Travis, and Fastlane. While the basic foundation has been laid, there’s lots of room for customizations and expansions, especially for teams.
CI triggers ✅
Testing, and especially building and deploying your apps on every commit is neither cost-effective or fast. If your team is using a branch-based workflow, you might benefit from only running tests on pull requests. Travis offers integrations with GitHub, where every PR has to pass the Travis tests/checks to be able to merge. This feature can be helpful to ensure no branches are getting merged without passing all the tests. For the deployment part, you can set up our Travis builds to only run fastlane lanes if we’re building on the alpha/beta/release branch, for example. What matters is that the builds are entirely customizable to fit every team’s specific needs.
Code coverage ☂️
While a pull request has to pass the current tests to be merged, nothing is stopping you from writing new code without writing relevant tests. To combat this bad practice, I’d highly recommend checking your code coverage with tools like codecov.io. You can then integrate with e.g. Travis and GitHub to set up a lower limit for code coverage. Comparing reports and watching your coverage development over time can help boost the quality of your team’s code.
Fastlane actions 🔨
While we’ve set up fastlane to deploy our binaries, there’s a lot of things it can do for you. We can make fastlane run an emulator and take screenshots and deploy metadata to Google Play and App Store automatically. Also, we can integrate it with Slack apps, to notify our workspace every time a new build is available;
We can also publish a Twitter update.
The bigger your project becomes, the longer the build times will get, and the more content you will need to download for each build. There are lots of ways to speed up those builds and cut down on expensive CPU-time. Perhaps the most obvious action we can take is caching what can be cached. The way we set up our Travis builds earlier, will cause our server to download and install Flutter for each build. This can be easily fixed;
And more… 🎉
Since we’re running on Unix VMs, we can pretty much do everything our hearts may desire. We can run emulators and do extended testing, wire up test results to stats dashboards, connect to your IoT-toaster to make a toast on each build, and much more!
While we’ve gone through a basic Travis + Fastlane configuration in this post, I’d recommend looking through the Travis docs and Fastlane docs, as well as check out other CI/CD systems to find one that fits your needs. While I’ve used build matrices in this post, I’d also recommend to check out Travis build stages if you’re looking into extended unit testing, integration testing, etc. to more easily orchestrate your builds.
This is my first of (hopefully) several posts sharing my experiences and thoughts on different emerging technologies and techniques. If you have any questions, please hit me up on Twitter or create an issue in my sample GitHub repo.