Zero-idle self-hosted GitHub Actions runners for multi-tenant Kubernetes.
Warning
Two required upgrade steps, and one rollback that is not symmetric. Read Upgrading below before deploying. Rolling back past the PriorityClass allowlist migration re-arms a cluster-wide apiserver outage, and upgrading correctly does not protect you from it.
Highlights
Measured worker right-sizing. The AGC sizes workers from what jobs actually use, not what the template asks for. Two opt-in profiles: NodeShare divides a node's allocatable capacity, Throughput derives from observed usage. Validated on GKE: a worker took a derived 1500m where its templates asked for 2 and 3 CPU.
A capacity gate that stops claiming jobs the cluster cannot place. A RunnerSet refuses work it has nowhere to run instead of acquiring a job and stranding it. Whether the cluster can grow is now a gateway-level fact, not something each tenant asserts. Opt-in via spec.capacityGate.mode, default Off.
Automatic re-run for interrupted workers. Evicted, preempted, drained, and hand-deleted workers get their job re-run instead of vanishing. The eviction counter's new cause label tells the four apart.
GitHub Enterprise Server gateways address their own appliance. They never did: the GMC set no GITHUB_API_BASE_URL, so token exchange fell back to api.github.com and a GHES tenant failed before acquiring any job. A private CA in front of the appliance is now trusted via spec.githubCABundleRef.
Note
Untested against a real GHES appliance. The flow and the plumbing have test coverage. No GHES host has ever run it.
Upgrading
Two steps, both guarded: skip either and the upgrade stops with a message naming the fix. Full procedure in the upgrade guide, which also documents rollback per component: GMC, AGC, proxy, and worker image.
1. Apply the chart's CRDs. Part of every upgrade of this chart, not a one-off: Helm installs the chart-root crds/ directory on a fresh install only.
helm show crds oci://ghcr.io/actions-gateway/charts/actions-gateway \
--version 1.3.0 | kubectl apply -f -Charts carry no leading v. Images are tagged v1.3.0, charts 1.3.0.
2. If you set priorityClassAllowlist.configMapName, move those names into allowedPriorityClasses. The allowlist is now a cluster-scoped PriorityClassAllowlist CR. It fails closed, so names you do not carry across start being rejected.
Caution
Rolling back past step 2 re-arms a cluster-wide outage, and upgrading correctly does not protect you from it. The allowlist moved off a ConfigMap because a ValidatingAdmissionPolicy paramKind on a core type stays broken for the life of a kube-apiserver process once its bindings go empty, which helm uninstall does. Read Rolling back past this change before reverting.
Three changes that need no action, but will be visible. Each links its own section of the upgrade guide.
- Worker quota accounting now counts native sidecars,
RuntimeClassoverhead, and storage — a tightResourceQuotamay admit fewer workers than it did (#883, #887). New this release: Sizing the platform-ownedResourceQuotaturns runner shapes and concurrency ceilings into the numbers, so the first install is a calculation rather than a guess. - An
EgressProxypool drops theapp: actions-gateway-proxylabel — the pool is recreated once on upgrade (#1117). v2alpha1now warns on every apply — deprecated, not removed; see Deprecations below (#828).
Deprecations
Nothing is removed in this release. Both notices below point at v2.0.0.
v2alpha1 is deprecated, and as of this release the apiserver warns on every apply. It stays served until v2.0.0, which removes it. Moving to v2beta1 is a re-apply rather than a migration tool: Before you upgrade past v2.0.0.
v1alpha1 and Classic are removed at v2.0.0 too. Tenants still on v1alpha1 migrate with gag-migrate: Migrating a tenant to v2. Unlike v2alpha1, v1alpha1 emits no apiserver warning today, so nothing will remind you it is going away.
Everything since v1.2.0
Over 450 commits. These 63 are the ones that ship in the product; build, CI, test, and docs work is left out.
New features (22)
- Per-
RunnerSetworker usage sampling (#741) - Measured sizing recommendations in
RunnerSetstatus (#744) - Opt-in sizing profiles applied at pod-build time (#746)
- Report a
Throughputprofile cancelled by an injected CPU limit (#956)
- Opt-in capacity gate: refuse jobs the cluster cannot place (#902)
- Gate intake on the node autoscaler's own declination (#913)
- Refuse to claim jobs when namespace quota has no headroom (#793)
- Pre-claim quota rung ported to the scale-set tier (#868)
- Recover preempted workers off
PreemptionByScheduler(#972) - Recover drained workers on the deletion mark (#1011)
- Eviction recovery ported to the scale-set tier (#851)
- Bound a worker orphaned while the AGC is down with a lifetime cap (#914)
GitHub Enterprise Server (untested against a real appliance)
- Trust a private CA in front of a GHES appliance (
spec.githubCABundleRef) (#1137)
- Bring-your-own proxy autoscaler via
EgressProxymanagedAutoscaling(#758) - Managed VPA opt-in for the GMC and per-gateway AGC (#761)
- Export
AGCAutoscalingUnavailableas a Prometheus gauge (#763) - Export
GitHubEgressIncompleteas a gauge for fleet alerting (#1038) - Count scale-set poll errors in the shared counter (#878)
- Alias
runner_setontoworker_pods_reaped_totalso scale-set series join (#1020)
Fixes (41)
Scale-set listener
- Deregister scale-set runner records on reap, and sweep unclaimed ones (#1052)
- Re-offer a scale-set job whose runner name will not register (#1048)
- Measure and fix the AGC restart queue replay (#1138)
- Flush the listener's pending message deletes on shutdown (#1148)
- Stop stranding a worker pod on a reclaimed job Secret (#1119)
- Reclaim the per-job JIT-config Secret (#727)
- Latch the capacity gate past the reap with a one-probe slot (#1000)
- One autoscaler loop's two verdicts must not gate intake (#949)
- Give up on assignments GitHub no longer holds (#1108)
- Defer ceiling-blocked jobs instead of spinning on redelivery (#1105)
Shutdown and lifecycle
- Delete broker sessions reliably on
SIGTERM(#739) - Forward
SIGTERMtoRunner.Workerso cancelled jobs report (#747) - Arm the worker termination relay before starting the child (#872)
- Drain in-flight
CONNECTtunnels on proxy shutdown (#748) - Linger for endpoint removal inside the drain budget (#755)
- Give a Running scale-set worker a reap deadline (#824)
- Delete the worker pod of a job the listener abandons (#1032)
- Reap tenant worker pods when a v2 gateway is deleted (#1140)
Quota accounting
- Count native sidecars and
RuntimeClassoverhead in the worker footprint (#883) - Count the storage keys in the worker footprint (#887)
Naming and identity
- Keep a truncated worker pod name valid and unique (#921)
- Bound the derived
RunnerGroupname via one shared helper (#922) - Keep v1 and v2 agent pools apart during coexistence (#915)
- Key a v2 proxy pool solely on its own identity label (#1117)
- A gateway-scoped AGC no longer reconciles v1
RunnerGroups(#1036)
GitHub Enterprise Server (untested against a real appliance)
- Make GHES gateways address their own appliance (#1034)
- Address
GITHUB_API_BASE_URLfor the re-run call (#973) - Retry an evicted job's re-run until GitHub accepts it (#1010)
Admission and API
- Move the PriorityClass VAP
paramKindoff a core type (#958) - Reject a
nodeShare.allocatabledeclaring neither cpu nor memory (#946) - Ratchet the v2beta1 single-
runnerLabelrule onto the field (#847) - Generate the missing
ClusterCapacitydeepcopy (#928) - Dedupe apiserver deprecation warnings to one log line per process (#1021)
Tenant migration (gag-migrate)
- Dual-read the privileged grant across both label domains (#911)
- Retry a transiently unreachable webhook in
gag-migrate --apply(#904) - Make a Docker-in-Docker tenant actually migratable (#856)
GMC and proxy
- Let a rollout restart of a managed Deployment take effect (#1104)
- Derive the AGC
NO_PROXYapiserver exemption from the cluster (#912) - Own every namespaced v1 child so a stripped finalizer cannot leak them (#848)
- Fail AGC startup on an unreadable proxy CA cert (#1133)
- Read classic-tier run identity from the
githubcontext (#967)
API and metric surface
One new CRD: PriorityClassAllowlist, cluster-scoped, the only kind added since v1.2.0. It is also the only CRD the chart ships in its chart-root crds/ directory, which Helm installs on a fresh install and skips on every upgrade. That is why applying CRDs is step 1 of Upgrading above, and why the upgrade hard-fails without it.
Thirteen spec fields and two status fields are added, and nothing is removed — verified against the CRD schemas at both tags, not assembled by hand. The rest are new fields, metrics, and settings this tag publishes for the first time. There is no generated API reference yet, so the CRDs themselves are the authority: kubectl explain, or the signed actions-gateway-crds-v2.yaml attached below.
Sixteen metrics are added and none removed, so existing queries keep working. The shipped Grafana dashboards were rebuilt around them and the PrometheusRule gains an ActionsGatewayGitHubEgressIncomplete alert — both in deploy/monitoring/, described in observability-dashboards.
New spec fields (13)
RunnerSet
spec.sizing.profile:Static,Binpack,Throughput, orNodeShare(defaultStatic)spec.sizing.nodeShare.allocatable/.workersPerNode: the NodeShare envelopespec.sizing.minRequests/.maxRequests/.limitHeadroomPercent: bounds on what a profile may derivespec.capacityGate.mode:OfforObserve(defaultOff)spec.maxWorkerLifetime: cap on a worker pod's lifetime
ActionsGateway
spec.clusterCapacity.nodeAutoscaling:PresentorAbsent(defaultPresent)spec.agcAutoscaling.mode: managed VPA for the AGC,Off,Initial, orRecreatespec.githubCABundleRef.name: ConfigMap holding a private CA for a GHES appliance
EgressProxy
spec.managedAutoscaling: bring-your-own proxy autoscaler opt-in (defaulttrue)
PriorityClassAllowlist (new cluster-scoped kind)
spec.allowedPriorityClasses: the platform allowlist, replacing the watched ConfigMap
New status fields (2)
RunnerSet.status
sizingProfileState:ActiveorAwaitingSamplessizingRecommendation[]: per-containerobservedPeak,observedP95,requests,limits,sampleCount,windowStartTime
New configuration: 4 chart values, 3 tunables, 1 renamed flag
Nothing here is required — every item defaults to the previous behaviour. Listed because none of it is discoverable from the CRDs.
Chart values — the GMC's own managed VPA, off by default
vpa.enabled(defaultfalse)vpa.updateMode(default"Off")vpa.minAllowed/vpa.maxAllowed(default{})
Environment tunables — documented with their defaults in troubleshooting and observability-metrics
PROXY_SHUTDOWN_DRAIN_TIMEOUT(default45s) — the proxy's whole shutdown budget, covering endpoint linger then in-flightCONNECTdrainPROXY_SHUTDOWN_LINGER(default10s,-1sto skip) — how long to wait for endpoint removal before drainingAGC_QUOTA_ADMISSION(default on) — setfalseto turn the namespace-quota rung off AGC-wide
Renamed flag — only affects an install that passes GMC arguments directly; the chart handles it
--priority-class-allowlist-configmap→--priority-class-allowlist-name(#958)
New metrics (16)
All prefixed actions_gateway_, listed here without it. Their remedies and alerting guidance are in observability-metrics.
Worker usage sampling
worker_usage_cpu_peak_coresworker_usage_memory_peak_bytesworker_usage_job_cpu_peak_coresworker_usage_job_memory_peak_bytesworker_usage_jobs_sampled_totalworker_usage_jobs_unsampled_totalworker_usage_poll_errors_total
Scale-set capacity and intake
scaleset_advertised_capacityscaleset_capacity_withheldscaleset_jobs_deferredscaleset_jobs_abandoned_total
Worker recovery
eviction_rerun_failures_totaleviction_recovery_identity_unknown_total
Gateway health
agc_autoscaling_unavailablegithub_egress_incompletebroker_session_leaks_total
New Kubernetes Event reasons (8)
A surface separate from the condition reasons below — these land in kubectl get events, and six have their own section in troubleshooting. None was removed.
WorkerPodLifetimeExceeded— the lifetime cap killed a workerWorkerPodOrphanedRunning— a Running worker was reapedWorkerPodCompletedPending— a worker was reaped while Pending after its job finishedWorkerPodsReapedOnGatewayTeardown— workers cleared as a gateway was deletedWorkerDrainTimeout— a drain did not finish in budgetWaitingForWorkerDrain— deletion is blocked pending drainAssignmentAbandoned— the listener gave up an assignmentWorkerCapacityDeclined— also a condition reason, listed below
New condition reasons (28)
Most are documented with their remedies in observability-metrics and troubleshooting. Five are not yet: SizingWithinRange, SizingDriftDetected, InsufficientSamples, JobsProvisioning, and GitHubEgressAllowed. Until the operator docs catch up, their godoc in api/apiconditions/conditions.go is the reference.
Sizing
SizingProfileActiveSizingProfileOverriddenSizingWithinRangeSizingDriftSizingDriftDetectedInsufficientSamplesCPULimitInjectedNoCPULimitInjected
Capacity and job intake
CapacityAvailableWorkerCapacityDeclinedWorkerCeilingReachedScaleUpDeclinedGateModeUnsupportedAwaitingProbeAwaitingWorkerPodsJobsProvisioningJobProvisionStalledRunnerNameConflict
AGC autoscaling
AGCAutoscalingActiveAGCAutoscalingDisabledAGCAutoscalingUnavailableVPACRDNotInstalled
GHES, egress, and CA trust
GitHubEgressAllowedGitHubEgressIncompleteApplianceRangesRequiredCABundleNotFoundCABundleInvalid
Lifecycle
GatewayTerminating
Validation
This is the first release whose tag is gated by a passing end-to-end run on real infrastructure, rather than advised by one. The gate predates v1.2.0. Getting a verdict out of it took five candidates: rc.1 through rc.3 aborted before finishing, rc.4 passed but never caught a live worker pod, and v1.3.0-rc.5 passed with the pod-level check rc.4 missed. rc.5 needed its own verdict regardless — it carries product changes past rc.4, so rc.4's pass does not transfer.
73/73 e2e specs on Kata microVM workers, on live GKE — the four legs, and what none of them assert
Run against the signed release artifacts rather than a build (run):
- The full e2e matrix on gateway-provisioned runners, 73/73 specs, on Kata microVM-isolated workers
- Both sizing profiles actuating on real worker pods, with the derived value confirmed at the pod:
1500mwhere the templates asked for 2 and 3 CPU - The signed v2 CRD artifact downloaded, signature-verified, applied, and all five CRDs registered
- Teardown back to zero nodes
That is what the gate itself asserts. It does not assert the capacity gate, the namespace-quota rung, or anything GHES — the run targets github.com, and it builds an autoscaling cluster with headroom rather than a constrained one. Those rest on the unit, envtest, and e2e suites instead — several of which grew this cycle to reach a live cluster, listed under Testing and internal quality below.
Every PR now upgrades from the last released chart, not just HEAD to HEAD (#1008). It immediately caught what became step 1 above: #958 shipped a CRD into chart-root crds/, breaking every v1.2.0 upgrade while CI stayed green.
This tag adds no product code beyond that candidate.
Full changelog: v1.2.0...v1.3.0
Project and tooling
Documentation is now versioned. Since this release cycle the site publishes a build per stable tag, so these notes link 1.3.0 pages rather than whatever main says today, and an operator on an older release reads that release's docs. Older versions were seeded, so 1.0.0 through 1.3.0 are all live.
Three operator guides are new, and one grew by a third. Worker right-sizing and Sizing the platform-owned ResourceQuota are linked from their features above. Node shutdown budgets across platforms is reference material with no feature of its own: terminationGracePeriodSeconds is a request, not a guarantee, and it collects what each platform actually grants — the proxy asks for 60s and gets 15 on a GKE Spot node. Troubleshooting gained 36 sections, most of them the remedy for a condition or event reason listed above.
The rest is contributor-facing, listed because a release is also a claim about how the project is built.
Release, docs-site, CI, and tooling (25)
Documentation site
- Versioned docs via mike — stable default, dev opt-in (#768)
- Publish docs from stable release tags (#766)
- Derive the site announce bar from the git tags (#775)
- Gate the release on the docs-site announce bar naming the tag (#801)
- Publish the backlog on the dev docs site, filterable (#1059)
- Gate site-side link resolution, fix 13 site-only 404s (#1062)
Release process
- Release delta report + scope-ledger convention (#1058)
- Surface operator caveats in the pre-flight, don't rely on memory (#992)
- A status file and a sentinel for the release gate (#1161)
- Relay e2e spec progress into the release-validation gate (#1159)
- Make the release gate able to fail on a dead sizing profile (#867)
Test and CI speed
- Show live spec progress and render the JUnit report (#1152)
- Report unit-run progress from the
go test -jsonstream (#1162) - Compile the e2e images' dependency tree once, and cache it across runs (#870)
- Overlap the e2e runner disk cleanup with job setup (#871)
- Cut the suite's serial tail (#874)
- Measure coverage in one workspace-wide invocation (#952)
- Run workspace unit tests as one multi-module invocation (#722)
- Change-scope local golangci-lint to the modules a diff affects (#724)
Repository tooling
- Allocate Q-IDs from a ref claim, not a counter line (#823)
- Merge driver resolving STATUS.md Queue rows by ID (#879)
- Repo-wide leftover conflict-marker gate (#732)
- Auto-rebase stranded Dependabot Go PRs (#827)
- Parse workflow YAML with a real parser, in a new devtools/ module (#1114)
- Add nolintlint + a ratcheted funlen gate (#771)
Testing and internal quality (13)
Coverage that reaches real infrastructure rather than a fake, and the consolidation work behind it. 52 test and 15 refactor commits landed in this window; these are the ones that changed what the suites can observe.
Tests that drive a live cluster
- Assert the capacity gate's autoscaler matcher against a live cluster-autoscaler (#929)
- Extend the live-autoscaler drift gate to Karpenter (#1024)
- Gate day-2
helm upgradeover a live release (#935) - Validate the v1→v2 migration live on GKE (#909)
- Prove a quota-blocked job redelivers to a sibling gateway (#1028)
- Scale-set disruption recovery under the chart's real RBAC, and the ordering fix it caught (#1018)
- Measure the node-drain path on both tiers (#895)
- Execute the cosign download path and
verify-releaseitself (#1145)
Consolidation
- Code-restraint sweep: trim narrative comments, dedup helpers, drop dead weight (#1013)
- Merge the twin worker-quota evaluators into one shared core (#1022)
- Decompose the GMC
main()and AGCrun()god functions (#765) - Unify the broker/scale-set error taxonomy in
githubapp/httpx(#794) - Share the version-neutral v2 API code, gate the identical remainder (#760)
Security
No security advisory accompanies this release, and no fix here is embargoed or backported. What it carries is dependency maintenance: a security bump of gRPC to v1.82.1 and, in the build-tools module, x/net to v0.56.0 (#749), plus six routine dependency-group updates.
Three fixes below harden handling of credentials and trust material rather than patching a reported vulnerability: an AGC that now fails startup on an unreadable proxy CA cert (#1133), and two that stop a per-job JIT-config Secret being stranded or reused (#727, #1119).
Every pull request — with no path filter, deliberately — runs govulncheck across the workspace and a Trivy scan of the images, and main re-runs both on every push.
Container images
Pin these immutable multi-arch index digests rather than the floating v1.3.0 tag. Each index serves both linux/amd64 and linux/arm64, so one pinned digest schedules on either.
- gmc —
ghcr.io/actions-gateway/gmc@sha256:e29c56f3bcc7bbaeace702a6011469b9ed7995d80abb1993b454c369b868ca3a - agc —
ghcr.io/actions-gateway/agc@sha256:326c5498618c7bc3ac1f31d834f355dd178d95003071c704a4a17a3c28efcc33 - proxy —
ghcr.io/actions-gateway/proxy@sha256:8f1b832bd5b98cdf7d13b306c6b563197bb55b6602782081bdd4a0cb4706c90c - worker —
ghcr.io/actions-gateway/worker@sha256:3954b73f158f1f7d41a4d8b8362c9f3b916fd8b447c83f7d84b8356f9c5ddf17 - wrapper —
ghcr.io/actions-gateway/wrapper@sha256:f0b5eabb1e9c4aef5c6d26e84da3c3bcd7f3e27500990d4dd90f15df74d23d8d
The chart's digest fields ship empty on purpose — an unconfigured install fails closed rather than running a floating tag. These are the values to put in them.
Verifying this release
We sign every image and both charts.
make verify-release VERSION=v1.3.0The signed v2 CRD manifest and SHA256SUMS ship as release assets with detached cosign bundles, and the publish workflow attests build provenance.