Comparison · ARC alternative
Why GitHub Actions Gateway over ARC?¶
Actions Runner Controller (ARC) struggles with one job: many runner sets, many tenants, one shared cluster, each tenant safely capped by its own ResourceQuota. GitHub Actions Gateway (GAG) is built for that job.
When the job is disrupted
The problem ARC leaves you with¶
All four trace to one root, and it is not a missing feature. ARC models a cluster with one owner, so it has no primitive separating what the platform owns from what a tenant owns. That is a reasonable product for a single-tenant cluster, and the same gap from four angles once teams share one.
-
ResourceQuotais unsafe
A quota-blocked or evicted job can't recover on its own:
- claimed before the quota is known, so the runner cannot start
- its single-use registration is already spent when quota is found
- ARC retries every 30 s, recycling every 10 min (0.13.1)
- the job reads assigned, so the 24 h queue timeout never fires
-
Critical jobs starve
No way to reserve capacity for expensive runners:
- each
AutoscalingRunnerSetonly caps itself withmaxRunners - no primitive for "GPU always keeps N slots"
- cheap CPU pods exhaust the quota; big tests stall
- each
-
Listener pods pile up
One always-on listener pod per scale set, running 24/7:
- a pod slot and a pod IP each
- held alive just to long-poll GitHub
- the count tracks runner sets, not job volume
-
Platform team is the bottleneck
Every tenant is a manual checklist:
- namespace, quota, RBAC, scale sets, NetworkPolicies, egress
- per-team setup; every later change is a ticket
What changes with GAG¶
Three capabilities that only pay off together, and what they unlock:
A tenant declares only namespaced resources; the GMC provisions the rest inside a platform-owned quota it cannot write, with no cluster admin after the install (the object set). The cost model runs the utilization argument in numbers, but a benchmark at scale is on the roadmap and not yet done: treat those figures as a model, not a measurement.
GAG vs ARC (scale-set mode)¶
This is not a protocol argument. GAG acquires jobs with the same runner-scale-set protocol ARC uses, shipped as the v2 default, so every row below is additive rather than a different-architecture trade-off. What differs is what surrounds that shared core.
Each ARC cell carries the chart version it was read at and the date it was read. A cell with no stamp asserts nothing; see how to read the ARC column under the table.
| Capability | ARC (scale-set mode) | GitHub Actions Gateway |
|---|---|---|
| Runner-scale-set acquisition (single-acquirer, no fan-out) | yes 0.14.2 · 2026-08-12 |
yes, by default v2 |
| Ephemeral, single-use runner pods | yes 0.14.2 · 2026-08-12 |
yes |
| Custom runner pod template & image | yes 0.14.2 · 2026-08-12 |
yes |
| Bind a runner set to a named GitHub runner group | yes, runnerGroup per scale seta name that resolves to no group fails the reconcile rather than falling back to the installation default 0.14.2 · 2026-08-15 |
v2 runnerGroup, or one defaultRunnerGroup per gatewaythe same failure, moved to admission, so the set is refused before it exists |
Multi-label runner sets (a runs-on array matches) |
yes, since 0.14.0runnerScaleSetLabels, deduplicated against the set name0.14.2 · 2026-08-15 |
v2 every runnerLabel is registered |
| Workers scale to zero between jobs | yes, by default 0.14.2 · 2026-08-12 |
yes, by default |
Safe under a per-tenant ResourceQuota |
claims first, then discovers the quota retries pod creation every 30 s and burns a fresh single-use registration every 10 min, indefinitely; the job reads as assigned, so GitHub's 24 h queue timeout never fires 0.14.2 · 2026-08-12 |
never claims a job it can't place reads quota headroom before claiming, so nothing is spent and the job stays visibly queued until there is room |
| Auto-re-run jobs disrupted mid-flight (eviction / preemption / drain) | no re-run mechanism exists the runner registration is removed and the job is given up on; 0.13.1 recovers the pod, never the job 0.14.2 · 2026-08-12 |
re-runs itself in seconds 15–26 s measured for a preemption or drain; evictions, drains and hand-deleted workers all covered, under a per-run budget |
| Stop claiming jobs when the cluster can't place the worker | acquires every available job unconditionally the seat exists; no cluster state is consulted in it 0.14.2 · 2026-08-12 |
opt-in capacityGateoff by default. Bounds the rate of wasted claims to roughly one per pendingPodDeadline; it does not eliminate the first |
| Bound a stranded worker with no live controller | pass-through onlyactiveDeadlineSeconds is copied from the pod template if an operator wrote one; nothing derives, reconciles or defaults a bound0.14.2 · 2026-08-15 |
maxWorkerLifetime stamped on every workeras its activeDeadlineSeconds, so the kubelet enforces it and workers stay bounded with the controller down |
| Guaranteed floor for critical runner types | no reserved floor ordering works, since priorityClassName reaches the runner pods; minRunners and maxRunners are per scale set, so neither holds a slice another set cannot spend (the two gaps)0.14.2 · 2026-08-12 |
priority tiers per runner set |
| Throttle the rate new workers start (anti-stampede) | no per-set start-rate control its throttles are controller-wide and meter API calls, not worker onset 0.14.2 · 2026-08-12 |
opt-in per-set creation-rate limit for shared-egress onset (NAT, firewall, VPN) |
| Pod Security Admission on tenant namespaces | neither scale-set chart sets one no pod-security.kubernetes.io/* label is templated, so a namespace keeps whatever policy the cluster already applied and the chart asserts nothing about it0.14.2 · 2026-08-15 |
baseline per tenant namespacereconciled rather than opt-in, in-tree PodSecurity, so no Kyverno or Gatekeeper is required |
| Default-deny worker network isolation | ships no NetworkPolicy at allneither scale-set chart templates one, so worker pods reach whatever the cluster default allows, cloud metadata included; the isolation is a separate project you build and keep in sync 0.14.2 · 2026-08-15 |
default-deny, reconciled per tenant DNS and the tenant's own proxy only, re-derived as tenants come and go |
| Two tenants claiming one scale-set name | binds to the existing set instead of failing a set whose name already exists in the runner group is reused, with no check on who owns it, so both namespaces' listeners acquire from one queue 0.14.2 · 2026-08-15 |
admission refuses the pair GitHub-wide; a collision carried in from an older release is reported on the gateway instead |
| Per-tenant dedicated egress IPs | points at a proxy you already run provisions no pool, manages no lifecycle, gives no per-tenant addresses 0.14.2 · 2026-08-12 |
provisioned per-tenant pool, live-validated on GKE 2026-07-13: one distinct, stable per-tenant NAT IP. A stable source IP needs Cilium Egress Gateway or cloud NAT beneath the pool, both specified v2 |
| GitHub App private key kept out of the cluster | the listener reads the key either way the PEM sits in githubConfigSecret and is copied into the generated listener config Secret too; opt-in Azure Key Vault keeps it out of etcd, but the listener still fetches the key itself0.14.2 · 2026-08-12 |
v2 opt-in workloadIdentityan external signer signs the App JWT, so no App key exists in the cluster in any form. Its default githubApp member is the same in-cluster PEM as ARC's |
| Listener footprint at rest | one always-on listener pod per scale set, each holding a pod IP they run in the controller's namespace, so the cost lands on platform pod density rather than the tenant's quota 0.14.2 · 2026-08-12 |
1 shared pod, ~12 KiB per listener session |
| Control-plane availability out of the box | one replica, no PodDisruptionBudgetreplicaCount: 1, with leader election documented as enabled only above one; neither scale-set chart ships a PDB0.14.2 · 2026-08-15 |
two replicas, leader election, PDB, anti-co-location by default, and the lease is released on shutdown, so failover takes seconds rather than a lease timeout |
| Per-tenant utilization metrics | opt-in, per scale set ships commented out; carries a namespace label but nothing aggregates across sets, and no quota-headroom series0.14.2 · 2026-08-12 |
per tenant and per runner set plus a dashboard and 20 alert rules as code |
| Right-size runner resources from measured usage | no feedback loop, so runner resources stay a guessAutoscalingRunnerSet status carries runner counts and a phase, and nothing else0.14.2 · 2026-08-12 |
v2 measured recommendations in RunnerSet status+ opt-in profiles that auto-apply them at pod build ( Binpack/Throughput/NodeShare), a SizingDrift condition, and per-job peak metrics |
| Warn before the runner agent falls below GitHub's minimum | no version check exists scale sets are created with DisableUpdate: true, so a pinned image stays pinned; the Outdated phase tracks spec drift, not agent version0.14.2 · 2026-08-15 |
reported before GitHub enforces it and an image reference naming no version says so rather than passing silently |
| Cross-tenant fleet health view (platform admin) | one sample dashboard, no alert rules nothing aggregates across scale sets or tenants 0.14.2 · 2026-08-12 |
single-pane fleet rollups degraded, egress-stale and quota per gateway, plus a platform dashboard |
| Multiple gateways per namespace | multiple AutoscalingRunnerSets0.14.2 · 2026-08-12 |
v2 multiple scoped gateways per namespace |
| Reusable runner pod templates | template inlined per AutoscalingRunnerSetthe chart takes one template: PodSpec per release; nothing shares it between sets0.14.2 · 2026-08-12 |
v2 shared RunnerTemplatecluster-wide ClusterRunnerTemplate |
Every GAG capability above is available today.
Rows marked v2 need the actions-gateway.com/v2beta1 API, which is where new tenants start.
Which release a capability arrived in is on Features; the version selector at the top of the page switches between what each release shipped.
How to read the ARC column¶
Every ARC cell renders one of two things, and the difference is what it rests on:
- a verdict ( or ) always carries a stamp naming the chart version it was read at and the date it was read;
- unverified () means we believe the claim and have not checked it. No verdict is being asserted, and the cell carries no stamp.
There is no third case. A verdict without its stamp fails our own build, so the page cannot quietly go back to asserting things nobody measured.
ARC moves, and an undated comparison rots into a false one. This page used to carry one blanket measurement note for the whole column, which made a checked claim and an assumed one look identical, and two rows went false at datable releases with nothing to notice it: 0.13.1 (2025-12-23) changed how a quota-blocked pod creation is retried, and 0.14.0 (2026-03-19) added multi-label scale sets, which GAG matched in Q726. Under the per-cell stamp an aging claim degrades to unverified instead of to wrong.
What the current stamps were read against
Every cell above was read against ARC gha-runner-scale-set 0.14.2
(released 2026-05-22), at commit
9bb16ae:
controller source and chart values, not the documentation. The stamps carry
two dates because there were two passes: the original column on
2026-08-12, and the eight rows added on 2026-08-15, when 0.14.2 was
still the newest published chart. The tag is what
the stamp names; the commit is what makes it re-checkable, since a link to a
branch drifts out from under the claim it was cited for. Per-cell evidence,
file by file, is in the competitive analysis.
If you are evaluating on a later ARC than a cell names, re-check that cell rather than trusting it, and tell us what changed.
Two rows with fine print¶
Auto-re-run covers disruption, never failure. Eviction, preemption, a drain and a stray kubectl delete pod all come back, on both acquisition tiers.
A job that failed and a run you cancelled never do.
Nor do workers the reaper took, with one exception: a worker reaped while still Pending is re-run on both tiers once capacity returns.
The full boundary.
Right-sizing is structural, not a feature race. An ephemeral pod runs one job and lives minutes, so stock Vertical Pod Autoscaler cannot size it. The loop only closes inside the controller that builds the pods. Appendix D.7.
Where ARC is ahead¶
Some of it is capability, not only maturity. Measured 2026-08-06. Each gap below is tracked, and what closes it and when is on the public roadmap; the support entitlement is the one we do not plan to match.
- A GitHub Support entitlement, covering ARC installed via the official Helm charts, GitHub Enterprise Server 3.9 and later. GAG has none. Read the scope exclusions before relying on it: Kubernetes orchestration, policy application, and template customization are explicitly out of scope, which is much of what a multi-tenant platform team actually pages about.
containerMode: kubernetes, which runscontainer:andservices:steps as separate pods with a provisioned volume. GAG runs one worker pod per job, so that path is Docker-in-Docker (under Kata, unprivileged) rather than a non-privileged pod-per-step model.- GHES that is actually tested. GAG serves GHES gateways and marks both of its GHES features untested against a real appliance.
The maturity gap is real too: ARC is GA and widely deployed, while GAG's v2 API has only just reached beta (v2beta1, its first stability contract).
That is why the v1 to v2 migration runs on a committed, documented schedule with a working gag-migrate tool.
The discipline is the "won't strand you" signal while the track record accumulates.
One thing is not a differentiator either way: both ride the same Public-Preview runner-scale-set protocol, through the same actions/scaleset client library.
Secure by default¶
Built for shared clusters running other teams' code: the multi-tenant hardening ships as reconciled defaults, not a post-install project.
-
Risk reduction
Untrusted job code is boxed in by default:
baselinePod Security Admission per namespace- Default-deny network: DNS + own proxy only
- App keys read-only; never in env, never cached
- Or opt in to no App key in the cluster at all
- Controller writes confined to tenant namespaces
- Sharing a proxy across namespaces needs the owner's consent
-
Lower operational cost
What you'd hand-build around ARC, reconciled from one CR:
- NetworkPolicies · PSA · RBAC · egress
- No Kyverno/OPA required: in-tree PodSecurity
- Kept in sync as tenants come and go
-
Ready out of the box
Secure by default; looser is an explicit opt-in:
- Default-deny ingress, cluster-only DNS
- Per-tenant egress IPs, mutual-TLS metrics
- Signed images, SBOM, and SLSA provenance
- Three validated worker templates, one
kubectl apply -k
Sandboxing is not the runtimeClassName field¶
Both GAG and ARC can set one. The differentiator is the layer underneath.
Kata bounds the kernel, not the pod network, so cloud metadata still answers from inside the guest. GAG's default-deny NetworkPolicies close that path.
GAG's own CI runs this way, building a kind cluster inside a worker pod with zero privileged: true (how, and what Kata does not buy you).
Not yet a claim about untrusted pull requests, which need the egress work on the roadmap.
Threat model and abuse-response playbooks: Security, Security operations.