7.9 KiB
gitea-macos-runner
A Swift daemon that gives a self-hosted Gitea instance on-demand macOS CI capacity from a single
Apple Silicon Mac. It polls Gitea for queued Actions jobs that request macOS, boots a fresh
ephemeral macOS VM through Apple's Virtualization.framework for each one, lets Gitea's own
gitea-runner execute exactly one job inside that VM, and then destroys the VM. Nothing from a
job survives into the next: every build starts from an identical, freshly cloned base image.
How it works
Gitea host daemon ephemeral VM
│ │ │
│ GET /api/v1/admin/actions/ │ │
│ jobs?status=queued │ │
│◄────────────────────────────────┤ poll every 5s │
│ [{id, labels: ["macos-arm64"]}]│ │
├────────────────────────────────►│ │
│ │ APFS clone base image (instant) │
│ ├──────────────────────────────────►│
│ │ boot headless, NAT networking │
│ │ resolve IP (/var/db/dhcpd_leases) │
│ │ ssh in │
│ ├──────────────────────────────────►│
│ │ gitea-runner register --ephemeral │
│◄────────────────────────────────┼───────────────────────────────────┤
│ runner appears, job dispatched │ │
├─────────────────────────────────┼──────────────────────────────────►│
│ │ gitea-runner daemon │
│ job completes; server refuses │ runs ONE job │
│ a second job and deletes the │ │
│ ephemeral registration │ │
│ │ destroy VM + clone │
│ ├───────────────────────────────► ✗ │
A background reconcile loop (every 5 minutes) deletes runner registrations left behind by VMs that were killed uncleanly, so the Gitea runner list does not accumulate dead entries.
Requirements
- Apple Silicon Mac. Virtualization.framework macOS guests are ARM-only.
- macOS 26 or newer on the host; macOS 27 or newer strongly recommended. The automated image
builder uses
VZMacGuestProvisioningOptions(macOS 27) to create the admin user and skip Setup Assistant. Both host and guest must be 27+ — an older guest silently ignores the options and stalls at Setup Assistant. - Disk: ~60 GB free for a vanilla image (IPSW ~15 GB plus a sparse ASIF disk); 140 GB+ if you provision Xcode.
- RAM: 16 GB minimum. Each guest defaults to 8 GB, and macOS caps the host at 2 concurrent macOS VMs regardless of hardware.
- Gitea 1.25 or newer (1.26+ recommended). 1.25 added the admin jobs API with the
labelsfield this daemon depends on. - A code-signed app bundle. The binary must carry the
com.apple.security.virtualizationentitlement; ad-hoc signing (codesign -s -) is sufficient, so no paid Apple developer account is required.
Quickstart
git clone <this repo> && cd gitea-macos-runner
# Build, bundle (binary + Resources + Info.plist), ad-hoc sign with the
# virtualization entitlement, then copy to ~/Applications and symlink the CLI
# into /usr/local/bin.
make install # = make build bundle sign, then the install step
# Write a starter config to ~/.config/gitea-macos-runner/config.json
gitea-macos-runner config init
# Fill in gitea.instanceURL, gitea.adminTokenFile, and the registration token settings.
$EDITOR "$(gitea-macos-runner config path)"
# Read the config back with secrets redacted, to confirm it parses and validates.
gitea-macos-runner config show
# Verify entitlements, config, Gitea reachability, disk space, and host/guest versions.
gitea-macos-runner doctor
# Build a base image from an IPSW (long — installs macOS, then provisions the guest).
# The image is named "default", which is also what `daemon` and `vm boot` look for.
gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_*.ipsw
# Optional: add Xcode to the image.
gitea-macos-runner image provision default --xcode-xip ~/Downloads/Xcode_XX.xip
# Install and start the LaunchAgent (runs in your GUI login session — not a LaunchDaemon).
gitea-macos-runner service install
gitea-macos-runner service status
Then push a workflow that targets the runner:
# .gitea/workflows/macos.yml
name: macOS build
on: [push]
jobs:
build:
runs-on: macos-arm64
steps:
- uses: actions/checkout@v4
- run: sw_vers && swift build
The daemon picks the job up within one poll interval, boots a VM, and tears it down when the job finishes.
Documentation
- docs/setup.md — full Gitea-side and host-side walkthrough, config reference, image building, service installation, verification.
- docs/security.md — threat model, isolation boundaries, token handling.
- docs/troubleshooting.md — symptom → cause → fix.
Command reference
Every subcommand accepts the global options --config PATH (-c, default
~/.config/gitea-macos-runner/config.json) and --verbose.
| Command | Purpose |
|---|---|
daemon [--image NAME] [--once] |
The scheduler loop. Normally started by launchd via service install. --image defaults to default; --once runs a single scheduling tick and exits. |
image build [--name NAME] [--ipsw PATH] [--disk-gb N] |
Install macOS into a new base image and provision it. --name defaults to default; without --ipsw the latest supported restore image is downloaded. --disk-gb overrides guest.diskGB. |
image provision NAME [--xcode-xip PATH] |
Re-run guest provisioning on an existing image; optionally install Xcode from a .xip. |
image list |
List base images in the store. |
image delete NAME [--force] |
Delete a base image and its disk. --force (-f) skips the confirmation prompt. |
vm boot [--image NAME] [--slot N] [--keep] |
Clone an image, boot it, print its IP, and wait for Ctrl-C. --slot picks which persistent per-slot MAC to use (default 0); --keep leaves the clone on disk. |
vm list |
List ephemeral VM clones on disk. |
service install [--executable PATH] |
Write and load ~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist. |
service uninstall |
Unload the LaunchAgent and remove its plist. |
service status |
Report LaunchAgent installation and run state. |
doctor [--json] [--no-fail] |
Preflight checks. --json emits machine-readable results; --no-fail exits zero even when checks fail. |
config init [--force] [--instance-url URL] |
Write the annotated example config. --force (-f) overwrites an existing file. |
config show |
Print the effective configuration with secrets redacted. |
config path |
Print the configuration file path. |