A one-line bump to a Flux resource — a HelmRelease chart version, an
OCIRepository tag, a Kustomization edit — can add, remove, or mutate dozens
of rendered Kubernetes resources. The git diff shows the line; it doesn't show
that. konflate does: it renders the Flux cluster at the PR's merge-base and
at its head using flate, diffs
the two, and presents the result as a GitHub-style review UI with the blast
radius, image changes, render failures, and heuristic danger flags surfaced up
front.
-
konflate lists the open pull requests for one repository from its forge (GitHub / GitLab / Forgejo, cloud or self-hosted) using the native Go SDK.
-
For each PR it clones the repo, computes
merge-base(head, target), and extracts both trees (so changes that landed on the base branch after the PR opened don't pollute the diff — exactly how GitHub computes a PR diff). -
It renders the Flux cluster at both trees with flate (two orchestrators sharing one source cache) and pairs the outputs into resource-level changes.
-
It produces a
DiffResult: per-resource YAML diffs with server-side syntax highlighting (with word-level intra-line highlighting and expandable folded context), a navigation tree (HelmRelease/Kustomization→ kind → resource), plus the review signals — impact (blast radius), image changes, render failures, and danger lint (data-loss, privilege, RBAC, availability, and immutable-field changes — a StatefulSetvolumeClaimTemplatesbump, a workload selector edit, a PVC storage-class swap or shrink, aroleRefchange — that read like ordinary diffs but wedge the apply with "field is immutable" until the resource is recreated). The lint also reasons about what Flux will actually do on merge: changes under a suspended Kustomization/HelmRelease (they won't roll out), a PR flippingspec.suspend(resuming applies everything accumulated while parked, at once), and removal semantics underprune— a pruning Kustomization really deletes the resource in-cluster, a non-pruning one orphans it, silently left running.The image changes signal lists the
containerandinitContainerimage references that changed across every rendered workload — so it captures whatever the charts and kustomizations actually deploy: app images, sidecars, and controller images pulled in by OCI Helm charts alike, each keyed to the workloads that reference it. (A chart's own OCI artifact version bump shows up as a changedHelmRelease/OCIRepositoryresource in the diff; its effect on the running images surfaces here.) -
The three-panel web UI renders it — PRs on the left, changed resources in the middle, the diff on the right — and updates live over a websocket as renders complete. Diff rendering runs in a bounded, per-PR-coalescing job queue.
konflate is read-only toward your forge: it never writes comments, statuses, or checks. PRs refresh automatically — each open PR re-renders on a configurable interval (the missed-webhook backstop), and an authenticated CI push or a verified inbound webhook updates one immediately. There is no manual refresh trigger, so a public instance exposes no unauthenticated way to make it do work.
docker run --rm -p 8080:8080 \
-e KONFLATE_REPO='github://onedr0p/home-ops' \
-e KONFLATE_TOKEN="$GITHUB_TOKEN" \
ghcr.io/home-operations/konflate:rollingOpen http://localhost:8080; konflate lists the open PRs and renders them. The token is optional — without one it works against public repositories, just with the forge's lower unauthenticated API rate limit (see Authentication).
konflate publishes an OCI Helm chart to oci://ghcr.io/home-operations/charts/konflate:
helm install konflate oci://ghcr.io/home-operations/charts/konflate \
--namespace konflate --create-namespace \
--set config.repo='github://onedr0p/home-ops' \
--set secret.token="$GITHUB_TOKEN"Every value is documented in the chart's generated README,
charts/konflate/README.md, built from
values.yaml — which also ships a
values.schema.json for editor
autocompletion and helm install-time validation. The config.* and secret.*
chart values map onto the KONFLATE_* environment variables in
Configuration below.
All configuration is via environment variables.
| Variable | Default | Description |
|---|---|---|
KONFLATE_REPO |
(required) | The repository, as a forge URI, e.g. github://owner/repo. |
KONFLATE_TOKEN |
(none) | Forge API token. Optional — read-only auth that raises the API rate limit and unlocks private repos. Gates no feature (see Authentication). |
KONFLATE_CLUSTER_PATH |
(repo root) | Directory flate renders from (the GitRepository root that Flux spec.path resolves against). Empty = repo root — correct for the standard ./kubernetes/... layout. |
KONFLATE_PR_FILTER_EXPR |
true |
CEL expression deciding which PRs konflate renders (and shows by default). PRs it excludes are still listed — greyed, under a "hidden" pill — but never rendered. Evaluates against a pr variable (fields: number, title, author, draft, open, merged, state, fork, headRef, headSha, baseRef, url, createdAt, and labels as [{name, color}]) and must return a boolean; compiled and type-checked at startup. Empty defaults to true — every open PR. Forks are gated separately by KONFLATE_RENDER_FORK_PRS, so editing this can't accidentally enable them — see Filtering & forks. Example: pr.labels.exists(l, l.name == "cluster/production") && !pr.draft. |
KONFLATE_RENDER_FORK_PRS |
false |
Render fork PRs. true and the filter admits them. Kept separate from the filter so editing the expression can't silently enable forks. |
KONFLATE_WEBHOOK_SECRET |
(none) | Secret for verifying inbound webhooks. Set it to enable POST /hooks; unset ⇒ 501. |
KONFLATE_PUSH_TOKEN |
(none) | Bearer token for the CI push endpoint. Set it to enable POST /api/prs/{n}/refresh; unset ⇒ 501. |
KONFLATE_PORT |
8080 |
Main HTTP port (UI, API, websocket, webhook). |
KONFLATE_METRICS_ADDR |
:9090 |
Listen address for the separate metrics server. Bind to loopback to keep it private. |
KONFLATE_LOG_LEVEL |
info |
debug, info, warn, or error. |
KONFLATE_LOG_FORMAT |
json |
json or text. |
KONFLATE_CACHE_DIR |
XDG cache | flate source cache (Helm charts, OCI layers, git) and konflate's rendered diffs (a state/ subdir). Persist it across restarts so open PRs reload instantly and the merged shelf survives. |
KONFLATE_CLONE_DIR |
$TMPDIR |
Base directory for ephemeral per-diff clones (cleaned up after each render). |
KONFLATE_MAX_DIFF_CONC |
(auto) | Max concurrent diff renders. Unset/0 auto-derives from the CPU budget (GOMAXPROCS, capped at 4); higher = more throughput, more memory. |
KONFLATE_REFRESH_INTERVAL |
30m |
Go duration. Each open PR re-renders if no webhook refreshed it within this window, and the open-PR list is reconciled this often — the missed-webhook backstop. 0 disables periodic refresh entirely (inbound webhooks/pushes become the only triggers); a positive value is floored to 1m so a tiny interval can't hot-loop the forge API. |
KONFLATE_CLOSED_PR_MAX |
25 |
Max merged PRs kept on the "recently merged" shelf below the open list (most-recent win). 0 disables the count cap. Each retained PR holds its rendered diff, so this bounds disk + memory; with KONFLATE_CLOSED_PR_TTL=0 too (and a persistent cache volume) merged diffs are kept forever — durable permalinks. |
KONFLATE_CLOSED_PR_TTL |
336h |
How long a merged PR stays on the shelf before pruning (Go duration, e.g. 720h = 30d). 0 disables the age cap. The shelf is persisted under KONFLATE_CACHE_DIR, so it survives a restart when that volume is durable. |
KONFLATE_MERGE_COMMAND |
(per forge) | Go text/template for the Copy to merge command shown on the review screen and PR list (konflate never runs it — you paste it into your own shell). Empty = the forge default (gh/glab/tea). Only .Number and .Repo are exposed, both shell-safe. |
Merged PRs move to a collapsed Recently merged group below the open list (their diff is frozen at merge time); abandoned (closed-unmerged) PRs are dropped immediately.
konflate decides what to render with two independent gates, AND-ed together:
KONFLATE_PR_FILTER_EXPR— a CEL boolean over theprfields above (compiled and type-checked at startup; a malformed expression fails fast) that says which PRs to render. Defaulttrue(every open PR).KONFLATE_RENDER_FORK_PRS— a plain on/off switch for forks, off by default.
A PR renders only if the expression admits it and (it isn't a fork, or fork rendering is on). Anything excluded by either gate is still tracked and listed — greyed, under a "hidden" pill, out of the default view — but never rendered, so its code never runs.
Forks are off by default because rendering one runs untrusted external code through flate — it fetches whatever sources the fork declares (SSRF, resource exhaustion). Crucially the fork gate is separate from the expression, so narrowing the filter for unrelated reasons can never accidentally enable forks. Narrow what renders with the expression:
# only PRs labelled for the production cluster, excluding drafts
KONFLATE_PR_FILTER_EXPR='pr.labels.exists(l, l.name == "cluster/production") && !pr.draft'
# everything except Renovate's PRs, on the main branch
KONFLATE_PR_FILTER_EXPR='pr.author != "renovate[bot]" && pr.baseRef == "main"'To render fork PRs, flip the gate on — and ideally still scope which ones with the expression:
# ⚠️ renders untrusted external code from every fork the filter admits
KONFLATE_RENDER_FORK_PRS=true
# forks only from one trusted contributor (gate on + an author filter)
KONFLATE_RENDER_FORK_PRS=true
KONFLATE_PR_FILTER_EXPR='!pr.fork || pr.author == "trusted-contributor"'Keep the fork gate off on any public or shared instance.
A konflate instance tracks one repository and renders one cluster — the Flux
entry point at KONFLATE_CLUSTER_PATH (the repo root by default). It has no
built-in notion of several clusters living in one repo.
So for a monorepo that holds more than one cluster, run one konflate per cluster and scope each with the PR filter (and, for a folder-per-cluster layout, its cluster path). The usual convention is a per-cluster PR label:
# the production instance
KONFLATE_CLUSTER_PATH='kubernetes/clusters/production' # render this cluster (folder-per-cluster)
KONFLATE_PR_FILTER_EXPR='pr.labels.exists(l, l.name == "cluster/production")'# the staging instance
KONFLATE_CLUSTER_PATH='kubernetes/clusters/staging'
KONFLATE_PR_FILTER_EXPR='pr.labels.exists(l, l.name == "cluster/staging")'The filter is what keeps each instance's list to its own cluster — without it
every instance would list every PR and render an empty diff for the clusters a
PR doesn't touch. (Branch-per-cluster instead? Filter on the target branch, e.g.
pr.baseRef == "production".) With the Helm chart these are config.clusterPath
and config.prFilterExpr — one release per cluster.
Give each instance its own KONFLATE_CACHE_DIR — don't point two at the same
volume. Even for the same repo, konflate guards the bare mirror and the persisted
diff state with in-process locks only, so two processes sharing them would race
(the same reason konflate runs as a single instance). A separate PVC per release,
or a distinct subPath of one, keeps them isolated.
First-class multi-cluster support — one instance spanning a folder- or branch-per-cluster monorepo — is tracked in #54.
KONFLATE_REPO encodes the forge type, the (optional) self-hosted host, and the
repository path in one unambiguous value:
scheme://[host]/path
- scheme —
github,gitlab, orforgejo. - host — a self-hosted instance (
hostorhost:port). Omit entirely for the cloud SaaS (github.com / gitlab.com / codeberg.org). - path —
owner/repo, orgroup[/subgroup]/repofor GitLab.
| Forge URI | Resolves to |
|---|---|
github://onedr0p/home-ops |
GitHub cloud |
github://ghe.example.com/team/cluster |
GitHub Enterprise Server |
gitlab://group/subgroup/cluster |
GitLab cloud (gitlab.com) |
gitlab://gl.example.com/group/cluster |
self-hosted GitLab |
forgejo://me/home-ops |
Forgejo cloud (codeberg.org) |
forgejo://git.example.com/me/home-ops |
self-hosted Forgejo |
The forge token (KONFLATE_TOKEN) is optional and used only for forge read
auth — it raises the API rate limit and unlocks private repositories. It gates
no behaviour: konflate works the same with or without it.
The inbound endpoints are gated solely by their own secret, independent of the token:
| Endpoint | Enabled when… | Otherwise |
|---|---|---|
POST /hooks |
KONFLATE_WEBHOOK_SECRET set |
501 |
POST /api/prs/{n}/refresh |
KONFLATE_PUSH_TOKEN set |
501 |
So a public, secret-less instance — even one pointed at a repo you don't own —
exposes no way to make it do work: there is no manual-refresh endpoint, and the
webhook/push endpoints return 501 until you set their secret. PRs still stay
current via the per-PR auto-refresh (see Triggering
re-renders).
Main server (KONFLATE_PORT):
| Method & path | Purpose |
|---|---|
GET / |
The web UI. |
GET /api/prs |
Tracked PRs and each one's diff-job status. |
GET /api/prs/{n}/diff |
A PR's rendered diff (200 ready/error, 202 still rendering). |
GET /api/prs/{n}/summary |
The diff's headline facts only — impact, cautions, image bumps, failures — without the per-resource render. JSON by default; Accept: text/markdown returns a paste-ready comment body (?forge=github for [!CAUTION] admonitions, else plain). Ready ⇒ 200; while rendering, JSON returns 202 and Markdown returns 503 + Retry-After (so curl --retry waits it out). Every response carries an X-Konflate-Render-Status header (ok/failures/error/pending) for CI gating — see below. |
POST /api/prs/{n}/refresh |
Auth (bearer KONFLATE_PUSH_TOKEN) — re-render one PR. 501 unless the token is set. |
POST /hooks |
Verified forge webhook — re-renders the affected PR. 501 unless the secret is set. |
GET /ws |
Websocket stream of diff-job status events. |
GET /healthz, GET /readyz |
Liveness / readiness. |
Operational server (KONFLATE_METRICS_ADDR): GET /metrics.
The summary endpoint doubles as a PR-comment source for CI — ask for Markdown
and post it straight back (one comment, edited in place on each push). While a
render is in flight it answers 503 + Retry-After, so curl --retry waits it
out with no polling loop of your own:
curl -fsS --retry 10 --retry-delay 3 -H 'Accept: text/markdown' \
"https://konflate.example.com/api/prs/${PR_NUMBER}/summary" \
| gh pr comment "${PR_NUMBER}" --body-file - --edit-lastThe comment carries a <!-- konflate:pr-N --> marker so a poster can find and
update its own comment. konflate keeps PRs rendered on its own (webhook /
interval), so the diff is normally ready by the time CI asks anyway.
Gating the workflow on the render. Every summary response (Markdown or JSON)
carries an X-Konflate-Render-Status header, so the same request that fetches
the comment also tells CI whether to pass:
| Value | Meaning |
|---|---|
ok |
Rendered cleanly. |
failures |
Rendered, but one or more resources failed to render (the diff is shown, minus those). |
error |
The render itself errored — no diff produced. |
pending |
Still rendering. The Markdown path 503s until a terminal verdict, so --retry never leaves you here. |
status=$(curl -fsS --retry 10 --retry-delay 3 -H 'Accept: text/markdown' \
-o body.md -w '%header{x-konflate-render-status}' \
"https://konflate.example.com/api/prs/${PR_NUMBER}/summary")
gh pr comment "${PR_NUMBER}" --body-file body.md --edit-last # always post what rendered
[ "$status" = ok ] || { echo "::error::konflate render: ${status}"; exit 1; }The check above blocks on both error and failures; relax it to
case "$status" in ok | failures) ;; *) exit 1 ;; esac if a partial render
shouldn't fail the PR. (%header{} needs curl ≥ 8.3; older curl can -D - and
grep the header instead.)
konflate lists and renders PRs at startup; after that it keeps them current itself, with two optional triggers for immediacy:
Automatically (always on) — every open PR re-renders once its last render is
older than KONFLATE_REFRESH_INTERVAL (default 30m), and the open-PR list is
reconciled on the same interval to pick up newly opened and merged PRs. This is
the missed-webhook backstop and needs no configuration. (Merged PRs are frozen
and never auto-refresh.) A webhook or push refreshing a PR resets its clock, so
a busy PR isn't needlessly re-rendered and load staggers across PRs.
From a CI workflow (KONFLATE_PUSH_TOKEN set) — re-render a PR immediately
after you push to it:
curl -fsS -X POST \
-H "Authorization: Bearer ${KONFLATE_PUSH_TOKEN}" \
https://konflate.example.com/api/prs/${PR_NUMBER}/refreshNative webhooks (authenticated mode, KONFLATE_WEBHOOK_SECRET set) — point a
forge webhook at https://konflate.example.com/hooks with the shared secret.
konflate verifies the signature with the per-forge scheme automatically:
| Forge | Header | Verification |
|---|---|---|
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256, sha256= + hex |
| Forgejo | X-Gitea-Signature |
HMAC-SHA256, bare hex |
| GitLab | X-Gitlab-Token |
constant-time compare of the secret |
Rate limiting is intentionally not built in — put konflate behind your reverse proxy / ingress and rate-limit there.
Served on the separate operational port (keep it off your public ingress):
| Metric | Type | Meaning |
|---|---|---|
konflate_diff_jobs_total |
counter | Completed renders, by result. |
konflate_diff_duration_seconds |
histogram | Render wall-clock (clone + 2 renders). |
konflate_diff_queue_depth |
gauge | PRs queued or rendering. |
konflate_pull_requests |
gauge | Open PRs tracked. |
konflate_http_requests_total |
counter | Main-server requests, by status class. |
Plus the standard Go runtime and process collectors.
mise is the single source of truth for the toolchain —
both the go and node versions are pinned in .mise/config.toml, shared with
go.mod and the container build, and grouped (non-automerged) in Renovate — and
it is the task runner. The UI is Svelte 5 + Vite +
Tailwind v4 (all latest), built into internal/web/dist and embedded via
go:embed. All UI dependencies are declared in internal/web/package.json.
mise run ui-install # install UI deps (npm ci)
mise run ui-typecheck # svelte-check
mise run ui-build # build the UI bundles into internal/web/dist
mise run ui-test # Playwright headless-Chromium UI tests
mise run build # go build ./...
mise run test # unit + server tests (race-enabled in CI)
mise run lint # golangci-lint
mise run generate # regenerate the chart README + values.schema.json
mise run helm-lint # lint the Helm chart
mise run helm-unittest # helm-unittest template tests
mise run dev # run konflate locally (set KONFLATE_REPO first)Tests come in four tiers:
-
Unit — pure logic (config, diff render/lint/impact, engine pairing, webhook crypto, provider mapping) plus the HTTP server and the websocket hub driven over real sockets with a fake engine. Run by
mise run test. -
UI (
mise run ui-test) — Playwright drives the real built UI in headless Chromium with the API and websocket stubbed by a fixture, asserting the 3-panel render, filtering, and split view. Runs in CI. -
Chart —
helm lint,helm-unittesttemplate tests (image/digest, secret conditionals, verbatimmergeCommand, conditional env), and a kind-backedhelm testsmoke check that installs the chart and probes/readyz(mise run helm-test). Run in CI. -
Integration (
-tags integration, env-gated) — renders a real PR with the real engine; skips unlessKONFLATE_REPO+KONFLATE_INTEGRATION_PRare set:KONFLATE_REPO=github://owner/repo KONFLATE_INTEGRATION_PR=123 \ mise run test-integration
konflate is designed to be safe to expose internally, and to leak nothing even if it were public:
- Read-only toward forges. It never writes comments, statuses, checks, or any other forge state.
- No secret leakage. Renders run with flate's missing-secrets allowance, so
Kubernetes
Secretvalues are never materialized; no API type or log line carries the forge token. - XSS-safe rendering. Only chroma-produced, HTML-escaped token spans are
inserted as markup; every other value is set as text. A strict
Content-Security-Policy(script-src 'self') blocks injected inline scripts as a backstop. - No unauthenticated trigger surface. There is no manual-refresh endpoint,
and the webhook/push endpoints return
501until their secret is set. See Authentication. - Constant-time comparison for the push token and the GitLab webhook token.
See LICENSE.