This post has been triggered by a rather long succession of failures to get any kind of build working with Unity 3D using my locally hosted runner with GitLab. I’m clearly missing a trick and, let’s be honest, magically grasping hold of a ‘trick’ at 1am probably isn’t the easiest thing to do. So, as I rest by bum between the chair and keyboard once more my mind wanders to the realm of GitHub Actions; does a solution exist in this alternate realm (and, more importantly, will I be able to get it to work!). Let’s see, shall we? π
The Repository
We need a repository and, I didn’t actually know this, but it looks like the standard options for ‘.gitignore’ auto-generated files do include an option for ‘Unity’, so we’ll be selecting that from the get-go. I’ve made the repository private to protect what will surely be awesome source code (or to save from impending embarrassment). The repository name and description will give you a rough idea of what we’re going to create. π

Let’s Build a Great Game (no, not really)
Our concept then is ‘big-red-ball’, the best game about a red ball that we can conjure up in not much time at all. I’m going to set up for ‘full-force 3D’ and drop a project in our local repository:

At this point, I realise that I have a project setup fail, of sorts, leading to every file in existence getting caught up as a change:

This is caused by an extra, nested, folder being created on project initialisation. I could toy with the .gitignore but I’m just going to move all of my project files to the root folder of the repository, for ease. Voila, much better!

Here’s the sample game running which will serve as our testbed for a GitHub Action-based build:

Just to ensure the build process works OK with something not quite out-of-the-box I included an additional font asset called ‘Thaleah_PixelFont’. I’ll illustrate the source code at the very end of the post, but it’s not all that relevant to proceedings from this point forward. At this stage, I’ve pushed all code back to the master branch and this is what the repository looks like:

We’re ready to start building a GitHub Action that will handle the build of our game and generation of artifacts (and activate a license). It is important to note that only enabled scenes that have been added to the project’s ‘Build Settings’ will be built by default, as shown (either drag the scene from the project tab directly or use the ‘Add Open Scenes’ option):

Request an Activation File
I’m going down the route of acquiring a Unity personal license. Click on the ‘Actions’ tab within the repository and then select the ‘set up a workflow yourself’ option.

Using the guidance found here I generated the following workflow, which leads to the end game of using the ‘Build Action’ later down the line. Here’s how my Action is laid out, with the only area needing a tweak is the ‘unityVersion’ property (I’ve called the workflow file ‘activation.yaml’ for clarity).
name: Acquire activation file on: [push] jobs: activation: name: Request manual activation file π runs-on: ubuntu-latest steps: # Request manual activation file - name: Request manual activation file id: getManualLicenseFile uses: webbertakken/unity-request-manual-activation-file@v1.1 with: unityVersion: 2019.3.9f1 # Upload artifact (Unity_v20XX.X.XXXX.alf) - name: Expose as artifact uses: actions/upload-artifact@v1 with: name: ${{ steps.getManualLicenseFile.outputs.filePath }} path: ${{ steps.getManualLicenseFile.outputs.filePath }}
This will likely take a couple of minutes to run. Once this process completes you’ll be left with an artifact (.alf):

We can download this and do a little round trip in order to get hold of the relevant ‘secret’ to implant into our repository, with these steps (copied verbatim from the aforementioned link):
- Download the manual activation file that now appeared as an artifact.
- Visit license.unity3d.com and upload it.
- You should now receive your license file (Unity_v20XX.x.ulf) as a download.
- Open Github > Your repository > Settings > Secrets.
- Add a new secret called UNITY_LICENSE and copy the contents of your license file into it.
After download, I did just have to unzip the file before uploading it. The process was seamless:



With the ‘.ulf’ file in hand you just need to open it in the text editor of your choice and then, as detailed in the steps previously, add a secret within the repository with the key of ‘UNITY_LICENSE’ and a value based on the contents of this file.

This one-time activation process is now complete! The documentation alludes to this but doesn’t expressly state it, but my gut feeling here was to delete the activation.yaml file to prevent this process from running perpetually on every push. We need the artifact file only once, presumably up until the point that we switch Unity versions.
Preparing a Unit Test
To fully trial out the build/test Action available to us let’s set up one test for our project, just for illustration. Inside Unity, I navigated to ‘Window > General > Test Runner’ and, after making sure I had the ‘Assets’ folder selected, I selected ‘Create Playmode Test Assembly Folder’.

From here, I was prompted to create a test script, which I did.

This auto-generated a new assembly inside the solution called Tests (utilising NUnit), with a ‘NewTestScript.cs’ file with the following content:
using System.Collections; using System.Collections.Generic; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; namespace Tests { public class NewTestScript { // A Test behaves as an ordinary method [Test] public void NewTestScriptSimplePasses() { // Use the Assert class to test conditions } // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use // `yield return null;` to skip a frame. [UnityTest] public IEnumerator NewTestScriptWithEnumeratorPasses() { // Use the Assert class to test conditions. // Use yield to skip a frame. yield return null; } } }
Keeping within the remit of making this as basic as possible, the only change I made was to add mock code to the ‘NewTestScriptSimplePasses’ test, as follows:
// A Test behaves as an ordinary method [Test] public void NewTestScriptSimplePasses() { GameObject sut = new GameObject("MyGameObject"); Assert.AreEqual("MyGameObject", sut.name); }
Back inside Unity, you can trigger tests using the ‘Run All’ button. As you can see, we’ve gone all green!

This gives us another piece to our puzzle to ensure that any test step in our Action is working as expected.
The Build Action
The documentation and resources surrounding this are excellent. Based on the information discovered here I stubbed out a simple build/test Action. This integrates our ‘UNITY_LICENSE’ secret and makes mention of our Unity version, where required. Other than that, not too much tinkering is required to get this up and running.
name: Actions π on: pull_request: {} push: { branches: [master] } env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} jobs: build: name: Build my project β¨ runs-on: ubuntu-latest steps: # Checkout - name: Checkout repository uses: actions/checkout@v2 with: lfs: true # Cache - uses: actions/cache@v1.1.0 with: path: Library key: Library # Test - name: Run tests uses: webbertakken/unity-test-runner@v1.3 with: unityVersion: 2019.3.9f1 # Build - name: Build project uses: webbertakken/unity-builder@v0.10 with: unityVersion: 2019.3.9f1 targetPlatform: StandaloneWindows64 # Output - uses: actions/upload-artifact@v1 with: name: Build path: build
Notice the use of the ‘cache’ step, which caches ‘Library’ files from previous builds leading to a significant performance boost and lower build times (up to 50% savings, apparently). The ‘output’ step handles how our artifacts are uploaded. As a target platform, I am just locking in on 64-bit Windows, but you can hook this up simultaneously to as many target platforms as you need.
More details specifically about the Unity Builder Action can be found here.
On push (or pull request, as configured) the Action kicks off perfectly, as shown:

We have, on inspection of the logs, evidence of our configured tests passing which is nice:

Lastly, the Action did output an artifact that contains a ‘.exe’ and supporting files:

There are more complex ways to configure this kind of Action but it’s pleasantly surprising how easy a simple build, test and artifact upload Action is to configure (even the licensing hurdles are easy enough to bolt over).
An Aside
As promised at the start of the post, here’s the very basic code behind the sample game (XML comments omitted, for brevity):
BidRedBallController
This checks, on update, for any horizontal or vertical movement (GetAxisRaw gets the axis change value without any smoothing, meaning the value will be -1, 0 or 1) and moves the player as required. There’s a multitude of better ways to detect an ‘out-of-bounds’ situation but as I know the respective playing area I’ve included a fixed check, that respawns the player at a start location if they leave the play area.
using UnityEngine; public class BidRedBallController : MonoBehaviour { public Transform PlayerTransform; public float PlayerSpeed = 1f; private void Update() { // Translate the player position (based on speed) PlayerTransform.Translate( new Vector3( Input.GetAxisRaw("Horizontal") * PlayerSpeed * Time.deltaTime, 0f, Input.GetAxisRaw("Vertical") * PlayerSpeed * Time.deltaTime)); // Basic protection for the player going 'out of bounds' if (Mathf.Abs(PlayerTransform.position.x) > 5f || Mathf.Abs(PlayerTransform.position.z) > 5f) { PlayerTransform.position = new Vector3( GameManager.GameManagerInstance.RespawnPosition.transform.position.x, GameManager.GameManagerInstance.RespawnPosition.transform.position.y, GameManager.GameManagerInstance.RespawnPosition.transform.position.z); } } }
CollectableTrigger
This script checks for the player (checked by ‘tag’ assignment) colliding directly with the collectable, aka entering the ‘trigger’ zone. A particle effect is triggered at the location of the collectable and the object is disabled. The player score is then incremented.
using UnityEngine; public class CollectableTrigger : MonoBehaviour { public ParticleSystem collectedEffect; private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { ParticleSystem newEffect = Instantiate( collectedEffect, new Vector3(gameObject.transform.position.x, gameObject.transform.position.y, gameObject.transform.position.z), gameObject.transform.rotation); newEffect.Play(); gameObject.SetActive(false); GameManager.GameManagerInstance.IncrementScore(); } } }
Exit
Nothing particularly fancy here. When the exit objects trigger-based collider is entered by the player it sparks off the game complete process, as handled by the ‘GameManager’ instance. I’ve been trying to get in the habit of ensuring I use ‘.compareTag’ instead of ‘==’, as research suggests a bit of a performance buff when using this method (less CIL instructions are generated when the C# is translated – something I’d like to verify myself later on).
using UnityEngine; public class Exit : MonoBehaviour { private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { GameManager.GameManagerInstance.GameComplete(); } } }
GameManager
This class, with a ‘singleton’ set up for access by other types, forms the hub for game interactions (which suffices for this sample). This consumes some information about the start/exit spawn locations alongside references to UI components. As the player picks up collectables a score is incremented, leading to the exit portal being activated. The player reaching this portal triggers the game complete state (a button is shown to reload the scene). A timer is configured and its value is incremented in the ‘Update’ method, using the inbuilt ‘Unity.deltaTime’ helper.
using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; public class GameManager : MonoBehaviour { private float currentTime = 0f; private bool gameStarted = false; [Header("Level Parameters")] public int RequiredScore; public GameObject ExitPortal; public GameObject RespawnPosition; [Header("UI")] public Button StartButton; public GameObject TryAgainButton; public Text TimeText; public Text ScoreText; public Text ExitText; // Our core, singleton, GameManager instance private static GameManager gameManagerInstance = null; // The current score for the player private int score; public static GameManager GameManagerInstance { get { // Create a new GameObject and add a GameManager script to it if it doesn't already exist (allocating this to the gameManagerInstance field) if (gameManagerInstance == null) { gameManagerInstance = new GameObject("GameManager").AddComponent<GameManager>(); } // Return the instance for global use return gameManagerInstance; } } public void IncrementScore() { score++; ScoreText.text = $"Score: { score }"; if (score >= RequiredScore) { ExitText.gameObject.SetActive(true); ExitPortal.SetActive(true); } } public void StartGame() { score = 0; currentTime = 0; StartButton.gameObject.SetActive(false); Time.timeScale = 1f; gameStarted = true; } public void GameComplete() { ExitText.gameObject.SetActive(false); ExitPortal.SetActive(false); Time.timeScale = 0f; TryAgainButton.SetActive(true); } public void TryAgain() { SceneManager.LoadScene("LevelOne"); } private void Awake() { // In Unity horrific things happen when you use constructors. To that end, ensure that only one GameManager comes into existence at game start. Time.timeScale = 0f; gameManagerInstance = this; } private void Update() { if (gameStarted) { currentTime += Time.deltaTime; TimeText.text = $"Time: { currentTime:f1}s"; } } }
As always, I hope this proved helpful and interesting; perhaps even leading you to play around with building your Unity projects using GitHub Actions. Happy coding until the next time.