Skip to content

About

Many self-hosted GitHub Actions runners on one Mac, and the page that says whether any of them is broken.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

132 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Actions Runners

CI Docs Release License

Actions Runners: many self-hosted GitHub Actions runners on one Mac, and the page that says whether any of them is broken

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.

At a glance

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.

The Fleet tab on a live host: every registered runner online, zero drift, memory pressure normal, and a grid of runner cards grouped by project, every one idle

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.

What's new in v0.3

  • More than one Mac. scripts/join-host.sh joins 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 cockpit CLI and an MCP server with one verdict for the fleet, queue ETAs, and cockpit wait for 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.

Fifteen seconds

The Fleet tab while a runner dies and is repaired: one runner card turns red and reads dead with no pid, a critical launchd-dead row opens in Drift naming the last exit code, health.sh --repair restarts the service in a terminal strip along the bottom, and the card returns to idle with a fresh pid as the drift row closes

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.

On your Mac: Fleet Cockpit

Fleet Cockpit's popover during a disk-floor freeze: the verdict "Disk floor is holding jobs" with a Preview cleanup button, the host lane with the disk gauge under its floor line, and square red pills for the three held runners

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.

More than one Mac

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 timer

GitHub 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.

Documentation

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.

Five-minute quick start

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:7878

Then 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.sh

Needs 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.

How it fits together

One macOS host running the runner LaunchAgents, the job hooks and local probes alongside the fleetd daemon, whose collector and server share one in-memory snapshot backed by SQLite; GitHub above it, and below it a browser dashboard reading over SSE and a second Mac's agent reporting inbound over a heartbeat

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.

What it watches

A matrix of what this machine says against what GitHub says, with the six disagreeing cells named: launchd-missing, launchd-dead, offline and orphan, plus label-mismatch and stuck-queue as a separate kind of problem

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.

The numbers exclude what would make them wrong

The Analytics tab: runs, success rate, CI time, hosted-macOS allowance saved, peak concurrency, never-scheduled runs, and a table classifying why jobs failed and whose problem each cause is

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.

Why self-host on Apple Silicon

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 is in here

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.

Things learned the hard way

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.sh replaced 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 done flag that is only recomputed inside the pass it gates is a latch. The backfill reported complete, 0 pending while 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.

Contributing

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.

License

MIT.

About

Many self-hosted GitHub Actions runners on one Mac, and the page that says whether any of them is broken.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages