CI/CD with Unity, GitHub Actions, and Fastlane

EDIT: Since writing this article, Unity Actions has been rebranded as GameCI. Please check game.ci for the latest documentation.

I’m of the opinion that every modern software project should have a CI/CD pipeline. I don’t think this opinion should be too controversial. I’m also a big fan of the Unity game engine for the ease with which it facilitates game development. Unfortunately, getting a working CI/CD pipeline for a Unity project has not been as easy as I would like. Recently, I’ve had more success with GitHub Actions, so hopefully this guide will help others.

Other options

Before going into GitHub Actions (and specifically Unity Actions), I should mention that there are other options for CI/CD. Unity’s Cloud Build service was where I started, but while it seems to handle the integration part of CI/CD, it didn’t adequately do delivery for me. I would have to manually download the builds and then upload them to Google Play and the App Store. I wanted to automate this distribution as well, so I looked to other options. I considered Jenkins, but I wanted something in the cloud, where I didn’t have to host my own server. For a while, I used Travis CI, and though I ran into a lot of issues, it did work well enough until a point. Whenever I changed anything in my build process, the Travis CI pipeline would inevitably break. I would fix the issues, until I ran into one issue where I found that it was just easier to just switch to GitHub Actions than to fix Travis. I already had my project on GitHub, so the ability to keep the repository and build server tightly coupled also appealed to me. If your project is not on GitHub, then you would likely want to consider one of these other options.

Continuous Integration

If you’re going to use GitHub Actions for your Unity project, I recommend reading the documentation about Continuous Integration with GitHub Actions. I also recommend you create and configure a workflow to use Unity Actions, with Unity Builder as the step used to build.

For my workflow, this is what the integration half looks like (I’ll get to the delivery half in a bit):

https://github.com/finol-digital/Card-Game-Simulator/blob/develop/.github/workflows/main.yml

For the most part, the integration half of my workflow mirrors what the Unity Actions documentation suggests. In order to get Android builds working, I added the “free disk space” and “create android keystore” steps. The “free disk space” step is a workaround for GitHub Actions running out of memory when building for Android. The “create android keystore” is a solution to not keep the keystore in the repo by instead recreating the keystore file by passing in the appropriate secrets.

I am also using a custom build method Cgs.Editor.BuildCgs.BuildProject in order to be able to pass in the keystore passwords and to be able to create an Android app bundle .aab instead of a regular .apk. I’m hoping that in the future, this custom build method will be made unnecessary by being able to provide these parameters to the default Unity Builder build method.

After running the buildForAllPlatforms jobs, executables are built for Android, OSX, Windows, and Linux; but for iOS, an Xcode project is generated instead of an .ipa. Building the .ipa can only be done on macOS, but the Unity Builder action runs on Linux, so we’re going to need to do this build in another job. This is fine, since I wanted to create a second job to handle the distribution of the executables anyway.

Continuous Delivery

This is what the delivery half of my workflow looks like:

https://github.com/finol-digital/Card-Game-Simulator/blob/develop/.github/workflows/main.yml

This releaseToStore job automates the process of submitting new versions to Google Play and the App Store for me. It runs on macOS in order to be able to generate the .ipa, and it will only run after the buildForAllPlatforms job successfully completes and only when a release is published through GitHub. Tying the releaseToStore job to GitHub Releases helps ensure that all platforms stay in sync, with the same version number and release notes.

As an aside, you may have noticed that we made builds for Windows and Linux, but they are not included in the releaseToStore job. This is because I haven’t been able to find a good solution for automating submissions to the Microsoft Store, and if there is a central store for Linux apps, I don’t know what it is. Furthermore, the Unity Builder action does not currently support UWP builds, so for now, I am stuck manually building and submitting UWP builds to the Microsoft Store. And anyone who wants the Win32 or Linux builds can download them directly from GitHub.

I am using Fastlane for the submissions to Google Play and the App Store. Once again, I recommend reading the official Fastlane documentation, and I’ll only give a quick overview of the 3 lanes I created. One small note: I don’t use any Gemfile, since I think I’m OK with Fastlane updating itself. I haven’t run into any issue from the lack of a Gemfile.

This first lane is the simplest. All it does is run the upload_to_play_store action by passing in the path to the .aab file that we built earlier. The only dependencies are setting the ANDROID_PACKAGE_NAME, getting the json_key_file, and updating the metadata folder. I recommend reading the Fastlane documentation about how to get the json key file, which should then be passed in through a secret, as demonstrated in the third step of the releaseToStore job.

This lane is the most complicated, since it actually does 2 things: 1) build the .ipa, and 2) deliver it to the App Store. Before running the ‘ios release’ lane, note the fifth step of the releaseToStore job. That is a workaround to fix the permissions in a Unity Xcode project that was generated on an OS other than macOS.

Fastlane typically requires configuration through an Appfile, Deliverfile, and Matchfile (if you choose to use Match, as I did). I believe that the Appfile, Deliverfile, and Matchfile that I have created should work generically, assuming you set the correct values in your workflow for the environment variables APPLE_CONNECT_EMAIL, APPLE_DEVELOPER_EMAIL, APPLE_TEAM_ID, APPLE_TEAM_NAME, FASTLANE_PASSWORD, MATCH_PASSWORD, MATCH_PERSONAL_ACCESS_TOKEN, MATCH_URL, and IOS_APP_ID. These values should come from your team’s App Store Connect. For the Fastfile that I created, I believe only the profile should need to be changed from “sigh_com.finoldigital.CardGameSim_appstore_profile-path” to match your app. Once again, I recommend reading the official documentation, especially for Match.

This last lane would be the simplest since it just copies part 2 of ‘ios release’, but there is one issue: the OSX executable needs to be signed and packaged before it can be delivered to the Mac App Store. For that, you can use sign-osx-build.sh. To use sign-osx-build.sh, you need to set the correct values for MAC_APPLICATION_CERTIFICATE, MAC_APPLICATION_PASSWORD, MAC_INSTALLER_CERTIFICATE, MAC_INSTALLER_PASSWORD, MAC_APP_ID, and PROJECT_NAME. Here is an example of creating Mac certificate and password through secrets on Travis. You should also create your own .entitlements file to match the needs of your app. If you have sub-modules that need to be deep codesigned, you can include them in MAC_APP_BUNDLE_PATHS.

You can check out the complete project on GitHub. I’m happy with how much quicker I can release updates with this CI/CD pipeline, and I hope that this guide helps others with the same.

Striving to create software that delights.