GitHub Actions on NoMac · NoMac
GitHub Actions on NoMac
Your GitHub Actions jobs on a fresh Mac with Xcode: change runs-on to nomac.
1. Connect GitHub
Sign in at nomac.app, open CI and press Connect GitHub. It installs the NoMac CI app on your account or organization, on every repository or the ones you pick. You need some credit.
2. Change one line
.github/workflows/ci.yml
jobs:
test:
runs-on: nomac
steps:
- uses: actions/checkout@v5
- name: Build and test
run: |
set -o pipefail
xcodebuild test -scheme App -destination \
'platform=iOS Simulator,name=iPhone 17 Pro,OS=27.0' \
| xcbeautify
Each job gets its own Mac. Keep set -o pipefail when you pipe into xcbeautify: GitHub's default shell has no pipefail, so a failed test would otherwise pass. 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.0, iOS 27.0 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, 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 that ran no job is credited back.
Jobs at once
2 per account; a job waits in GitHub's queue for a free one. Ask for more.
Longest job
6 hours, as on GitHub's own runners.
Out of credit
Jobs wait in GitHub's queue and start once you buy credit (GitHub cancels a job queued for 24 hours).
Build incrementally: nomac-cache
Set runs-on to nomac-cache and the job gets its repository's build cache, a 20 GB disk kept between jobs. It holds the checkout, DerivedData, the tool cache of setup actions, and the Swift package, CocoaPods, Carthage, Homebrew, npm, Yarn and pnpm caches. Checkout then fetches only new commits, and Xcode rebuilds only what they changed.
.github/workflows/ci.yml
jobs:
test:
runs-on: nomac-cache
- Who updates it. Only a job that passed, in a run of a push, a schedule or a manual dispatch on the repository's default branch. Every other job starts from the cache and its changes are thrown away: a pull request, another branch, a failed or cancelled job.
- Forks never read it. A job from a fork gets a Mac without the cache. GitHub can still hand it a Mac started for another job; then it stops at Set up runner, with no log, before its first step. Re-run it. In a public repository, give pull requests from forks a clean Mac:
runs-on: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository && 'nomac' || 'nomac-cache' }}. - Clean Macs stay clean. A
runs-on: nomacjob never gets a cache, so keep release and TestFlight builds there. Usenomac-cachealone:[nomac, nomac-cache]asks for a runner with both, which NoMac never has. - One Mac at a time. A second job asking for the cache while one runs starts without it and saves nothing. A job that has the cache sees
NOMAC_BUILD_CACHE=1. - What a job leaves there, later jobs read. Never write secrets into the checkout or DerivedData.
- Build folders in the checkout. actions/checkout deletes every file git does not track, ignored ones too:
Pods,node_modules,.buildand a-derivedDataPathinside the checkout start empty each job. Give itclean: falseto keep them, and leave DerivedData at Xcode's default path. - Space and reset. DerivedData of other Xcode versions is removed when a job starts, and with less than 8 GB free the cache is emptied first and the job builds from scratch. On the CI page you can see each repository's cache and reset it. A cache no job used for 30 days is deleted.
Sign and send to TestFlight
With the App Store add-on, a release workflow signs and sends to TestFlight with NoMac's steps, and no Apple secret is stored in GitHub: NoMac keeps the App Store Connect key and makes the profiles.
Terminal
permissions:
id-token: write
contents: read
jobs:
release:
runs-on: nomac
steps:
- uses: actions/checkout@v5
- uses: NoMac-app/actions/sign@v1
- run: |
xcodebuild archive -scheme App -configuration Release -destination 'generic/platform=iOS' -archivePath build/App.xcarchive -xcconfig "$NOMAC_XCCONFIG"
xcodebuild -exportArchive -archivePath build/App.xcarchive -exportPath build/export -exportOptionsPlist "$NOMAC_EXPORT_OPTIONS"
- uses: NoMac-app/actions/testflight@v1
with:
ipa: build/export/App.ipa
- Off until you allow it. On the CI page, under Signing from jobs, make the CI certificate (one of your team's distribution certificates, with a key of its own), then allow a repository's workflow file with the bundle IDs it may sign.
- Only that workflow, on the default branch. Tags sign too if you allow them. A pull request, another branch, a reusable workflow it calls or a fork never signs, and only a job on a NoMac Mac does.
- What the job's Mac gets. The CI certificate and the profiles of the allowed bundle IDs, in a keychain of its own, for that job: none of your other secrets. Every step of an allowed job can read it, so allow only workflows you trust; if its key ever leaks, revoke the CI certificate at Apple and your Macs keep signing with theirs.
- TestFlight waits until Apple has the build. The app needs its App Store Connect record, and its export compliance answered once (
ITSAppUsesNonExemptEncryptionin its Info.plist).
Secrets and safety
A CI Mac runs only the job: it gets the job's secrets from GitHub, and none of your NoMac secrets or drives; signing reaches it only through the sign step of a workflow you allowed. Nothing else reaches it, including your NoMac API keys, and it is deleted when the job ends. A nomac-cache job also gets its repository's build cache, never any other drive, and a job that signed never updates the cache.
Any other CI: nomac run
From GitLab, Buildkite, Bitbucket, Jenkins or a script, nomac run runs one command on a fresh Mac of its own and stops the Mac after. It uploads the folder as nomac sync does (not .git, build output, dependency folders or secret files), prints the command's output on stdout as it arrives, exits with its code, and copies back what --pull names, also when the command failed. A cancelled job (SIGTERM, SIGINT or SIGHUP) stops the Mac too.
Terminal
# .gitlab-ci.yml
ios-tests:
image: node:24
script:
- npx -y @nomac/cli run --pull build/tests.xcresult -- xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -resultBundlePath build/tests.xcresult
artifacts:
when: always
paths: [build/tests.xcresult]
- It needs
NOMAC_API_KEYas a masked CI variable. Whoever can run a pipeline with it can use your NoMac account: the Mac gets your NoMac secrets. A protected variable reaches only protected branches, so merge request pipelines need it unprotected, or no secret there. - Each run is a fresh Mac with no drive, so nothing carries over between runs, unless
nomac run --drive NAMEnames one: then caches carry over between runs with that name. It never uses or stops a Mac you or your agents run, and while it runs it counts as one of your account's Macs: when the account already runs as many as it may, it waits for one up to--queueseconds (600 by default). - One command runs at most an hour (
--timeout, 3540 s); its output keeps 8 MiB, so pipe long logs through xcbeautify or pull the full log. The image needs the OpenSSH client (node:24has it, slim and alpine images do not). A job killed outright (SIGKILL) cannot stop it: the command runs on to its limit and the Mac stops 15 minutes after. Billed per minute.