Setting up a CI/CD pipeline for Unity Part 2

RunningMattress
6 min readMay 20, 2023

--

In the first article in this series, we discussed how to set up a pipeline to ensure the stability of code changes going into our main branch. This helps to keep our code in an always compiling state and the unit tests passing, this largely covered our CI section.

In this second article, we’ll cover the CD side of things by way of setting up a pipeline for automating the build and release of our project. We’ll reuse a lot of the same concepts from our previous article and stick with GameCI for building.

Let’s define what our CD process needs to cover:

  1. Something will need to trigger the pipeline
  2. We need to checkout the project
  3. Ideally, we should test it before we build it.
  4. Next, we’ll do the actual build of the project
  5. Finally, we need to release to a destination of our choice (In this article we’ll use GitHub Releases)

For our example project, we’re gonna imagine we’re ultimately aiming to ship a mobile project, and for simplicity’s sake, we’ll pick Android for our first pipeline. However with some very minor and easy adjustments everything we’ll talk about here can easily be adjusted for other platforms.

How often to trigger the build

We have a few different options to trigger our build. In a true CI/CD setup you want to deploy as often as possible, but realistically you should scale this to suit your team’s needs and capabilities.

Here’s some options to consider:

  1. Every commit
  2. Every day
  3. Every week

The less frequent the builds the more changes are likely to be included and therefore the more possibilities for bugs to be introduced. So pick your cadence wisely but be cautious about creating more builds than you can test. With that in mind, we’ll create a weekly build trigger for now. As before we’ll make a new workflow file at .github/workflow/build.yml

name: Weekly Build
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 1"

Notice we’ve also added the workflow dispatch trigger as well, this leaves us the freedom to manually start our builds if we need to. Scheduling a GitHub action uses the cron syntax, if you’re not familiar with it I recommend using crontab guru as a great resource for building and understanding expressions.

Checkout and Test

As before we’ll checkout and test in the same way. Unlike before we’re not operating on a PR but the GitHub checkout action will still help us out of the box by checking out the latest code on the branch it’s running on, in our case: main.

We’ll run our tests again now, this may seem counterintuitive since we ran tests before we merged. However, games can take a very long time to build and test, especially if you start adding playmode tests that need to run in real-time or near real-time. With multiple developers all raising changes all day long, we can’t easily enforce a rule to ensure PRs are fully up to date before merging. We do have a few other options however and we’ll talk about those in a future article, for the purpose of this article however we’ll continue to assume we can’t rely on the tests always being run on up-to-date code.

Build

Let’s take our new trigger code and combine it with the previous PR check code

name: Weekly Build
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 1"

jobs:
testAllModes:
name: Build
runs-on: ubuntu-latest
steps:

- uses: actions/checkout@v2
with:
lfs: true

- uses: game-ci/unity-test-runner@v2
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
projectPath: path/to/your/project
githubToken: ${{ secrets.GITHUB_TOKEN }}
testMode: EditMode

- uses: actions/upload-artifact@v2
if: always()
with:
name: Test results
path: artifacts

The build process isn’t too different from the test process, we just use the builder action instead and pass in the platform we wish to build.

- uses: game-ci/unity-builder@v2
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
targetPlatform: Android
projectPath: "path/to/your/project"
buildsPath: "build"

As with the Test action, GameCI does all the hard work of figuring out which Unity version we’re using and installs it for us as well as any dependencies like the Android SDKs.

We can use the upload artifact action to store the build result.

- uses: actions/upload-artifact@v2
with:
name: Build
path: build

This will start to consume a fair amount of space over time but we’ll look at resolving that in the next step.

Release

So now everything is building let’s look at creating a release, we will later look at more complex release pipelines and target actual storefronts, but for now, we’ll make simple GitHub releases instead.

We’ll first need to tag the release so GitHub knows what to call the release and what we’re actually releasing. We’ll put this step just above the Unity build step as we’ll need to use some of the outputs in our build.

# Tag
- name: Bump version and push tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
release_branches: main

The above action will use our commit messages to decide on the appropriate version number, it does this based on semantic release conventions, I’m a big fan of the Conventional Commit format so this works out greatly in our favour. For example, any commit I prepend with fix: will result in a patch version bump where as feat: creates a minor version bump, and finally BREAKING CHANGE: will result in a major release.

This gives us a great deal of versatility if the conventional commit spec is followed, and in a future article, we’ll talk about how we can enforce following the spec in a manner that isn’t too controlling.

We’ll quickly amend our Unity build step now to grab the version out of our tag step. Below we’ve set the versioning type to Custom and the version we pass in is the result of our tag step ${{steps.tag_version.outputs.new_tag}}

That should enable our build version to always match the latest tag.

      # Build
- uses: game-ci/unity-builder@v2
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: "path/to/your/project"
buildsPath: "build"
versioning: Custom
version: ${{ steps.tag_version.outputs.new_tag }}
targetPlatform: Android

On to the actual release side of things, we’ll again use another GitHub action to help us out here:

# Create Release
- uses: ncipollo/release-action@v1
with:
body: ${{ steps.tag_version.outputs.changelog }}
token: ${{ secrets.GITHUB_TOKEN }}
generateReleaseNotes: true
tag: ${{ steps.tag_version.outputs.new_tag }}
name: Release ${{ steps.tag_version.outputs.new_tag }}

This step reuses some of the information we previously generated when tagging the release, because as well as tagging our repository with the appropriate version it also created a changelog for us using the same conventional commit spec. This changelog uses the additional information provided in the spec to group changes by feature and change type providing a log that gives your quality engineers and users a very clear and easy-to-read log of exactly what’s new.

As mentioned above, always uploading the build to the workflows artifact storage will start consuming a lot of space over time. Since we need to upload the build to the release as well this is also wasting time uploading twice! So instead we’ll remove the artifact upload above, and add it to our release instead by adding the below line to the release step, and remove the build artifact upload (make sure to leave the test results upload!).

artifacts: "build"

Altogether your script should look something like this:

name: Weekly Build
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 1"

jobs:
testAllModes:
name: Build
runs-on: ubuntu-latest
steps:

#Checkout
- uses: actions/checkout@v2
with:
lfs: true

# Test
- uses: game-ci/unity-test-runner@v2
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
projectPath: "path/to/your/project"
githubToken: ${{ secrets.GITHUB_TOKEN }}
testMode: EditMode

# Upload Test Results
- uses: actions/upload-artifact@v2
if: always()
with:
name: Test results
path: artifacts

# Tag
- name: Bump version and push tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
release_branches: main

# Build
- uses: game-ci/unity-builder@v2
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: "Unity CI CD"
buildsPath: "build"
versioning: Custom
version: ${{ steps.tag_version.outputs.new_tag }}
targetPlatform: Android

# Create Release
- uses: ncipollo/release-action@v1
with:
body: ${{ steps.tag_version.outputs.changelog }}
token: ${{ secrets.GITHUB_TOKEN }}
generateReleaseNotes: true
tag: ${{ steps.tag_version.outputs.new_tag }}
name: Release ${{ steps.tag_version.outputs.new_tag }}
artifacts: "build/Android/*.apk"

Once again raise a pull request and merge this to your main branch once the Unit tests have finished.

You’ve now got a complete CI/CD pipeline that can run as frequently/infrequently as you need!

Follow for the rest of this series and other articles. Next, we’ll be looking at some improvements and optimisation we can do to this pipeline.

--

--

RunningMattress

Lead Gameplay Programmer, experience at a range of studios from small start-ups to AAA, I specialise in: Jenkins, Unity and DevOps with nearly 10 years exp