scaleset
Autoscale GitHub Actions self-hosted runners on any infrastructure. A Rust client for the GitHub Actions Runner Scale Set APIs, the APIs actions-runner-controller is built on, without Kubernetes.
- Complete: scale sets, runner groups, just-in-time (JIT) runner configs and message sessions, on github.com, GHE.com and GitHub Enterprise Server.
- Resilient: a listener that acquires jobs, acknowledges messages, retries with backoff, waits out session conflicts after a restart, and replaces lost sessions. Token refresh and rate limits are handled for you.
- Testable: a stateful emulator of the whole service ships behind a feature flag, so an autoscaler can be tested end to end without a network.
- Rust-first: async on Tokio,
reqwest, typed IDs,tracing, secrets inSecretString. It follows the protocol of GitHub's Go client,actions/scaleset, not its API (why).
[]
= "0.1"
= { = "1", = ["macros", "rt-multi-thread", "signal"] }
Features:
rustls(default): TLS through rustls.native-tls: TLS through the platform's library instead. Enable one of the two; without TLS the client cannot connect.testing: the emulator, for tests and thescaleset-emulatorbinary.
Status: 0.x, so the API may change between minor versions. GitHub does not
document the scale set APIs; this crate follows the behavior of
actions/scaleset.
Quick start
use HashSet;
use ;
use ;
/// Keeps one runner for every job assigned to the scale set.
async
examples/autoscaler.rs is a complete autoscaler
that starts runners through any shell command (for example docker run),
keeps a minimum of warm runners, and cleans up on Ctrl-C.
How it works
A scale set is a group of ephemeral self-hosted runners. You register it once; GitHub then routes matching jobs to it and tells you, through a message session, how many runners it needs.
- Register a scale set with a name (and optionally more labels) at a repository, organization or enterprise.
- Listen: the listener long-polls the scale set's message queue,
reporting your capacity (
max_runners). Each poll returns a batch of job messages or, after about 50 seconds, nothing. - Scale: every event carries the current statistics. Keep
total_assigned_jobsrunners available, each started with a JIT config. - Run: GitHub hands each job to an idle runner. The runner runs one job and exits.
Scale on statistics, never on message counts. A batch holds at most 50
messages, and a job that no runner picks up in time is canceled and
reassigned up to three times, each time with new messages.
total_assigned_jobs is always current.
The listener
Listener::run does the bookkeeping:
- opens the message session, and closes it on shutdown;
- acquires every available job, so none is left unassigned;
- acknowledges a message only after your scaler returns
Ok; after an error the message is delivered again, so make scaling idempotent; - backs off and retries on failures, and gives up after more than
max_failures(default 10) failures in a row; - waits on standby while another session is open, such as one left by a previous run that crashed;
- opens a new session if the old one disappears.
scale takes &mut self and is never called concurrently, so a scaler can
keep state without locks.
Slow provisioning
The listener waits for scale before polling again. If a runner takes long
to start (a VM booting), reconcile elsewhere: a
tokio::sync::watch::Sender<ScaleSetStatistics> is itself a scaler that
publishes the latest statistics.
use Listener;
use ;
async
Busy runners
Statistics say how many runners are needed, not which ones are working.
Before stopping a runner, ask a JobTracker fed with each message:
use ;
Doing it by hand
client.create_message_session(scale_set_id, owner) returns a
MessageSession. Its subscribe(max_runners) returns a Subscription, the
pull API the listener is built on: next() yields events and ack()
acknowledges.
MessageSession exposes the raw calls: get_message, delete_message and
acquire_jobs.
Authentication
| Credentials | When |
|---|---|
Credentials::github_app(client_id, installation_id, pem) |
GitHub App: recommended |
Credentials::github_app_with_provider(installation_id, SignerJwtProvider::new(client_id, signer)) |
GitHub App whose key stays in a KMS or HSM: implement Rs256Signer |
Credentials::github_app_with_provider(installation_id, provider) |
any JwtProvider, such as a signing service |
Credentials::personal_access_token(token) |
PAT: simpler, but broadly scoped |
The client exchanges credentials for a registration token and then an Actions service admin token, refreshes it before it expires, and replaces it if the service rejects it early. See GitHub's docs for the permissions needed.
For GitHub Enterprise Server, use your GHES URL. Multiple labels per scale
set need GHES 3.18 or later with the
DistributedTask.AllowRunnerScaleSetCustomLabels feature flag (on by
default from 3.21); without it, extra labels are dropped.
HTTP, retries and rate limits
Transport belongs to you; retries belong to the SDK.
use ;
- Transport errors, 5xx other than 501, and 429 are retried four times with
jittered exponential backoff (1 s up to 30 s).
Retry-Afteron a 429 or 503 is honored in full. TLS certificate errors are not retried. - Each long-poll attempt gets two minutes, whatever the client's timeout.
- Rate-limited responses (429, or 403 with
Retry-After,X-RateLimit-Remaining: 0or a secondary-limit message) come back as aRateLimitErrorwith its kind andretry_after. max_elapsedcaps the time spent on a request. Rather than sleeping into that deadline, the SDK returns the last response.- Credentials only travel over HTTPS. For tests and isolated GHES labs,
ClientBuilder::allow_http(true)permits plain HTTP; github.com and GHE.com always need HTTPS.
Change the policy for some calls only, for example to requeue on a rate limit instead of blocking:
use Duration;
use ;
/// Returns how long to wait before requeueing, if rate limited.
async
Testing
The testing feature adds scaleset::testing::Emulator: GitHub and the
Actions service, in your test process. It is stateful, not a mock: scale
sets, sessions, runners and jobs live in it, statistics are computed from
them, messages are redelivered until acknowledged, and tokens expire.
[]
= { = "0.1", = ["testing"] }
use Duration;
use RunnerScaleSetSpec;
use ;
async
Tests can also start or complete jobs by hand, expire tokens and sessions,
read statistics, and inspect every request. The scaleset-emulator binary
serves the same emulator, with a JSON control API, for clients in any
language:
Security
- Prefer GitHub App credentials, and keep app keys in a KMS or HSM with
SignerJwtProviderwhere you can. - JIT configs and tokens are
SecretStrings: redacted fromDebugoutput and wiped from memory on drop. Treat a JIT config as a secret until its runner has used it. - Report vulnerabilities as described in SECURITY.md.
Minimum supported Rust version
Rust 1.88. Raising it is a minor-version change.
Contributing
See CONTRIBUTING.md. docs/DESIGN.md explains how the crate is put together and why.
License
MIT. The protocol handling follows GitHub's MIT-licensed
actions/scaleset.