Skip to main content

gh-runners

CI codecov PyPI Downloads Python 3.11+ Typed: mypy strict Linted: ruff License: MIT Platform: Linux | Windows

Cross-platform GitHub Actions self-hosted runner manager. One CLI to set up, manage, and tear down self-hosted runners across multiple orgs.

Self-hosted runners save real money on your GitHub Actions bill. GitHub-hosted runners charge per-minute and the costs add up fast, especially for Rust/Tauri builds where a single CI run can burn 30-60 minutes of billable time. With gh-runners, you run those same builds on your own hardware at zero marginal cost. A mid-range desktop running 10 parallel runners will pay for itself in weeks if you have active CI.

Built primarily for Rust/Tauri/Node CI but works for any workload. Linux and Windows supported. macOS PRs welcome.

Security note. Set runner_user on each org (Linux). That gives the runners a dedicated unprivileged account with its own home, and runs containers under rootless Podman instead of a root Docker daemon — so a workflow cannot read your ~/.ssh keys or reach a Docker socket, and a pull request that triggers CI cannot walk out with your credentials.

Leave runner_user unset and the runners execute as whoever ran the tool. Such a runner can read anything you can and, if you are in the docker group, obtain root. That is only appropriate when every workflow able to run on it is one you already trust.

docs/runner-isolation.md documents the design with measured evidence and re-runnable probes.

Install

# Run without installing
uvx gh-runners --help

# Or install as a tool
uv tool install gh-runners

# Or pip
pip install gh-runners

Privileges

Run as yourself. Never prefix with sudo.

Three identities do the work, and the tool moves between them itself:

Identity Does Examples
you reads config, mints tokens with your gh auth, calls the GitHub API status, logs, check-host
root mutates host state, held briefly useradd, /etc/fstab, /etc/subuid, mounts
runner everything inside a runner's home, as its owner config.sh, caches, systemctl --user

When an operation needs root, you are asked for a password once, with the reason stated, and the grant covers the rest of the run.

Running the whole tool under sudo breaks it in ways that look like unrelated bugs: gh reads root's config and reports "not logged in" while your own auth is fine, uv vanishes from PATH, and diagnostics that should consult GitHub silently answer from local state instead.

In CI or cron, where there is no terminal to prompt on, privileged operations fail immediately with an explanation rather than hanging. Grant a passwordless sudo rule for the host if they need to run unattended.

Quick Start

Linux

# 1. Clone and configure
git clone https://github.com/nazq/gh_runners.git
cd gh_runners
cp config.example.toml config.toml
# Edit config.toml with your org URL, runner count, etc.

# 2. Check prerequisites
gh-runners check-host

# 3. Install the toolchain and the runners in one step.
#    (--toolchain keeps runners on the versions in config.toml rather than
#    whatever the host happens to have; omit it if the toolchain is current.)
gh-runners setup --toolchain

Run as yourself — never with sudo. See Privileges.

Windows

# 1. Clone and configure
git clone https://github.com/nazq/gh_runners.git
cd gh_runners
copy config.example.toml config.toml
# Edit config.toml

# 2. Check prerequisites
gh-runners check-host

# 3. Verify globally installed tools match config versions
gh-runners setup-toolchain

# 4. Setup runners (as Administrator — installs as Windows Services)
gh-runners setup --token YOUR_TOKEN

Runners auto-start on reboot on both platforms.

Commands

All commands support --org <name> to target a specific organization.

Command Description
gh-runners check-host Verify build toolchain prerequisites
gh-runners list-packages List all available toolchain packages
gh-runners setup-toolchain Install isolated toolchain (Linux) or verify versions (Windows)
gh-runners setup Download, configure, install as services
gh-runners setup --toolchain The above, preceded by setup-toolchain
gh-runners status Show all runner service states and active jobs
gh-runners start Start all runner services
gh-runners stop Stop all runner services
gh-runners restart Wait for jobs, clean work dirs, restart
gh-runners restart --force Restart immediately (may interrupt jobs)
gh-runners clean Clean _work directories (stop runners first)
gh-runners logs <org> <N> Show last 50 log lines for runner N
gh-runners remove Unregister from GitHub, remove services

setup and remove accept an optional --token TOKEN. If omitted, a registration token is fetched automatically via the gh CLI.

Configuration

Copy config.example.toml to config.toml:

[runner_version]
version = "2.331.0"

[timeouts]
job_wait_seconds = 3600
poll_interval = 10

# Pluggable toolchain — each package gets its own sub-table
[toolchain]
packages = ["rust", "node", "cargo-tools", "python"]

# rust, node and python accept `extra_versions`: additional versions kept
# alongside the default, for repos that pin one. Each lands in its own
# directory, so a repo on an older version and one on the default can
# build concurrently on the same host.
[toolchain.rust]
version = "1.97"
extra_versions = ["1.92.0"]

[toolchain.node]          # installed by fnm
version = "22.14.0"
extra_versions = ["20.19.0"]

[toolchain.python]        # installed by uv
version = "3.13"
extra_versions = ["3.11", "3.12", "3.14"]

[toolchain.cargo-tools]
crates = "cargo-llvm-cov just tauri-cli"

# Add as many orgs as you need
[[org]]
name = "MyOrg"
url = "https://github.com/MyOrg"
runner_group = "Default"
runner_count = 4
name_prefix = "runner"
service_prefix = "gh-runner-myorg"
extra_labels = ""

Key settings

  • runner_count: Parallel runners per org. Match to your CPU cores / build needs.
  • name_prefix: Shows in GitHub UI as prefix-1, prefix-2, etc.
  • service_prefix: Systemd service name prefix (Linux) or Windows Service name.
  • base_dir: Where runner binaries live. Defaults to ~/.gh-runners/<org_name>.
  • extra_labels: Additional labels beyond the automatic self-hosted,Linux/Windows,X64.

Toolchain packages

Each package is a TOML sub-table under [toolchain]. The packages array controls which ones are installed. Run gh-runners list-packages to see all available packages and their supported architectures.

Built-in packages: rust, node, cargo-tools, go, pnpm, bun, pwsh, python.

Note: Some cargo crates (especially tauri-cli) take 15+ minutes to compile on first install. This is normal for Rust — subsequent installs are cached.

Architecture

Linux: Toolchain Isolation

Runners share one toolchain at /opt/gh-runners/toolchain with its own RUSTUP_HOME and Node.js, separate from your personal ~/.cargo and ~/.nvm. It lives outside any home directory because a home is drwxr-x---, and a runner user cannot traverse into one however the toolchain itself is owned. Each systemd unit loads that runner's .env via EnvironmentFile=, so runners never see your dev tools.

RUSTUP_HOME is shared — rustup only reads from it during a build. Everything a build writes to is per-runner: CARGO_HOME, the npm, uv, pip, pnpm and Go caches, plus CLOUDSDK_CONFIG and DOCKER_CONFIG. A shared CARGO_HOME fails outright (failed to create directory <CARGO_HOME>/git/db/<dep>), and a shared cloud config is how google-github-actions/auth ends up overwriting your active gcloud account with a credential that dies when the job does.

With runner_user set, each org's runners also get their own unprivileged account, so a workflow cannot read your files at all. See docs/runner-isolation.md for that design and the evidence behind it.

With runner_user set (the isolated layout):

/opt/gh-runners/toolchain/     # root:root 0755 — readable by every runner
├── .rustup/                   #   shared: rustup only reads it at build time
├── .cargo/bin/                #   binaries on PATH; CARGO_HOME is elsewhere
└── node/

/srv/gh-runners/               # root:root 0755 — each runner owns its subtree
└── ghr-myorg/                 # drwx------ ghr-myorg
    └── MyOrg/
        ├── runner-1/          # installation + _work/
        │   ├── .env           # per-runner CARGO_HOME, caches, cloud config
        │   ├── .cargo/        # written to by builds, so never shared
        │   ├── .gcloud/
        │   └── .docker/
        └── runner-2/

Without runner_user, everything lives under ~/.gh-runners/ owned by you, and the toolchain falls back to ~/.gh-runners/shared-toolchain/.

Windows

On Windows, gh-runners setup-toolchain verifies that globally installed tool versions match your config. Runners use whatever Rust/Node is on the system PATH. Each runner is a native Windows Service via GitHub's built-in svc.cmd.

Platform Detection

Auto-detects OS and CPU architecture:

  • OS: Linux, Windows (macOS ready for PRs)
  • Arch: x64, arm64, arm

Prerequisites

Linux

  • Python 3.11+ with uv
  • git, gcc, curl
  • Then run gh-runners setup-toolchain for Rust + Node

Windows

  • Python 3.11+ with uv
  • Git for Windows (winget install Git.Git)
  • Visual Studio Build Tools with C++ workload
  • Rust (winget install Rustlang.Rustup)
  • Node.js LTS (winget install OpenJS.NodeJS.LTS)

Run gh-runners check-host to verify everything is present.

Usage in GitHub Actions

jobs:
  build:
    runs-on: [self-hosted, Linux, X64]
    steps:
      - uses: actions/checkout@v4
      - run: cargo build --release

  # Target a specific runner by its name label
  build-specific:
    runs-on: [self-hosted, Linux, X64, gh-runner-myorg-1]
    steps:
      - uses: actions/checkout@v4

Development

just check       # Everything CI runs: lint + typecheck + tests
just lint        # Ruff lint + format check
just fix         # Auto-fix lint issues and format
just typecheck   # mypy --strict
just test        # pytest with the 95% coverage gate
just test-fast   # pytest without coverage — faster inner loop
just coverage    # HTML coverage report
just run status  # Run any gh-runners command via uv

Testing

No test touches the real system. This tool creates user accounts, edits /etc/fstab and /etc/subuid, and runs userdel -r — a suite that could execute any of that for real would be more dangerous than no suite.

Every subprocess goes through platform.run_cmd, so the fake_run fixture severs all of them at once, and an autouse backstop fails any test that reaches subprocess directly. Filesystem writes take an explicit path, so tmp_path covers the rest. If you add a call site that bypasses run_cmd, a test will tell you rather than your machine finding out.

Coverage must stay at or above 95% (fail_under in pyproject.toml, plus the Codecov project and patch gates). Platform-specific branches are not excluded from that number — the suite stubs is_windows/is_linux so both halves are reachable from any host.

Contributing

PRs welcome, especially for macOS support. Run just check before submitting — lint, types and the coverage gate must all pass clean.

Adding a new package

The toolchain system is pluggable. To add a new package (e.g. deno):

  1. Add an install function in gh_runners/packages.py:
def _install_deno(tc_dir: Path, arch: str, cfg: dict[str, Any]) -> None:
    version: str = cfg.get("version", "2.0.0")
    # Download and extract into tc_dir / "deno" or similar
    # The cfg dict is the full TOML sub-table, so any keys
    # you define under [toolchain.deno] are available here.
    ...
  1. Register it in the PACKAGES dict (same file):
PACKAGES: dict[str, Package] = {
    # ... existing packages ...
    "deno": Package(
        name="deno",
        description="Deno JavaScript/TypeScript runtime",
        install_fn=_install_deno,
        supported_archs={"x64", "arm64"},
        default_version="2.0.0",
        host_checks=[
            HostCheck(
                name="deno",
                cmd=["deno", "--version"],
                parse=lambda out: out.splitlines()[0].split()[1],
                why="Deno runtime",
            ),
        ],
    ),
}
  1. Add a path helper if the package installs to its own directory:
def deno_home(tc_dir: Path) -> Path:
    return tc_dir / "deno"

Then add it to toolchain_env() in toolchain.py so it appears in the runner PATH.

  1. Update config.example.toml with a commented-out example.

That's it. No config schema changes needed — users just add "deno" to their packages list and create a [toolchain.deno] sub-table with whatever keys your install function reads.

Troubleshooting

Runner shows offline in GitHub

gh-runners status
gh-runners logs MyOrg 1
gh-runners restart

Registration token expired

Tokens expire after 1 hour. If you have gh CLI authenticated, gh-runners setup and gh-runners remove fetch tokens automatically. Otherwise:

gh api -X POST orgs/YOUR_ORG/actions/runners/registration-token --jq .token
gh-runners setup --token TOKEN

Disk space

Rust target/ dirs and node_modules grow fast:

gh-runners stop
gh-runners clean
gh-runners start

Re-register runners

gh-runners remove
gh-runners setup

Release files for gh-runners 2.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for gh-runners 2.0.0
File Size Uploaded
gh_runners-2.0.0.tar.gz 163.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for gh-runners 2.0.0
File Interpreter ABI Platform
gh_runners-2.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 229.9 kB

Release files / gh_runners-2.0.0.tar.gz

Download URL gh_runners-2.0.0.tar.gz
Size 163.9 kB
Tags Source
SHA-256 checksum
How to use checksums
72c446d90142f63d2276510284278eb54e8fd658b29937a721e0f15aa73e10ad
BLAKE2b-256 checksum
How to use checksums
f15eccc510a7cfa2fcd7082bd611971d57b378a61204662acc80cd2fa7ad88b7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / gh_runners-2.0.0-py3-none-any.whl

Download URL gh_runners-2.0.0-py3-none-any.whl
Size 66.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b48f158bfd3e3536d94299f0dce2289e04a940f70a03f8c2693f7e23f425bcab
BLAKE2b-256 checksum
How to use checksums
cf3aac21853533a533d8d79d634975e8a337f8ec60e0978e667f42d1b05cab46
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

2.3.0

2 release files

2.2.1

2 release files

2.2.0

2 release files

2.1.0

2 release files

This release

2.0.0 This release

2 release files

1.2.0

2 release files

1.1.0

2 release files

1.0.1

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page