macOS CI capacity plan (#11010, #11011, #11012)¶
Status: implemented 2026-10-09; the owner steps below are still open. Owner decisions are in issues #11010, #11011 and #11012. The earlier CircleCI migration plan (#10887) has the sourced CircleCI plan numbers.
Rule: every pull-request macOS job runs in exactly one place. Hosted
GitHub macos-26 is the fallback for fork PRs only.
Pools¶
| Pool | Concurrency | Carries |
|---|---|---|
CircleCI m4pro.medium |
1 macOS job at a time | PR Playground tests, all post-merge and nightly macOS work |
Self-hosted Mac automobile-mac |
N runner processes (N = 4 recommended) | Small, non-simulator PR jobs |
Self-hosted Mac automobile-mac-heavy |
exactly 1 runner process | PR xcodebuild and simulator jobs, one at a time |
Namespace auto-mobile-macos |
1 macOS job (trial) | iOS Device Capture to WHEP |
CircleCI (#11010)¶
CircleCI runs one macOS job at a time for this organization, so it carries one PR job and the work that is not on the PR critical path.
Pull requests¶
ios-playground-tests is the only CircleCI job on the PR path (gated on
run-ios, which path filtering sets from the same paths as filter-ios in
pull_request.yml). It is advisory and must stay off the required-check list.
The CircleCI mirrors of Swift Packages, Build Xcode Projects and Build Root SPM
Package are gone; those jobs run on GitHub only.
Post-merge¶
merge.yml schedules no macOS job. On every push to main the setup pipeline’s
detect-main-macos-changes job diffs HEAD~1..HEAD against
.circleci/main-macos-paths.txt:
| Path group | Paths | CircleCI job |
|---|---|---|
run-main-ios |
ios/**, scripts/ios/**, Package.swift, SwiftFormat/SwiftLint config |
Build Xcode Projects (main): generate, drift check, build |
run-main-desktop |
android/desktop-app/**, android/desktop-core/** |
Build Desktop App (macos) |
Both groups also match .circleci/**, so a config change revalidates itself.
Swift coverage keeps its existing trigger: merge.yml’s swift-code-coverage job
starts a run-swift-coverage-main pipeline for coverage-input changes and
downloads the badge.
Coalescing. Each post-merge job first runs
scripts/ci/circleci-main-superseded.sh <group>. If a newer main commit changes a
path in the same group, that commit’s pipeline also runs the job on a tree that
contains this one, so the older job halts (green) before doing any work. The last
commit in a burst that touches the group is never superseded, so a burst of N
merges costs one run for that commit. The check fails open: if it cannot read
main, the job runs. CircleCI’s project-level “auto-cancel redundant workflows” is
not used for this. It ignores the default branch, and it would cancel an iOS
run in favour of a newer pipeline that has no iOS changes.
Nightly¶
nightly.yml schedules no hosted macOS job. A CircleCI scheduled pipeline on main
with run-nightly-macos=true runs the nightly-macos workflow, chained in this
order with terminal requirements so a failure never skips later jobs:
- XCTestRunner Simulator Tests (the PR
run-ios-simlabel opt-in still uses.github/workflows/xctestrunner-simulator-tests.yml; a policy test keeps the selected tests and daemon budget identical) - Swift Packages Sweep (Xcode 26.6)
- Build Xcode Projects Sweep (Xcode 26.6)
- macOS Node Unit Tests
- macOS Node Host Integration Tests
- macOS BATS Shell Tests
- macOS BATS Integration Tests
- XCTestRunner Thread Sanitizer
- Swift Packages Sweep (Xcode 26.5)
- Build Xcode Projects Sweep (Xcode 26.5)
CircleCI posts no commit status for the scheduled pipeline (#11615), so
.github/workflows/circleci-nightly-visibility.yml (ubuntu, daily 10:40 UTC, not a
required check) runs scripts/ci/report-circleci-nightly.sh. It reads the nightly
through the CircleCI API with the existing CIRCLECI_TOKEN secret and opens or
updates one circleci-nightly-macos issue naming each red job, its job URL and its
artifact names (for example bats-unit-joblog/bats-unit-joblog-durations.tsv).
Without the secret, or if CircleCI refuses it, the run skips with a notice.
The Xcode 26.5 sweeps run last because every iOS PR already builds on 26.5. The
nightly sweeps build unsigned (the CircleCI executor pins signing off); the
GitHub sweeps signed when certificates were configured. Release and signing
workflows (build-ctrl-proxy-ios-ipa, build-network-filter-probe,
build-prototype-agent, build-screen-capture-helper, the desktop installers)
stay on GitHub.
Self-hosted Mac (#11011)¶
The owner’s Mac (ARM64, 16 cores: 12 performance + 4 efficiency, 128 GiB) runs
two runner pools for owner-authored same-repository PRs. Fork PRs always use
hosted macos-26 / macos-latest. Routing is the existing expression
(github.event.pull_request.user.login == 'kaeawc' and same repository), plus a
rollout switch, the repository variable AUTOMOBILE_MAC_POOLS_ENABLED, on every
self-hosted Mac job (the four that were on the Mac before the pools gained it in
11580). Unless it is true, those jobs run on hosted macos-26 /¶
macos-latest. Without the switch, a job labelled for a pool with no online
runner queues forever, and its required check with it: on 2026-10-10 no runner
took a job between 18:25 and 20:02 UTC and one PR’s seven Mac jobs waited 19–64
minutes until they were cancelled (#11580).
| Pool | Jobs |
|---|---|
automobile-mac |
SwiftLint, Swift Code Coverage, Build Desktop App (macos-latest), Installer Minimal (macos-latest), Swift Packages, Build Root SPM Package |
automobile-mac-heavy |
Build Xcode Projects (Playground, CtrlProxy), Prototype Simulator iOS 26, iOS Playground Tests (label-forced fallback only) |
- The heavy pool has exactly one runner process, so xcodebuild and simulator
jobs never overlap, and each job owns the simulators it creates. Heavy jobs
have tight
timeout-minutes(25, or 30 for the Playground fallback). - Build Xcode Projects is one job that builds Playground and then CtrlProxy in
separate named steps (#11580). As two matrix legs they ran one after the other
on the heavy runner anyway and repeated the setup. Prototype Simulator
needsthat job, so the runner serves the build behind the requirediOS Buildgate first; it still runs whatever the build’s result. - The PR-time XcodeGen drift check (#10939) is the
Check XcodeGen Project Driftstep of Build Xcode Projects, so it runs on the heavy lane. - Every moved job isolates
HOME,TMPDIR, the XDG dirs andCFFIXED_USER_HOMEunder$RUNNER_TEMP, checks out withclean: true, and removes the isolated home at the end, like the existing self-hosted jobs. - Xcode: hosted runners use
maxim-lobanov/setup-xcode. Self-hosted runners usescripts/ci/select-self-hosted-xcode.sh <version>, which exportsDEVELOPER_DIRfor that job only, so concurrent jobs do not fight overxcode-select. It fails when the requested Xcode is missing instead of building on another toolchain. - Swift Packages builds unsigned on the Mac: its signing flags are false whenever the job is routed there, so no certificate is imported into the owner’s keychains.
- Cache keys of the moved jobs include
runner.environment, so hosted and self-hosted runs never restore each other’s SwiftPM or DerivedData state. - Required check names are unchanged (
SwiftLint,Swift Code Coverage,Build Root SPM Package,Installer Minimal (macos-latest),iOS Build), so thegreen-mainruleset needs no edit.
Exception: XCTestRunner Simulator Tests (run-ios-sim label). The label
opt-in still runs on hosted macos-26 for same-repo PRs. Its daemon steps start
an AutoMobile daemon on the default port and install gems and Homebrew packages,
which would collide with the owner’s own daemon and tooling on this Mac. Moving
it needs a dedicated runner user (see owner steps) and an isolated daemon port.
It is a rare, opt-in label run, so this was left for an owner decision.
N for the small pool: 4. Swift package builds and the desktop Gradle build
are CPU-bound and each can use most of the cores. Four small jobs plus the heavy
lane keep about one performance core per process plus headroom for the owner’s
own work. Memory (128 GiB) is not the limit. That is five runner processes in
total: the existing mac runner plus three new small runners, and one heavy
runner.
Namespace macOS (#11012)¶
iOS Device Capture to WHEP (ios-device-webrtc) runs on the Namespace profile
namespace-profile-auto-mobile-macos for owner-authored same-repository PRs.
The Namespace trial allows one macOS job at a time (6 vCPU / 14 GB, billed at
10× the Linux rate), and WHEP fires only on WebRTC changes, so it rarely waits
for that slot. The CircleCI copy is gone.
Routing, in order:
- Same-repo PR,
AUTOMOBILE_NAMESPACE_MACOS_ENABLEDistrue(opt-in; set it after creating the profile),NAMESPACE_RUNNERS_DISABLEDnottrueandIOS_WEBRTC_HEAVY_LANEnottrue: Namespace macOS. - Same-repo PR otherwise, with
AUTOMOBILE_MAC_POOLS_ENABLED=true: the heavy self-hosted lane (the documented fallback, for example if ScreenCaptureKit cannot capture on the Namespace VM). - Everything else, including forks: hosted
macos-26.
The job keeps its one capture retry for the known ScreenCaptureKit flake and has
a 30-minute timeout. On the heavy lane it isolates HOME like the other
self-hosted jobs, but it still starts an AutoMobile daemon and MediaMTX on fixed
ports, so use that fallback only with the heavy runner under a dedicated user.
Job → runner, before and after¶
| Job | Before (same-repo PR) | After (same-repo PR) | Fork PR |
|---|---|---|---|
| SwiftLint, Swift Code Coverage | self-hosted automobile-mac |
self-hosted automobile-mac |
macos-26 |
| Build Desktop App / Installer Minimal (macOS) | self-hosted automobile-mac |
self-hosted automobile-mac |
macos-latest |
| Swift Packages (Xcode 26.5) | hosted macos-26 + CircleCI mirror |
self-hosted automobile-mac |
macos-26 |
| Build Root SPM Package | hosted macos-26 + CircleCI mirror |
self-hosted automobile-mac |
macos-26 |
| Build Xcode Projects (Playground, CtrlProxy) | hosted macos-26 + CircleCI mirror |
self-hosted automobile-mac-heavy |
macos-26 |
| Prototype Simulator iOS 26 | CircleCI | self-hosted automobile-mac-heavy |
macos-26 |
| iOS Playground Tests | CircleCI (GitHub macos-26 if label-forced) |
CircleCI (heavy lane if label-forced) | CircleCI |
| iOS Device Capture to WHEP | hosted macos-26 + CircleCI mirror |
Namespace auto-mobile-macos |
macos-26 |
XCTestRunner Simulator Tests (run-ios-sim) |
hosted macos-26 |
hosted macos-26 (exception, see above) |
macos-26 |
| merge.yml: Build Desktop App (macOS), Generate/Build Xcode Projects | dormant (if: false) |
CircleCI post-merge, path-gated, coalesced | n/a |
| nightly.yml: macOS BATS/Node, Xcode sweeps, TSan, XCTestRunner Simulator Tests | hosted macos-latest / macos-26 |
CircleCI nightly-macos |
n/a |
The “After” self-hosted and Namespace placements take effect once the owner steps
below are done; until then the moved jobs run on hosted macos-26.
Owner steps¶
These need a login or a credential, so they were not done in the change.
CircleCI (web app; do not script them against the API)¶
- Project Settings → Advanced: confirm “Enable dynamic config using setup workflows” is On (already required).
- Project Settings → Triggers → Add scheduled trigger: name
nightly-macos, branchmain, daily at 00:00 UTC (the oldnightly.ymlcron), pipeline parameterrun-nightly-macos=true(boolean). Until this exists the nightly macOS jobs do not run anywhere. - Project Settings → Advanced: leave “Auto-cancel redundant workflows” as it is; post-merge coalescing does not rely on it (see above).
- After the first nightly, check that the
xcode: "26.6"image still exists (the sweep fails at VM start if CircleCI retires it) and that Build Desktop App (macos) can install JDK 21 and the Android command-line tools on the image. Neither has run on CircleCI yet.
Self-hosted Mac runners¶
Registering runners needs a registration token, so it was not done here. Run on
the Mac as the user that owns ~/actions-runner:
- Keep an Xcode 26.x installed (currently 26.6 at
/Applications/Xcode.app, next to Xcode 27.1 beta) with its iOS platform (xcodebuild -downloadPlatform iOS). Swift Packages, Build Root SPM Package, Build Xcode Projects, the Playground fallback and Prototype Simulator request 26.5;bash scripts/ci/select-self-hosted-xcode.sh 26.5uses 26.5 when installed and otherwise the newest installed 26.x at or above it (26.6 today), printing which it chose (#11422). It never selects Xcode 27. - Get a registration token (valid for one hour):
TOKEN="$(gh api -X POST repos/kaeawc/auto-mobile/actions/runners/registration-token --jq .token)"
- Add three small-pool runners (the existing
macrunner, labelsself-hosted,macOS,ARM64,automobile-mac, stays as the first one):
for i in 2 3 4; do
dir="$HOME/actions-runner-small-$i"
mkdir -p "$dir" && cd "$dir"
tar xzf "$HOME/actions-runner/actions-runner-osx-arm64-2.337.0.tar.gz"
cp "$HOME/actions-runner/.path" "$HOME/actions-runner/.env" .
./config.sh --unattended --url https://github.com/kaeawc/auto-mobile \
--token "$TOKEN" --name "mac-small-$i" --labels automobile-mac --work _work
./svc.sh install && ./svc.sh start
done
- Add exactly one heavy runner. It must have the
automobile-mac-heavylabel and must NOT haveautomobile-mac, otherwise small jobs could land on it and overlap a simulator job:
dir="$HOME/actions-runner-heavy"
mkdir -p "$dir" && cd "$dir"
tar xzf "$HOME/actions-runner/actions-runner-osx-arm64-2.337.0.tar.gz"
cp "$HOME/actions-runner/.path" "$HOME/actions-runner/.env" .
./config.sh --unattended --url https://github.com/kaeawc/auto-mobile \
--token "$TOKEN" --name mac-heavy --labels automobile-mac-heavy --work _work
./svc.sh install && ./svc.sh start
Recommended: install the heavy runner under a dedicated macOS user account
instead, so its CoreSimulator device set, ~/Library and launchd services
are separate from the owner’s own simulators and AutoMobile daemon. With a
dedicated user, log in as that user once so its LaunchAgent can start.
- Check the pools:
gh api repos/kaeawc/auto-mobile/actions/runners \
--jq '.runners[] | [.name, .status, ([.labels[].name] | join(","))] | @tsv'
Expect mac, mac-small-2..4 with automobile-mac, and mac-heavy with
automobile-mac-heavy, all online.
- Turn the routing on:
gh variable set AUTOMOBILE_MAC_POOLS_ENABLED --body true. To fall back to hosted runners, set it to anything else (gh variable set AUTOMOBILE_MAC_POOLS_ENABLED --body false). Runs created after that use hosted runners for every Mac job. Jobs already queued for the Mac stay there until their run is cancelled, for example by a new push.
Namespace macOS¶
- In the Namespace dashboard, create a macOS (Apple Silicon) runner profile
named
auto-mobile-macos, so jobs can requestnamespace-profile-auto-mobile-macos. Do this before merging: with the kill switch off, a same-repo WebRTC PR otherwise waits for a profile that does not exist. - Run one WebRTC PR (or add the
webrtclabel) and confirmiOS Device Capture to WHEPcaptures on the Namespace VM (ScreenCaptureKit needs a window server session; the job’s simulator-window probe fails loudly if it is missing). - If it cannot capture, set
gh variable set IOS_WEBRTC_HEAVY_LANE --body true(heavy self-hosted lane) and record why in #11012. - Check the macOS minute cost against the trial/plan on the Namespace usage page.