162 lines
10 KiB
Markdown
162 lines
10 KiB
Markdown
# 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](docs/setup.md#code-signing).
|
|
|
|
## Quickstart
|
|
|
|
```sh
|
|
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:
|
|
|
|
```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.
|
|
|
|
## CI
|
|
|
|
This repo builds itself. [`.gitea/workflows/build.yml`](.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](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] [--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. |
|
|
| `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. |
|