Skip to content
Perafan18Public

About

macOS menu bar app for self-hosted GitHub Actions runners. Tells you whether your runner will actually get work, not just whether the process is up.

Topics

Resources

Contributing

Security policy

Stars

5 stars

Watchers

0 watching

Forks

Repository files navigation

Standfast

The Standfast app icon and the headline "Know whether your Mac's runners will get work." beside the Standfast window, where build-mac on acme/widget is selected: running on this Mac, offline on GitHub, with 1 job waiting for this machine

Standfast is a menu bar app for the self-hosted GitHub Actions runners on your Mac. It tells you whether each one is actually going to get work, not only whether its process is up. A slim quick menu keeps the fleet glanceable; the Standfast window holds the complete operational picture and controls.

brew install --cask perafan18/tap/standfast

Signed with a Developer ID and notarized by Apple, for macOS 14 or later. The formula that builds it from source and the Standfast.zip download are under Install.

Why it exists

A runner whose registration has lapsed keeps its process happily running. svc.sh status still prints Started. Activity Monitor still shows it. The machine is fine. The next job simply never arrives, and you find out when someone asks why the build has been queued for an hour.

So Standfast asks two questions instead of one, and never collapses them:

  • launchd answers whether the process is alive.
  • The GitHub API answers whether GitHub still counts on it.

Only the second one decides whether your build runs. That is why a runner that is up but unreachable gets a state of its own instead of being filed under "stopped" — the symptom looks similar and the fix is not.

What the menu bar status icon means

The icon answers for every runner on the Mac at once: a job in progress first, then whatever needs fixing, then the quiet states. The quick menu has a row for each runner.

Icon Shown as Meaning
✓ Ready Registered, connected, waiting for work. The one you want to see.
⚙ Running Running a job right now.
⚠ Disconnected The process is alive, but GitHub cannot see it. No job is coming.
☾ Stopped The service is not running. Start it from the quick menu or the Standfast window.
? The answer that went quiet, such as GitHub not answering Standfast could not tell — and says why.
↻ Starting You just started it; GitHub has not acknowledged it yet. Held for 30s.
◌ — This Mac has no runners at all. Not a failure to read one.

Standfast never answers with a shrug when it cannot tell. It names the answer that went quiet and why — no token in Settings and no GitHub CLI either (GitHub not asked), a credential GitHub refused, a rate limit, an answer with nothing usable in it, launchd could not be asked, and their GitLab and managed-fleet counterparts — because each one has a different fix. In the code these states are idle, busy, disconnected, stopped, unknown and starting.

Quick menu, window and Settings

The quick menu is deliberately small. It shows one row per runner, in discovery order and marked with its state's symbol, each opening a submenu with that runner's state, progress, current managed job and latest operation receipt; any compact discovery or thermal warning; when Standfast last looked; and four fixed actions: Refresh Now, Open Standfast, Settings, and Quit. A conclusively stopped runner may offer Start inside its submenu. Stop, Restart, history, maintenance, preferences and confirmations do not live there.

Open Standfast brings forward one persistent window, named Standfast: a sidebar of your runners beside a detail form for the one you select. The sidebar keeps discovery order, and groups runners under GitHub, GitLab and Managed Fleet once there is more than one kind. Runners that share a name, as the Mac's hostname often makes them, are titled with what tells them apart, as in the menu: mac-mini · web-app. A row that is ready and waiting leaves its state to its symbol, so the rows with words are the ones to look at. The detail form answers the two questions on rows of their own — is it running on this Mac, and will GitHub or GitLab give it work — then shows its current job, the latest operation outcome, recent history, installed version, manual disk measurement and safe cleanup, and its logs. Start, Stop, Restart and the runner's destination sit in the window's toolbar, and in a Runner menu in the menu bar while Standfast has a Dock tile. Repository runners open the repository's workflow-runs page; organization and enterprise runners open their runner-settings page, which is the honest shared-scope destination GitHub provides.

The sidebar's first row is the Overview. It puts the runners that need somebody ahead of the rest, then anything discovery could not read, whether macOS is throttling this Mac for heat, and every runner with both of its answers side by side.

Service operations keep their own receipts per runner. In-flight work is visible immediately and remains visible for as long as the command is running. An order in flight, or one that failed, also shows on its runner's sidebar row, so an order given from that row's context menu still answers while another runner is selected. After an operation reaches an accepted, uncertain, or failed outcome, its terminal receipt remains for a nominal five-minute wall-clock window and is pruned by the next normal scan at or after the window ends. A system clock correction can shorten or extend the displayed interval. After that nominal window, any terminal receipt — including a failure — can expire before you see it; the runner's current state remains, but that operation explanation is no longer displayed. A command that returned is described as request sent, not as a state the next probe has not proved. Timeouts say the result is uncertain, and Restart says when Stop completed but the Start phase failed or timed out, so the recovery step is clear.

Settings has three tabs. General owns the notification switches, the switch that keeps this Mac awake while a job runs, Open at login, and whether Standfast also takes a Dock tile; Runners, the folders of runners started by hand; Accounts, the optional GitHub token and an optional token for each GitLab instance named in gitlab-runner's config.toml. Those preferences share their existing state with the app; moving the controls did not create a second copy.

Accessibility and app identity

The status item exposes both the Standfast label and the aggregate fleet value to assistive technology. The sidebar rows, the detail form and operation feedback expose names, roles and values; native controls preserve keyboard order and visible focus, and text accompanies every meaningful symbol and status dot except a ready runner's, which leaves its state to the symbol while VoiceOver still reads the word. No state depends on color or motion alone. The only animation — an operation receipt appearing — becomes an immediate change under Reduce Motion.

The app draws with macOS's own controls, text styles and semantic colors, so it follows light and dark, Increase Contrast and Reduce Transparency the way the system does. On macOS 26 and later the system gives the sidebar, toolbar, menus and Settings tabs their Liquid Glass; Standfast draws none of its own.

The rounded sentinel/beacon app icon is Standfast's product identity; it does not report live runner state. Its checked-in 1024×1024 source contains no text or third-party mark, is compiled into the complete Standfast.icns family by Scripts/build-icon.sh during make app, and is included before the bundle is signed.

What it is building, and for how long

Select a busy runner in the Standfast window and its Current Job section says which job:

Running testflight — 1m 20s, usually 2m 50s

Under it, the last five jobs show how each one ended and how long it took. The job's name, its history and the usual duration all come out of the log the runner already writes beside itself — no extra API call and nothing to configure. The runner announces every job it picks up and every result it hands back, and Standfast reads the tail of that file. Whether the runner is busy right now is still GitHub's answer, from the same status request Standfast already makes for that runner, so the running line appears only once GitHub says so.

"Usually" is the median of the last few successful runs of that same job. Not the mean, and not every run: a build canceled after ten seconds is a real event and a terrible estimate, and one of them would drag a mean down by half a minute and keep it there. With fewer than three runs to go on, Standfast shows no estimate at all — elapsed time only. A number invented from two samples is worse than no number.

The line shows elapsed time against a reference, never a countdown. A bar under it fills toward the usual time and goes away once the job passes it, since a full bar would read as done. A job that overruns keeps saying what it usually takes, which is exactly when you want to know.

What the runner is costing you

A runner eats a disk quietly. On the machine this was built against, _work was 4.5 GB — of which 4.33 GB was the hosted tool cache — and _diag was 9 MB growing by about ten a day, with no rotation of any kind. Nobody looks at either until the disk is full.

Each runner's Maintenance section measures both, broken down by what each directory is for rather than as one total:

Disk use measured 4m ago
Tool cache — 4.33 GB
Repository checkouts — 431 MB
Downloaded actions — 16.6 MB
Logs — 9.2 MB

The breakdown is the point. 4.5 GB is a number to be alarmed by; 4.33 GB of cache is a number to press a button about. It is measured with du when you ask, never on a timer, and the Maintenance section says how old the numbers are even while it is collapsed.

Of the runner's own folders, Standfast offers to delete exactly three, and the shortness of that list is the feature:

Offered Why
_work/_tool The hosted tool cache. An actions/setup-* step downloads it back.
_work/_actions The actions your workflows use. The runner re-fetches any it cannot find.
Old _diag logs Nothing written to in over a week, worker logs above all.

The repository checkouts are never offered. Nothing in one is a cache: a workflow that wrote a file git does not track loses it, and the next run pays for a full clone where it would have paid for a fetch. _work/_temp is not offered either — it reads as the safest of the lot and is the most dangerous, because it is $RUNNER_TEMP, it holds the scripts of a job in flight, and it is empty whenever the runner is idle.

Deleting is offered only while that runner is idle or stopped, read off that runner and never off the fleet, and the state is checked again immediately before anything goes — a job can arrive between the click and the delete. The deletion itself is a rename: the directory is moved aside in one atomic syscall and taken apart afterward, so a job that lands a microsecond late finds no tool cache, which is a slower build, rather than half a tool cache, which is a failed one.

The log the runner is writing right now is never deleted, and neither is the history Standfast shows you. The one other thing ever offered is Standfast's own: cache folders an earlier cleanup moved aside into _work/.standfast-trash under old names that no longer say which cache they held.

Open at login

Off by default, and switched on from Settings. Standfast asks macOS to register it and then asks macOS back what actually happened, so the switch shows the state of the registration rather than the state of the request — including the case where macOS keeps the registration and the user has switched it off in System Settings, where nothing failed and the app still will not launch.

Install

With Homebrew:

brew install --cask perafan18/tap/standfast

That puts the app, signed with a Developer ID and notarized by Apple, in Applications. Or download Standfast.zip from the latest release, unzip it, and move Standfast.app to Applications.

Building it on your Mac instead

The same tap has a formula that builds Standfast from source, and so needs Xcode 16 or later. Built with an Xcode older than 26, it runs without Liquid Glass on macOS 26 and later:

brew install perafan18/tap/standfast

Spotlight and Launchpad do not look inside Homebrew's Cellar, and they ignore a symlink to it, so copy the app into Applications — and copy it again after every upgrade:

rm -rf /Applications/Standfast.app
ditto "$(brew --prefix standfast)/Standfast.app" /Applications/Standfast.app
open /Applications/Standfast.app

Each upgrade is a new build, so macOS asks once whether the new build may read the token the previous build stored in your Keychain; choose Always Allow. You can also build it from source with git.

Requirements

  • macOS 14 or later.

  • A runner to watch: one installed as a service — the one ./svc.sh install sets up — is found on its own. One started by hand with ./run.sh is watched once you add its folder in Settings.

  • A way to ask GitHub, either of:

    • a GitHub token pasted into Settings, which Standfast keeps in your login Keychain and uses for its requests to api.github.com; or
    • the GitHub CLI, authenticated once with gh auth login. Standfast uses it only while no token is stored.

    Queued work needs the token: the gh path never answers it, because what it returns cannot be attributed to a runner's labels.

There is nothing to configure

Standfast finds your runners the way launchd does. It reads the LaunchAgent each runner installed, and the .runner file each one keeps beside itself:

~/Library/LaunchAgents/actions.runner.<owner>-<repo>.<name>.plist   → where it lives
<runner directory>/.runner                                          → who it is

No path to type and no repository to name. If gh is already signed in, there is no token to paste either. Several runners on one Mac work out of the box and are told apart by where they are registered when they share a name. Runners registered to an organization or to a GitHub Enterprise Cloud account work too.

Three other kinds of runner are found from where they already describe themselves:

  • GitLab runners, from gitlab-runner's own ~/.gitlab-runner/config.toml. Their state comes from the GitLab instance each one names, asked with a GitLab token from Settings. The runner's own token stays in that file; Standfast never uses it.
  • Supervisor-managed ephemeral fleets, from the read-only status snapshot that actions-runner-fleet publishes at ~/.local/state/actions-runner-fleet/status-v1.json. Standfast watches their slots; starting, stopping and cleaning them belong to the supervisor.
  • Runners started by hand with ./run.sh, which leave no LaunchAgent. They are never guessed by walking the disk: you add their folder in Settings, and Standfast re-reads it on every scan and watches the runner's process. It cannot start or stop them, and says so in the runner's Status section and on its grayed toolbar buttons.

Each runner is asked about by its own agentId, so a second runner on the same repository can never be mistaken for the first.

Known limitations

These are real and deliberate, not oversights:

  • Runners started by hand with ./run.sh are not found on their own. They leave no LaunchAgent, so their folder has to be added in Settings, and Standfast can watch them but not start or stop them. They must run under your own account: Standfast looks only at your processes, never at another user's.
  • GitHub Enterprise Server is not supported. The host in the runner's gitHubUrl is parsed for the scope and then thrown away: status is asked of api.github.com — or of github.com through gh when no token is stored — and the workflow-runs or runner-settings destination is built there too. A GHES runner reads "GitHub not answering". (Runners registered to a GitHub Enterprise Cloud account — github.com/enterprises/... — do work.)
  • Queued work is answered only for repository runners. GitHub has no endpoint that lists what is waiting at the organization or enterprise level, so those runners say the question cannot be answered there instead of showing an empty queue.
  • State is re-read every 15 seconds, and a slow answer from GitHub or gh can stretch that. The menu says when it last looked, which is the only honest way to tell.
  • The job history goes back about twenty jobs, and no further. It is read from the tail of the runner's own logs; anything older is a question for the repository's GitHub workflow-runs page. Organization and enterprise runners have no honest cross-repository runs page, so their button opens runner settings instead.
  • A half-uninstalled runner nags forever. If a .plist is left behind without its runner directory, the Standfast window's Overview keeps it visible as unreadable and the quick menu surfaces a compact discovery warning. Delete the stray .plist to clear it.
  • A returned service command is not a proved state. svc.sh start can exit 0 before launchd and GitHub agree on the result, so Standfast reports the request as sent and lets the following probes establish the runner's state. A timeout remains explicitly uncertain rather than being called success or failure.

Privacy

Standfast itself adds no telemetry or analytics, and never checks for updates to itself. Its GitHub requests are functional. It asks for each configured runner's status. For a repository runner, it lists that repository's queued workflow runs and their jobs. It also asks the public endpoint for the latest actions/runner release, so the Standfast window can say when an installed runner is out of date. That release request runs once when Standfast launches and then no more often than every 24 hours while that same instance stays open; relaunching starts a new instance and a new first check.

With a token stored in Settings, those requests go straight to api.github.com over HTTPS. Without one, status and the release check go through the installed GitHub CLI, launched with the environment Standfast inherited. Current gh versions may send their own pseudonymous telemetry; Standfast neither adds to nor suppresses that delegated behavior. GitHub documents the data and opt-out controls at GitHub CLI telemetry. In particular, GH_TELEMETRY=false or DO_NOT_TRACK=true disables it for the inherited environment.

The only credentials Standfast keeps are the ones you paste into Settings — a GitHub token and, if you watch GitLab runners, one token per GitLab instance — in your login Keychain. Settings says whether one is stored and never what it is. A GitLab runner's status is asked of the GitLab instance named in that runner's own config.toml, with that instance's token only, and never over plain http: an instance served without TLS is not asked at all.

The app reads the runner's own _diag locally: listener-log contents provide job history and the installed runner version, while file metadata and disk usage support maintenance. After explicit confirmation, maintenance can delete eligible old logs. Standfast never uploads _diag contents; see SECURITY.md for the complete boundary.

Building from source

You need Xcode 16 or later; the Command Line Tools alone cannot build it, because from the macOS 27 SDK on, SwiftUI's @State is a macro whose plugin ships only inside Xcode.app. macOS 26 and later draw Liquid Glass only for an app built with Xcode 26 or later.

swift package clean && swift test  # about 1,250 tests; no installed runner required
make app                           # assembles .build/Standfast.app
make check                         # strict local bundle, menu and window/AX lifecycle check

make check requires an unlocked graphical session and Accessibility permission for the calling terminal. Missing permission or an unreadable menu/window tree is a failure. CI explicitly runs STANDFAST_AX_MODE=skip ./Scripts/check-app.sh as a packaging/process smoke test; its green result does not cover the status menu, the Standfast window, Settings, focus, keyboard navigation, or window lifecycle.

The package is plain SwiftPM with no dependencies and no .xcodeproj — project files generate unreadable merge conflicts and scare off contributors.

The code is split into two targets. RunnerKit holds everything with no UI — discovery, the launchd probe, the GitHub client and the state machine. Standfast is the SwiftUI app on top of it. Quick-menu, Control Center (the code's name for the Standfast window), operation feedback, per-runner button rules and settling behavior are plain presentation values rather than decisions buried in view code, so the tests can read them. Both targets are tested, and the whole suite runs on a machine with no runner installed.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md for the conventions this codebase actually enforces, and for the list of changes that look like obvious cleanups and are regressions.

License

MIT. See LICENSE. What changed and when is in CHANGELOG.md.

About

macOS menu bar app for self-hosted GitHub Actions runners. Tells you whether your runner will actually get work, not just whether the process is up.

Topics

Resources

Contributing

Security policy

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages