Ephemeral macOS VMs for GitHub Actions — powered by Tart
If you run iOS, macOS, or Apple-platform CI on GitHub Actions, you need macOS runners. GitHub's hosted runners work, but they're expensive and you can't customize the image. Self-hosted runners on bare metal are fast and cheap — but managing them is painful: stale state bleeds between jobs, runner registration is manual, and there's no easy way to scale.
Graftery fixes this. It brings the same ephemeral, scale-to-zero model that Actions Runner Controller (ARC) provides on Kubernetes — but runs directly on a Mac. Every job gets a fresh VM clone. When the job finishes, the VM is destroyed. No state leaks, no drift, no cleanup scripts.
GitHub Actions Your Mac
───────────── ────────
Job queued ──── scaleset poll ────▶ Graftery sees demand
│
├─ Clones base VM image
├─ Injects JIT runner config
├─ Boots ephemeral Tart VM
├─ Runner picks up the job
└─ VM destroyed on completion
Graftery speaks the actions/scaleset protocol natively — the same wire protocol ARC uses. No custom API, no webhook glue.
Clean room every job — Each job runs in a fresh VM clone. No state leaks between jobs, ever.
Scale to zero — No jobs? No VMs. Runners spin up on demand and tear down when done. Configure a warm pool (min_runners) for faster pickup.
Custom VM images — Drop shell scripts into bake.d/ and Graftery bakes them into a prepared image. Install Xcode, CocoaPods, Homebrew packages — whatever your builds need. Content-hashed so reprovisioning only happens when scripts change.
Pre/post job hooks — Native GitHub Actions runner hooks (ACTIONS_RUNNER_HOOK_JOB_STARTED / COMPLETED). They show up as collapsible sections in the Actions UI.
Orphan cleanup — On startup, Graftery finds and removes VMs left behind by crashes. Session conflicts with GitHub are retried automatically with exponential backoff.
Prometheus metrics — Host CPU/memory/disk, per-VM CPU/memory/uptime, job counters — all exposed via a /metrics endpoint. Includes Apple hypervisor (XPC) process tracking for accurate VM resource attribution.
Dry-run mode — Test your setup without GitHub or Tart. Simulates the full lifecycle with fake jobs so you can validate config, control socket, and UI integration end-to-end.
Graftery ships as both a macOS menu bar app and a standalone CLI. They share the same Go backend — the app wraps the CLI in a native Swift UI.
| Best for | Interactive use on a Mac with a display | Headless servers, automation, launchd/systemd |
| Install | Download DMG | Download binary |
| Runner sets | Multiple — manage unlimited independent configs | One per process |
| Config | 6-step setup wizard + tabbed editor with auto-save | YAML file + CLI flags |
| Metrics | Live time-series charts (CPU & memory), menu bar gauges | Prometheus /metrics endpoint |
| Logs | Built-in log viewer with search, level filtering, color | Structured logs to stderr |
| Controls | Menu bar start/stop per runner, enable/disable auto-start | SIGINT/SIGTERM |
| Runs as | Menu bar app | Foreground process |
Tip
Already using ARC on Kubernetes? Graftery uses the same protocol and the same runs-on: label convention. Your workflows don't need to change — just point a scale set name at your Mac and go.
| Requirement | Details |
|---|---|
| Sonoma or later | |
brew install cirruslabs/cli/tart |
|
| GitHub App credentials or a Personal Access Token | |
| Tart image with the Actions runner binary & startup script (details) |
Download the latest DMG from the Releases page, open it, and drag Graftery into your Applications folder.
That's it — no dependencies beyond Tart.
Tip
Building from source? See Building from Source at the bottom of this page.
- Launch Graftery from Applications (or Spotlight).
- The setup wizard walks you through creating your first runner configuration — name it, enter your GitHub credentials, choose a base VM image, and set runner limits.
![]() |
![]() |
| Step 1 — Name your configuration | Step 3 — Authentication |
- The runner connects to GitHub and begins listening for jobs automatically.
- The menu bar icon shows live status. Click it to start/stop runners, add new configurations, or open the management window.
- Open Manage Configurations for the full editor — tabbed settings, live CPU & memory charts, and a built-in log viewer.
Each runner configuration is stored as a YAML file in ~/Library/Application Support/graftery/configs/. You can manage everything through the UI — the setup wizard for new configs, and the tabbed editor for changes (auto-saved on every edit).
You can also edit the YAML files directly with any text editor if you prefer.
# ── GitHub target ────────────────────────────────────────
url: https://github.com/your-org # org or repo URL
name: macos-runner # scale set name (= runs-on: label)
# ── Authentication (choose one) ─────────────────────────
# Option A: GitHub App
app_client_id: "Iv1.abc123"
app_installation_id: 12345678
app_private_key_path: /path/to/private-key.pem
# Or inline:
# app_private_key: |
# -----BEGIN RSA PRIVATE KEY-----
# ...
# Option B: Personal Access Token
# token: ghp_xxxxxxxxxxxx
# ── Runner settings ──────────────────────────────────────
base_image: ghcr.io/cirruslabs/macos-runner:sonoma
max_runners: 2 # Apple allows max 2 macOS VMs per host
min_runners: 0 # warm-pool size
runner_group: default
runner_prefix: runner # used for orphan detection on startup
# labels: # defaults to scale set name
# - macos
# - sonoma
# ── Provisioning ─────────────────────────────────────────
# tart_path: /opt/homebrew/bin/tart
# provisioning:
# scripts_dir: /path/to/custom/scripts
# skip_builtin_scripts: false
# prepared_image_name: ""
# ── Logging ──────────────────────────────────────────────
log_level: info # debug | info | warn | error
log_format: text # text | jsonDownload the latest graftery binary from the Releases page and place it somewhere in your PATH.
# Example: install to /usr/local/bin
curl -fSL https://github.com/diranged/graftery/releases/latest/download/graftery-darwin-arm64 \
-o /usr/local/bin/graftery
chmod +x /usr/local/bin/grafteryTip
Building from source? See Building from Source at the bottom of this page.
# Using a config file
graftery --config /path/to/config.yaml
# Using individual flags
graftery \
--url https://github.com/your-org \
--name macos-runner \
--app-client-id Iv1.abc123 \
--app-installation-id 12345678 \
--app-private-key-path /path/to/private-key.pem \
--base-image ghcr.io/cirruslabs/macos-runner:sonoma \
--max-runners 2
# Using a PAT instead of a GitHub App
graftery \
--url https://github.com/your-org \
--name macos-runner \
--token ghp_xxxxxxxxxxxx \
--base-image ghcr.io/cirruslabs/macos-runner:sonomaWhen --config is provided, the file is loaded first and any additional flags override its values.
| Flag | Req | Default | Description |
|---|---|---|---|
--config |
Path to YAML config file | ||
--url |
yes | GitHub org or repo URL | |
--name |
yes | Scale set name (runs-on: label) |
|
--app-client-id |
* | GitHub App Client ID | |
--app-installation-id |
* | GitHub App Installation ID | |
--app-private-key-path |
* | Path to PEM file | |
--app-private-key |
* | PEM contents inline | |
--token |
* | Personal access token | |
--base-image |
ghcr.io/cirruslabs/macos-runner:sonoma |
Tart VM image | |
--max-runners |
2 |
Max concurrent VMs | |
--min-runners |
0 |
Warm pool size | |
--labels |
(same as --name) |
Additional labels | |
--runner-group |
default |
Runner group name | |
--runner-prefix |
runner |
VM name prefix | |
--log-level |
info |
debug / info / warn / error |
|
--log-format |
text |
text / json |
* Provide either GitHub App credentials or --token.
Logs go to stderr by default. Use --log-level debug for verbose output, or --log-format json for structured logs.
Applies to both the macOS app and CLI.
Graftery automatically bakes a prepared VM image from your base Tart image. The first run (or whenever scripts change) triggers provisioning:
Base image ──▶ Clone ──▶ Boot ──▶ Run bake.d/* scripts ──▶ Save prepared image
(lexicographic order)
A content hash of all scripts is cached — subsequent runs skip provisioning if nothing changed.
Drop your own scripts into the user scripts directory:
~/Library/Application Support/graftery/scripts/
bake.d/
50-install-tools.sh # brew install jq terraform
60-setup-xcode.sh # sudo xcode-select -s ...
hooks/
pre.d/
50-start-metrics.sh # custom pre-job hook
post.d/
50-emit-metrics.sh # custom post-job hook
Note
Merge behavior: User scripts merge with built-ins. Same-name files override. Execution is lexicographic (50-* runs after 01-* through 03-*).
Override the directory:
provisioning:
scripts_dir: /path/to/custom/scriptsgraftery --reprovision --config config.yaml # force a fresh bake
graftery --skip-builtin-scripts --config config.yaml # only run user scriptsHooks use GitHub Actions' native runner hook mechanism and appear in the job UI as collapsible sections:
| Hook type | Location | Visible in |
|---|---|---|
| Pre-job | hooks/pre.d/*.sh |
"Set up runner" |
| Post-job | hooks/post.d/*.sh |
"Complete runner" |
Hooks receive standard Actions environment variables (GITHUB_REPOSITORY, GITHUB_RUN_ID, etc.).
The base Tart image must include:
| Component | Note |
|---|---|
| GitHub Actions runner | At ~/actions-runner/ — all cirruslabs/macos-runner images include this |
| Tart guest agent | All non-vanilla Cirrus Labs images include this |
| python3 | Required by the setup-info script |
The default
ghcr.io/cirruslabs/macos-runner:sonomasatisfies all requirements.
Need CocoaPods for your builds? Create a bake script:
# ~/Library/Application Support/graftery/scripts/bake.d/50-install-cocoapods.sh
#!/bin/bash
set -euo pipefail
export PATH="/Users/admin/.rbenv/shims:/Users/admin/.rbenv/bin:$PATH"
eval "$(rbenv init - 2>/dev/null)" || true
gem install cocoapods
sudo ln -sf "$(rbenv which pod)" /usr/local/bin/podRestart the runner — it detects the new script, reprovisions, and every future VM ships with pod.
See the examples/ directory:
| Example | Description |
|---|---|
| iOS / React Native | CocoaPods, ccache, Expo prebuild, workflow caching for Pods and DerivedData |
tart not found
The tart binary must be in your PATH:
brew install cirruslabs/cli/tartOr specify the path explicitly via CLI flag or config:
graftery --tart-path /opt/homebrew/bin/tart --config config.yamltart_path: /opt/homebrew/bin/tartAuthentication errors
| Error | Fix |
|---|---|
| "either GitHub App credentials or --token is required" | Provide one auth method |
| "specify either GitHub App credentials or --token, not both" | Use only one method |
| Private key errors | Check PEM path is correct and readable. For inline YAML, use | block scalar |
Orphaned VMs
On startup, Graftery auto-removes VMs matching the runner prefix. To clean up manually:
tart list # list all VMs
tart stop runner-abc12345 # stop
tart delete runner-abc12345 # deleteScale set registration fails
- Verify
--urlpoints to a valid GitHub org or repo - Ensure your GitHub App has the required permissions, or your PAT has
admin:org(org-level) /repo(repo-level) scope
Max runners limit
Apple's virtualization framework allows max 2 concurrent macOS VMs per host. The default max_runners: 2 reflects this. Setting it higher may cause VM creation failures.
Logs
| Mode | Location |
|---|---|
| macOS App | ~/Library/Logs/graftery/graftery.log (menu bar -> Open Logs) |
| CLI | stderr — use --log-level debug for verbose output |
Requires Go 1.26+ and Xcode command-line tools (for Swift UI and code signing).
make build-cli # CLI binary only (no CGO, no Swift)
make build-app # full macOS .app bundle
make build-dmg # drag-and-drop DMG installer
make install # → /Applications/Graftery.app
make clean # remove build artifactsAll artifacts are placed in the build/ directory.
Built for Apple silicon · Powered by Tart · Speaks actions/scaleset



