# runner-manager [![npm version](https://img.shields.io/npm/v/@ivan-murzak/runner-manager.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@ivan-murzak/runner-manager) [![Crates.io](https://img.shields.io/crates/v/runner-manager?style=for-the-badge&logo=rust)](https://crates.io/crates/runner-manager) [![GitHub Release](https://img.shields.io/github/v/release/IvanMurzak/GitHub-Runner-Scaler-UI?style=for-the-badge&logo=github)](https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/releases) [![Release Status](https://img.shields.io/github/actions/workflow/status/IvanMurzak/GitHub-Runner-Scaler-UI/release.yml?style=for-the-badge&logo=github)](https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](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. ![Preview](https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI/blob/main/docs/img/preview.png?raw=true) ## 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 ```
Other installation methods and details ### 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.` 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.
## 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`. ## 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 /_cache runner-manager host cache reset-root # Keep the caches under /_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 # Let jobs bind-mount a host directory into containers runner-manager host docker-policy disallow-path runner-manager host docker-policy report # Summarise recorded Docker policy violations runner-manager host protect-path add # Hide a host directory from every runner and job (Linux) runner-manager host protect-path remove 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- 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- 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 "" ``` 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 `/_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, `//`, 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/`, 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//` 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 "" ``` 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. ## 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.