Build log

How do you run Unity CI builds on a self-hosted Mac mini?

Register the Mac mini as a GitHub Actions runner with its own labels, call Unity in batch mode from a script instead of a container, and keep the workspace between runs so the Library stays warm. For GoTasty, an Android development build went from 52 minutes cold to under 8 minutes warm.

6 min read

Black and white photograph-style image of a Mac mini on a dark desk next to an external SSD, a terminal window glowing with scrolling build logs and a phone running a low-poly street scene.

GoTasty, the Unity 6 street food game I build at GOGOGO LLC, has nobody who opens the Unity Editor to make a build. CI is the compiler. Until November that CI ran on GitHub-hosted runners: GameCI in Docker on Linux for Unity, a hosted macOS runner for the Xcode archive. On 3 November the paid runner minutes hit the organisation's spending cap. On 4 November both workflows moved to a Mac mini M4 on my desk. These are the notes.

Why a self-hosted Mac instead of hosted runners?

  • Cost. A machine you already own does not bill per minute, and Unity builds are long.
  • A warm Library. Unity's Library folder is its import cache. A hosted runner starts empty or restores a large cache on every run. A self-hosted runner can simply keep it.
  • One machine for both platforms. The same Mac builds the Android APK or AAB, writes the iOS Xcode project and archives it with Xcode. Unity 6 keeps imports per platform, so switching between Android and iOS does not reimport everything.

The price is that the machine is yours to keep alive, and only one Unity build runs at a time.

How do you pick the runner per run?

Both workflows take a runner input with two values: mac, the default (pushes use it too), and cloud. The job picks its runner from that input, so the old hosted path still works when the Mac is busy or offline:

runs-on: ${{ inputs.runner == 'cloud' && 'gotasty-8core'
  || fromJSON('["self-hosted","macOS","ARM64","gotasty-mac"]') }}

The custom label gotasty-mac matters. It keeps other self-hosted jobs in the organisation from landing on this machine by accident.

How do you run Unity without GameCI?

GameCI wraps Unity in a Linux container, which is the right tool on hosted Linux runners. On a Mac you can call the editor binary directly. A small script (tools/ci_mac_unity.sh in our repository) runs Unity 6000.0.85f2, installed through Unity Hub with the Android and iOS modules, in batch mode with our build method, and does three extra things:

  • takes a machine-wide lock with shlock, so two jobs never open the same project at once;
  • logs free memory before and during the build, and whether Android emulators are running, because an emulator on the same machine competes for RAM;
  • writes outputs to a fixed folder on disk, Builds/GoTasty/<platform>/<run>/, and prints the path in the run summary instead of uploading large artifacts.

The licence is a normal Unity Personal licence activated on the machine, not a secret passed into a container.

What should persist between runs, and what should not?

On the Mac the checkout step does not wipe the workspace. Before each build, a cleanup step deletes only generated folders that must be rebuilt (the Addressables settings, for example) and keeps the rest: the Library, generated code, baked city kit prefabs and the downloaded Firebase SDK. That is where the time goes. Our measured numbers for an Android development APK: about 52 minutes cold, 43 of them inside Unity plus a full-history checkout, and 7.7 minutes warm.

The checkout uses fetch-depth: 0. We set the Android versionCode from the commit count, as GameCI's semantic versioning did, and a shallow clone would give every build the same version. The iOS build number is the workflow run number.

What does one run look like, from push to APK?

  1. A push to main, or a manual dispatch with runner: mac, queues the job for the gotasty-mac label.
  2. The checkout updates the existing workspace with the full history, and the cleanup step removes only generated folders.
  3. The build script takes the Unity lock, logs memory and runs Unity in batch mode with the build method.
  4. The APK or AAB lands in Builds/GoTasty/Android/<run>/, and the run summary prints that path.
  5. For a store bundle, the city content that loads from our CDN is uploaded in the same job.

From there, the QA agents can install the build on the emulator straight from that folder, so nothing has to come back down from GitHub.

How do you keep the runner alive after a reboot?

Our repository and build outputs live on an external SSD, and a launchd service could not reach that volume. The fix is less elegant and it works: a LaunchAgent opens a small .command script in Terminal at login, and the script restarts the runner's run.sh whenever it exits. After every macOS update, check that it still starts.

What about Xcode and CocoaPods on the same Mac?

The iOS archive needs Xcode 26 for the current Firebase packages and for App Store uploads. The job needs no sudo: it finds the newest /Applications/Xcode*.app with version 26 or later and sets DEVELOPER_DIR for that job only. CocoaPods comes from Homebrew, because the system Ruby's pod was too old for Xcode 26 projects. Pod sources stay cached on disk, which mattered on 8 November, when GitHub's release downloads failed for about half an hour. That story has its own post.

Would I do it again?

Yes, with the cloud path kept alive as a fallback. One machine is a single point of failure, and some weeks need two builds at once. But a warm Unity Library on a fast local disk changed how often we can afford to build, and that changes how fast agents and humans get feedback. The bigger picture, building the game with AI coding agents, is on atakanozalan.com.

Sources

  1. Unity 6 · Unity Technologies
  2. Mac mini · Apple
  3. Self-hosted runners · GitHub Docs
  4. Using labels with self-hosted runners · GitHub Docs
  5. Configuring the self-hosted runner application as a service · GitHub Docs
  6. GitHub Actions billing · GitHub Docs
  7. Getting started · GameCI
  8. Unity Editor command line arguments reference · Unity Documentation
  9. actions/checkout · GitHub
  10. Homebrew Formulae: cocoapods · Homebrew

Questions

Asked about this post.

Can you run Unity builds on a self-hosted GitHub Actions runner?
Yes. Register the machine as a runner with its own labels, install Unity through Unity Hub with the platform modules you need, activate a licence on the machine and call Unity in batch mode from a workflow step.
Why use a Mac mini for Unity CI?
It builds Android and iOS on one machine, including the Xcode archive, and it keeps the Unity Library warm between runs. For GoTasty that brought a warm Android development build to 7.7 minutes, against about 52 minutes cold.
Do you need GameCI on a self-hosted Mac?
No. GameCI runs Unity in a Linux container, which suits hosted Linux runners. On a Mac you can call the Unity editor binary directly in batch mode from a script.
How do you start a GitHub Actions runner at login on macOS?
A launchd service is the usual way. If the runner lives on an external disk that the service cannot reach, a LaunchAgent that opens a restart script in Terminal at login works instead.