Ephemeral Tart VM runners on macOS. One config file, dynamic job profiles, shared host cache, zero idle resource consumption.
GitHub Actions (cloud)
|
| workflow triggers (push, PR, @claude mention, @codex mention, dispatch)
v
+------------------------------------------------------------+
| Your Mac (Apple Silicon) |
| |
| Coordinator Runner (always-on, ~128 MB) |
| - Receives boot-vm jobs |
| - Checks RAM budget (ram-ledger.sh) |
| - Clones golden image (APFS CoW, near-instant) |
| - Generates JIT runner token via GitHub API |
| - Boots ephemeral VM with shared cache mount |
| - VM picks up the real job, runs it, exits |
| - Cleanup job destroys VM and releases RAM |
| |
| +------------------------------------------------------+ |
| | Tart VM (ephemeral, destroyed after each job) | |
| | Image: configurable golden image | |
| | Tools: Node, Python, Flutter, Claude Code, etc. | |
| | Mounts: | |
| | shared/ -> JIT config + runner script | |
| | cache/ -> host-level dependency cache | |
| +------------------------------------------------------+ |
+------------------------------------------------------------+
brew install cirruslabs/cli/tart
brew install esolitos/ipa/sshpass
brew install gh jq
gh auth login # needs admin:org scopeEdit pipeline.yml with your GitHub org and desired profiles:
github:
org: your-org-name
runner_name: my-coordinator
host:
reserved_ram_gb: 4 # rest goes to VMs
profiles:
developer:
ram_mb: 8192
cpu: 6
timeout_minutes: 60
coder:
ram_mb: 2048
cpu: 2
timeout_minutes: 60
marketer:
ram_mb: 5120
cpu: 4
timeout_minutes: 30./setup.sh installThis will:
- Check prerequisites (tart, sshpass, gh, jq, python3)
- Generate
coordinator/config.env,run-coordinator.sh, and the LaunchDaemon plist - Download the GitHub Actions runner binary
- Register the coordinator runner with your org
- Print the
sudocommands to install the LaunchDaemon
# Pull a pre-built macOS base
tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest base-macos
# Apply base config (disables sleep, installs runner binary)
# Boot base-macos, SSH in, run: bash /Volumes/My\ Shared\ Files/shared/setup-base.sh
tart run base-macos --no-graphics --dir="shared:images/base"
# Build your workhorse image
bash images/videogen-and-browser/provision.sh
tart clone videogen-and-browser workhorse
tart delete videogen-and-browserCopy from workflows/ to each repo's .github/workflows/:
| Workflow | Trigger | Profile | Use Case |
|---|---|---|---|
developer.yml |
PR, dispatch | developer (8 GB) | Claude Code review, scripting, Flutter |
coder.yml |
PR, dispatch | coder (2 GB) | Lightweight AI coding |
marketer.yml |
push, PR, dispatch | marketer (5 GB) | Playwright, FFmpeg, content pipeline |
claude-agent.yml |
@claude mention |
coder (2 GB) | AI agent triggered by issue/PR comments |
codex-agent.yml |
@codex mention |
coder (2 GB) | AI agent triggered by issue/PR comments |
./setup.sh validate # health check
./setup.sh test # integration testsSame golden image, different resource allocations. Profiles are defined in pipeline.yml:
| Profile | RAM | CPU | Timeout | Runner Label |
|---|---|---|---|---|
developer |
8 GB | 6 | 60 min | self-hosted, developer |
coder |
2 GB | 2 | 60 min | self-hosted, coder |
marketer |
5 GB | 4 | 30 min | self-hosted, marketer |
Adding a new profile — just add it to pipeline.yml and run ./setup.sh config:
profiles:
my-new-profile:
ram_mb: 4096
cpu: 4
timeout_minutes: 45No script edits required. Create a matching workflow template with VM_TYPE: my-new-profile.
VM RAM ceiling = total host RAM minus reserved. Jobs exceeding the ceiling queue automatically.
Example (16 GB Mac, 4 GB reserved = 12 GB ceiling):
- 5x coder (10 GB) — yes
- 2x marketer (10 GB) — yes
- 1x developer + 1x marketer (13 GB) — no, marketer queues
- 1x developer + 2x coder (12 GB) — yes (exactly at ceiling)
Comment @claude <prompt> or @codex <prompt> on any issue or PR to trigger an AI coding agent. Both use the lightweight coder profile (2 GB) and participate in the normal RAM queue.
Required secrets:
ANTHROPIC_API_KEY— for Claude Code workflowsOPENAI_API_KEY— for Codex workflowsGITHUB_TOKEN— automatic, used for PR comments
System tools (Node, Python, FFmpeg) are baked into the golden image. Project deps (node_modules, pip) are cached on the host and mounted into VMs:
Host: <pipeline-dir>/cache/ ← persists across VM lifetimes
│
└── mounted into every VM via Tart --dir
│
VM: /Volumes/My Shared Files/cache/
1. Trigger (push, PR, @claude mention, dispatch)
2. GitHub queues boot-vm job → [self-hosted, coordinator]
3. Coordinator runs dispatch.sh:
a. ram-ledger.sh acquire → check RAM budget
b. tart clone → APFS CoW clone (~1s)
c. gh api generate-jitconfig → one-time runner token
d. tart run → boot VM with mounts
e. SSH in, start runner with JIT config
4. VM runner registers, picks up the real job
5. Job runs inside isolated VM
6. Runner exits
7. Cleanup job: tart delete + ram-ledger.sh release
8. Machine returns to idle
./setup.sh validate # health check
./setup.sh test # integration tests
./setup.sh config # regenerate config after editing pipeline.yml
bash coordinator/ram-ledger.sh status # view RAM allocation
bash scripts/cache-cleanup.sh # prune stale cache (>7 days)
bash scripts/teardown-all.sh # emergency: kill all VMs
bash scripts/disk-audit.sh # report disk consumers
bash scripts/health-check.sh # detailed system checkpipeline/
├── pipeline.yml # Configuration (edit this)
├── setup.sh # Setup / config / validate / test
├── lib/
│ └── parse-config.py # YAML → shell config generator
├── coordinator/ # VM lifecycle + RAM accounting
│ ├── config.env # Generated by setup.sh
│ ├── dispatch.sh # Core: clone → JIT → boot → run
│ ├── ram-ledger.sh # RAM budget: acquire/release/status
│ ├── run-coordinator.sh # Generated by setup.sh
│ └── install-coordinator.sh # Legacy wrapper → setup.sh
├── images/ # Golden image provisioning
│ ├── base/ # Base macOS setup
│ └── videogen-and-browser/ # Workhorse image
├── workflows/ # GitHub Actions templates
│ ├── developer.yml # Claude Code (8 GB)
│ ├── coder.yml # Lightweight coding (2 GB)
│ ├── marketer.yml # Content pipeline (5 GB)
│ ├── claude-agent.yml # @claude trigger
│ ├── codex-agent.yml # @codex trigger
│ └── pipeline-selftest.yml # System validation
├── tests/
│ ├── integration/ # setup.sh test suite
│ ├── node-basic/ # Environment check
│ ├── playwright/ # Browser test
│ └── flutter-hello/ # Flutter test
├── scripts/ # Operational utilities
│ ├── health-check.sh
│ ├── cache-cleanup.sh
│ ├── teardown-all.sh
│ ├── rebuild-image.sh
│ └── disk-audit.sh
└── launchd/ # Generated plist
Each Mac runs its own coordinator. Set a unique runner_name in each machine's pipeline.yml:
# Machine A
github:
runner_name: mac-mini-a
# Machine B
github:
runner_name: mac-mini-bGitHub Actions distributes jobs across all coordinators automatically.
| Problem | Solution |
|---|---|
| VM doesn't boot | ./setup.sh validate — check Tart, golden image, disk space |
| Runner doesn't register | Check gh auth status, verify org name in pipeline.yml |
| SSH timeout | VM may need more boot time. Check tart ip <vm-name> |
| RAM denied | bash coordinator/ram-ledger.sh status — check for stuck VMs |
| Stale VMs | bash scripts/teardown-all.sh to reset |
| Cache mount fails | Verify VirtioFS works: tart run <vm> --dir=test:/tmp/test |
| LaunchDaemon not starting | sudo launchctl print system/com.pipeline.coordinator |