Docs · GitLab CI

GitLab CI on NoMac

Your GitLab.com CI jobs on a fresh Mac with Xcode: connect a runner, then tag the jobs nomac. On a self-managed GitLab, use nomac run.

Make a runner in GitLab

In your project: Settings, CI/CD, expand Runners, Create project runner (for a group: Build, Runners, Create group runner). Give it the tag nomac and no other, leave Run untagged off, and create it. Copy its token from the next page, where GitLab shows it once; it starts with glrt-.

Connect it

Sign in at nomac.app, open CI, paste the token under GitLab and press Connect. NoMac checks it with GitLab and keeps it encrypted. You need some credit.

Tag your jobs

.gitlab-ci.yml
ios-tests:
  tags: [nomac]
  script:
    - xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=27.0' -resultBundlePath build/tests.xcresult | xcbeautify
  artifacts:
    when: always
    paths: [build/tests.xcresult]

Each job gets its own Mac. Its log streams to GitLab as it runs, and cancels, timeouts and retries work as on any runner. GitLab runs scripts with pipefail, so a failed test fails the job even when piped into xcbeautify. The CI page lists every job with its minutes and cost.

What you get

The machine
A fresh macOS VM for every job, deleted when the job ends: 6 cores and 8 GB of an Apple silicon Mac (M4 to M6).
Installed
macOS 26.6.2, Xcode 27.2 (default) and Xcode 27.0 (/Applications/Xcode-27.0.app), iOS 27.0 Simulator, iOS 27.2 Simulator; Node 24.20.0, npm 11.19.0, Ruby 3.4.11, Bundler 4.0.23, CocoaPods 1.17.0, xcbeautify 3.2.1, XcodeGen 2.46.0, Git 2.54.0, Git LFS 3.8.0, GitHub CLI 2.100.0, jq 1.7.1, Python 3.14.7, uv 0.13.0, simulator-use 0.15.1, nvm 0.40.8, Swift 6.4.2, Homebrew 7.0.11.
Price
1.5¢ a minute ($0.90 an hour) from your NoMac credit, billed per minute from the Mac's start to its stop, a minute at least. No subscription. A Mac on which no stage of the job ran, or whose job NoMac lost, is credited back.
Jobs at once
2 per account, GitHub and GitLab jobs together; more wait in GitLab's queue. Ask for more.
Longest job
6 hours. For jobs over an hour, raise the project's timeout in GitLab (60 minutes by default), and set the runner's maximum job timeout to 6 hours (21600 seconds).
Out of credit
NoMac stops taking the account's jobs, so they wait in GitLab's queue and start once you buy credit. GitLab drops a job that waits a day, and one waiting over an hour once the runner shows offline, 2 hours after NoMac stops asking.
GitLab
GitLab.com. GitLab asks a new account to verify its identity (phone or card) before it runs any pipeline. From a self-managed GitLab, run Mac steps with nomac run.

What runs

  • script, before_script and after_script, artifacts and reports, variables (masked and file variables too), GIT_STRATEGY, submodules, retries and allow_failure with exit codes.
  • image and services are not used: the Mac is the machine. cache is skipped, since each job's Mac is new and deleted at its end; archiving Pods or DerivedData would only cost minutes.

Secrets and safety

  • A CI Mac runs only the job: it gets the job's variables from GitLab and nothing from your NoMac account (no secrets, drives or API keys), and it is deleted when the job ends.
  • NoMac runs only jobs with the tag nomac. A runner that takes another job (it has other tags, or runs untagged jobs) is paused on NoMac before any Mac starts, so it takes no more of your jobs; that job fails, saying why. Fix the runner in GitLab, then press Resume on the CI page.
  • A fork's merge request pipeline runs in the fork, on the fork's runners, unless a member of your project runs it in your project. Then it uses your runners and variables with the fork's .gitlab-ci.yml, so read the change first. To keep merge request pipelines off NoMac, mark the runner Protected in GitLab: it then takes only jobs from protected branches and tags.
  • Disconnect a runner on the CI page, or delete it in GitLab: NoMac then takes none of its jobs.

For agents

With a NoMac API key: POST /api/v1/ci/gitlab/runners with {"token": "glrt-..."} connects a runner, GET /api/v1/ci/gitlab/runners lists them, DELETE /api/v1/ci/gitlab/runners/ID disconnects one and POST /api/v1/ci/gitlab/runners/ID with {"resume": true} resumes a paused one.