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/standfastSigned 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.
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.
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.
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.
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.
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.
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.
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.
With Homebrew:
brew install --cask perafan18/tap/standfastThat 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.
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/standfastSpotlight 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.appEach 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.
-
macOS 14 or later.
-
A runner to watch: one installed as a service — the one
./svc.sh installsets up — is found on its own. One started by hand with./run.shis 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
ghpath never answers it, because what it returns cannot be attributed to a runner's labels. - a GitHub token pasted into Settings, which Standfast keeps in your login Keychain and
uses for its requests to
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-fleetpublishes 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.
These are real and deliberate, not oversights:
- Runners started by hand with
./run.share 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
gitHubUrlis parsed for the scope and then thrown away: status is asked ofapi.github.com— or ofgithub.comthroughghwhen 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
ghcan 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
.plistis 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.plistto clear it. - A returned service command is not a proved state.
svc.sh startcan 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.
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.
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 checkmake 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.
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.
MIT. See LICENSE. What changed and when is in CHANGELOG.md.
