Skip to content

ysya/runscaler

Repository files navigation

runscaler

Release Go Version License Go Report Card

Auto-scale GitHub Actions self-hosted runners as Docker containers or macOS VMs. Powered by actions/scaleset.

Runners are ephemeral — each container/VM handles exactly one job and is removed upon completion. No Kubernetes required.

Table of Contents

How It Works

flowchart LR
    A["GitHub Actions<br/>(job queue)"] -- long poll --> B["runscaler<br/>(this tool)"]
    B -- Docker API --> C["Runner Containers<br/>(ephemeral)"]
    B -- Tart CLI --> D["macOS VMs<br/>(ephemeral)"]
Loading
  1. Registers a runner scale set with GitHub
  2. Long-polls for job assignments via the scaleset API
  3. Spins up Docker containers or macOS VMs with JIT (just-in-time) runner configs
  4. Removes containers/VMs automatically when jobs complete
  5. Cleans up all resources and the scale set on shutdown

Features

  • Zero Kubernetes — runs directly on any Docker host or Apple Silicon Mac
  • Ephemeral runners — each job gets a fresh container/VM, no state leakage
  • Auto-scaling — scales from 0 to N based on job demand via long-poll (no cron, no polling delay)
  • Docker-in-Docker — optional DinD support for workflows that build containers
  • macOS VMs via Tart — native Apple Virtualization.framework with APFS Copy-on-Write cloning
  • VM warm pool — pre-boot macOS VMs for instant job pickup (~2s vs ~30s cold boot)
  • Shared volumes — cross-runner caching via named Docker volumes
  • Multi-org support — manage multiple scale sets from a single process, mix Docker and Tart backends
  • Single binary — no runtime dependencies beyond Docker (or Tart for macOS)
  • Config file or flags — TOML config with CLI flag overrides

Quick Start

Prerequisites

  • Docker backend: Docker running on the host

  • Tart backend (macOS): Apple Silicon Mac with Tart installed:

    brew install cirruslabs/cli/tart
    
    # Pull a macOS runner image (pre-installed with Xcode and runner dependencies)
    tart pull ghcr.io/cirruslabs/macos-tahoe-xcode:latest

    Note: Apple's Virtualization.framework limits each host to 2 concurrent macOS VMs. Set max-runners accordingly. Each VM slot is assigned a deterministic MAC address to prevent DHCP lease exhaustion — no sudo required.

    The default VM resources from Cirrus Labs images:

    Image CPU Memory Disk
    ghcr.io/cirruslabs/macos-tahoe-xcode:latest 4 cores 8 GB (8192 MB) 120 GB
    ghcr.io/cirruslabs/macos-sequoia-xcode:latest 4 cores 8 GB (8192 MB) 120 GB

    Override per VM with cpu and memory under [tart] in config. For iOS builds (Xcode), 8 GB+ is recommended.

  • A GitHub Personal Access Token — required scopes depend on token type and runner level:

    Token type Organization runners Repository runners
    Classic PAT admin:org repo
    Fine-grained PAT Self-hosted runners: Read and write + Administration: Read Administration: Read and write

    Note: The token owner must be an org owner (for org runners) or have admin access to the repo (for repo runners). Fine-grained PATs targeting an organization may also require admin approval depending on org policy.

Install

Shell script (Linux/macOS):

curl -fsSL https://raw.githubusercontent.com/ysya/runscaler/main/install.sh | sh

Installs to ~/.local/bin by default (no sudo required). Set INSTALL_DIR to customize, or RUNSCALER_VERSION to pin a version:

# Install to a custom location (e.g. system-wide)
curl -fsSL https://raw.githubusercontent.com/ysya/runscaler/main/install.sh | INSTALL_DIR=/usr/local/bin sh

# Pin a specific version
curl -fsSL https://raw.githubusercontent.com/ysya/runscaler/main/install.sh | RUNSCALER_VERSION=v1.2.3 sh

Go install:

go install github.com/ysya/runscaler@latest

Binary releases:

Download from Releases and add to your PATH.

Run

# Generate config interactively
runscaler init

# Validate everything before starting
runscaler validate --config config.toml

# Start scaling
runscaler --config config.toml

# Or using CLI flags directly
runscaler \
  --url https://github.com/your-org \
  --name my-runners \
  --token ghp_xxx \
  --max-runners 10

# Dry run — validate config, Docker, and images without starting listeners
runscaler --dry-run --config config.toml

Then in your workflow:

jobs:
  build:
    runs-on: my-runners  # matches --labels (defaults to --name if not set)
    steps:
      - uses: actions/checkout@v4
      - run: echo "Running on auto-scaled runner!"

Commands

Command Description
runscaler Start the auto-scaler (default)
runscaler init Generate a config file interactively
runscaler validate Validate configuration and connectivity
runscaler status Show current runner status via health endpoint
runscaler doctor Diagnose and clean up orphaned containers/VMs
runscaler version Show version, commit, build date, and runtime info
runscaler update Update runscaler to the latest release
runscaler update --check Check for updates without installing

Updating

# Update to the latest release (downloads, verifies checksum, replaces binary in-place)
runscaler update

# Check if a newer version is available without installing
runscaler update --check

runscaler update downloads the archive for your platform, verifies its SHA-256 checksum against the release's checksums.txt, then atomically replaces the running binary. Restart runscaler after updating.

Troubleshooting with doctor

If runscaler is killed unexpectedly (e.g. kill -9, crash, power loss), Docker containers or Tart VMs may be left behind. Use doctor to detect and clean them up:

# Check for orphaned resources
runscaler doctor

# Auto-remove orphaned containers, VMs, and volumes
runscaler doctor --fix

The --fix flag will refuse to run if runscaler is currently active (detected via health endpoint), preventing accidental removal of in-use resources.

Configuration

Configuration can be provided via a TOML config file (--config) or CLI flags. When both are provided, CLI flags take priority over config file values.

Config File (TOML)

Docker backend (default):

# config.toml
url = "https://github.com/your-org"
name = "my-runners"
token = "ghp_xxx"
max-runners = 10
min-runners = 0
labels = ["self-hosted", "linux"]
runner-image = "ghcr.io/actions/actions-runner:latest"
runner-group = "default"
log-level = "info"
log-format = "text"

[docker]
socket = "/var/run/docker.sock"
dind = true
shared-volume = "/shared"

Tart backend (macOS):

# config.toml
backend = "tart"
url = "https://github.com/your-org"
name = "macos-runners"
token = "ghp_xxx"
max-runners = 2          # Apple limits 2 concurrent macOS VMs per host
runner-image = "ghcr.io/cirruslabs/macos-tahoe-xcode:latest"
labels = ["self-hosted", "macOS"]
log-level = "info"

[tart]
cpu = 4                  # CPU cores per VM (0 = use image default)
memory = 8192            # Memory in MB per VM (0 = use image default)
runner-dir = "/Users/admin/actions-runner"  # default
pool-size = 2            # pre-warm 2 VMs for instant job pickup (~2s vs ~30s cold boot)

Token Security

Avoid passing tokens as CLI flags (visible in ps output). Two alternatives:

Option 1: RUNSCALER_TOKEN environment variable — automatically used when no --token flag or config value is set:

export RUNSCALER_TOKEN=ghp_xxx
runscaler --url https://github.com/org --name my-runners

Option 2: env: syntax in config file — reference any environment variable by name:

token = "env:GITHUB_TOKEN"  # reads from $GITHUB_TOKEN at startup

Priority: --token flag > RUNSCALER_TOKEN env var > config file value (including env: resolution).

Multiple scale sets (mixed Docker + Tart):

# Global defaults (inherited by all scale sets)
runner-image = "ghcr.io/actions/actions-runner:latest"
runner-group = "default"
max-runners = 10
log-level = "info"

[docker]
socket = "/var/run/docker.sock"
dind = true

# Each [[scaleset]] runs independently.
# Inherits global settings if omitted.

[[scaleset]]
url = "https://github.com/your-org"
name = "linux-runners"
token = "ghp_aaa"

[[scaleset]]
backend = "tart"
url = "https://github.com/your-org"
name = "macos-runners"
token = "ghp_bbb"
max-runners = 2
runner-image = "ghcr.io/cirruslabs/macos-tahoe-xcode:latest"
labels = ["self-hosted", "macOS"]
[scaleset.tart]
pool-size = 2

CLI Flags

Flag TOML key Default Description
--config Path to TOML config file
--url url (required) Registration URL (org or repo)
--name name (required) Scale set name (used as runs-on label)
--token token (required) GitHub Personal Access Token
--backend backend docker Runner backend (docker or tart)
--max-runners max-runners 10 Maximum concurrent runners
--min-runners min-runners 0 Minimum runners to keep warm
--labels labels <name> Runner labels (comma-separated)
--runner-group runner-group default Runner group name
--runner-image runner-image ghcr.io/actions/actions-runner:latest Runner image (Docker image or Tart VM image)
--docker-socket [docker] socket /var/run/docker.sock Docker socket path (Docker backend)
--dind [docker] dind true Mount Docker socket into runners (Docker backend)
--shared-volume [docker] shared-volume Shared Docker volume path (Docker backend)
--tart-cpu [tart] cpu 0 (image default) CPU cores per VM (Tart backend)
--tart-memory [tart] memory 0 (image default) Memory in MB per VM (Tart backend)
--tart-runner-dir [tart] runner-dir /Users/admin/actions-runner Runner install directory inside Tart VM
--tart-pool-size [tart] pool-size 0 Number of pre-warmed VMs for instant job pickup
--log-level log-level info Log level (debug/info/warn/error)
--log-format log-format text Log format (text/json)
--dry-run dry-run false Validate everything without starting listeners
--health-port health-port 8080 Health check HTTP port (0 to disable)

Deployment

Systemd

[Unit]
Description=GitHub Actions Runner Auto-Scaler
After=docker.service
Requires=docker.service

[Service]
Type=simple
ExecStart=/usr/local/bin/runscaler --config /etc/runscaler/config.toml
Restart=on-failure
RestartSec=10s

[Install]
WantedBy=multi-user.target

Building

# Current platform
make build

# All platforms (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64)
make all

Architecture

Built on top of actions/scaleset, the official Go client library for GitHub Actions Runner Scale Sets.

Key components:

cmd/runscaler/       CLI entry point, commands (init, validate, status, doctor, version)
internal/
  config/            Configuration management with Viper (flags + TOML)
  backend/           RunnerBackend interface + Docker/Tart implementations
  scaler/            Implements listener.Scaler for runner lifecycle
  health/            Health check HTTP server
  versioncheck/      GitHub releases API client for update notifications and in-place binary updates

The RunnerBackend interface abstracts container/VM lifecycle:

  • DockerBackend — manages runner containers via Docker API
  • TartBackend — manages macOS VMs via Tart CLI (clone → run → exec → stop → delete)

The scaler implements three methods from the scaleset Scaler interface:

  • HandleDesiredRunnerCount — Scales up runners to match job demand
  • HandleJobStarted — Marks runners as busy
  • HandleJobCompleted — Removes finished runners

License

MIT

About

Auto-scale GitHub Actions self-hosted runners as ephemeral Docker containers or macOS VMs. No Kubernetes required.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors