Kudzu is a small Go service that decides whether it's safe to deploy a given service to a given environment, and lets the GitHub Enterprise merge queue enforce that decision.
In a trunk / merge-queue setup, merging is deploying. So the lever for blocking
deploys is the merge queue's required status checks. A merge_group-triggered
GitHub Actions job asks Kudzu is it safe to deploy?; if the answer is no, the job
exits non-zero, the required check fails, and GitHub ejects the PR from the queue.
PR queued ──> merge_group event ──> "Kudzu Gate" job ──GET /v1/gate──> Kudzu
│ │
allowed? ───┤ open / frozen / tripped
exit 0 (merge) ◄── open
exit 1 (eject) ◄── frozen | tripped
A gate is keyed by (service, environment) and has one effective state, computed
with the precedence tripped > manual freeze > scheduled freeze > open:
| State | Meaning | Set by |
|---|---|---|
open |
Deploys/merges allowed (allowed: true). |
default |
frozen |
Temporarily blocked. | manual freeze, or an active scheduled window |
tripped |
A deploy failed and the circuit breaker fired. Sticky until reset. | POST /v1/deploy-result {status:"failed"} |
Any non-open state ejects PRs from the queue (the chosen always-eject behaviour).
Read (token optional, controlled by KUDZU_REQUIRE_READ_AUTH):
| Method & path | Purpose |
|---|---|
GET /v1/gate?service=&env= |
Effective gate for one service/env ({state, allowed, reason, source, since, actor, expires_at}). expires_at is set when the state lapses on its own — a freeze TTL or the end of the active schedule window — and absent for a trip or an open-ended freeze. The merge-queue check reads .allowed. |
GET /v1/gates |
All known gates (dashboard). |
GET /v1/schedules |
Every freeze window Kudzu knows about, across all gates. Each entry carries its service/env, an active flag, and since/until bounds while active. Pass ?service=&env= to narrow it to one gate. |
GET / and GET /ui |
The gate board — a read-only HTML dashboard (see below). |
GET /docs |
This API, documented (see below). |
GET /openapi.json |
The same API as an OpenAPI 3.1 document. |
GET /healthz / GET /readyz / GET /metrics |
Liveness / readiness (pings Redis) / Prometheus. |
GET /versionz |
Build info for the running binary: module version, VCS revision and time, dirty flag, Go version, GOOS/GOARCH. Read straight from the Go build info, so nothing has to be stamped in at build time. |
Write (require a bearer token from KUDZU_WRITE_TOKENS):
| Method & path | Body |
|---|---|
POST /v1/gate/freeze |
{service, env, reason, actor, ttl_seconds?} |
POST /v1/gate/unfreeze |
{service, env, actor} — clears a manual freeze and resets a trip |
POST /v1/deploy-result |
{service, env, status:"success"|"failed", repo:"owner/name", base?, sha?, run_url?, actor?} |
POST /v1/schedules |
{service, env, reason, cron, duration_seconds} (recurring) or {…, start, end} (one-off) |
DELETE /v1/schedules/{id}?service=&env= |
remove a window |
GET / (or /ui) serves a read-only dashboard of every known gate. It leads
with the one thing you open it for — how many gates are blocked right now —
then lists the blocked gates in a table — service, environment, state, source,
reason, actor, age — above a quieter service/environment table of the open ones.
Anything that lifts by itself (a freeze with a ttl_seconds, or a scheduled
window) shows when. It refreshes itself every 15 seconds.
The page is a single html/template embedded in the binary: no separate
frontend, no build step, no external assets or fonts, so it works offline and
behind a strict CSP. It is read-only — freezing and unfreezing stay on the
authenticated API.
It is served under the same auth rules as the other read endpoints, so with
KUDZU_REQUIRE_READ_AUTH=true a browser cannot load it (browsers do not send
bearer tokens). If you need both, put Kudzu behind an authenticating
ingress/SSO layer and leave KUDZU_REQUIRE_READ_AUTH off.
GET /docs renders the API — endpoints grouped by area, parameters, request
bodies, response codes and every schema's fields. GET /openapi.json serves the
same content as an OpenAPI 3.1 document, for client generators, API clients and
contract tests. Both ship inside the binary; the page is rendered from the spec,
so the two cannot disagree.
Keeping the spec honest is a test problem, and three tests do the work:
- every route in the router's table is documented, and nothing is documented that is not served;
- every write endpoint declares a bearer token, and no read endpoint claims to;
- each schema's fields match the JSON tags of the Go type it describes, by reflection — so renaming a field without touching the spec fails the build.
Prose (summaries, descriptions, response-code meanings) is not checked. Treat it as documentation, not a contract.
POST /v1/deploy-result {status:"failed"} increments a consecutive-failure
counter; once it reaches BREAKER_FAILURE_THRESHOLD (default 1) the gate trips.
A success resets the counter. The trip is sticky until POST /v1/gate/unfreeze.
If a GitHub App is configured (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID,
GITHUB_APP_PRIVATE_KEY[_FILE]), a trip also lists the repo's
gh-readonly-queue/<base>/* branches and posts a failure commit status
(context = REQUIRED_CHECK_CONTEXT) to each head SHA, evicting in-flight merge
groups immediately. Without the App, a trip is simply caught by the next
merge_group gate check. The App needs statuses:write and contents:read.
| Var | Default | Notes |
|---|---|---|
KUDZU_ADDR |
:8080 |
listen address |
REDIS_ADDR / REDIS_PASSWORD / REDIS_DB |
localhost:6379 / – / 0 |
state store |
KUDZU_WRITE_TOKENS |
– | comma-separated bearer tokens for write endpoints (fail-closed if empty) |
KUDZU_REQUIRE_READ_AUTH |
false |
also require a token on reads |
BREAKER_FAILURE_THRESHOLD |
1 |
consecutive failures that trip the breaker |
REQUIRED_CHECK_CONTEXT |
kudzu-gate |
commit-status context used for eviction; must match the required check name |
GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID |
– | enable eviction |
GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE |
– | PEM inline or file path |
GITHUB_API_BASE_URL |
https://api.github.com/ |
set to https://HOST/api/v3/ for GHES |
- Enable the merge queue on the trunk branch ruleset.
- Add
.github/workflows/merge-queue-gate.yml(seegithub/examples) and make its job a required status check — its name must equalREQUIRED_CHECK_CONTEXT. - Add the deploy-result hook to your deploy workflow (example).
- Set repo variable
KUDZU_URLand (if read auth is on) secretKUDZU_TOKEN.
make up # Kudzu + Redis via docker compose on :8080
make test # unit tests
make run # run against a local Redis (REDIS_ADDR=localhost:6379)Example session (token local-dev-token):
curl localhost:8080/v1/gate?service=orders\&env=production
curl -X POST localhost:8080/v1/gate/freeze -H 'authorization: Bearer local-dev-token' \
-H 'content-type: application/json' \
-d '{"service":"orders","env":"production","reason":"incident","actor":"you"}'A Helm chart lives in deploy/helm/kudzu. Create the secret
it references first:
kubectl create secret generic kudzu-secrets \
--from-literal=write-tokens=tokenA,tokenB \
--from-literal=redis-password=... \
--from-literal=github-app-id=123456 \
--from-literal=github-app-installation-id=7891011 \
--from-file=github-app-private-key=app.pem
helm upgrade --install kudzu deploy/helm/kudzu \
--set image.tag=<tag> \
--set config.redis.addr=redis-master:6379 \
--set github.evictionEnabled=trueEach published release also pushes the packaged chart to GHCR as an OCI
artifact, so you can install a released version without checking out the repo
(the chart's default image.tag matches the release):
helm upgrade --install kudzu oci://ghcr.io/cuotos/charts/kudzu --version <X.Y.Z> \
--set config.redis.addr=redis-master:6379 \
--set github.evictionEnabled=trueThe service is stateless (state lives in Redis) and runs ≥2 replicas with
liveness/readiness probes, a Prometheus ServiceMonitor, and an optional HPA
and NetworkPolicy.
The chart bundles a single-node Redis (redis.enabled: true, the default) to
get you running quickly. It has no persistence — a Redis restart clears all
gate state (freezes, trips, schedules) — so treat it as convenient, not durable.
For production, disable the bundled Redis and point Kudzu at an external/HA (and ideally persistent) Redis:
helm upgrade --install kudzu oci://ghcr.io/cuotos/charts/kudzu --version <X.Y.Z> \
--set redis.enabled=false \
--set config.redis.addr=my-redis-master:6379When redis.enabled is true, REDIS_ADDR is derived from the bundled Service
and config.redis.addr is ignored.
cmd/kudzu service entrypoint (config, wiring, graceful shutdown)
internal/gate domain: state model, effective-state rules, Service, ports
internal/schedule freeze-window evaluation (cron + duration / one-off)
internal/store gate.Store: redis (prod) and memory (tests/local)
internal/github GitHub App evicter (gate.Evicter)
internal/httpapi router, handlers, bearer auth, logging/metrics middleware, UI
internal/observability Prometheus metrics + live gate-state collector
deploy Dockerfile + Helm chart
github composite "Kudzu Gate" action + example workflows