The lab where the ci-wizard method is proven: self-hosted GitHub Actions runners on one Linux host that measure every job they take and publish the receipts live. No job should be lost or slowed by the runner itself, and the numbers that say whether that holds are public.
It is the runner stack behind the askalf projects' CI. The second badge above is live: the median start and the median run of the jobs it ran over the last 24 hours. pitlane.askalf.org shows them in full, with their p90s and the runner-suspected failures beside them, and docs/method.md says how each number is measured and how to check it.
The first target is start latency and the queue tail: the time a job waits for a runner is what users feel. The run time is measured too, down to the runner's wrap-up after the last step (docs/run-time.md).
| Target | Value |
|---|---|
| Queue wait p50 | under 5 s |
| Queue wait p90 | under 30 s |
| Failures caused by the runner | 0 |
| Jobs sharing a workspace | 0 |
These are targets, not results. The median start is usually 2 s because warm rows hold an idle listener; a cold row registers first, which takes about 15 s (docs/pools.md). The p90 runs over target during bursts, when more jobs queue than rows are free, and the live page shows which hours did. Failures the runner is suspected of causing are counted from how each job ended (docs/outcomes.md); a suspicion, not proof.
- A manifest, not hand-registered runners.
runners.manifestlists every row: its scope (one repository, an organization, or a shared pool), its tier, the account it runs as and its mode.gha-runners-apply.shreconciles the host against it: systemd slices, template units, per-row drop-ins, directories and the pinned runner release. See runners/README.md. - Modes. An ephemeral row takes one job, then registers again with a fresh just-in-time configuration; that is the normal mode and the one the targets are measured on. An adopt row is an existing persistent runner that apply only places in its slice, and a managed row is a persistent runner apply builds; both keep their state between jobs (runners/README.md, Modes).
- Three tiers. read rows read code and post reviews, exec rows run repository code, and build rows run your own deploys as root, with the docker socket, behind a guard that refuses any job its allow list does not name (docs/ops-tier.md).
- Scale to zero.
gha-ondemandwatches each repository's queued self-hosted jobs, starts a stopped row whose labels cover one, and parks a row that sat idle. A verifiedworkflow_jobwebhook starts it at once; a poll is the backstop. - Pools and caps. A shared pool of rows registers, at each start, for the repository of the job routed to it, so a burst in any repository draws on the whole pool (docs/pools.md).
- Isolation. Each read and exec row listed in
runners.isolatedruns as its own account with its own tool cache, and only a lane's rows can read that lane's keys (docs/isolation.md). Build rows run as root and cannot be isolated that way. A canary probes what a job can actually read (canary/). - CPU shape. A row is pinned to a window of CPUs, so test runners and compilers size their worker pools to that window instead of the whole host (docs/cpu.md).
- A supervisor. It watches for stuck jobs, version drift and disk or memory pressure, and records how every job ended (docs/supervisor.md, docs/outcomes.md).
- box-ci. A second way to run a repository's workflows on the host, with act, at each commit, posting the verdict as commit statuses (docs/box-ci.md).
- Showcase. A line on a job's run page with its own queue wait, and the live badge above (docs/showcase.md).
The rows share one kernel; there is no virtual machine between a job and the host. On top of that, docs/isolation.md lists what is still shared:
- The network. Jobs share the host's loopback and can reach the local services listening on it. A network namespace per job is planned, not built.
- Key files within a lane. The rows of one lane share that lane's key file through a group, so a job on any row of the lane can read the key every other row of it uses.
- Build rows. They run as root with the docker socket; the build guard limits which workflows they run, not what those workflows can do.
- Untrusted code. Running rows that take code you did not write in gVisor is designed, not built.
runs-on names a row's labels. Beyond that, a job behaves as it would on any self-hosted runner, with
one difference to plan for: exec rows have no docker socket, so container jobs (container:) and
service containers (services:) do not run there. Jobs that need docker belong on a build row, if
they are your own deploys, or on a hosted runner.
You need:
- a Linux x86_64 host with systemd and cgroup v2 (Ubuntu 24.04 is the tested system), root, bash,
Node.js 22 at
/usr/bin/node, the GitHub CLI (gh), and curl, jq and openssl; - a GitHub App (or a fine-grained token) for each repository or organization it serves;
- for the fast start, GitHub's
workflow_jobwebhook delivered to the dispatcher's loopback port (7311 by default) through a tunnel or reverse proxy.
runners/install.sh check says which of these the host has. Then:
-
Copy runners/examples/minimal/ (one repository, one row, docs/example-minimal.md) or the fuller runners/examples/ to a directory of your own, and put your repositories in it.
-
Install it. The installer checks the prerequisites, checks your config as one set (a config with a problem installs nothing), lays out the code, the units and the config at the paths runners/README.md lists, and writes the dispatcher's settings once, in dry mode with a fresh webhook secret. It starts nothing.
sudo runners/install.sh install --config path/to/your/config -
It ends by printing the rest: GitHub access for each owner, then
/opt/askalf/gha-runners-apply.sh bootstrap # accounts, directories, the pinned runner /opt/askalf/gha-runners-apply.sh apply --restart-idle systemctl enable --now gha-ondemand.service pitlane-supervisor.servicethen the webhook, and
MODE=ononce the dispatcher's journal shows the starts you expect.
To upgrade, pull and run sudo runners/install.sh install again: without --config it replaces
the code and units and keeps the config and settings on the host.
The scale-to-zero dispatcher (runners/gha-ondemand.mjs) manages the rows of every owner the
manifest names (repo:<owner>/<repo> and pool:<owner> scopes); no owner is built into the code.
Facts from each project's own documentation:
| pitlane | actions-runner-controller | terraform-aws-github-runner | Hosted fast-runner services | |
|---|---|---|---|---|
| Runs on | one Linux host, systemd | Kubernetes | AWS (EC2, Lambda) | the vendor's machines |
| Job boundary | a Unix account per row, slices, a shared kernel | a pod per runner | an instance per runner (per job in its ephemeral mode) | usually a VM per job |
| Scale to zero | yes | yes | yes | not applicable |
| Public live numbers | aggregate numbers on a public page, measured from every job; each job's own run page carries its wait (except rows that skip the summary line, fleet status rows by default) | not part of the project | not part of the project | varies by vendor |
| Cost | the host | the cluster | the AWS bill | per minute |
pitlane trades the stronger VM boundary of the instance and VM designs for one host you already run, with no cluster and no cloud bill, and publishes its own numbers so the trade can be judged.
node --test runners/*.test.mjs canary/*.test.mjs box-ci/*.test.mjs
The tests run the shell scripts against stubs, with no network and no root. CI runs them on GitHub-hosted runners, so a pull request from a fork never reaches a self-hosted row.
Each release carries the source tarball of its tagged commit, attested keyless through GitHub OIDC (Sigstore) by release.yml. Check a download before you install it:
gh attestation verify pitlane-<version>.tar.gz --owner askalf
What changed between releases: CHANGELOG.md.
docs/README.md is the index, with a glossary of the words these docs use (row, lane, tier, pool, warm, park).
See CONTRIBUTING.md. Report a vulnerability privately, as SECURITY.md describes, never in a public issue.