Announcing Runny
I have a couple of side projects that aren’t open source, but have enough activity to run out of
macOS runner minutes for GitHub Actions pretty quickly, and those runner minutes are expensive
enough that just paying for them isn’t really high on anyone’s list. I host some runners on a Mac
Mini in my utility room, and a while back, they went silent for a few weeks, unnoticed. The root
cause, when I finally dug it out, was an SSH session with no deadline. I was using sand, it had
shelled out to sshpass, the connection wedged pre-handshake, and a blocking pipe read starved the
rest of the runtime. No timeout, no error, no log line, just silence.1 I got angry about this and
Anthropic’s Fable had just dropped, so I, of course, fired up Claude Code and built my own
thing. That thing is runny; it recently hit 1.1, and I felt compelled to write about it.
runny is an observable macOS GitHub Actions runner daemon: it boots ephemeral, single-use VMs on Apple’s Virtualization.framework, registers each one with GitHub as a just-in-time runner, lets it run exactly one job, and then destroys it. It speaks tart’s bundle and OCI image format, so the whole existing ecosystem of tart images works unmodified, but there’s no tart dependency. The VM boots in-process via vz, Go bindings straight to the framework.
I took a lot of inspiration from tools like sand, tartelet, and Cilicon2, but also aimed to do better than those tools3. runny hit 1.0 toward the end of June, with two core things in place4:
runnyd, the daemon. Runs one state-machine per runner slot: pull image, clone, boot, SSH provision, register to GitHub, run a job, destroy, repeat. Failures destroy-and-recycle the slot, back off in case of transients, and write machine-readable postmortems to disk.runnyctl, the operator CLI.runnyctl whyanswers “what happened to that job?” with the recorded history,doctorchecks the host environment,statusstreams live state,debuglets you break into a VM to see why that job went wrong.
Notably absent: observability beyond “some logs.” 1.1, which I tagged today, closes that gap. We’re big OpenTelemetry fans at work (and runny does solve a problem my team has at work…), so I built on that ecosystem and as of 1.1, every runner cycle emits traces and metrics. Each cycle becomes a tree of spans for the image resolution & pull, GHA runner download, SSH provisioning, runner launch, etc. Warm starts (current image, smooth sailing for SSH & runner provisioning, etc.) show up very differently than cold starts (fresh image pull needs to happen, new runner tarball needs downloading, GitHub Actions blips). It took a couple of runs at the design, but I’m pretty happy with where it ended up.
There’s a couple other small features in 1.1: better management of daemon access & recording of
debug sessions, scrambling of guest passwords at provision-time so the well-known cirruslabs image
credentials go away at boot, per-pool guest environment variables/setup scripts, and a prune
subcommand to reclaim disk space (unused images, runner tarballs, etc.).
If you have an Apple Silicon Mac and a GitHub App with runner administration permission, the onboarding guide walks from empty host to first job. The short version:
# menu-bar app + daemon
brew install --cask bojanrajkovic/tap/runny-app
# or, headless:
brew install bojanrajkovic/tap/runny
sudo runnyctl install-daemon
The full 1.1 release notes are on GitHub. Issues, questions, and complaints about ~my~ Claude’s Go are all welcome.
-
In fairness to past me, the outage was invisible partly because the repos it served were low-traffic. But “nobody noticed” is an indictment of the system, not a mitigation. A CI fleet whose failure mode is indistinguishable from a quiet week is not a CI fleet. ↩
-
For example: VM disks are APFS clones of the image to save space, it provisions what the image needs over SSH, and it uses
tart’s OCI image format. ↩ -
Ability to run totally headless, observability & logs, “no silent failures” as an ethos. ↩
-
There’s also a frankly perfunctory Runny.app, which runs as a menu-bar app that gives you health, logs, etc. at a glance, and can install/manage the daemon as a user-level LaunchAgent. You can skip it entirely and run the whole thing headless on a Mac in a closet; this is where mine lives. ↩
Leave a comment