Files
gitea-macos-vm-orchestrator/README.md
T
abkslm ee19744496
build / build (push) Successful in 2m32s
update README.md
2026-08-07 04:37:01 -07:00

9.0 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; 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.

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] 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.