# 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 `labels` field this daemon depends on. - A code-signed app bundle. The binary must carry the `com.apple.security.virtualization` entitlement; ad-hoc signing (`codesign -s -`) is sufficient, so no paid Apple developer account is required. ## Quickstart ```sh git clone && 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: ```yaml # .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](docs/setup.md) — full Gitea-side and host-side walkthrough, config reference, image building, service installation, verification. - [docs/security.md](docs/security.md) — threat model, isolation boundaries, token handling. - [docs/troubleshooting.md](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. |