Register, supervise and observe a fleet of self-hosted GitHub Actions runners
across one or more Apple Silicon Macs — one runner per repo, each its own
directory and its own LaunchAgent. One Node daemon per coordinator has no build
step, keeps local history in SQLite, and serves a single page; optional
two-replica HA adds managed PostgreSQL and its locked pg driver. A second
Mac joins in one command, and a menu bar app gives the whole fleet one verdict.
The dashboard exists because GitHub has no cross-repo Actions view, and half of the question — is that runner even alive — is not answerable from GitHub at all.
| Problem | GitHub shows workflow runs; macOS shows processes. Neither can tell you when the two disagree, why a job is queued, or whether the host can safely take more work. |
| System | One dependency-free Node daemon reconciles GitHub, launchd and the host, stores the history in SQLite, and streams one dashboard over SSE. |
| Operating shape | One Apple Silicon Mac or a few, one isolated runner per private repository, supervised by launchd. Extra Macs report outbound to one coordinator. |
| Evidence | Built from a live fleet (27 runners and 7,631 observed jobs when first measured in September 2026; two hosts since), including failures reproduced against the real host rather than fixtures alone. |
| Safety boundary | Private repositories with trusted contributors. Destructive operations are dry-run by default. |
What this demonstrates: CI/CD infrastructure, observability, macOS systems work, failure modeling, operational safety, and pragmatic system design.
A live fleet, with repository and host names replaced. Green means launchd, GitHub and this machine agree.
Caution
Self-hosted runners on public repositories are a security risk. Any fork's pull request can execute code on your Mac. This project is built for private repos with trusted contributors. Read the security guide before you register anything.
- More than one Mac.
scripts/join-host.shjoins a second host in one command, mirroring the coordinator's runners one per label set. Standby tiers run CI on a primary host first and wake the standby only when the primary is full, stuck or unfit. - A host on/off switch. Take a whole Mac out of CI from the dashboard or
scripts/host-drain.sh --off; busy runners finish their job first, and the confirm names any repo no other host serves. - Fleet Cockpit. A macOS menu bar app, a
cockpitCLI and an MCP server with one verdict for the fleet, queue ETAs, andcockpit waitfor a PR's checks. - Failures that are not your code, named. A full shared memory table
(
shm-low), a job orphaned by a draining runner, and the account's spending limit each get a verdict, instead of reading as a red test. - Fewer surprises on a new host. Preflight checks the Xcode license, the fleet's Xcode version and the plain CLI tools jobs call; every runner gets the same job PATH.
The full list is in the changelog.
A real runner on a real fleet, killed and repaired while the daemon watched. Repository and host names replaced. The clock runs at 6.5x; nothing else does.
The service is killed, leaving the launchd job loaded with no process behind
it — the failure no runner plist's KeepAlive will undo, because none of
them sets it. Nothing external revives it and no job for that repo will ever
start again. The daemon notices on its own poll, opens launchd-dead in Drift
with the last exit code, and raises an alert; health.sh --repair restarts the
service, and the row closes once launchd and GitHub agree again.
None of it is staged. It is frames of the real dashboard against a real fleet,
which is why it is the one asset in docs/img/ that cannot be produced from
fixtures — a fixture fleet can hold a dead runner, but it cannot die.
docs/brand.md records how it was made.
A menu bar app for the Mac you sit at. One verdict for the whole fleet — host
down, disk floor, dead service, saturated, account blocked, config drift,
waiting, clear — with the evidence and one next move; a pill per runner; the
queue with start and finish estimates. When the dashboard itself stops
answering it checks the host out of band (SSH, GitHub's view of the runners, a
runner on another machine) and still tells you which of those it is. A
cockpit command line and an MCP server give agent sessions the same answers.
See Fleet Cockpit.
A second Mac runs a small agent that reports outbound on a heartbeat, so it opens no port. One command sets it up from a clone, as a dry run until you say otherwise:
cp examples/fleet.env.second-host fleet.env # edit the marked lines
scripts/join-host.sh # preflight, and what it would register
scripts/join-host.sh --apply # the agent, mirrored runners, the health timerGitHub gives a job to the first idle runner whose labels match, and has no
way to say "prefer this host". Standby tiers do it with
the one lever there is, which runners are online: the standby host's runners
stay drained until the primary has been at its admission cap for a minute, has
a run queued for two, or is unfit, and drain again after ten quiet minutes.
scripts/host-drain.sh --off takes a host out of rotation for maintenance and
--on brings back exactly the runners it stopped. See
Federation.
addisdev.github.io/actions-runners
| Get started | A runner registered, the dashboard up, and a real workflow job running on your own Mac. |
| Concepts | What a runner is here, what launchd will not do for it, what drift means, and why a job is queued. |
| Design notes | Why it is shaped this way. Every argument is grounded in something that went wrong on a real fleet. |
git clone https://github.com/addisdev/actions-runners.git ~/actions-runners
cd ~/actions-runners
./preflight.sh # check this host
./register.sh owner/project-ios # register first runner
cd dashboard && ./fleetctl.sh install # start the dashboard
./fleetctl.sh token # save this token
# Open http://localhost:7878Then point a workflow at it. timeout-minutes is not optional: GitHub's
six-hour default never applied to self-hosted runners, so a hung job holds its
runner until somebody notices.
jobs:
build:
runs-on: [self-hosted, macos, arm64]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- run: ./scripts/build.shNeeds macOS 14+ on Apple Silicon, Homebrew at /opt/homebrew, the
gh CLI authenticated, python3, and Node 22.5 or
newer for the dashboard. The full list is in
Get started.
One process does both jobs. The collector and the server share the snapshot in
memory, so the live view never polls, and there is one LaunchAgent to reason
about at 3 am instead of two. Everything runs under launchd — which is also how
it gets a GitHub token, because gh keeps that token in the login keychain and
only a launchd job can read it.
Drift is the set of states where launchd and GitHub disagree about a runner, and each one is silent from whichever side you happen to be looking at. A runner registered on GitHub with no LaunchAgent queues jobs forever. A listener GitHub calls offline looks fine locally.
One rule exists because no runner plist sets KeepAlive — launchd loads the
service, starts it, and then does nothing further. A crashed runner is never
revived, and the only symptom is one repo's jobs queuing while every other repo
looks healthy.
The other question the fleet has to answer is why a job is sitting in a queue. That used to be one check — is a runner idle — which separated the wrong pair, because "every runner is busy" and "the host is saturated" both look like no runner is idle, and only the first is fixed by adding a runner. It is now eight classified causes, each carrying the evidence it used, and exactly one of them offers to add a runner.
Cancelled runs are out of every duration percentile, because a cancelled run's duration measures how long until something killed it. Jobs that never reached a runner are out of concurrency, because they present as day-long intervals overlapping everything — 139 of them once reported a peak concurrency of 34 on a host with 16 runners, a number that cannot happen.
conclusion = 'failure' is one value covering causes that need different people
to do different things. Measured over 30 days on this fleet, 58% of failed
jobs were not about the code at all — they were the account's Actions spending
limit refusing to start the job, which blocks self-hosted jobs too, on runners
that were idle and healthy the whole time.
GitHub-hosted macOS minutes bill at 10x against the included allowance on private repos. More critically, one iOS repo exhausting that allowance blocks Actions account-wide, taking down the cheap Ubuntu jobs in unrelated repos with it. A fleet on hardware you own removes that shared fate.
The cost you pay is owning the host's health. Idle listeners are nearly free at about 7 MB each; two simultaneous Xcode builds are not. Sizing is per repo rather than fleet-wide, because average concurrency across 27 runners was 0.2 while 21% of 7,631 jobs still waited over a minute to start — work queueing while 26 runners sat idle, because a runner serves one job at a time and each repo had exactly one.
If you want cloud autoscaling, use actions-runner-controller instead. This is a small Mac fleet, no Kubernetes and no cloud runner control plane.
| What it is | |
|---|---|
dashboard/ |
The daemon and the page. fleetd.js, the collector and server, SQLite, and a browser UI with no build step. Single-host mode uses Node built-ins; optional PostgreSQL HA uses the locked pg dependency. |
| Fleet scripts | preflight.sh, register.sh, status.sh, health.sh, runs.sh, cleanup.sh at the top level; the rest in scripts/. Every destructive one is dry-run by default. |
hooks/ |
job-started and job-completed. The only mechanism that can hold a job already dispatched to a runner. |
examples/ |
Workflow files, fleet.env for three deployment shapes, and the LaunchAgent plists. |
cockpit/ |
Fleet Cockpit: the macOS menu bar app, its Swift core, the cockpit CLI and MCP server, widgets, and a Claude Code skill. |
docs/ |
The handbook, its figures, and the rig that renders them. |
Every script is documented with its flags and what it refuses to do in the scripts reference.
Each of these changed the design, and each is written up properly in the design notes.
- Swap used on macOS is an accumulator, not a gauge. The host tile once led with it and showed orange on a perfectly healthy machine: 4.3 GB of swap "used" while memory was 71% free, load was 1.0, and thirty seconds of sampling showed zero swapins. It now leads with the kernel's own memory pressure and the swap-in rate.
- Alerting on levels rather than transitions sends 240 notifications an hour for one dead runner, and the second one is already ignored.
- There is deliberately no load-average rule. One ordinary Xcode build drives load past 100 on 12 cores. Alerting on that fires on healthy behaviour every day, which is how people learn to ignore alerts.
- Removal has to name its target.
deregister.shreplaced a script whose only selector was--keep <dir,dir,…>— to remove one runner you named every other runner, and an empty or mistyped keep-list removed everything. - A
doneflag that is only recomputed inside the pass it gates is a latch. The backfill reportedcomplete, 0 pendingwhile 17 completed runs had no job detail at all. The symptom was silence. - The YAML is parsed, not grepped. A line scanner reads
runs-on:out of a heredoc and reports a job that does not exist, and one finding like that is enough for somebody to stop believing the whole screen.
Issues and pull requests are welcome. CONTRIBUTING.md has
the layout, the test commands and the release gates;
SUPPORT.md says where to ask a question. Security reports go
through SECURITY.md, not the issue tracker.
New here? The good first issues are scoped to one file with the verify command written down, and Discussions is the place for fleet designs and questions. What comes next is discussed in What's next after v0.3.
MIT.






