Setting up a CI/CD pipeline for Unity Part 1

RunningMattress
7 min readMay 16, 2023

--

A CI/CD pipeline is an incredibly valuable addition to any Unity project.

Throughout this series, we’ll discuss what value it can bring to your project, how to set one up, and how to scale this for larger projects.

For the first article in this series, we’ll use GitHub actions and GameCI to automate our Pull Requests back into our main branch.

All the various steps involved in our pipeline
All the various steps involved in our pipeline

Firstly let’s define a few key concepts for this series.

What is a CI/CD pipeline?

A CI/CD pipeline or Continuous Integration and Continuous Delivery is the automation of integration and delivery of your project to ensure consistency and quality. In terms of a Unity project, and in particular, what we’ll showcase in this article, this means automating the building and testing of your project before we merge it back into our mainline branch. In future parts of this series, we’ll look at automating the building and release of the project to various deployment targets.

What are GitHub Actions

GitHub actions are a yaml-based syntax that allows us to define a series of steps to automate a task. These automated tasks are run on servers hosted by GitHub with a variety of operating systems and configurations available. Often these are composed of several smaller actions such as GitHub’s clone action to checkout your repository. There’s a huge wealth of these available on the GitHub actions Marketplace allowing developers to piece together several of these to create incredibly powerful and robust pipelines.

Setting up the project

First, let’s take a simple Unity Project as an example and add some Unit tests to the codebase. This gives us something to test in the next step and starting a project with a test pipeline in place helps to further that practice as you develop more of the game.

Below you can see the very trivial feature and tests we created to prove our pipeline.

public static class SampleFeature
{
public static List<string> UniqueStrings;

public static bool TryAddUniqueValue(string newValue)
{
//Init the list if null
UniqueStrings ??= new List<string>();

//Early exit if already added
foreach (string item in UniqueStrings)
{
if (item == newValue)
{
return false;
}
}

//Add the value
UniqueStrings.Add(newValue);
return true;
}
}
public class SampleFeatureTests
{
[SetUp]
public void SetUp()
{
SampleFeature.UniqueStrings?.Clear();
}


// Test that we can add a single value
[Test]
public void CanAddAValue()
{
SampleFeature.TryAddUniqueValue("test");

Assert.AreEqual(1, SampleFeature.UniqueStrings.Count);
}

// Test that we can add many values
[Test]
public void CanAddManyValues()
{
SampleFeature.TryAddUniqueValue("test");
SampleFeature.TryAddUniqueValue("test2");

Assert.AreEqual(2, SampleFeature.UniqueStrings.Count);
}

// Test that we cannot add duplicates
[Test]
public void CannotAddTheSameValue()
{
SampleFeature.TryAddUniqueValue("test");
SampleFeature.TryAddUniqueValue("test");

Assert.AreEqual(1, SampleFeature.UniqueStrings.Count);
}
}
Passing Unit tests in Unity
Passing Unit Tests

Creating our simple CI/CD pipeline

Moving on to the GitHub actions side of things, as mentioned earlier GameCI will be doing the bulk of the work for our actions.

Here we want to create an action that will be run against every single pull request we raise. This will validate that the project compiles and all Unit tests pass, finally then providing some simple feedback to the person raising the pull request to let them know their PR is good to be merged.

Breaking this down into some more manageable steps that we can begin writing a script for we have:

  1. Define what triggers the action (what causes it to run)
  2. Checkout the project
  3. Use GameCI to run the tests (this will trigger a compilation to achieve this)

Looking at GameCI’s documentation we can see we’ll need to do some work upfront to generate an appropriate serial key for the project to use so we’ll start with this.

We’ll run the provided workflow from GameCI to do this. We can then use the serial key as a GitHub Actions secret to enable our workflow to use the license.

Make a file at .github/workflow/activation.yml

name: Acquire activation file
on:
workflow_dispatch: {}
jobs:
activation:
name: Request manual activation file 🔑
runs-on: ubuntu-latest
steps:
# Request manual activation file
- name: Request manual activation file
id: getManualLicenseFile
uses: game-ci/unity-request-activation-file@v2
# Upload artifact (Unity_v20XX.X.XXXX.alf)
- name: Expose as artifact
uses: actions/upload-artifact@v2
with:
name: ${{ steps.getManualLicenseFile.outputs.filePath }}
path: ${{ steps.getManualLicenseFile.outputs.filePath }}

Push this to your main branch and then follow these steps to get a licence ulf file

  1. Follow these (one-time) steps for simple activation.
  2. Manually run the above workflow.
  3. Download the manual activation file that now appeared as an artifact and extract the Unity_v20XX.X.XXXX.alf file from the zip.
  4. Visit license.unity3d.com and upload the Unity_v20XX.X.XXXX.alf file.
  5. You should now receive your license file (Unity_v20XX.x.ulf) as a download. It's ok if the numbers don't match your Unity version exactly.
  6. Open Github > <Your repository> > Settings > Secrets.
  7. Create the following secrets;

UNITY_LICENSE - (Copy the contents of your license file here)

UNITY_EMAIL - (Add the email address that you use to log into Unity)

UNITY_PASSWORD - (Add the password that you use to log into Unity)

Our Repository Secrets
Our Repository Secrets

So now our license is all set up we’re good to create our pipeline. Let’s create another pipeline file in the .github/workflow folder, we’ll call it pr_check.yml.

We’ll start with the trigger conditions

name: Test project

on: [pull_request]

jobs:
testAllModes:
name: Run Tests
runs-on: ubuntu-latest
steps:

Let’s add GitHub’s checkout script.

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

The default setup of this will checkout the head of your PR branch, or in simpler terms the latest commit on your branch. This makes it incredibly straightforward to use.

Next, we’ll add the GameCI step to run our tests.

- 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

And that’s it. That’s all we need to run our playmode and editmode tests.

To make it easy to view the results we’ll also upload them as artifacts.

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

Bringing that all together should give you something like this

name: Test project

on: [pull_request]

jobs:
testAllModes:
name: Run Tests
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

You can now commit that to a branch, raise a pull request and sit back and watch as the action springs to life to start validating your changes. Once this has been completed successfully, go ahead and merge it so all future branches can use this.

A successful check run
A successful check run

Set up GitHub rules

The pipeline we’ve just created is all well and good but without rules in place anyone can circumvent the check and merge bad code to the main branch causing instability, future test run breaks and ultimately someone having to spend time fixing it all.

So let’s head over to the repository settings and into the branch protections tab, from here we can create a new rule for our main branch and then add some restrictions. For now, we’ll use the “Require a pull request before merging” and the “Require status checks to pass before merging" rules and then add our check to this list “Run Tests”.

With these rules in place, it ensures that the pipeline must succeed before the PR gets merged. Of course, any admin can still override these in the event they need to. I would, however, always advise against this as when the inevitable emergency crops that makes you think you need to skip the checks are exactly the time you want something else double checking everything your most likely very stressed self has just done in an attempt to rectify the situation. Trust me, I speak from experience here. Shipping a fix that breaks more things than were previously broken is not fun.

Our GitHub Branch Rules
Our GitHub Branch Rules

And with that, you’re done, you have a simple pipeline that ensures your Unit tests pass on every single PR. Just remember that Unit tests are not a replacement for QA and only serve to prove that individual code units/modules are working the way you expect, there’s no guarantee how all that code will behave when you bring it all together, but you can help reduce a lot of risk with Unit testing. You can further reduce that risk by adding playmode tests which we’ll talk about a bit more in a future part of this series.

Follow for the rest of this series and other articles. Next, we’ll be looking at automating the Continuous Delivery side of CI/CD and creating a pipeline to create regular automated releases on GitHub. Check out part 2 here:

--

--

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