Files
gitea-macos-vm-orchestrator/README.md
T
2026-08-08 17:39:40 -07:00

10 KiB

gitea-macos-vm-orchestrator

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, which is not a restricted entitlement — ad-hoc signing (codesign -s -) grants it, so the runner works with no Apple developer account. A Developer ID Application certificate is nonetheless recommended: it anchors the bundle's code identity to your team, which is what keeps a macOS Local Network grant alive across rebuilds. See Code signing.

Quickstart

git clone <this repo> && cd gitea-macos-runner

# Build, bundle (binary + Resources + Info.plist), sign with the virtualization
# entitlement (Developer ID if a matching certificate is in the keychain, ad-hoc
# otherwise), 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).
# On a terminal this also offers to grant macOS Local Network access, which the agent
# needs to reach its guests; `permissions grant` does the same thing on its own.
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.

CI

This repo builds itself. .gitea/workflows/build.yml runs on macos-arm64 — the very runners this project provides — and does a full swift test, make all, and a check that the resulting .app carries the virtualization entitlement and its runtime resources. A green run is also an end-to-end test of the runner: it means a guest image really can check out a repo, run the Swift toolchain, and produce a signed bundle.

For it to run at all you need:

  • the repo pushed to a Gitea 1.25 or newer instance with Actions enabled ([actions] ENABLED = true),
  • gitea-macos-runner daemon running on an Apple silicon host and registered with that instance,
  • a base image built and provisioned (image build) — image list should show PROVISIONED: yes.

The workflow deliberately runs no doctor, vm, daemon, or image steps. Those start a VM or probe the host's ability to start one, and the job is already inside a guest; Virtualization does not nest, so they would fail for reasons that say nothing about the code.

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] [--grant-local-network allowlist|prompt|none] Write and load ~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist. Also evicts any agent left behind under a previous label. On a terminal it offers to configure Local Network access when that is unconfigured, defaulting to no; --grant-local-network decides it up front.
service uninstall Unload the LaunchAgent and remove its plist.
service status Report LaunchAgent installation and run state.
permissions status Report whether macOS Local Network access is configured, and whether the code identity is stable enough to hold an interactive grant. Run it under sudo to see the allowlist — it is written into root's preferences, which an ordinary login cannot read.
permissions grant [--method allowlist|prompt] [--subnet CIDR ...] [--reboot|--no-reboot] Grant Local Network access. allowlist (default) writes the subnet allowlist with sudo — all of RFC 1918 unless --subnet narrows it — and needs a reboot. prompt launches the installed .app so the system alert is attributed to it rather than to Terminal, and applies immediately.
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.