Mein Lab ist ein einziges Repository: ein GitOps-Monorepo, aus dem
Flux
mehrere Talos-Cluster reconciled. Alles, was eine Version trägt, steht darin — Helm-Charts, Container-Images, die Talos- und Kubernetes-Version, dazu CLI-Pins in einem Dockerfile und in shell.nix. Manuell halte ich das nicht aktuell. Das macht
Renovate
, und zwar so, dass es mir die langweiligen Bumps abnimmt und mich genau bei den drei, vier Dingen stoppt, bei denen ein blinder Merge teuer wäre.
Der Trick mit der lokalen Config#
Ich fahre Renovate selbst-gehostet mit --platform=local — keine Hosted-App, die auf eine Forge zugreift, sondern ein Lauf gegen den Checkout. Genau dabei stolpert man über eine Eigenheit: lokale Presets in extends werden nicht unterstützt. Ich kann also nicht einfach "extends": [".renovate/groups.json"] schreiben und die Config in Fragmente zerlegen.
Die Lösung ist ein kleiner JS-Entrypoint, der die Fragmente zur Laufzeit zusammenführt:
1// renovate.config.mjs — merged renovate.json mit .renovate/*.json
2const base = readJson("renovate.json");
3const { customManagers } = readJson(".renovate/customManagers.json");
4const { packageRules: groupRules } = readJson(".renovate/groups.json");
5
6export default {
7 ...base,
8 extends: (base.extends ?? []).filter((p) => !p.startsWith(".renovate/")),
9 customManagers,
10 packageRules: [...(base.packageRules ?? []), ...groupRules],
11};
So bleibt die Basis in renovate.json, die Custom-Manager und die Gruppen liegen getrennt in .renovate/ — und die .renovate/-Einträge fliegen aus extends raus, bevor Renovate sie als (nicht existente) Presets missversteht.
Die Grundhaltung: automerge by default, Handbremse für das Fundament#
Die Basis ist bewusst aggressiv. platformAutomerge, prConcurrentLimit: 0 (kein Limit), und die SOPS-Dateien sind komplett ausgeklammert:
1{
2 "extends": ["config:recommended", ":dependencyDashboard"],
3 "platformAutomerge": true,
4 "prConcurrentLimit": 0,
5 "ignorePaths": ["**/*.sops*"]
6}
Die drei Kubernetes-Manager (flux, helm-values, kubernetes) sind auf k8s/clusters/.+\.ya?ml$ eingegrenzt. Was darunter liegt und nicht zum Fundament gehört, wird automatisch gemerged:
1{
2 "matchManagers": ["flux", "helm-values", "kubernetes", "kustomize", "regex"],
3 "matchFileNames": ["k8s/clusters/**"],
4 "automerge": true
5}
Und dann die Handbremse. Für alles, was im Fehlerfall den ganzen Cluster mitnimmt, ist automerge explizit aus:
1{
2 "matchFileNames": [
3 "k8s/clusters/**/applications/kube-system/cilium/**",
4 "k8s/clusters/**/applications/flux-system/flux/**",
5 "k8s/clusters/**/applications/rook-ceph/rook-ceph/**",
6 "k8s/clusters/**/applications/cert-manager/cert-manager/**",
7 "k8s/clusters/**/applications/vault/vault/**",
8 "k8s/clusters/**/secrets/**"
9 ],
10 "automerge": false
11}
Das ist die ganze Philosophie in zwei Regeln: Die Blätter pflegen sich selbst, die Wurzel fasse ich von Hand an.
Reihenfolge erzwingen: Rook vor Ceph#
Bei Storage reicht „nicht automergen" nicht — hier ist die Reihenfolge kritisch. Der Rook-Operator muss vor den Ceph-Daemon-Images hoch. Renovate kann das nicht erzwingen, aber es kann es mir unübersehbar machen — über Labels und einen PR-Body, der zur Checkliste wird:
1{
2 "matchPackageNames": ["rook-ceph"],
3 "groupName": "rook-ceph-operator",
4 "labels": ["CRITICAL-STORAGE", "MERGE-1st-BEFORE-CEPH"],
5 "prBodyNotes": ["## :warning: CRITICAL: STORAGE INFRASTRUCTURE", "..."]
6},
7{
8 "matchPackageNames": ["quay.io/ceph/ceph"],
9 "groupName": "ceph-images",
10 "labels": ["CRITICAL-STORAGE", "MERGE-2nd-AFTER-ROOK"]
11}
Die Labels MERGE-1st / MERGE-2nd stehen direkt im PR-Titel-Kontext. Selbst wenn ich um drei Uhr morgens einen Storage-PR sehe, sagt mir das Label die Reihenfolge, bevor ich den Body lese. Ceph-Images sind zusätzlich auf /^v\d+\.\d+\.\d+$/ gepinnt — keine RCs, keine latest-Überraschungen.
Custom-Manager: Versionen, die in Kommentaren leben#
Nicht jede Version steckt in einem Helm-values.yaml. Talos- und Kubernetes-Versionen für tuppr
, CLI-Pins im ZeroClaw-Dockerfile, Tool-Versionen in shell.nix — die annotiere ich mit einem Kommentar, und ein Regex-Manager liest ihn:
1# renovate: datasource=docker depName=ghcr.io/siderolabs/installer
2talosVersion: v1.11.2
Der zugehörige Manager wendet dieses Muster auf alle YAML-Dateien an:
1{
2 "customType": "regex",
3 "managerFilePatterns": ["/\\.ya?ml$/"],
4 "matchStrings": [
5 "#\\s*renovate:\\s*datasource=(?<datasource>\\S+)\\s+depName=(?<depName>\\S+)\\n\\s*(?:talos|kubernetes)?[Vv]?ersion:\\s*(?<currentValue>\\S+)"
6 ]
7}
So bleiben selbst Versionen, die in einer Kustomize-URL oder einem ARG im Dockerfile stecken, im Renovate-Blick. Ein eigener Manager pinnt sogar die Gateway-API-CRDs über die Release-URL und die shell.nix-Tool-Pins.
Gruppieren, damit ein PR eine Entscheidung ist#
groups.json bündelt zusammengehörige Bumps zu einem PR — weil ich nicht über zehn einzelne Sidecar-Bumps entscheiden will, sondern über ein Upgrade:
| Gruppe | Was zusammenfällt |
|---|---|
talos | siderolabs/installer — die Talos-Version für tuppr |
kubernetes | siderolabs/kubelet — die K8s-Version für tuppr |
flux | alle ghcr.io/fluxcd/*-Controller + fluxcd/flux2 |
envoy-gateway | Controller-Chart und vendored CRD-Chart (selber Release-Train) |
zeroclaw-cli-tools | alle CLI-Pins im ZeroClaw-Image auf einmal |
Was bleibt#
Das Dependency-Dashboard ist mein einziger Cockpit-Blick: ein Issue, das alles Offene listet. Der Rest läuft im Hintergrund — und passt nahtlos zu zwei Dingen, über die ich schon geschrieben habe: zur Supply-Chain mit cosign und Kyverno (signierte Images, die Renovate bumpt, werden bei der Admission trotzdem verifiziert) und zu meinem Hang, wiederkehrende Operationen in Tasks zu gießen statt sie zu merken.
Die Lektion nach ein paar Monaten ist banal und trägt trotzdem: Automerge ist eine Vertrauensgrenze. Was darunter liegt, will ich nie wieder von Hand bumpen. Was darüber liegt — Cilium, Flux, Rook, Cert-Manager, Vault —, will ich nie wieder nicht von Hand bumpen.
Nachtrag (25. Juni 2026): Auch meine eigenen Images übernimmt jetzt Renovate#
Als ich diesen Artikel schrieb, endete Renovates Zuständigkeit an der Registry-Grenze: Dritt-Images bumpte der Bot, meine eigenen Images aus oci.${EXTERNAL_DOMAIN} liefen noch über einen Sonderweg. Die trugen nämlich nur :latest und wurden per Digest festgenagelt — ein Bash-Skript (pin-zeroclaw-images.sh) löste den signierten :latest-Digest via crane digest auf, verifizierte ihn mit cosign und schrieb image:…@sha256:… ins Manifest. Ein ZeroClaw-Cron (image_pin_check, alle sechs Stunden) machte dasselbe hands-off und öffnete bei Digest-Drift einen Radicle-Patch.
Diese ganze Maschinerie ist weg. Sobald meine CI für zeroclaw, ci-broker, radicle/node, snac, choom/relay und hugo echte SemVer-Release-Tags baut (v0.1.0, v0.1.1 …), braucht es keinen Digest-Pin-Tanz mehr — Renovate behandelt sie wie jedes andere Docker-Image. Das Skript, der Task und der Cron sind gelöscht.
Der Haken: Flux löst ${EXTERNAL_DOMAIN} erst beim Reconcile auf, also kann Renovates nativer Kubernetes-Manager den Image-Namen im Manifest gar nicht lesen. Deshalb ein eigener customManager, der die Version über einen # renovate:-Kommentar direkt über der image:- oder tag:-Zeile zieht:
1{
2 "customType": "regex",
3 "managerFilePatterns": ["/k8s/clusters/.+\\.ya?ml$/"],
4 "datasourceTemplate": "docker",
5 "matchStrings": [
6 "# renovate: datasource=\\S+ depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+image:\\s*\"?[^\\s\":]+:(?<currentValue>[^\"@\\s]+)"
7 ]
8}
Im Manifest steht dann nur noch ein Kommentar über dem Image:
1# renovate: datasource=docker depName=oci.this-is-fine.io/zeroclaw/zeroclaw
2image: "oci.${EXTERNAL_DOMAIN}/zeroclaw/zeroclaw:v0.1.0"
Ein hübscher Nebeneffekt fällt bei radicle/node ab: Der Node steckt als Sidecar in gleich vier verschiedenen Deployments (seed-node, ci-broker, die fix-store-ownership-Init und der ZeroClaw-Sidecar). Geben alle vier denselben depName an, bumpt Renovate sie im Gleichschritt — kein Replikations-Skew mehr, bei dem ein Sidecar auf einer älteren Node-Version festhängt als der Rest. Genau die Sorte Drift, die ich vorher mit dem Digest-Pin-Cron von Hand jagen musste, löst sich jetzt über ein geteiltes Feld in der renovate.json. Damit ist die letzte Insel manueller Versionspflege im Lab geschlossen: Was eine Version trägt, trägt jetzt auch einen # renovate:-Kommentar — egal, ob es von fremden Servern kommt oder aus meiner eigenen Pipeline.
