README.md
Rama main · réplica de lectura del repositorio original.
# runner-manager
[](https://www.npmjs.com/package/@ivan-murzak/runner-manager)
[](https://crates.io/crates/runner-manager)
[](https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases)
[](https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/actions/workflows/release.yml)
[](https://opensource.org/licenses/MIT)
**Local-first autoscaling for ephemeral GitHub Actions self-hosted runners.**
Use your own Windows, macOS or Linux machine to pick up GitHub Actions jobs only when work
is waiting. `runner-manager` registers a just-in-time runner, lets it complete one job and
removes it afterwards. You get local compute without an idle runner or an inbound network
service.

## Features
- ✅ **Works behind NAT:** no inbound ports, webhooks or servers.
- ✅ **Starts clean:** every job gets a fresh runner, and a fresh workspace by default.
- ✅ **Survives reboots:** auto-starts on Windows, macOS or Linux.
- ✅ **Protects hardware:** set concurrency limits for every target.
- ✅ **Tests safely:** monitor demand before enabling automation.
- ✅ **Shows live activity:** inspect runners, jobs and errors in the TUI.
- ✅ **Reuses build caches:** opt one repository into persistent workspaces.
- ✅ **Secures credentials:** secrets stay in the operating system store.
## Install
Install on any OS with Node.js 18 or newer:
```sh
npm i -g @ivan-murzak/runner-manager
```
<details>
<summary>Other installation methods and details</summary>
### Homebrew
On macOS or Linux:
```sh
brew install IvanMurzak/tap/runner-manager
```
### Install script
On macOS or Linux, with no Node.js installation required:
```sh
curl -fsSL https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases/latest/download/install.sh | sh
```
On Windows with PowerShell 5.1 or 7, with no Node.js installation required:
```powershell
irm https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases/latest/download/install.ps1 | iex
```
Then check the installation:
```sh
runner-manager --version
```
Every path above is a terminal command, deliberately: Gatekeeper on macOS and SmartScreen
on Windows act on the quarantine flag a *browser* sets, and `curl`, `irm`, `tar`, `brew`,
`npm` and `cargo` do not set one, so no install here raises a security prompt.
The published macOS binaries (install script, Homebrew, npm) are signed with the project's
Developer ID certificate under one identifier, and are not notarized. macOS ties the
permissions it gives the service, such as reading a runner root on an external volume, to
that signature, so they carry over from one release to the next. A binary built with `cargo
install` is not: the linker signs it ad hoc on Apple Silicon and not at all on Intel, and
macOS asks again after every rebuild.
### Which one to pick
The **install script** is the one to use for a boot-start service. It installs to a fixed
location (`~/.local/bin`, or `%LOCALAPPDATA%\Programs\runner-manager` on Windows) that
does not move when a toolchain moves, and `service install` records the binary's absolute
path.
An **npm** global binary lives under the *active* Node prefix, which moves when you switch
versions with nvm, fnm, volta or asdf. `runner-manager service status` reports the recorded
path as stale when that happens; re-run `service install` to fix it. The npm package name is
scoped: plain `runner-manager` on npmjs.com is an unrelated project.
### Install script details
Both scripts detect your OS and CPU, verify the archive's SHA-256 against the release's
published `SHA256SUMS`, and abort without installing anything if it does not match. To pin a
version. A piped script gets no arguments of its own, hence the separator:
```sh
curl -fsSL https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases/latest/download/install.sh | sh -s -- --version 1.2.3
```
```powershell
$env:RUNNER_MANAGER_INSTALL_VERSION = '1.2.3'
irm https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases/latest/download/install.ps1 | iex
```
### Read the script before running it
```sh
curl -fsSL -o install.sh https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases/latest/download/install.sh
less install.sh
sh ./install.sh
```
```powershell
irm https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases/latest/download/install.ps1 -OutFile install.ps1
Get-Content .\install.ps1 | more
Get-Content .\install.ps1 -Raw | iex
```
The last line runs the script's text rather than the file on purpose: a Windows client's
default execution policy is `Restricted`, which refuses `.\install.ps1` after you have
already read it.
### From source
```sh
cargo install runner-manager
```
Or from a checkout of this repository, which is how to run it before the first release is
tagged:
```sh
cargo build --release -p runner-manager
```
An unreleased checkout reports `X.Y.Z+git.<commit>` from `runner-manager --version`
(`.dirty` is appended when tracked files differ). Release artifacts built by the release
workflow report the published `X.Y.Z`. This makes a schema-changing integration build
distinguishable from the last published binary even while `Cargo.toml` still carries the
same package version.
The binary lands in `target/release/`. Both need the Rust toolchain pinned in
[rust-toolchain.toml](rust-toolchain.toml). Put it somewhere permanent before
`service install` records its absolute path.
If `runner-manager --version` is not found after installing, the installer printed the one
line that adds its directory to your `PATH`. Neither script edits a shell profile or the
registry on your behalf.
</details>
## Update
One command, whichever way you installed it:
```sh
runner-manager update
```
It works out how this copy was installed, compares your version against the
newest release, and updates through that same channel. An install-script or
standalone binary is replaced in place; `npm`, `brew` and `cargo` are asked to
do their own job. The archive it downloads is verified against the release's
published SHA-256 before anything is replaced, and a mismatch installs nothing.
To see what it would do without changing anything:
```sh
runner-manager update --check
```
If the service was installed from the binary this command updated, no further
action is needed. Its daemon notices that source change, stops starting runners,
waits without a deadline for every job it owns, swaps its private copy, and is
restarted by the operating system. Until then, `runner-manager service status`
still reports the old version.
Managed WSL hosts are separate hosts and are not updated by `update`, because
updating one drains that host's own jobs. `update` names each one that runs
another version with the command that updates it,
`runner-manager wsl install --distribution NAME`, which needs no administrator
rights.
If `service install` recorded a **different** source binary, ordinary `update`
leaves that service alone and names the separate path. To request the same
job-safe handover from the currently installed binary regardless of that path,
run `runner-manager update --force` (or inspect it first with `update --check
--force`). This retains a recoverable backup beside the old service source and
replaces only that source; it does not stop the service or cancel a job. The
command can return while a long job is still running. On macOS, the old daemon
hands its login-Keychain credential to the new binary just before it restarts,
because the keychain ties an item to the exact build that wrote it. If `service
status` still reports an unreadable credential after the new version starts,
**and only then**, run the exact `auth login` command printed by `update
--force`, in a Terminal in the Mac's desktop session (an SSH session cannot
unlock the login keychain). It names the new service binary and its existing
data directory. Authentication replaces the credential, not the database, runner
profiles or settings.
`update` refuses two things rather than overwriting them: a `cargo build` inside
a checkout of this repository, and the private copy the service runs. Both name
what to do instead.
## Quick start
These four commands connect one repository, allow one concurrent job and keep the agent
running after a reboot:
```sh
# 1. Sign in. Prints a code to enter on GitHub, then the URL to install the App.
runner-manager auth login
# 2. Point a repository at this machine. Omit --max-capacity for monitor-only.
runner-manager repo add OWNER/REPO --host-label home --max-capacity 1
# 3. Arm it.
runner-manager repo set-scale OWNER/REPO --enabled true
# 4. Keep it running across reboots.
runner-manager service install
```
Installation starts the agent immediately; a login-mode Windows install does
not wait for the next sign-in and runs through a no-console supervisor, so it
does not leave an extra terminal window open.
The `repo add` command prints the routing label it reserved, such as `rm-home-win-x64` for
host label `home` on a Windows x64 machine. Use that label in the repository workflow:
```yaml
jobs:
build:
runs-on: rm-home-win-x64
```
`repo add` creates the `default` runner profile. A repository can also have named profiles:
```sh
runner-manager repo profile add OWNER/REPO --name native --max-capacity 2 --execution native
runner-manager repo profile add OWNER/REPO --name isolated --max-capacity 2 \
--execution isolated --backend oci --image registry.example/runner@sha256:<64-hex-digest>
runner-manager repo profile list OWNER/REPO
runner-manager host isolation status --json
```
Each named profile prints its immutable selector and a copyable `runs-on` label. A profile
matches when it carries every static label required by the job, ignoring case; extra profile
labels are allowed and the selector is optional. If several local profiles match, scaling
is refused: require a unique selector or adjust their labels. Inactive autoscaling profiles
also participate in this ambiguity check, so disabling an isolated profile cannot redirect
its demand to a native sibling. A matrix expression such as `${{ matrix.runner }}` cannot
be resolved for automatic scaling. Commands that change a repository policy need
`--profile NAME` once the repository has several profiles. An isolated profile cannot be
enabled until its execution provider reports ready; provider failure never starts it as a
native runner. Keep fork and untrusted pull-request workflows off a personal host unless
you explicitly accept that trust boundary.
A Linux boot service reaches the system's **rootful** Podman service instead, through
`/run/podman/podman.sock`: the service's sandbox forbids the namespaces rootless Podman
creates. Each isolated job then gets a container with a user namespace of its own
(`--userns=auto:size=65536`), so its root is an unprivileged host id no other job shares,
and its own network namespace (`slirp4netns`), so two jobs can each listen on the same
port and neither reaches the other or the host's loopback. Set it up once with
`sudo runner-manager host isolation prepare`, which gives the `containers` account its
subordinate id ranges in `/etc/subuid` and `/etc/subgid` and enables `podman.socket`.
Rootful Podman enforces `--storage-opt size=...` only on XFS with project quotas; on other
storage `host isolation status` reports `disk_quota_unavailable` until the operator either
moves Podman's storage or accepts running without a disk quota with
`host isolation set-disk-quota --required false`, in which case a job can fill the
engine's storage. Isolated jobs get no dependency caches, no Docker and no `runner.env`.
Rootless Podman normally has to enforce `--storage-opt size=...` itself. On managed WSL,
Podman 4.9 cannot initialize that project quota as a rootless user, even on an XFS loop
mount. An operator may instead set `RUNNER_MANAGER_OCI_RUNTIME` in the managed service to
an absolute Linux path for a root-owned, non-writable Podman-compatible storage helper.
The helper contract is deliberately fail-closed:
- every `--storage-opt size=Nm` probe and create is routed to a finite filesystem whose
writable capacity for that container is no larger than `N` MiB;
- the helper owns slot allocation, serialization, ENOSPC recovery and orphan lookup for
every later Podman-compatible command; it never falls back to the ordinary unbounded
graph root;
- `runtime --storage-opt size=Nm info --format=json` returns ordinary Podman info plus
`runnerManagerStorage` with schema `1`, mode `exclusive-filesystem-pool`,
`requestedMiB: N`, and `hardCap: true` only after checking the finite store; and
- graph root, run root, helper and attempt runtime paths stay on the distribution's Linux
filesystems, never under `/mnt`.
Missing or malformed attestation remains `DiskQuotaUnavailable`. An invalid, mutable,
non-root-owned or DrvFS helper path is refused instead of falling back to `podman`. The
disposable one-slot reference fixture in `tests/managed-wsl-oci-acceptance.sh` exercises
this contract with real writes until `df` reports at most filesystem bookkeeping space
and ext4 returns `ENOSPC`; a production helper may manage a larger pool but must preserve
the same per-create hard bound and command routing. On Windows,
`tests/managed-wsl-oci-restart-acceptance.ps1` terminates only the named distribution and
checks that the remounted helper recovers exact-generation resources and durable journal
leases without another JIT registration. The separate
`tests/managed-wsl-oci-reboot-acceptance.ps1` uses explicit `prepare`,
`verify-after-reboot`, and `cleanup` phases for an operator-driven full Windows reboot.
It records Windows kernel boot evidence but contains no command that reboots Windows.
Queue a workflow, then watch the runner start and complete the job:
```sh
runner-manager tui
```
On Windows the dashboard also reports whether WSL is unsupported, absent,
empty, unavailable, or present. Managed distributions additionally report
degraded, draining, recovering, backoff, and recovery-blocked states.
Organizations use the same commands with `org` in place of `repo`.
<!-- GIF placeholder: adding a repository, enabling scaling and watching its first job. -->
## Commands
```bash
runner-manager auth login # Sign in with GitHub's device flow
runner-manager auth status [--list] [--permissions] # Inspect access and App permissions
runner-manager auth logout # Purge the local credential
runner-manager host show # Show capacity, secret store and REST budget
runner-manager host set-capacity N # Change the host limit without restarting the service
runner-manager host set-auto-update BOOL # Daily automatic updates (default: true)
runner-manager host set-poll-interval [--idle 10s] [--active 60s] [--coordinated 300s] # How often GitHub is polled: idle, busy, and covered by a coordinator
runner-manager host set-memory-margin SIZE|off|on # Free memory a runner needs beyond its estimate (default 1GiB), or off
runner-manager host set-runtime-root --path PATH # Put disposable runner workspaces under PATH
runner-manager host reset-runtime-root # Return runner placement to the platform default
runner-manager host isolation status [--json] # Report execution provider readiness
runner-manager host isolation prepare # Set up rootful Podman for a Linux boot service (sudo)
runner-manager host isolation set-disk-quota --required true|false
runner-manager host env show # Show what native runners add to their environment
runner-manager host env set NAME=VALUE # Give every native runner a variable
runner-manager host env unset NAME # Remove a runner variable
runner-manager host doctor [--json] # Check this machine is ready to run jobs
runner-manager host prepare [--yes] [--only CHECK] # Fix what the doctor found, elevating only if needed
runner-manager host prepare --revert CHECK # Undo what prepare changed for one check
runner-manager host required-tools [--set git,node] # Tools every runner must find on its PATH
runner-manager host cache show # Show the dependency caches runners get, and their size
runner-manager host cache set-enabled --enabled BOOL # Turn dependency caches on or off for every policy
runner-manager host cache set-root --path PATH # Keep the caches under PATH instead of <runner root>/_cache
runner-manager host cache reset-root # Keep the caches under <runner root>/_cache again
runner-manager host cache set-max-size N # Cap the caches' total size in GiB (0 removes the cap)
runner-manager host cache set-tool TOOL --state on|off|default # Turn one cache on or off for every policy
runner-manager host cache prune # Remove least recently used idle caches over the cap now
runner-manager host docker-cleanup show # Show Linux Docker cleanup support and mode
runner-manager host docker-cleanup set-mode --mode off|report|apply
runner-manager host docker-cleanup report # Report tracked cleanup candidates without deleting them
runner-manager host docker-cleanup sweep # Sweep cleaned attempts using the configured mode
runner-manager host docker-policy show # Show what native Linux jobs may ask of Docker
runner-manager host docker-policy set-mode --mode off|audit|enforce
runner-manager host docker-policy allow-path <PATH> # Let jobs bind-mount a host directory into containers
runner-manager host docker-policy disallow-path <PATH>
runner-manager host docker-policy report # Summarise recorded Docker policy violations
runner-manager host protect-path add <PATH> # Hide a host directory from every runner and job (Linux)
runner-manager host protect-path remove <PATH>
runner-manager host protect-path list # Show protected directories and whether the service enforces them
runner-manager host forget-local # Move this account's unused configuration aside
runner-manager repo add OWNER/REPO --host-label HOST # Add a repository in monitor-only mode
runner-manager repo add OWNER/REPO --host-label HOST \
--max-capacity N [--label LABEL] [--enable] # Allow runners for a repository
runner-manager repo list # List repository policies
runner-manager repo set-capacity OWNER/REPO [--profile NAME] --max-capacity N
runner-manager repo set-scale OWNER/REPO [--profile NAME] --enabled BOOL
runner-manager repo add-label OWNER/REPO [--profile NAME] --label LABEL
runner-manager repo remove-label OWNER/REPO [--profile NAME] --label LABEL
runner-manager repo set-workspace OWNER/REPO [--profile NAME] --mode ephemeral
runner-manager repo set-workspace OWNER/REPO \
[--profile NAME] --mode persistent --path PATH # Keep each slot's _work between jobs
runner-manager repo remove OWNER/REPO [--profile NAME] [--purge]
runner-manager repo profile add OWNER/REPO --name NAME # Add a named runner profile
runner-manager repo profile list OWNER/REPO # List profiles and selectors
runner-manager repo profile show OWNER/REPO [--profile NAME] # Inspect one unambiguous profile
runner-manager repo profile set-capacity OWNER/REPO [--profile NAME] --max-capacity N
runner-manager repo profile set-scale OWNER/REPO [--profile NAME] --enabled BOOL
runner-manager repo profile add-label OWNER/REPO [--profile NAME] --label LABEL
runner-manager repo profile remove-label OWNER/REPO [--profile NAME] --label LABEL
runner-manager repo profile set-workspace OWNER/REPO [--profile NAME] --mode ephemeral
runner-manager repo profile set-workspace OWNER/REPO [--profile NAME] \
--mode persistent --path PATH
runner-manager repo profile set-execution OWNER/REPO [--profile NAME] --mode native
runner-manager repo profile set-execution OWNER/REPO [--profile NAME] --mode isolated \
--backend auto --image PINNED-REFERENCE [--cpu N --memory N --disk N]
runner-manager repo profile set-pool OWNER/REPO [--profile NAME] --pool on|off [--any] # Answer this host's rm-pool-<os> label, or rm-pool-any
runner-manager repo profile remove OWNER/REPO [--profile NAME] [--purge]
runner-manager repo cache show OWNER/REPO # Show the caches this repository's runners get
runner-manager repo cache set-enabled OWNER/REPO --enabled BOOL
runner-manager repo cache set-namespace OWNER/REPO --shared NAME # Share one cache between policies you trust equally
runner-manager repo cache set-namespace OWNER/REPO --own
runner-manager repo cache set-tool OWNER/REPO TOOL --state on|off|default
runner-manager org add ORG --host-label HOST # Add an organization in monitor-only mode
runner-manager org add ORG --host-label HOST \
--max-capacity N [--label LABEL] [--enable] # Allow runners for an organization
runner-manager org list # List organization policies
runner-manager org set-capacity ORG --max-capacity N # Change organization capacity
runner-manager org set-scale ORG --enabled BOOL # Enable scaling or drain runners
runner-manager org add-label ORG --label LABEL # Add a runs-on label
runner-manager org remove-label ORG --label LABEL # Remove a runs-on label
runner-manager org set-pool ORG --pool on|off [--any] # Answer this host's rm-pool-<os> label, or rm-pool-any
runner-manager org remove ORG [--purge] # Remove a policy and optional retained data
runner-manager org cache show ORG # Show the caches this organization's runners get
runner-manager org cache set-enabled ORG --enabled BOOL # Organizations have no cache until you turn it on
runner-manager org cache set-namespace ORG --shared NAME
runner-manager org cache set-namespace ORG --own
runner-manager org cache set-tool ORG TOOL --state on|off|default
runner-manager status [--json] # Print a host snapshot
runner-manager daemon run # Run the agent in the foreground
runner-manager service install [--start-at boot|login] # Start the agent automatically
runner-manager service status # Check service health
runner-manager service uninstall # Remove the service but keep local state
runner-manager tui # Open the terminal dashboard
runner-manager update [--check] # Install the newest release over this one
runner-manager wsl list # Name this machine's WSL distributions
runner-manager wsl install --distribution NAME [--capacity N] # Make a WSL2 distribution a second runner host
runner-manager wsl status --distribution NAME [--json] # Report that host's real state
runner-manager wsl detach --distribution NAME # Stop managing it, deleting no Linux data
runner-manager coordinator join URL --code CODE # Join an optional coordinator (one-time code)
runner-manager coordinator status [--json] # Show the coordinator link
runner-manager coordinator leave # Forget the coordinator and its key
runner-manager coordinator stats [--json] # Show the coordinator's job statistics and placements
```
Host capacity changes take effect on the next allocation without restarting the
service. The limit is shared by all projects on that host and is checked again
before each runner starts. Lowering it lets current jobs finish and prevents
new runners until the host has room under the new limit. If the host settings
cannot be read, new launches wait until they can be read again.
On Windows, the preview isolated backend targets **Hyper-V-isolated Windows
containers** through a Docker-compatible Windows container runtime. Candidate
hosts are Windows 11 Pro or Enterprise with Docker Desktop switched to Windows
containers, or Windows Server Standard or Datacenter with Moby or Mirantis
Container Runtime. Both paths require Hyper-V and Containers; native client and
Server acceptance is still pending. Docker Desktop on Windows Education can run
Linux containers only, and Docker Desktop is not supported on Windows Server.
Microsoft documents that Windows containers use a parent Job Object and that
Hyper-V isolation applies resource controls to both that container job and its
utility VM. Windows also supports [nested Job
Objects](https://learn.microsoft.com/windows/win32/procthread/nested-jobs):
ordinary descendants inherit the job chain, and the most restrictive limits
remain effective. Runner Manager uses that supported nesting seam to install a
256-process `ActiveProcessLimit` around `Runner.Listener` and its descendants.
The bootstrap sets no breakaway flag.
The provider copies the verified runner package into a fresh writable layer
and never mounts a host directory, device, credential, or container-runtime
socket. Before JIT crosses container stdin, it reads back Docker's exact
Hyper-V, pinned-image, CPU, memory, disk, network, mount, device, and privilege
configuration. The in-container bootstrap then creates and queries the Job
Object and returns a typed, one-use nonce attestation. Only an exact 256-process
limit plus kill-on-close proof unlocks the private JIT channel;
`Runner.Listener` is created suspended, assigned to the job, membership-checked,
and then resumed. Any timeout, malformed proof, changed container setting, or
assignment failure stops the container without a native fallback.
This backend remains preview-held until real jobs, restart/recovery and
resource-exhaustion tests pass on declared Windows client and Server variants.
Desktop/UI automation, host devices, container actions, and service containers
remain explicitly unsupported. The control provides dependency isolation for
the documented trusted-workflow model; it is not a hostile-code containment
claim. `host isolation status` still reports missing features, runtime
permission, Linux-container mode, runtime degradation, and the pending native
acceptance notice separately.
Add `--help` to any command to see every option. Failures name the command that fixes them
and use a distinct exit code for each failure class.
## Run Linux jobs too: manage a WSL2 distribution
One Windows workstation can serve Windows jobs and Linux jobs at the same time. On Windows,
`runner-manager wsl install` turns a WSL2 distribution you already have into a second managed
host: it installs the matching Linux binary, signs that host in with its own GitHub credential,
installs the Linux service, and registers a Windows task that keeps the distribution running.
Each host then has its own capacity, its own policies and its own runner label.
You never create a staging folder, copy a token out of a Windows credential store, hand-write a
systemd unit, or make a scheduled task yourself. If a step of this section asks you to,
it is a defect in the product.
### Before you start
- **Windows.** The `wsl` family and `--host wsl:...` exist on every platform and refuse, with a
reason, on anything but Windows.
- **An installed WSL2 distribution** with any name. Check with `runner-manager wsl list`, which
prints the exact spelling each command needs. WSL1 is refused and names the conversion command.
- **systemd inside it.** Put `systemd=true` under `[boot]` in that distribution's `/etc/wsl.conf`
and run `wsl --terminate NAME`. Without systemd there is no Linux service to install, and the
preflight refuses before anything is changed.
- **Root inside it,** which is the default for a distribution you have not reconfigured.
- **x86-64 or 64-bit ARM.** Those are the Linux architectures this project publishes.
- **A browser on this Windows machine,** for the one sign-in described below.
Nothing here installs a distribution, edits `.wslconfig`, or touches the registry.
### Make it a runner host
```powershell
runner-manager wsl list
runner-manager wsl install --distribution Ubuntu --capacity 8
```
`wsl install` runs eight stages in order and prints what each one did:
1. Preflight: WSL2, root, architecture, systemd, and whether the task name is free.
2. The release archive matching this Windows build's exact version, checksum verified.
3. The Linux binary, replaced atomically.
4. The credential, issued only if that distribution does not already hold one.
5. Capacity, only when you passed `--capacity`.
6. The Linux systemd service. If its active private copy is older, its daemon
cooperatively stops accepting new jobs, waits without a deadline for its local
work journal to drain, replaces the copy, and lets systemd restart it. The
installer waits for that restarted process before continuing; it never uses a
systemd stop to upgrade a running service. A legacy copy that cannot provide
this handover is left running and the upgrade refuses rather than risking a job.
7. The Windows login task that keeps the distribution alive. It starts a companion from
version-independent paths under `state/bin`, so an update swaps those files and leaves the task
as it is; a running companion restarts onto the new build by itself.
8. A read-back of the real state, which is what decides whether the command succeeded.
Nothing is changed and no sign-in happens until the preflight has passed.
**Administrator rights.** Updating a host needs none. Windows may refuse to *register* the
lifecycle task without them: the first time for a distribution, and once more when upgrading from
0.4.34 or earlier, whose task named a versioned companion. Then `wsl install` asks for them with
one administrator prompt (the same one `host prepare` uses) for that step alone. Where no prompt
can be shown, such as over SSH, it says so and prints the exact command to run once from an
elevated prompt.
### Adopting a distribution that already runs runner-manager
The same command. `wsl install` is convergent, so it probes before it writes and adopts what
is already correct: a valid credential is kept, and an active service is adopted rather than reinstalled.
Policies, the runtime root and in-flight work are left alone.
An active out-of-date service takes the cooperative handover above; a disabled active
unit is handled the same way before it is enabled again. Capacity changes only when
you pass `--capacity`. Re-running it on a healthy host changes nothing, and is the
documented way to upgrade that host after you update on the Windows side.
A distribution first set up before 0.4.57 keeps its data under `/root`. The first `wsl install`
that finds it idle moves it to `/var/lib/runner-manager`, where every root service has kept its
data since, exactly as `sudo runner-manager service install --start-at boot` does: the old tree is
kept, and the service then runs each job under its own `rm-runner-N` account. A host with runners
still running waits for a later run. A drop-in of your own under
`/etc/systemd/system/runner-manager.service.d/` that names the old tree also stops the move, and
`wsl install` names that file: change or remove it, then run `wsl install` again.
### Each host signs in separately
`runner-manager --host wsl:Ubuntu auth login` runs the browser sign-in on Windows, where the
browser is, and hands the credential it issues straight into the Linux host's own machine store
over a pipe. The credential is never written down on Windows.
Each host needs its own sign-in, and copying one is not an option that merely looks untidy: it
does not work. GitHub invalidates both halves of a token pair whenever either half is renewed,
so two daemons sharing one credential take turns logging each other out. `wsl install` does the
sign-in for you when the distribution holds no credential of its own, and skips it when it does.
#### If direct sign-in in WSL keeps waiting after browser approval
Use the brokered Windows command instead of running `sudo runner-manager auth login` inside the
distribution:
```powershell
runner-manager --host wsl:Ubuntu auth login
```
It performs GitHub's device flow from Windows and delivers the resulting credential directly to
Ubuntu's machine store over stdin; it neither writes the credential to Windows disk nor requires
`sudo`. This is useful when GitHub's device-flow poll for a direct Linux login remains at
`Waiting for approval...` (or reports `slow_down`) after the browser says the device is connected.
Verify that the Linux host received the replacement:
```powershell
runner-manager --host wsl:Ubuntu auth status
```
If a running Linux daemon continues to report `unauthorized` for a credential that this command
has replaced, restart that daemon once so it reads the new credential:
```powershell
wsl --distribution Ubuntu --user root --exec systemctl restart runner-manager.service
```
### Configure it with the commands you already use
`--host local` is the default and is this machine. `--host wsl:NAME` carries any command in the
list above into that distribution instead:
```powershell
runner-manager --host wsl:Ubuntu repo add OWNER/REPO --host-label home --max-capacity 4
runner-manager --host wsl:Ubuntu repo set-scale OWNER/REPO --enabled true
runner-manager --host wsl:Ubuntu repo list
runner-manager --host wsl:Ubuntu host set-capacity 4
runner-manager --host wsl:Ubuntu status
```
The Linux host reserves its own routing label, such as `rm-home-linux-x64`, so a workflow picks
Windows or Linux by choosing which label it runs on. `--capacity` on `wsl install` and
`--host wsl:NAME host set-capacity N` set the same number; use whichever fits the moment.
### Check what is really there
```powershell
runner-manager wsl status --distribution Ubuntu
runner-manager wsl status --distribution Ubuntu --json
```
Every line is read from the distribution and from Task Scheduler, never from this machine's
record of it, and the record is reported separately as drift when the two disagree. The report
names the WSL version, the Linux binary, the credential, the systemd unit, the lifecycle task,
the capacity, and the workload diagnostics below. `--json` is versioned and safe to script
against.
**When this host is available.** WSL distributions are registered per Windows user, so this
feature promises unattended Linux availability **after that user logs on**, not between a
Windows reboot and the first interactive logon. `wsl status` says so on every run rather than
leaving you to discover it after a restart.
**Safe automatic recovery.** A Windows recovery companion runs inside each managed distribution's
per-user lifecycle task; unlike the boot service's `LocalSystem` account, that process can see the
owning user's WSL registrations. After a continuous five-minute transport failure it requests a
drain and can terminate and restart only that exact distribution. Recovery proceeds only after
two fresh guest heartbeats report no busy attempts (idle orphaned listeners do not deadlock it),
two complete GitHub inventories report no busy or online managed registrations, the lifecycle
task is verified as product-owned, and no unmanaged Actions runner service exists.
Missing, stale, contradictory, or unauthorized evidence leaves the host in
`recovery-blocked`; it never falls back to a global `wsl --shutdown`. Recovery is capped at three
attempts per hour.
Run `wsl install` once after upgrading an older managed distribution so its lifecycle task gets
the shared recovery fence. Fresh installations configure it automatically.
**Docker is diagnosed, not installed.** If the distribution runs a Docker engine, status reports
its version; if not, status says container jobs would fail while ordinary jobs still run. It is
never a reason to refuse provisioning, and this tool never installs a workload dependency for
you.
### When something goes wrong
Every failure names the stage it happened in and whether anything was changed.
A preflight failure changed nothing at all. Any later failure leaves the earlier stages
standing, and the remedy is always the same: fix what the message names and then
run `wsl install` again. It will skip whatever is already correct.
```powershell
runner-manager wsl status --distribution Ubuntu # what is actually in place
runner-manager wsl install --distribution Ubuntu # converge the rest
```
A scheduled task of this product's name that this product did not create is never modified or
removed. If you made a keep-alive task by hand, `wsl install` stops and asks you to rename or
remove it first.
### Stop managing it
```powershell
runner-manager wsl detach --distribution Ubuntu
```
`detach` removes this machine's lifecycle task and its record of the host, and nothing else. It
is deliberately not called `uninstall`: the distribution stays registered with WSL, and its
runner-manager, service, credential, policies, workspaces and packages all stay exactly where
they are. The command prints the explicit Linux commands to run inside the distribution if you
want to undo that half as well.
## Optional coordinator
By default every host polls GitHub on its own, so a new job in a busy repository can wait up to
the active poll interval (60 s) before a runner starts. A **coordinator** is a small server you
host yourself that receives GitHub's `workflow_job` webhooks and tells the hosts that serve the
job's repository and labels to poll **now**. It only wakes hosts: each host still decides what
to start, under its own capacity, policies and `host doctor` checks, with its own GitHub
credential. No token, JIT configuration or job log ever passes through the coordinator.
```bash
# on the coordinator's machine
runner-manager-coordinator invite --name win-big # prints a one-time code
# on the host
runner-manager coordinator join https://runners.example.com --code rmc_...
runner-manager coordinator status
```
`join` creates an Ed25519 key pair for this host; the private key goes into the same secret
store as the GitHub credential and never leaves the machine. The host connects out over
`wss://`, so it keeps working behind NAT. Without a coordinator, or while it is unreachable, the
host polls exactly as before. A woken target is polled at most once every 5 seconds and never
inside a rate-limit back-off. `coordinator leave` removes the key and the setting.
Given its own GitHub App's id and private key, the coordinator also runs a **reconciliation
sweep**: every 2 minutes it reads the queued jobs of every repository its connected hosts serve
(conditional requests, so an unchanged queue costs nothing against the hourly quota) and wakes
the hosts for any queued job no wake was sent for, so a lost webhook still starts a runner.
Each target is then in one of three poll modes, shown per target by `status [--json]`, `host
doctor` and the TUI:
| Mode | When | Polling |
|---|---|---|
| `standalone` | no coordinator joined | idle 10 s / active 60 s, as before |
| `coordinated` | link up, and the coordinator's sweep covers the target | every 300 s (`host set-poll-interval --coordinated`, at least 60 s); wakes still poll at once |
| `fallback` | joined, but the link has been down for 30 s or the sweep does not cover the target | idle 10 s / active 60 s, at once |
A covered target that has a runner of this host running keeps the normal intervals, so a slot
that frees up is reused promptly.
**Shared pools.** A workflow that writes `runs-on: rm-pool-linux` (or `rm-pool-windows`,
`rm-pool-macos`) lets the coordinator start its runner on exactly one host of that OS instead of
on every host carrying the label; `runs-on: rm-pool-any` lets it pick a host of any OS that opted in.
Give each host's serving profile its pool label with
`repo profile set-pool OWNER/REPO [--profile NAME] --pool on [--any]` or
`org set-pool ORG --pool on [--any]` (`profile list`/`show` print `pool=`); another OS's pool label is
refused, and so is a second profile of the same target carrying the same pool label. One profile may
carry both its OS pool label and `rm-pool-any`. While the target is `coordinated`, that profile starts runners only
when the coordinator places one on it, and only within its own `max_capacity`, host capacity and
`host doctor` fitness; in `fallback` or `standalone` it serves its own demand as before.
**Job statistics.** A joined host samples the process tree of each native runner every 2 seconds
(off its reconcile loop) and reports, when the attempt ends, its peak memory and its average and
peak CPU. The coordinator matches the report to the job GitHub's webhook says that runner took,
keeps the last 30 samples per `repository/workflow/job` (90 days at most), and places a pool job
on a host that can hold the heaviest job queued for that pool, preferring a warm workspace, room
to spare and a host that has run those jobs fast. `runner-manager coordinator stats [--json]`
shows the heaviest jobs, the samples per host and the last placements with every host's score;
with no statistics yet, placement is the warm-workspace / free-memory order it always was.
Deployment, the GitHub App (webhook and sweep), shared pools, statistics and administration: [`docs/coordinator.md`](docs/coordinator.md).
## Customize your setup
### Get the machine ready for jobs
Some host settings make every job slower or make some fail, and none of them is visible from a
workflow log. `runner-manager host doctor` checks them, changes nothing, and needs no
administrator rights:
| Check | Platform | Severity | `host prepare` fix | Needs admin |
|---|---|---|---|---|
| `windows.symlink_privilege` | Windows | required | turn on Developer Mode (needs `--allow-developer-mode`) | yes |
| `windows.long_paths` | Windows | recommended | `LongPathsEnabled = 1` | yes |
| `windows.git_long_paths` | Windows | recommended | `git config --system core.longpaths true` | yes |
| `windows.defender_exclusion` | Windows | recommended | exclude the runner roots from real-time scanning (needs `--allow-av-exclusion`) | yes |
| `windows.execution_account` | Windows | recommended | none; use `service install --start-at boot` | - |
| `windows.execution_policy` | Windows | recommended | `Set-ExecutionPolicy RemoteSigned -Scope LocalMachine` | yes |
| `windows.pwsh` | Windows | info | none; install PowerShell 7 | - |
| `macos.launchd_priority` | macOS | recommended | none; `runner-manager update` repairs the plist | - |
| `macos.spotlight` | macOS | recommended | `mdutil -i off` for a runner root on its own volume | yes |
| `macos.sleep` | macOS | recommended | `pmset -a sleep 0` (a sleeping Mac freezes the job it runs) | yes |
| `macos.disk_sleep` | macOS | recommended | `pmset -a disksleep 0` | yes |
| `macos.autorestart` | macOS | recommended | `pmset -a autorestart 1` (start again after a power failure) | yes |
| `macos.wake_on_lan` | macOS | info | `pmset -a womp 1` | yes |
| `macos.unattended_login` | macOS | recommended | none; prints the System Settings steps for automatic login or FileVault, which are never changed for you | - |
| `macos.runner_root_location` | macOS | recommended | none; suggests `host set-runtime-root --path /Users/Shared/rman.noindex` for a root with a space or inside the service account's home | - |
| `macos.keychain_credential` | macOS | required | none; prints the exact `auth login --start-at login` | - |
| `host.runner_root_responsive` | all | required | none; a runner root that does not answer within 5 s (a hung external disk or network mount) | - |
| `host.capacity` | all | recommended | none; suggests `host set-capacity N` from memory and cores | - |
| `host.required_tools` | all | required | none; install the tool or extend PATH | - |
| `github.polling` | all | info | none; reports the idle and active poll intervals (and the coordinated one, while a coordinator's sweep covers targets), the share of polls GitHub answered `304`, and whether the service is slowing down for the account's rate limit | - |
`runner-manager host prepare` applies the fixes. It lists them and asks before changing anything
(`--yes` skips that question), runs what needs no privilege itself, and asks for administrator
rights once, for the rest together: a UAC prompt on Windows, the system password dialog (or
`sudo` on a terminal) on macOS. If you refuse, it reports what is left and carries on. A fix
that lowers security, Defender exclusion or Developer Mode, is never applied by `--yes` alone:
pass its flag or answer its own question. Every change is recorded with the value it replaced,
and `host prepare --revert CHECK` puts it back.
The same checks run on their own: `service install` reports them for the account the service
will run as and offers to fix them on a terminal, `status` and `service status` show a one-line
summary, and the dashboard lists failing checks with `f` to fix them. While a **required** check
fails, the service starts no runner and logs `host_unfit`; it re-checks every five minutes, so a
fix takes effect without a restart. A check it could not evaluate never holds runners back.
Name the tools every job needs with `runner-manager host required-tools --set git,pwsh,node`.
### Choose how often GitHub is polled
The service asks GitHub for queued jobs on two intervals. A repository with nothing happening is
polled every **10 seconds** (the idle interval): those polls are conditional requests, GitHub
answers them `304 Not Modified` while nothing changed, and a `304` does not count against your
hourly rate limit. A repository with a runner up or runs in progress is polled every **60
seconds** (the active interval), because its answers change and each one is charged. So is a
repository with a job this host could run but has not started yet, such as one waiting for a free
slot.
```bash
runner-manager host set-poll-interval --idle 10s --active 60s
```
The idle interval is at least 5 seconds and the active one at least 30; give either flag alone to
change only that one. A running service reads them again every 15 seconds, and each target adopts
them at its next poll. Every policy and profile on one repository shares one poll, and a
repository covered by two targets (an organization and a repository inside it) is read once for
both within the idle interval. An organization target too large to poll every few seconds (about
a hundred repositories at 10 seconds) stretches its own idle interval so it never costs more than
300 requests a minute.
With a joined [coordinator](#optional-coordinator) whose sweep covers a target, that target is
polled every **300 seconds** instead (`--coordinated`, at least 60), because the coordinator reads
its queue and wakes this host for new work; it returns to the two intervals above as soon as the
link has been down for 30 seconds.
GitHub's hourly limit belongs to your GitHub user, not to a machine: every host signed in as you,
and your own `gh`, draw from the same 5,000 requests. When fewer than a fifth are left, every
host slows its polling down (twice the active interval, four times under a twentieth) until the
hour resets, and `status`, `host show` and `host doctor` say so. A secondary rate limit stops
every poll on the host until the time GitHub names, or a minute when it names none, doubling each
time it comes back after a wait has ended. `host show` prints
what polling actually cost: full and `304` responses per hour, the busiest minute against
GitHub's 900-a-minute secondary limit, and what is left of the hourly limit.
### Keep heavy jobs from crowding one machine
Before it starts any runner (for a repository or organization label, a selector, or a launch the
coordinator placed here) the service checks this machine's memory. It starts the runner only when
free memory, less what the jobs already running may still grow into, holds the runner's expected
peak plus a **margin** (1 GiB by default):
```bash
runner-manager host set-memory-margin 2GiB # keep 2 GiB free beyond the estimate
runner-manager host set-memory-margin off # start runners by capacity alone, as before 0.4.53
runner-manager host set-memory-margin on # back on, with the margin kept
```
A runner takes any job its labels match, so the expected peak is per profile and stands for the
heaviest of its jobs: the coordinator's figure for the target when this host has joined one (the
heaviest job's p90 peak, from this host's own runs once it has any), otherwise the p90 of the
last 20 peaks this host measured for the profile's jobs (or the newest of three, if heavier),
otherwise 1 GiB. Sizes take a unit (`512MiB`, `2G`); a bare `2` is refused. A running job holds its own
estimate, less what it already uses, until it ends: a build's peak often comes late. A refused
start is not a failure: the job stays queued and the next pass asks again, `status` shows
`waiting for memory: … needs X GiB (… estimate + … margin), Y GiB free less Z GiB reserved for running jobs`, and the service logs
`memory_deferred` with the byte figures. A launch from the coordinator that does not fit is
refused with reason `memory`, so the coordinator places the job on another host. With nothing
running on the machine a runner always starts, even one larger than the free memory (logged as
`memory_admitted_alone`), so a job bigger than the machine is never starved. `host show` prints
the setting and the free memory now.
### Choose where runners work
By default every job runs in a disposable workspace under this machine's runner root. On Windows
that root is `%SystemDrive%\rman`, normally `C:\rman`, so build paths stay short. macOS and
Linux keep using the platform runtime directory, exactly as before. `runner-manager host show`
prints the effective path and whether it is `platform-default` or `configured`.
On macOS that default is under `~/Library/Application Support`: its path has a space, which
breaks job scripts that do not quote paths, and it sits inside the service account's home
folder, and so does every job's `TMPDIR`. `host doctor` reports both
(`macos.runner_root_location`). A folder with neither, which Spotlight also skips, is
`runner-manager host set-runtime-root --path /Users/Shared/rman.noindex`.
Put runners somewhere else, such as a faster disk:
```powershell
runner-manager host set-runtime-root --path "<GLOBAL_RUNNER_ROOT>"
```
Go back to the platform default:
```powershell
runner-manager host reset-runtime-root
```
Both take effect for the next runner and never relocate a running one. Both are also refused
while this host still has runner attempts it has not cleaned up, naming how many are active and
how many are awaiting cleanup, so run them once the host is idle. No existing directory is moved
or deleted.
### Give runners an environment
A runner started by the service inherits the service's environment, which is not your login
shell's. Set extra variables, such as cache locations, for every native runner on this machine:
```sh
runner-manager host env set npm_config_cache=/Volumes/Data/ci-cache/npm
runner-manager host env set ELECTRON_CACHE=/Volumes/Data/ci-cache/electron
runner-manager host env show
```
They live in `runner.env` in the configuration directory, one `NAME=VALUE` per line, and reach
the next runner started without a restart. Every job on the machine can read them, so do not put
secrets there; use repository or organization secrets instead.
Each platform also adds a few variables unless `runner.env` sets the same name:
- **macOS:** Homebrew's `/opt/homebrew/bin`, `/opt/homebrew/sbin` and `/usr/local/bin`, when
they exist, ahead of the service's minimal `PATH`, and `LANG=en_US.UTF-8` when the service
has no locale. Runners also run at normal priority rather than the service's background
priority.
- **Windows:** `USERPROFILE`, `HOME`, `APPDATA` and `LOCALAPPDATA` point at a profile inside
each runner's own attempt directory, so concurrent jobs no longer share the service account's
caches. That profile goes with the attempt; the dependency caches below are kept outside it.
.NET known-folder APIs ignore these variables and still report the service account's profile,
except local application data, which follows `USERPROFILE`.
- **Every platform:** `POWERSHELL_TELEMETRY_OPTOUT=1`, unless the service's own environment
already sets it to a non-empty value. On Windows, PowerShell 7.6.6 and earlier can hang a
step shell at startup on a host running several jobs at once: the first `pwsh` of a job finds
no telemetry identity file in its fresh profile, takes a session-wide lock to create one and
keeps it until it exits, and every other job's first `pwsh` waits for that lock with no
timeout. The opt-out skips that code. macOS and Linux runners share the account's profile, so
there it is a precaution. Set `POWERSHELL_TELEMETRY_OPTOUT=0` in `runner.env` to send
PowerShell telemetry again.
`TMPDIR`, `TEMP` and `TMP` are always the attempt's own and cannot be changed. Isolated runners
do not get any of this; their environment is the image's.
### Keep dependency caches between jobs
Every job starts in a fresh workspace, and on Windows with a fresh profile, so without help
pnpm, npm, NuGet and `setup-node` would download everything again in every job, and
`actions/cache` would never hit, because the cached paths change every attempt. `runner-manager`
points the common caches at directories that outlive the attempt, for every native runner,
with nothing to configure per machine:
```sh
runner-manager host cache show # the root, its size, and every variable runners get
runner-manager repo cache show OWNER/REPO
```
**Where.** Under `<runner root>/_cache` (for example `C:\rman\_cache`), so one Defender exclusion
and one Spotlight setting cover both, and paths stay short. A runner root with a space in it,
such as the macOS default under `Application Support`, uses `~/Library/Caches/runner-manager`
instead, because unquoted paths in tool installers break on spaces. `host cache set-root --path
PATH` moves it; the path is checked like a runner root (local, writable, outside application
data, not inside a runner root), so a network share or a WSL `/mnt/c` path is refused. On Linux
the systemd unit lets the service write only its own directories, so a root outside the runner
runtime directory also needs a `ReadWritePaths=` drop-in; without one, runners start without
caches and the service logs `dependency_cache_unavailable`.
`host doctor` checks a cache root outside the runner roots like one of them, and `host prepare`
excludes it from Defender with them.
**Who shares a cache.** Each repository gets its own namespace, `<root>/<owner>/<repo>`, shared
by all of its profiles and concurrent runners. A cache is an input to the next job: a job that
can write the npm cache can plant a package the next job installs, so no repository can read or
write another's. Organization policies accept jobs from every repository in the organization,
so they get no cache until you turn it on with `org cache set-enabled ORG --enabled true`. To
share one cache between repositories you trust equally, give them the same name with
`repo cache set-namespace OWNER/REPO --shared NAME`. This keeps repositories apart; it does not
protect a repository from a hostile workflow of its own, which is the model GitHub documents for
self-hosted runners.
**What runners get.** Caches built for concurrent use are shared by every runner of the
namespace. The others are per runner: each runner leases a numbered slot, `_slots/<n>`, and keeps
it until its attempt is cleaned up.
| Cache | Variables | Platforms | Shared | Default | Verified against |
|---|---|---|---|---|---|
| `git` | `RUNNER_MANAGER_GIT_CACHE_DIR` | all | yes | off | Git local cloning; actions/checkout repository reuse |
| `xdg` | `XDG_CACHE_HOME` | macOS, Linux | yes | on | XDG base directory specification |
| `tool-cache` | `RUNNER_TOOL_CACHE`, `AGENT_TOOLSDIRECTORY` | all | per runner | on | actions/runner `HostContext.cs`; @actions/tool-cache `_getCacheDirectory` |
| `dotnet` | `DOTNET_INSTALL_DIR` | all | per runner | on | actions/setup-dotnet `installer.ts` |
| `npm` | `npm_config_cache` | all | yes | on | npm config docs; `@npmcli/config` `loadEnv` |
| `pnpm` | `npm_config_store_dir` (pnpm 9, 10), `PNPM_CONFIG_STORE_DIR` (pnpm 11+) | all | yes | on | `@pnpm/npm-conf`; pnpm 11 release notes |
| `yarn` | `YARN_GLOBAL_FOLDER` | all | yes | on | Yarn configuration docs; berry `Configuration.ts` |
| `bun` | `BUN_INSTALL_CACHE_DIR` | all | yes | on | Bun global cache docs |
| `deno` | `DENO_DIR` | all | yes | on | Deno `deno_dir.rs` |
| `node-gyp` | `npm_config_devdir`, `npm_package_config_node_gyp_devdir` | all | yes | on | node-gyp `lib/node-gyp.js` |
| `electron` | `electron_config_cache`, `ELECTRON_CACHE` | all | yes | on | electron `npm/install.js`; app-builder |
| `electron-builder` | `ELECTRON_BUILDER_CACHE` | all | yes | on | electron-builder environment variables docs |
| `playwright` | `PLAYWRIGHT_BROWSERS_PATH`, `PLAYWRIGHT_SKIP_BROWSER_GC=1` | all | yes | on | Playwright browsers docs; `registry/index.ts` |
| `cypress` | `CYPRESS_CACHE_FOLDER` | all | per runner | on | Cypress advanced installation docs |
| `nuget` | `NUGET_PACKAGES`, `NUGET_HTTP_CACHE_PATH`, `NUGET_PLUGINS_CACHE_PATH`, `NUGET_SCRATCH` | all | yes | on | NuGet "Managing the global packages and cache folders" |
| `pip` | `PIP_CACHE_DIR` | all | yes | on | pip caching docs |
| `uv` | `UV_CACHE_DIR` | all | yes | on | uv environment reference |
| `go` | `GOMODCACHE`, `GOCACHE` | all | yes | on | Go modules reference; `go help environment` |
| `composer` | `COMPOSER_CACHE_DIR` | all | yes | on | Composer CLI docs |
| `cocoapods` | `CP_CACHE_DIR` | macOS, Linux | yes | on | CocoaPods `config.rb` |
| `ccache` | `CCACHE_DIR` | all | yes | on | ccache manual |
| `poetry` | `POETRY_CACHE_DIR` | all | per runner | off | Poetry configuration docs |
| `cargo` | `CARGO_HOME` | all | yes | off | Cargo environment variables |
| `gradle` | `GRADLE_USER_HOME` | all | yes | off | Gradle directory layout |
| `maven` | `MAVEN_ARGS=-Dmaven.repo.local=...` | all | per runner | off | Maven configuration docs |
| `pub` | `PUB_CACHE` | all | per runner | off | Dart pub environment variables |
| `bundler` | `BUNDLE_USER_CACHE` | all | per runner | off | `bundle config` man page |
Some choices are deliberate:
- **Per runner, not shared**: `@actions/tool-cache` deletes a version's folder before filling it,
with no lock, so two jobs installing the same Node version at once would delete each other's;
`setup-dotnet` replaces the shared `dotnet` host; Cypress and Poetry take no cross-process lock;
Maven 3.9 locks only within one JVM.
- **Off until you turn them on** (`host cache set-tool TOOL --state on`): `CARGO_HOME`,
`GRADLE_USER_HOME` and `PUB_CACHE` hold configuration, credentials, init scripts or installed
tools as well as caches, and Poetry keeps a virtualenv per workspace path in its cache.
- **Never set**: `YARN_CACHE_FOLDER` would override a zero-install project's committed cache and
break `--immutable-cache`; `LOCALAPPDATA` and `APPDATA` stay per attempt on Windows, because
they also hold tools' configuration and credentials, so each cache above is named explicitly
instead.
- **pnpm**: npm 12 prints `Unknown env config "store-dir"` because of `npm_config_store_dir`,
which pnpm 9 and 10 need. Turn `pnpm` off for a repository that does not use pnpm if the
warning bothers you.
- **setup-python on macOS** ignores the tool cache and installs under
`/Users/runner/hostedtoolcache`. Create that directory once for the runner account if
workflows use it; on Linux and Windows `AGENT_TOOLSDIRECTORY` covers it.
What you set yourself always wins: platform defaults, then these caches, then `runner.env`, then
the attempt's own `TMPDIR`, `TEMP` and `TMP`. A cache any of whose variables `runner.env` or the
service's own environment already sets (a systemd drop-in, the launchd plist, a machine-wide
Windows variable), in any letter case, is left out entirely, so your `RUNNER_TOOL_CACHE` is never
paired with this `AGENT_TOOLSDIRECTORY`. `repo cache show` marks a cache `runner.env` takes over
as `set by runner.env or the service`. Turn one cache off for every
repository with `host cache set-tool TOOL --state off`, or for one with `repo cache set-tool`,
and every cache off with `host cache set-enabled --enabled false` or `repo cache set-enabled`.
**Disk.** The service measures the cache root every 30 minutes and keeps it under a cap, 20 GiB
by default (`host cache set-max-size N`, `0` for none), by removing whole namespaces, least
recently used first. A namespace a runner is still using is never removed; if those alone exceed
the cap, the service logs `dependency_cache_over_cap`. Only directories a launch created are
measured or removed. `host cache prune` does the same pass immediately; when the service runs as
another account (LocalSystem on Windows, root elsewhere) and yours cannot write its cache root, it
asks the running service to do the pass and prints the result. `status` shows the size and the
cap.
**Isolated runners** (OCI containers, Hyper-V containers) get no caches: those providers
deliberately mount no host directory, and their environment is the image's. `host cache show`
names each such policy.
**WSL.** A managed WSL distribution is a Linux host of its own: `--host wsl:NAME host cache ...`
runs in the distribution and edits its settings, and its caches live under its own runner root
on the Linux filesystem, never on `/mnt/c`.
### Reuse Git objects in ephemeral runners
Opt a repository into the Git checkout cache:
```sh
runner-manager repo cache set-tool OWNER/REPO git --state on
runner-manager repo cache show OWNER/REPO
```
Or set the host default with `host cache set-tool git --state on`. `off` disables
it; `default` removes the override (repository settings inherit the host). Host
and Repository Settings also expose **Git checkout cache** and its save action.
This setting is shared by a repository's profiles and applies to future native
runner launches without restarting the service. It is off by default because
preparing `.git` changes workflows that expect an entirely empty directory.
The first job checks out normally. Before removing that runner's workspace, the
manager saves a local Git object snapshot. The next runner gets an independent
`.git` in the standard `_work/<repo>/<repo>` workspace. `actions/checkout` then
authenticates and fetches the workflow's exact ref/commit as usual, including
shallow PR merge checkouts. `clean: true` remains compatible. Working files,
build output, credentials, hooks and source Git configuration are not copied.
No workflow hook or authentication token is required by the cache itself.
Snapshots use the dependency-cache root and its configured size cap/LRU pruning.
Namespace leases protect copies from pruning; per-repository locks serialize
Git copies and publication. A seeded job does not borrow
objects through alternates or hardlinks, so replacing or pruning the cache does
not invalidate it. Concurrent publications retain objects from both jobs and
retire the previous snapshot generation. A cache error falls back to ordinary
checkout and does not prevent workspace cleanup. Already populated persistent
workspaces are left intact and may refresh the same cache.
This automatic integration targets native **repository** policies, the standard
checkout path and HTTPS `github.com` origins. Organization policies, isolated
providers, SSH/custom-server origins, custom checkout paths and existing
repositories backed by external object stores are not accelerated. LFS payloads
and submodule repositories are not cached by this feature. A workflow that
manually runs `git clone ... .` should leave it disabled: its destination must
be empty. Snapshots are cache inputs within the same native-workflow trust
boundary as dependency caches. See [Git cache design and validation](docs/git-repository-cache.md).
### Keep a build cache between jobs
Slow to warm up? Give one repository persistent workspaces, so its dependency and build
caches survive from job to job:
```powershell
runner-manager repo set-workspace OWNER/REPO `
--mode persistent `
--path "<REPOSITORY_WORKSPACE_ROOT>"
```
Concurrent runners lease numbered slots, `s1`, `s2` and so on, under that directory. Each
slot keeps its `_work` directory for the next job that leases it. Runner binaries, the
registration handoff and lifecycle files are still removed after every job.
Then stop the workflow's checkout from wiping the cache it just filled:
```yaml
- uses: actions/checkout@v7
with:
clean: false
```
`clean: false` on its own does not make a workspace persistent. In the default mode
`runner-manager` removes the whole workspace after every job, whatever the checkout does.
Set the repository to persistent mode first; the checkout setting only stops Git from
deleting the retained files.
Persistence is for workflows you trust, and it is not isolation. Files under `_work` become
an input to later jobs on the same slot, so executables, generated sources and caches can
cross branch and job boundaries. Keep untrusted fork and pull-request workflows on the
default mode. Persistence is repository-scoped for the same reason: an organization policy
can accept jobs from many repositories, so it always uses fresh workspaces.
Go back to a fresh workspace for every job:
```powershell
runner-manager repo set-workspace OWNER/REPO --mode ephemeral
```
Like the host commands, this one is refused while the repository still has a runner attempt
awaiting cleanup, so run it once that repository is idle. Switching off persistence, or moving
it to another directory, keeps every slot you already have: no old directory is moved or
deleted. Remove the ones you no longer want yourself.
### Diagnostics retention and Docker resources
The daemon checks completed-attempt summaries in `logs/` at startup and hourly,
including while scaling is disabled. It keeps the newest 1,000 summaries for up to
30 days. Service and operator diagnostics rotate daily and keep at most 30 files per
log stem. Dependency caches follow their separate configured size cap.
Native Linux runners can account for Docker resources through a private local
endpoint for each attempt. The default `report` mode records ownership and reports
cleanup candidates without deleting Docker resources. Inspect the report, then use
`host docker-cleanup set-mode --mode apply` to enable cleanup after attempts have
been cleaned. `off` disables accounting for new attempts. The daemon reads changes
without a restart; WSL settings are addressed with `--host wsl:Ubuntu`.
Only newly tracked temporary containers, networks and anonymous volumes are
eligible. Named volumes, images, BuildKit/buildx caches, filesystem dependency
caches and persistent workspaces are retained. Add `--label runner-manager.retain=true`
at creation to retain a container, network or unnamed volume. Unreferenced does not
mean disposable: unknown and historical resources are never inferred to be owned.
Shared volumes/networks are not force-deleted; the engine refuses deletion while
another container uses them. Uncertain creations retain a recovery record so a
Docker operation that finishes after a crash can still be found.
The journal lives outside the attempt's workspace and survives daemon reloads.
Cleanup checks the host, authoritative attempt state, engine identity, resource
labels and creation fingerprints before acting. Docker images and build caches
continue to serve later jobs. No host-wide Docker prune is run. Linux accounting
does not change Windows behavior; the Windows Hyper-V provider separately removes
anonymous volumes with its owned containers, including on preparation failure.
An explicit remote endpoint, selected Docker context, or access to a raw engine
socket bypasses this accounting and needs workflow-managed cleanup. Ownership
labels are for trusted native workflows, not isolation from a job with Docker-root
access. Windows/macOS can inspect support but cannot enable the Linux feature.
Old manually installed runners and workspaces retained after a runner-root change
remain an operator cleanup task; the daemon does not infer ownership from a directory
name.
### Store application data somewhere else
Add `--data-dir DIR` to any command to place config, state, logs and the package cache under
your chosen root. Set `RUNNER_MANAGER_DATA_DIR` to make it the default. On macOS and Linux it
also moves the platform-default runner root, which lives inside that tree; on Windows it does
not. Either way it is not how you choose runner placement: `host set-runtime-root` decides that,
and it wins over the platform default everywhere. Re-run `runner-manager service install` after
changing the root.
### Adapt the dashboard to your terminal
Set `NO_COLOR` to remove colour, `TERM=dumb` to remove glyphs too, or
`RUNNER_MANAGER_TUI_ASCII=1` for ASCII frames. Use `RUNNER_MANAGER_TUI_LIGHT=1` for light
rows and `RUNNER_MANAGER_TUI_PLAIN_ROWS=1` for unshaded rows.
## Use the terminal dashboard
Open the live dashboard with `runner-manager tui`. Use these shortcuts to reach the view or
action you need:
`d` dashboard · `r` repositories · `n` runners · `a` activity · `s` repository settings ·
`h` host settings · `/` filter · `o` sort · `c` copy · `F5` refresh · `?` help · `q` quit
The repositories view groups runner profiles beneath each repository. Select the exact profile
row before pressing `s`; Repository Settings never guesses among siblings. The screen shows the
profile's immutable selector and copyable `runs-on`, and exposes its capacity, scaling, optional
labels, workspace, native/isolated execution mode, backend, pinned image and resource limits. It
also creates sibling profiles, drains or removes only the selected profile, and reports provider
readiness with a concrete remedy. Static `runs-on` labels are required; matrix expressions are
not resolved. The selector stays fixed and can be required to select one sibling
profile or a particular host for a job.
Every status is also written in words, so the dashboard remains usable without colour or
box-drawing characters.
<!-- GIF placeholder: navigating repositories, runners, activity and settings in the TUI. -->
## What you are granting
Before signing in, review the GitHub App permissions that every installation receives:
| Permission | Level | Used for |
|---|---|---|
| Repository → Administration | **Read and write** | Registering a just-in-time runner for a repository. |
| Repository → Actions | Read | Detecting queued jobs and the runs that hold them. |
| Repository → Metadata | Read | Accessing the repository identity required by GitHub. |
| Organization → Self-hosted runners | Read and write | Registering runners at organization scope. |
`Administration: Read and write` also permits deleting, renaming and transferring the
repository, and adding or removing collaborators. GitHub does not offer a narrower
repository permission for registering runners. A user who only monitors jobs grants the
same permissions because a GitHub App grants its complete permission set on installation.
Prefer organization scope when it fits your setup: it uses the narrower
`Organization → Self-hosted runners` grant ([verified](docs/spikes/d18-org-jit-verification.md)).
You can revoke the App in GitHub settings at any time and run `runner-manager auth logout`
to purge the local credential. The project creates no private key and declares no webhook.
To review this information from the terminal before signing in, run:
```sh
runner-manager auth status --permissions
```
## Supported platforms
| OS | Architectures |
|---|---|
| Windows | x64 (ARM64 via the install script, running the x64 build under emulation) |
| macOS | Apple Silicon, Intel |
| Linux | x64, ARM64 (glibc; on musl, build from source) |
## Development checks
The ordinary local gate is the same one the Windows x64, macOS ARM64, and
Linux x64 CI matrix runs for pull requests, pushes to `main`, and the release
call path:
```sh
cargo metadata --locked --format-version 1 >/dev/null
cargo fmt --check
cargo build --workspace --all-features
bash tests/assert-no-shippable-mutants.sh --scan-only
cargo clippy --all-targets -- -D warnings
cargo nextest run --workspace
cargo test --doc --workspace
```
The default nextest command always runs the complete deterministic inventories:
at least 256 local real-process chains and 32 mocked WSL chains. A selector can
only add an ignored diagnostic replay; it cannot narrow the CI run. Replay one
stable case with its first-divergence diagnostics and action transcript:
```sh
CLI_CHAINS_CASE=local-0007 cargo test -p runner-manager \
--test cli_chains_acceptance -- --ignored --exact replay_selected_case --nocapture
RUNNER_MANAGER_WSL_CHAIN_CASE=wsl-0007 cargo test -p runner-manager \
--bin runner-manager cli::wsl::acceptance::chains::replay_one_wsl_chain_case \
-- --ignored --exact --nocapture
```
## Licence
MIT. See [LICENSE](LICENSE).
### Automatic updates
Runner-manager checks for releases once every 24 hours while the TUI or daemon
is running (the first check starts after one minute). Automatic installation is
enabled by default, including when upgrading an existing installation. Change it
in TUI **Host settings**, or run `runner-manager host set-auto-update false`;
`true` enables it again. Disabled installations still check and record availability.
The preference lives in `config/updates.toml`; the last report is
`state/update-status.txt` within the application data directories.
TUI and background services sharing the same data directory coordinate checks
and updates. Updates use the existing installation channel and checksum verification.
Services finish active jobs before handing over to the new version; the TUI
restores the terminal and restarts with its original arguments. Linux (including
WSL), macOS and Windows use the same scheduler. Each WSL host checks independently.
The service account must have permission to update the installation and access to
its package manager. Failed checks are recorded and retried the following day;
`runner-manager update` remains available for an immediate retry. Checkout builds
are never automatically overwritten.