Amazon.co.uk Widgets

Log in

X
Don't fight macOS (DFMOS)

Don't Fight macOS (DFMOS)

A nod to DSMOS, the "Don't Steal macOS" message that Apple quietly logs to the console during boot as a gentle reminder that macOS is licensed software tied to Apple hardware. Consider this article a similar quiet reminder: don't take shortcuts when setting up macOS as a CI system. Work with the operating system, not against it.

TL:DR – The temptation to do things the Linux way on macOS is real and best avoided.

 

macOS Is Not Linux

This seems obvious, but it is a trap that catches engineers repeatedly. macOS is a UNIX-certified operating system built on Darwin and XNU, and it shares a great deal of surface area with Linux. The shell feels familiar, the filesystem hierarchy looks recognisable, and most of your favourite command-line tools are either present or one install away. That familiarity breeds a particular kind of overconfidence.

Linux CI systems are typically headless, stateless, and built to be imaged, containerised, or provisioned from scratch on every run. You control the entire stack. You can install packages as root without ceremony, pin versions by replacing binaries in /usr/local/bin, and rebuild the whole system in minutes if something goes wrong.

macOS is none of those things. It is a consumer and professional workstation operating system that Apple updates aggressively, on its own schedule, with its own opinions about where software lives, who owns what, and what is allowed to run. System Integrity Protection (SIP), Gatekeeper, notarisation requirements, and the sealed system volume are not obstacles to work around. They are the operating system. Fighting them is a losing battle.

The good news is that you do not need to fight them. You just need Homebrew.

Homebrew: The Missing Package Manager

Homebrew describes itself as "the missing package manager for macOS." That description is accurate and deliberately modest. Homebrew is the de facto standard for installing, managing, and updating developer tooling on macOS, and it has been since Max Howell created it in 2009.

For CI purposes, Homebrew solves a specific and important problem: how do you get a repeatable, auditable, updateable set of tools onto a macOS machine without violating the operating system's expectations about software ownership and placement?

Homebrew's answer is elegant. On Apple Silicon Macs, it installs everything under /opt/homebrew. On Intel Macs, it uses /usr/local. These are locations that Homebrew owns, that SIP does not protect, and that do not require you to fight the system to write to. Formulae are defined in Ruby, pinned to specific versions when needed, and the entire dependency graph is explicit and reproducible.

A Brewfile, generated via brew bundle dump and committed to source control, gives you a declarative manifest of every tool your CI agents need. Restoring a machine, or provisioning a new one, becomes a single command.

The Cost Nobody Talks About

Homebrew works best in a user context. This is not a limitation to route around. It is a design decision that reflects how macOS actually works, and it has real implications for how you set up CI agents.

Homebrew was designed to be run by a regular user, not root. In fact, running brew as root is explicitly discouraged and, in recent versions, actively warned against. The installer sets ownership of the Homebrew prefix to the installing user. Formulae are built and linked in that user's context. The PATH is configured in that user's shell profile.

When you try to run Homebrew as root, or from a system-level LaunchDaemon, or from a CI agent running as a service account with no home directory, you are immediately fighting the grain of the tool. Permissions break. Shell environments do not initialise correctly. Post-install scripts fail silently. Tools that depend on other Homebrew-managed libraries cannot find them because PATH is wrong.

The cost of Homebrew, then, is this: you need a real user, with a real home directory, with a properly initialised shell environment, and your CI agent needs to run in that context.

This feels inconvenient if you are used to Linux CI where everything runs as a service. It is not actually inconvenient. It just requires that you set things up properly once.

Setting Up the CI User Correctly

The right approach is to create a dedicated local user account on the macOS CI machine. This user should be a standard user, not an administrator, with the minimum privileges required to do the job. Give them a real home directory. Log in as them at least once interactively to initialise the home directory structure and the shell profile.

Install Homebrew as that user. Every tool your CI pipeline needs should be installed via Homebrew as that user. Do not mix in manual installs to /usr/local/bin or /usr/bin. Do not symlink things from system locations. Let Homebrew own its prefix entirely.

Configure the shell profile correctly.

Run your CI agent as a LaunchAgent, not a LaunchDaemon. This is the critical distinction. A LaunchDaemon runs at the system level, before any user logs in, as root or a system account. A LaunchAgent runs in the context of a logged-in user. By running your CI agent (whether that is a GitHub Actions runner, a Buildkite agent, a GitLab runner, or Jenkins) as a LaunchAgent for your dedicated CI user, you ensure it inherits the correct environment.

Install the agent's LaunchAgent plist into ~/Library/LaunchAgents/ for your CI user. Configure it to load at login. Enable auto-login for the CI user if the machine is headless, using the Accounts pane in System Settings, so that the LaunchAgent starts automatically after a reboot.

Surviving macOS Updates

Apple releases major macOS updates annually and point releases throughout the year. These updates can and do affect CI machines. The sealed system volume means Apple can replace system binaries wholesale. Xcode command-line tools are tied to the macOS version and sometimes need to be reinstalled after an update. The Homebrew prefix itself is occasionally affected by changes to the underlying system.

If you have set things up correctly, surviving a macOS update is a manageable process:

  1. After the update, run xcode-select --install to ensure the command-line tools are current.
  2. Run brew update && brew upgrade to bring all Homebrew packages up to date against the new system.
  3. Run brew doctor to identify and fix any issues introduced by the update.
  4. Run brew bundle check --file=Brewfile to confirm your declared toolset is still fully installed.

This is a few minutes of work. If you have fought the system by installing tools outside of Homebrew, by modifying system directories, or by running your agent as root, a macOS update can break things in ways that are genuinely difficult to diagnose. You will spend hours, not minutes.

Surviving Homebrew Tool Updates

Homebrew updates formulae regularly. This is a feature. Security patches, bug fixes, and new capabilities flow to you automatically when you run brew upgrade. It is also, occasionally, a source of breakage when a tool updates to a version that is incompatible with your build scripts.

The answer to this is not to freeze Homebrew or stop updating. The answer is to be explicit about versions where it matters, and to test your CI configuration against updates before they reach production agents.

For tools where version pinning is important, Homebrew supports brew pin <formula> to prevent a formula from being upgraded during brew upgrade. Use this sparingly and deliberately, and document why a pin exists. Pins accumulate and become invisible technical debt if left unmanaged.

For the majority of tools, accept updates and write CI scripts that are not fragile against minor version changes. If your build script breaks because a tool updated from version 3.1 to 3.2, the problem is usually in the script, not in the update.

The Brewfile as Source of Truth

Commit a Brewfile to your repository or to a dedicated configuration repository. This file is the authoritative declaration of what your CI agents need. It should be reviewed like any other piece of infrastructure code, updated deliberately, and tested when changed.

When you provision a new agent or rebuild an existing one, brew bundle install brings the machine to the declared state. When you need a new tool, add it to the Brewfile, test it, and merge the change. When you remove a tool, remove it from the Brewfile and run brew bundle cleanup to remove packages that are no longer declared.

This is infrastructure as code applied to macOS tooling. It is not glamorous, but it is reliable.

What Not to Do

For completeness, here is a list of approaches that feel expedient and cause pain:

Do not install tools as root. Homebrew will warn you. The warnings are correct. Tools installed this way will have wrong ownership, will not be on the right PATH, and will break in non-obvious ways.

Do not manually copy binaries into /usr/local/bin or /usr/bin. SIP protects /usr/bin. /usr/local/bin is owned by Homebrew on Intel Macs. Manual installs here will be overwritten or will conflict with Homebrew-managed versions.

Do not run your CI agent as a LaunchDaemon. It will not have a user environment. Homebrew tools will not be on PATH. You will spend significant time writing workarounds that a LaunchAgent gives you for free.

Do not skip brew doctor after updates. It catches problems early, before they cause mysterious build failures.

Do not ignore Homebrew warnings. Homebrew prints warnings for a reason. A warning about a keg-only formula not being on PATH, or about a formula being deprecated, is telling you something that will eventually matter.

The Payoff

Setting up macOS CI correctly, with a dedicated user, a LaunchAgent, a Brewfile, and Homebrew running in the right context, takes a few hours. Doing it wrong takes a few hours too, but those hours repeat themselves every time macOS updates, every time a tool updates, and every time something breaks in a way you cannot explain.

macOS is an excellent CI platform for Apple platform development. The hardware is fast, particularly on Apple Silicon. The toolchain integration with Xcode is tight. The operating system is stable and well-maintained. None of that value is available to you if you are constantly fighting the system to keep your agents running.

Work with macOS. Let Homebrew do what it was designed to do. Set up your user context correctly and leave it alone. Your future self, staring at a CI system that survived a major macOS update without incident, will be grateful.

DFMOS. Don't fight macOS.