https://github.com/diranged/graftery
A native macOS menu bar app that orchestrates ephemeral GitHub Actions runners on Apple Silicon using Tart VMs
https://github.com/diranged/graftery
apple-silicon ephemeral-runners github-actions github-actions-runner macos menu-bar-app self-hosted-runners swiftui tart virtualization
Last synced: 25 days ago
JSON representation
A native macOS menu bar app that orchestrates ephemeral GitHub Actions runners on Apple Silicon using Tart VMs
- Host: GitHub
- URL: https://github.com/diranged/graftery
- Owner: diranged
- License: apache-2.0
- Created: 2026-04-06T19:03:26.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-04-07T02:17:41.000Z (about 1 month ago)
- Last Synced: 2026-04-07T02:22:11.190Z (about 1 month ago)
- Topics: apple-silicon, ephemeral-runners, github-actions, github-actions-runner, macos, menu-bar-app, self-hosted-runners, swiftui, tart, virtualization
- Language: Swift
- Size: 508 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
Awesome Lists containing this project
README
Graftery
Ephemeral macOS VMs for GitHub Actions — powered by Tart
---
## Why Graftery?
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)](https://github.com/actions/actions-runner-controller) 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.
### How it works
```
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](https://github.com/actions/scaleset) protocol natively — the same wire protocol ARC uses. No custom API, no webhook glue.
### Core capabilities
**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.
### Two ways to run it
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](#-macos-app) | [Download binary](#-cli) |
| **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.
---
## Requirements
| 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](#base-vm-image-requirements)) |
---
# 
## Installation
Download the latest **DMG** from the [Releases](https://github.com/diranged/graftery/releases) page, open it, and drag **Graftery** into your Applications folder.
That's it — no dependencies beyond [Tart](#requirements).
> [!TIP]
> Building from source? See [Building from Source](#building-from-source) at the bottom of this page.
## Quick Start
1. **Launch Graftery** from Applications (or Spotlight).
2. 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
3. The runner connects to GitHub and begins listening for jobs automatically.
4. The **menu bar icon** shows live status. Click it to start/stop runners, add new configurations, or open the management window.
5. Open **Manage Configurations** for the full editor — tabbed settings, live CPU & memory charts, and a built-in log viewer.
## Configuration
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.
### Config file reference
```yaml
# ── 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 | json
```
---
# 
## Installation
Download the latest **`graftery` binary** from the [Releases](https://github.com/diranged/graftery/releases) page and place it somewhere in your `PATH`.
```bash
# 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/graftery
```
> [!TIP]
> Building from source? See [Building from Source](#building-from-source) at the bottom of this page.
## Usage
```bash
# 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:sonoma
```
When `--config` is provided, the file is loaded first and any additional flags override its values.
## CLI Flags
| 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`.
## Logging
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.
## Built-in scripts
| Script | Purpose |
|:---|:---|
|  | Installs `arc-runner-startup.sh` — reads JIT config, starts runner, shuts down when done |
|  | Generates `.setup_info` — VM info shown in GitHub Actions "Set up job" step |
|  | Installs pre/post job hooks via `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `COMPLETED` |
## Custom provisioning scripts
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:
```yaml
provisioning:
scripts_dir: /path/to/custom/scripts
```
## Forcing reprovisioning
```bash
graftery --reprovision --config config.yaml # force a fresh bake
graftery --skip-builtin-scripts --config config.yaml # only run user scripts
```
## Pre/post job hooks
Hooks 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.).
## Base VM image requirements
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:sonoma` satisfies all requirements.
## Example: adding a tool to the baked image
Need CocoaPods for your builds? Create a bake script:
```bash
# ~/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/pod
```
Restart the runner — it detects the new script, reprovisions, and every future VM ships with `pod`.
## More examples
See the [`examples/`](examples/) directory:
| Example | Description |
|:---|:---|
| [iOS / React Native](examples/ios-react-native/) | CocoaPods, ccache, Expo prebuild, workflow caching for Pods and DerivedData |
---
# 
tart not found
The `tart` binary must be in your PATH:
```bash
brew install cirruslabs/cli/tart
```
Or specify the path explicitly via CLI flag or config:
```bash
graftery --tart-path /opt/homebrew/bin/tart --config config.yaml
```
```yaml
tart_path: /opt/homebrew/bin/tart
```
Authentication 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:
```bash
tart list # list all VMs
tart stop runner-abc12345 # stop
tart delete runner-abc12345 # delete
```
Scale set registration fails
- Verify `--url` points 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 |
---
## Building from Source
Requires **Go 1.26+** and **Xcode command-line tools** (for Swift UI and code signing).
```bash
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 artifacts
```
All artifacts are placed in the `build/` directory.
## License
[Apache License 2.0](LICENSE)
---
Built for Apple silicon · Powered by Tart · Speaks actions/scaleset