CI/CD-Pipeline¶
Die Kamerplanter-CI/CD-Pipeline läuft vollständig auf GitHub Actions. Sie umfasst automatische Qualitätsprüfungen für Backend und Frontend, das Bauen und Veröffentlichen von Container-Images sowie die automatisierte Helm-Chart-Publikation. Ein Release stößt ein Maintainer von Hand an; danach laufen alle Schritte automatisch in der richtigen Reihenfolge ab.
Voraussetzungen¶
- Schreibzugriff auf das GitHub-Repository (
nolte/kamerplanter) - Kein manuelles Einrichten von Secrets notwendig — alle Workflows nutzen das automatisch verfügbare
GITHUB_TOKEN - Container-Images werden in die GitHub Container Registry (GHCR) unter
ghcr.io/nolte/gepusht
Branch-Strategie¶
| Branch | Zweck |
|---|---|
feature/* | Entwicklungsarbeit; CI läuft bei Pull Requests auf develop |
develop | Integrationsbranch; löst CI und Image-Build aus |
main | Repräsentiert den aktuell stabilen Release-Stand; wird nach jedem Release automatisch aktualisiert |
Hinweis
main wird nicht direkt für die Entwicklung verwendet. Commits landen über develop und Git-Tags auf main. Der Workflow release-cd-refresh-master.yml übernimmt diesen Schritt automatisch nach einem veröffentlichten Release.
Übersicht der Workflows¶
| Datei | Auslöser | Zweck |
|---|---|---|
backend.yml | Push/PR auf develop, Pfad src/backend/** | Lint + Tests Backend |
frontend.yml | Push/PR auf develop, Pfad src/frontend/** | Lint + Tests + Build Frontend |
docker-publish.yml | Push auf develop oder v*-Tag | Container-Images + Helm-Chart bauen und publizieren |
skaffold-verify.yml | PR auf develop, Pfad skaffold.yaml, helm/**, Dockerfiles | Helm-Lint + Skaffold-Diagnose |
release-drafter.yml | Push auf develop | Release-Notes-Entwurf automatisch aktualisieren |
release-publish.yml | Nur manuell (workflow_dispatch) | Einen Release-Entwurf veröffentlichen — der einzige Schritt, der ein Release entstehen lässt |
release-cd-deliver-docs.yml | Veröffentlichtes Release | MkDocs-Dokumentation auf GitHub Pages deployen |
release-cd-refresh-master.yml | Veröffentlichtes Release | main-Branch auf den Release-Stand aktualisieren |
release-lag.yml | Zeitplan, täglich 09:00 UTC (+ manuell) | Meldet, wenn develop Commits trägt, die kein veröffentlichtes Release enthält |
renovate-health.yml | Zeitplan, täglich 09:20 UTC (+ manuell) | Liest das Dependency Dashboard (#12) und meldet, wenn Renovate ein Problem berichtet oder die Manager-Inventur abweicht |
Backend-CI (backend.yml)¶
Der Backend-CI-Workflow läuft bei jedem Push auf develop und bei Pull Requests, sofern Dateien unter src/backend/ geändert wurden.
Was geprüft wird¶
- Ruff Lint — prüft den Python-Code auf Stil- und Qualitätsprobleme (
ruff check .) - Ruff Format — stellt sicher, dass der Code korrekt formatiert ist (
ruff format --check .) - Unit-Tests — führt alle Tests unter
tests/unit/mit pytest aus
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.14'
allow-prereleases: true
- name: Install uv
uses: astral-sh/setup-uv@<sha>
with:
# Liest die uv-Version aus [tool.uv].required-version. Kein Workflow
# schreibt eine uv-Version selbst hin.
version-file: src/backend/pyproject.toml
enable-cache: true
cache-dependency-glob: src/backend/uv.lock
- name: Install dependencies
run: |
uv sync --locked --extra dev
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
- name: Unit tests
run: pytest tests/unit/ -v --tb=short
Lokale Prüfung vor dem Push
Abhängigkeiten installieren¶
Die Backend-Abhängigkeiten werden aus uv.lock installiert, dem Hash-tragenden Lock, das aus pyproject.toml aufgelöst wird (interne Referenz: Dependency-Anforderung NFR-009). Der [dev]-Extra enthält pytest, ruff und weitere Entwicklungswerkzeuge:
Renovate-Gesundheit (renovate-health.yml)¶
Renovate meldet seine eigenen Fehlschläge nur im Dependency Dashboard (Issue #12) — nicht als roten Lauf, nicht als fehlende Pull Request. Ein Manager, der nichts mehr findet, sieht genauso aus wie „es gibt nichts zu aktualisieren".
Genau das passierte zwischen dem 02.08.2026 und dem 10.09.2026: Renovates pip-compile-Manager extrahierte sechs Wochen lang nichts, die Backend-Locks alterten unangetastet, und alle Lanes blieben grün. Das einzige Signal war eine Zeile ⚠️ WARN: pip-compile error im Dashboard.
Der Workflow liest deshalb täglich den Body von #12 und vergleicht:
- Repository-Probleme — jede
WARN/ERROR-Zeile unter## Repository problemsist ein Befund und wird wörtlich zitiert. -
Die Manager-Inventur —
pep621muss aus allen fünf gelockten PEP-621-Bäumen lesen (Backend + vier Service-Images),pip-compiledarf gar nicht auftauchen, und kein zweiter Manager (poetry,pip_requirements,pip-compile) darf eine Datei innerhalb eines dieser fünf Bäume lesen.Warum die Regel nicht „
poetrydarf nicht auftauchen" lautetGemessen mit
task renovate:dry-run(Renovate 44.94.1, 16.09.2026):enabled: falseschaltet die Abhängigkeiten eines Managers ab, nicht seine Extraktion.poetryliest weiterhin die zwei gelockten-losen Bibliotheken untersrc/libs/. Eine Regel „poetrydarf nicht auftauchen" hätte also jeden Tag auf einem korrekten Repository Alarm geschlagen.- Das Lock neben jeder Paketdatei — das Dashboard listet keine Lockdateien, deshalb wird diese eine Tatsache aus dem Checkout gelesen. Der Bericht sagt das ausdrücklich.
Bei einem Befund öffnet bzw. aktualisiert der Lauf ein einziges, dedupliziertes Issue (Label renovate-health) statt nur rot zu werden — ein roter Zeitplan-Lauf ist nach einer Woche roter Zeitplan-Läufe unsichtbar. Umgekehrt gilt: Kann der Lauf nicht entscheiden (Issue unerreichbar, Body leer, Dashboard unparsbar), wird der Lauf rot und es entsteht kein Issue — ein unbestimmtes Ergebnis darf nicht wie ein sauberes aussehen (NFR-018 §2).
Die Erwartung selbst steht in scripts/ci/check_renovate_dashboard.py (EXPECTED_PEP621_FILES, KNOWN_LOCKLESS_PEP621_FILES); ein neuer Python-Baum wird dort eingetragen, statt die Regel stillschweigend aufzuweichen.
task renovate:dry-run — die Konfiguration vor dem Merge beweisen¶
renovate.json5 ist die eine Konfigurationsdatei im Repository, die vor dem Merge nichts prüft: eine Regel, die nichts trifft, ein versehentlich abgeschalteter Manager, ein Tippfehler in managerFilePatterns — all das parst sauber und fällt erst beim nächsten Renovate-Lauf auf, Stunden später und auf develop. Genau so wurde der pip-compile-Manager sechs Wochen lang stumm.
task renovate:dry-run # Manager-Inventur + vorgeschlagene Updates
LOG_LEVEL=debug task renovate:dry-run # zusätzlich jede extrahierte Datei einzeln
Der Befehl fährt die gepinnte renovate/renovate-CLI in Docker mit --platform=local --dry-run=full gegen diesen Arbeitsbaum — inklusive nicht committeter Änderungen, was der Zweck ist. src/backend/.venv, src/frontend/node_modules und node_modules werden per Mount-Maskierung ausgeblendet, nicht per ignorePaths-Override: ein Override würde eine andere Konfiguration beweisen als die, die in Produktion läuft.
Ist GITHUB_TOKEN gesetzt, wird es durchgereicht; ohne Token laufen die Datasource-Abfragen anonym und stoßen schnell ans Rate-Limit — die Manager-Inventur stimmt dann trotzdem, die Update-Vorschläge nicht.
Gemessen am 16.09.2026 (Renovate 44.94.1) auf diesem Stand:
| Manager | Dateien | Bemerkung |
|---|---|---|
pep621 | 5 | Backend + vier Service-Images, jeweils mit uv.lock als lockFiles |
poetry | 2 | src/libs/kp_errortracking, src/libs/kp_vectordb — Bibliotheken ohne Lock; Abhängigkeiten repository-weit deaktiviert |
pip_requirements | 2 | nur docs/ und tools/rag-eval/ |
pip-compile | — | taucht nicht mehr auf |
Das ist die Messung, gegen die renovate-health.yml die Dashboard-Inventur hält.
Frontend-CI (frontend.yml)¶
Der Frontend-CI-Workflow läuft bei jedem Push auf develop und bei Pull Requests, sofern Dateien unter src/frontend/ geändert wurden.
Was geprüft wird¶
- TypeScript-Prüfung — strikter Typen-Check ohne Ausgabe (
tsc --noEmit) - ESLint — Qualitätsprüfung des TypeScript/React-Codes
- Vitest — alle Unit- und Komponenten-Tests
- Vite-Build — stellt sicher, dass der Produktions-Build fehlerfrei kompiliert
jobs:
lint-test-build:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 22 # (1)!
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
- run: npm run lint
- run: npm run test
- run: npm run build
- Nur der
lint-test-build-Job nutzt Node 22. Diebundle-budget- undlighthouse-Jobs im selben Workflow laufen auf Node 24 — passend zum Frontend-Dockerfile, das mitnode:24-alpinebaut (siehe Frontend-Image unten).
Lokale Prüfung vor dem Push
Build-Artefakt¶
Bei einem Push auf develop (nicht bei PRs) wird das fertige dist/-Verzeichnis als GitHub Actions Artefakt hochgeladen und für 7 Tage aufbewahrt. Das ermöglicht eine schnelle Inspektion des Build-Ergebnisses ohne lokales Kompilieren.
Performance-Budgets & CI-Gates (bundle-budget, lighthouse)¶
Zusätzlich zu lint-test-build laufen im selben Workflow zwei eigenständige Jobs, die die Frontend-Ladeperformance überwachen (interne Referenz: UI-NFR-003). Beide berühren nicht den required Check static — ein Verstoß fällt sichtbar auf, blockiert aber nicht den Automerge.
bundle-budget — hartes Gate. Der Job baut das Frontend und prüft danach mit npm run bundle:check (Skript scripts/check-bundle-budget.mjs) das initiale JavaScript- und CSS-Bundle sowie den dedizierten /dashboard-Route-Chunk gegen die Budgets in bundle-budget.json:
| Prüfung | Budget (gzip) | Gemessener IST-Wert |
|---|---|---|
| Initiales JavaScript-Bundle | 490 KB | ~471,8 KB |
| Initiales CSS-Bundle | 50 KB | unter Budget |
/dashboard-Route-Chunk | 12 KB | unter Budget |
Eine Überschreitung lässt den Job fehlschlagen; der Bundle-Analyzer-Report (Treemap via rollup-plugin-visualizer) wird als Artefakt bundle-stats hochgeladen. Möglich wird dieses Budget durch eine manualChunks-Vendor-Strategie in vite.config.ts: React, MUI-Core, Redux Toolkit und react-i18next werden eager in stabile, langlebige Vendor-Chunks gruppiert, während schwere routen-gebundene Bibliotheken (recharts, @mui/x-*, react-grid-layout) lazy bleiben.
300-KB-Ziel noch nicht erreicht
UI-NFR-003 formuliert für das initiale JavaScript-Bundle einen Zielwert von 300 KB gzip. Das aktuelle Budget von 490 KB sichert lediglich den gemessenen Ist-Stand gegen unbemerktes weiteres Wachstum ab, erreicht das Ziel aber noch nicht. Haupttreiber ist das eager geladene i18n-Übersetzungsbundle (~160 KB gzip). Das Erreichen des 300-KB-Ziels erfordert Lazy-Loading der Übersetzungen und ist als offener Folgeschritt vorgesehen.
lighthouse — Report-only. Der Job führt npm run lhci (Lighthouse CI, mobile Emulation mit gedrosseltem 4G-Netzwerk) gegen den gebauten dist/-Ordner aus und prüft die Core-Web-Vitals-Schwellenwerte aus UI-NFR-003 (First Contentful Paint < 1,5 s, Largest Contentful Paint < 2,5 s, Time to Interactive < 3,5 s, Cumulative Layout Shift < 0,1, Total Blocking Time < 200 ms) sowie den Performance- und Accessibility-Score (≥ 0,9). Alle Assertions in lighthouserc.json sind auf warn-Stufe konfiguriert — der Job blockiert den Build also nicht, macht Regressionen aber im Report sichtbar. Der vollständige Bericht wird als Artefakt lighthouse-report hochgeladen.
Container-Build und -Publikation (docker-publish.yml)¶
Dieser Workflow baut und publiziert alle Container-Images sowie den Helm-Chart. Er wird ausgelöst durch:
- Push auf
develop(wenn Backend-, Frontend- oder Helm-Dateien geändert wurden) - Push eines
v*-Tags (Release) — dann werden alle Komponenten gebaut, unabhängig von Pfadänderungen - Manuell über
workflow_dispatch
Pfad-basiertes Filtern¶
Damit nicht bei jeder Änderung alle Images neu gebaut werden, ermittelt ein changes-Job zuerst, welche Komponenten betroffen sind:
Bei einem v*-Tag oder manuellem Auslösen wird das Filtern übersprungen — es werden immer alle Komponenten gebaut.
Backend-Image¶
Das Backend-Image basiert auf python:3.14-slim und nutzt ein Multi-Stage-Dockerfile mit einer gemeinsamen base-Stage sowie getrennten dev- und prod-Zielen (docker build . ohne --target baut standardmäßig prod, da es die letzte Stage ist). Die dev-Stage läuft als root für Skaffold-Hot-Reload; die prod-Stage läuft als nicht-root Nutzer (UID 1000):
FROM python:3.14-slim AS base
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.12.12 /uv /bin/uv
ENV UV_PYTHON=/usr/local/bin/python3 UV_PYTHON_DOWNLOADS=never UV_PROJECT_ENVIRONMENT=/opt/venv PATH="/opt/venv/bin:$PATH"
COPY pyproject.toml uv.lock ./
FROM base AS prod
RUN uv sync --locked --no-install-project
COPY . .
RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -d /app -s /usr/sbin/nologin app \
&& chown -R 1000:1000 /app
USER 1000
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Das Image wird nach ghcr.io/nolte/kamerplanter-backend gepusht. Abhängigkeiten kommen ausschließlich aus dem Hash-tragenden uv.lock (interne Referenz: NFR-009): uv sync --locked verweigert ein Lock, das pyproject.toml nicht mehr erfüllt, und verifiziert jedes Artefakt gegen seinen Hash — die Abhängigkeiten liegen als eigene, cachebare Schicht unter /opt/venv, die Anwendung wird aus /app importiert.
Frontend-Image¶
Das Frontend-Image verwendet ebenfalls ein Multi-Stage-Dockerfile: Zuerst wird die React-App mit Node.js 24 gebaut, dann werden die statischen Dateien in ein schlankes, unprivilegiertes nginx-Image kopiert (läuft nicht als root, kompatibel mit runAsNonRoot):
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
Das Image wird nach ghcr.io/nolte/kamerplanter-frontend gepusht. Der interne Port ist 8080 (unprivilegiertes nginx darf nicht an Port 80 binden), gemappt auf den öffentlichen Port 8080 in Docker Compose bzw. Port 80 im Helm-Chart-Service.
Image-Tags¶
Docker-Metadata wird automatisch per docker/metadata-action erzeugt:
| Tag-Schema | Beispiel | Wann |
|---|---|---|
latest | kamerplanter-backend:latest | Push auf develop |
| Commit-SHA | kamerplanter-backend:a3f7c2b | Immer |
| Branch-Name | kamerplanter-backend:develop | Push auf Branch |
| Semantic Version | kamerplanter-backend:1.2.0 | v1.2.0-Tag |
| Major.Minor | kamerplanter-backend:1.2 | v1.2.0-Tag |
latest ist eine bewegliche Referenz
latest wird bei jedem Push auf develop überschrieben und zeigt danach auf andere Bytes. Für alles, was reproduzierbar sein muss — Deployments, Rollbacks, Fehleranalyse — brauchst du eine Referenz, die stehen bleibt. Das Helm-Chart benutzt deshalb seit #987 keinen beweglichen Tag mehr, sondern einen unveränderlichen Digest: Deployment und Rollback.
Wie teuer das ohne Digest ist, ist gemessen und nicht behauptet: mit pullPolicy: IfNotPresent — genau der Wert, den das Chart setzt — liefert ein Node nach rollout restart weiter das alte Image, obwohl die Registry unter demselben Tag längst andere Bytes ausliefert. Kubernetes erzwingt Always für ein :latest nur, solange imagePullPolicy nicht gesetzt ist; ein explizit gesetztes IfNotPresent gewinnt. So hat der inference-service wochenlang ein Image ohne die /pest/*-Routen bedient.
Was jedes Artefakt absichert¶
Jede Artefaktklasse, die dieses Projekt ausliefert, braucht eine benannte Stage, die sie absichert — sonst wird eine Lücke erst dann sichtbar, wenn sie ausgenutzt wird. Die folgende Matrix ist die Antwort auf die Frage „was garantiert eigentlich wer?" und wird beim Ändern der Pipeline mitgepflegt.
| Artefaktklasse | Sichernde Stage | Garantien |
|---|---|---|
| Container-Images (8 Stück) | build-* in docker-publish.yml | aus der Quelle gebaut, Integrität (Digest), Provenance (signiert) |
| Helm-Chart (OCI) | publish-helm-charts | aus der Quelle gebaut, Integrität (Digest), Provenance (signiert) |
openapi.json (Release-Asset) | openapi-asset in release-publish.yml | aus der Quelle gebaut, Anhang wird nach dem Upload verifiziert |
docker-compose-<version>.yml, .env.example-<version> | update-release-assets | aus der Quelle gebaut |
| Python-Abhängigkeiten | pip-audit, pip-licenses, lock-staleness in backend.yml | Policy geprüft (CVE, Lizenz, Lock-Integrität) |
Bekannte Lücken — bewusst offen, nicht übersehen:
- Die drei Release-Assets tragen keine Provenance. Für die von GitHub gehosteten Release-Dateien gibt es keinen Verifikationspfad, der dem Registry-Attest entspricht.
- Für JavaScript-Abhängigkeiten gibt es noch keine eigene Policy-Stage; Trivy deckt sie nur auf Stufe
CRITICALab.
Provenance prüfen¶
Images und Chart tragen von der Plattform erzeugte und signierte Build-Provenance (actions/attest-build-provenance). Vor einem Deployment prüfbar mit:
Was das aussagt und was nicht: Provenance belegt die Herkunft — welcher Commit, welcher Workflow-Lauf, welche gepinnten Eingaben. Sie ist keine Aussage darüber, dass ein Artefakt sicher ist. Sicherheitsbefunde bleiben Sache der Supply-Chain-Stages.
Helm-Chart¶
Das Helm-Chart für Kamerplanter liegt unter helm/kamerplanter/ und wird als OCI-Artefakt gepusht:
Bei einem Release-Tag werden version und appVersion in Chart.yaml automatisch auf die Release-Version gesetzt, bevor das Chart gepackt wird. Auf jedem anderen Ref greift dieser Rewrite nicht — dort wird die Version gepackt, die im Baum steht, und develop hat dafür einen eigenen Kanal (siehe Zwei Kanäle). Gleichzeitig pinnt scripts/ci/pin_chart_image_digests.sh jedes Kamerplanter-Image in values.yaml auf <version>@sha256:<digest> — adressiert über den YAML-Pfad, nicht über eine Textersetzung des Literals tag: latest. Derselbe Lauf liest die Datei anschließend erneut und bricht das Release ab, falls ein Kamerplanter-Image die Umstellung überlebt hat.
Der Digest wird dabei aus der Registry aufgelöst, nicht aus den Build-Jobs durchgereicht. Das prüft nebenbei, dass <image>:<version> überhaupt existiert — deshalb hängt publish-helm-charts seit #987 per needs: hinter den Image-Builds. Und es ist der Unterschied zwischen „unveränderlich" und „unveränderlich per Konvention": ein Versions-Tag lässt sich neu pushen, ein Digest nicht.
Warum nicht nur die Version?
Bis #987 pinnte dieser Schritt den nackten Versions-Tag. Das war besser als das :latest im develop-Stand, aber ein Tag bleibt eine Referenz auf einen Namen: wird der Publish-Workflow für ein bestehendes Tag erneut ausgeführt, zeigt derselbe Name auf andere Bytes, und kein Konsument kann das bemerken.
Zwei Kanäle: develop und Release¶
Unter derselben OCI-Adresse liegen zwei Sorten von Tags, und sie bedeuten Verschiedenes:
| Kanal | Version im Baum | Veröffentlichter OCI-Tag | Lebensdauer |
|---|---|---|---|
develop | eine Vorabversion mit dem Zusatz -dev, derzeit 0.2.1-dev | charts/kamerplanter:0.2.1-dev | wird bei jedem helm/-Merge überschrieben |
| Release | reine Version, vom Tag gesetzt | charts/kamerplanter:0.1.0 | gehört genau einem Release und wird nicht neu gepusht — mit einer gemessenen Ausnahme, siehe unten |
Zwei Dinge, die die Tabelle nicht sagt und die man leicht falsch liest:
- Am
-dev-Wert ist nur der Bezeichnerdeverzwungen, nicht die Nummer davor.0.2.1-devbenennt das beabsichtigte nächste Release, ist aber keine Zusage: Sobaldv0.2.1erscheint, sortiert0.2.1-devunter dem Release, und kein Turnus hebt den Wert an. Nötig ist das auch nicht — die Kollision bleibt bei jeder-dev-Nummer unmöglich, und genau das ist der Grund, warum die Prüfung nichts Stärkeres verlangt. - Der Beispiel-Tag in der Release-Zeile ist
0.1.0und nicht0.2.0.0.1.0trägt im Manifest noch den Zeitstempel seines Releases (created06.08.2026 um 13:38:04 UTC, 15 Sekunden nach der Veröffentlichung vonv0.1.0),0.2.0dagegen den einesdevelop-Builds.0.2.0ist damit der eine Release-Tag, für den die Spalte „Lebensdauer" nicht gilt — der Vorfall weiter unten, unrepariert und deshalb kein Beispiel für die Regel.
Die beiden Kanäle sind disjunkt, und das wird an beiden Enden erzwungen — nicht als Konvention, sondern als Prüfung:
scripts/check_chart_develop_version.pyläuft als Hook im Pflicht-Checkstaticund weist jede Chart-Version im Baum zurück, deren erster Vorab-Bezeichner nicht exaktdevlautet. Derdevelop-Baum kann damit keine Version tragen, die ein Release je publizieren würde.scripts/ci/determine_chart_version.shweist umgekehrt ein Release-Tag ab, das den Vorab-Bezeichnerdevträgt, und zwar bevor gepackt und gepusht wird.v0.3.0-devist deshalb kein gültiges Release-Tag.v0.3.0-rc1undv0.3.0-beta.1bleiben erlaubt — sie können mit demdevelop-Kanal nicht kollidieren.
Kein Deployment zeigt auf den -dev-Kanal
Der -dev-Tag wird bei jedem helm/-Merge nach develop mit anderen Bytes überschrieben. Das ist sein Zweck, kein Defekt. Ein targetRevision, ein --version oder ein image.tag, der darauf zeigt, ist deshalb kein fester Stand: Der ausgerollte Inhalt ändert sich, ohne dass sich im GitOps-Repository irgendetwas ändert — und niemand sieht einen Diff. Verankere ausschließlich eine reine Release-Version oder den Manifest-Digest.
Warum es diese Trennung gibt, gemessen: Bis zum 18.08.2026 trug der develop-Baum die Version 0.2.0 — die Version eines veröffentlichten Releases. Der Chart-Tag charts/kamerplanter:0.2.0 wurde am 13.08.2026 mit dem Release v0.2.0 publiziert und fünf Tage später aus develop erneut überschrieben, mit anderem Inhalt unter derselben Versionsreferenz. Ein Konsument konnte das nicht bemerken; sichtbar wurde es erst beim Vergleich des Zeitstempels org.opencontainers.image.created am OCI-Manifest mit dem Veröffentlichungsdatum des Releases.
# Chart direkt verwenden — immer eine veröffentlichte Version, nie eine -dev
helm pull oci://ghcr.io/nolte/charts/kamerplanter --version 1.2.0
Layer-Caching¶
Alle Image-Builds nutzen den GitHub Actions Cache (type=gha) für Docker-Layer. Das beschleunigt Folge-Builds erheblich, wenn sich nur wenige Schichten ändern.
Skaffold-Verify (skaffold-verify.yml)¶
Dieser Workflow läuft bei Pull Requests auf develop, wenn skaffold.yaml, Helm-Dateien oder Dockerfiles geändert wurden. Er stellt sicher, dass die lokale Entwicklungsumgebung weiterhin funktionsfähig ist.
Was geprüft wird¶
helm dependency build— lädt die Chart-Abhängigkeiten herunter (bjw-s common chart, valkey)helm lint— prüft das Helm-Chart auf Syntaxfehler mit den Dev-Valueshelm template— rendert alle Kubernetes-Manifeste und prüft die Templating-Logikskaffold diagnose— validiert die Skaffold-Konfigurationskaffold render— erzeugt gerenderte Manifeste und lädt sie als Artefakt hoch
Skaffold ist nur für die lokale Entwicklung
Skaffold wird ausschließlich für die lokale Entwicklungsumgebung (Kind-Cluster) verwendet. Produktions-Deployments laufen nicht über Skaffold, sondern über den docker-publish-Workflow in Kombination mit dem Helm-Chart.
Release-Prozess¶
Ein Release ist der Schritt, mit dem aus einem Stand auf develop eine ausgelieferte Version wird. Er läuft nicht automatisch an: Ein Mensch stößt ihn an, und alles danach ist automatisch.
Ein Entwurf ist keine Auslieferung
release-drafter.yml läuft bei jedem Push auf develop und hält einen Release-Entwurf aktuell. release-publish.yml läuft dagegen nur über workflow_dispatch. Das sichtbarste Release-Artefakt des Repositories ist deshalb dauerhaft ein Entwurf — er trägt eine Versionsnummer, listet die Änderungen und liest sich wie ein fertiges Release, hat aber kein Git-Tag, ist über die API nicht als Release abrufbar und liegt auf keinem Auslieferungspfad. Solange niemand ihn veröffentlicht, ist kein einziger Commit ausgeliefert.
Genau diese Lage ist gemessen worden: Ein Entwurf v0.2.1 existierte seit dem 13.08.2026; am 14.08. wurde eine Fehlerbehebung nach develop gemergt; am 16.08. lief in der Instanz weiterhin der Stand von davor. Das jüngste veröffentlichte Release war die ganze Zeit v0.2.0. Beobachtet wird das seitdem von release-lag.yml.
War der Entwurf absichtlich zurückgehalten? Nein — das ist inzwischen aktenkundig beantwortet. Der von release-lag.yml erzeugte Alarm (#1229) feuerte am 19.08.2026; v0.2.1 wurde noch am selben Tag veröffentlicht, und
1229 schloss sich am 20.08.2026 automatisch, weil die Prüfung danach¶
wieder grün war. Das war ein Versäumnis im Ablauf, kein absichtliches Zurückhalten.
Schritt 1: Release-Entwurf vorbereiten (automatisch)¶
Der release-drafter-Workflow aktualisiert bei jedem Push auf develop automatisch einen Release-Entwurf mit den Änderungen seit dem letzten Tag. Er schlägt dabei auch die nächste Versionsnummer vor.
Schritt 2: Release veröffentlichen (manuell — der eine Handgriff)¶
Ein Maintainer startet release-publish.yml von Hand: Actions → Release Publish → Run workflow, mit dem Tag des offenen Entwurfs. Sinnvollerweise zuerst mit dry_run: true — das validiert, ohne den Entwurf umzustellen.
# Äquivalent über die CLI
gh workflow run release-publish.yml -f tag=v1.2.0 -f dry_run=true
gh workflow run release-publish.yml -f tag=v1.2.0
Das Veröffentlichen ist der Moment, in dem das Git-Tag überhaupt erst entsteht — ein Entwurf hat keins.
Schritt 3: Was am veröffentlichten Release hängt (automatisch)¶
Das entstehende v*-Tag löst docker-publish.yml aus: Alle Images und der Helm-Chart werden mit der Release-Versionsnummer gebaut, und scripts/ci/pin_chart_image_digests.sh pinnt jedes Kamerplanter-Image in den Chart-Values auf <version>@sha256:<digest>.
Parallel starten am Ereignis „Release veröffentlicht" zwei weitere Workflows:
release-cd-deliver-docs.yml — deployt die MkDocs-Dokumentation auf GitHub Pages über einen wiederverwendbaren Workflow aus nolte/gh-plumbing.
release-cd-refresh-master.yml — aktualisiert den main-Branch auf den Stand des neuen Release-Tags. main zeigt damit immer auf den zuletzt veröffentlichten stabilen Stand.
Schritt 4: Release-Assets (automatisch)¶
Der update-release-assets-Job im docker-publish-Workflow hängt am Release folgende Dateien an:
docker-compose-1.2.0.yml— versionierte Docker-Compose-Datei für Self-Hosting.env.example-1.2.0— Vorlage für Umgebungsvariablen- Container-Image-Referenzen und Helm-Pull-Befehl im Release-Text
Zusammenfassung Release-Ablauf¶
sequenceDiagram
participant Dev as Maintainer
participant GH as GitHub
participant GHCR as ghcr.io
Note over GH: release-drafter hält laufend<br/>einen Entwurf aktuell
Dev->>GH: run release-publish.yml (tag=v1.2.0) — manuell
GH->>GH: draft = published, Git-Tag v1.2.0 entsteht
GH->>GH: docker-publish.yml startet (Trigger: v*-Tag)
GH->>GHCR: push backend image :1.2.0
GH->>GHCR: push frontend image :1.2.0
GH->>GHCR: push Helm chart :1.2.0 (Images auf Digest gepinnt)
GH->>GH: attach release assets
GH->>GH: deploy MkDocs docs
GH->>GH: update main branch Deployment und Rollback¶
Ein Cluster erfährt nicht dadurch von einer neuen Version, dass ein Tag umgebogen wird. Das veröffentlichte Chart referenziert jedes Kamerplanter-Image über einen unveränderlichen Digest, den der Release-Job beim Packen einsetzt:
image:
repository: ghcr.io/nolte/kamerplanter-backend
tag: 0.2.1@sha256:af9bec… # (1)!
pullPolicy: IfNotPresent # (2)!
- Entscheidend ist der Teil nach dem
@. Der Digest ist inhaltsadressiert und kann sich nicht bewegen. Die Version davor ist keine bewegliche Referenz, sondern ein Etikett — aufgelöst wird sie von niemandem. Im Repository steht an dieser Stelletag: latest:scripts/ci/pin_chart_image_digests.shschreibt beim Release jede Kamerplanter-Referenz auf<version>@sha256:<digest>um, undscripts/check_chart_image_digests.pyprüft im selben Job, dass keine entkommen ist. Derdevelop-Baum mitlatestist deshalb ausdrücklich kein Deploy-Kanal (siehe Zwei Kanäle). - Bleibt bewusst
IfNotPresent. Ein Digest ist inhaltsadressiert: Ein Image, das auf dem Node liegt, ist das angeforderte — ein erneuter Pull könnte das nur bestätigen.Alwayswürde jeden Pod-Start von der Erreichbarkeit der GHCR abhängig machen und nichts gewinnen. Mit einem beweglichenlatestwäre derselbe Wert eine Falle — ein weiterer Grund, warum der Baum nicht ausgerollt wird.
So kommt eine neue Version in die Produktion¶
Die Produktions-Instanz rollt ausschließlich Release-Versionen aus. Das ist eine Betriebsentscheidung und nicht bloß der zufällige Ist-Zustand: Die ArgoCD-Application verankert den Chart an einer veröffentlichten Chart-Version, nicht an einem Branch.
graph TD
A[1. Merge nach develop] --> B[2. docker-publish<br/>Images nach GHCR, Tag latest]
B -.Vorstufe, erreicht die Produktion nicht.-> C
C[3. Maintainer veröffentlicht ein Release<br/>MANUELL] --> D[4. docker-publish pinnt<br/>Chart-Images auf Version + Digest]
D --> E[5. targetRevision im GitOps-Repo<br/>auf die neue Chart-Version heben — MANUELL]
E --> F[6. ArgoCD synct, Pods rollen] Ausgeschrieben sind das sechs Sprünge, und jeder gehört einem anderen Akteur:
- Merge nach
develop— der Commit, der den Code ändert. Ein Mensch. docker-publish.ymlbaut die Images und pusht sie nach GHCR, wo:latestweiterwandert. Das ist die Vorstufe: Sie macht den Stand verfügbar, erreicht die Produktion aber nicht — nichts inhelm/kamerplanter/values.yamlaufdevelopändert sich dadurch. (Bis 2026-09-10 schrieb Renovate hier die neuen Digests per Pull Request nachdevelop; das versorgte kein Deployment und die PR wurde von jedem Publish neu rebased, ohne je zu mergen.)- Ein Maintainer schneidet ein Release — von Hand, über
release-publish.yml. Der erste manuelle Sprung. Passiert er nicht, ist nichts von Schritt 1 und 2 ausgeliefert, egal wie lange es her ist. docker-publish.ymlläuft auf dem entstandenenv*-Tag und pinnt überscripts/ci/pin_chart_image_digests.shjedes Kamerplanter-Image in den Chart-Values auf<version>@sha256:<digest>.targetRevisionim GitOps-Repository wird auf die neue Chart-Version gehoben — ebenfalls von Hand, innolte/k8s-home-lab, also außerhalb dieses Repositories. Der zweite manuelle Sprung, und der einzige, den kein Workflow dieses Repositories überhaupt sehen kann.- ArgoCD synct den neuen Stand. Die Pods rollen, weil sich die Pod-Spec geändert hat — nicht, weil jemand einen Neustart ausgelöst hat.
Zwei der sechs Sprünge tut ein Mensch
Sprung 3 (Release veröffentlichen) und Sprung 5 (targetRevision heben) sind keine Automatik. Beide sind Handgriffe eines Maintainers, beide können vergessen werden, und beide sehen von außen aus wie nichts — es gibt keinen roten Lauf, keine offene Pull Request, kein Artefakt, das fehlt. Ein gemergter Fix ist deshalb kein ausgelieferter Fix.
Wie weit das auseinanderlaufen kann, ist messbar: Vom 13.08.2026 19:41 UTC bis zum 16.08.2026 15:33 UTC verankerte die Produktions-Application das Chart auf v0.1.0 (veröffentlicht am 06.08.), während das jüngste veröffentlichte Release bereits v0.2.0 (veröffentlicht am 13.08.) war.
Der Deploy selbst ist also ein Commit — im GitOps-Repository —, kein Handgriff am Cluster. Dass die Digests im veröffentlichten Chart vorhanden und wohlgeformt sind, sichert scripts/check_chart_image_digests.py direkt nach dem Pinnen im Release-Job ab; welche Version die Instanz tatsächlich ausführt, beantwortet nur die Instanz selbst (siehe Prüfungen entlang der Auslieferungskette).
Rollback¶
Ein Rollback ist ein Commit im GitOps-Repository: targetRevision zurück auf eine vorherige Chart-Version setzen.
Nicht jede veröffentlichte Versionsnummer ist ein sicheres Rollback-Ziel
0.2.0 sieht wie der naheliegende Vorgänger von 0.2.1 aus, ist es aber nicht — siehe ArgoCD — targetRevision niemals auf den -dev-Kanal für das eine Release-Tag, bei dem diese Annahme nicht hielt. 0.1.0 steht oben, weil es noch seinen ursprünglichen Release-Zeitstempel trägt; im Zweifel den Manifest-Digest pinnen statt einer bloßen Versionsnummer.
ArgoCD zieht die ältere Chart-Version — mitsamt der Digests, die diese Version pinnt — und die Pods rollen zurück. Prüfe das Ergebnis im laufenden Pod, nicht in der Values-Datei und nicht am Controller-Status:
kubectl get pod -n kamerplanter -l app.kubernetes.io/name=backend \
-o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.containerStatuses[0].imageID}{"\n"}{end}'
imageID nennt den Digest, den der Kubelet tatsächlich gestartet hat. Er muss zu dem passen, den die Chart-Values unter dem zurückgesetzten Tag pinnen.
Nicht am laufenden Cluster editieren
syncPolicy.automated.selfHeal: true macht jede kubectl edit- oder kubectl set image-Änderung binnen Minuten rückgängig. Ein Rollback, der nicht im Git steht, ist kein Rollback, sondern eine Verzögerung.
Was ein Hotfix wirklich kostet
Ein Hotfix ist erst live, wenn beide manuellen Sprünge getan sind: Release veröffentlichen und targetRevision heben. Ein gemergter Pull Request allein bewegt in der Produktion nichts — auch dann nicht, wenn develop längst grün ist und die Registry die neuen Bytes hat. Der frühere Weg (workflow_dispatch + kubectl rollout restart) ist keine Abkürzung: Er hat, wie oben gemessen, ohnehin nie zuverlässig ein neues Image gezogen.
Produktion: die Application liegt im GitOps-Repository¶
Die ArgoCD-Application für den Talos-Cluster liegt nicht in diesem Repository, sondern in k8s-home-lab (src/applications/kamerplanter/deploy/argocd/application.yaml). Sie zieht das veröffentlichte Chart aus der Registry:
- Das OCI-Artefakt, kein Ordner im Git-Baum.
path: .ist die Wurzel des Artefakts selbst. - Eine veröffentlichte Chart-Version, kein Branch — die Release-Version ohne führendes
v, Releasev0.2.1ist also Chart0.2.1. Nur ein veröffentlichtes Release kann hier stehen, und niemand hebt diesen Wert automatisch an.
Nur Release-Versionen — bewusst so
Diese Instanz rollt ausschließlich Release-Versionen aus. Ein Commit auf develop erreicht sie nicht, auch dann nicht, wenn er helm/kamerplanter/** berührt: Der Anker ist eine veröffentlichte Chart-Version, und — seit die mit #1222 eingeführten Prüfungen den einen Weg geschlossen haben, auf dem diese Annahme schon einmal nicht hielt — bewegt sich eine veröffentlichte Version nicht, weil jemand mergt. Bevor es diese Prüfungen gab, ist genau das einmal passiert: charts/kamerplanter:0.2.0 wurde unter derselben Versionsreferenz aus develop überschrieben (siehe Zwei Kanäle).
Das ist der gewollte Zustand, keine Momentaufnahme — mit einer dokumentierten Ausnahme. Vom 16.08.2026 15:33 UTC (3e53606c8, der Fix zu
1210) bis zum 21.08.2026 15:17 UTC (405b43a2f) zeigte die Application¶
mit targetRevision direkt auf develop (path: helm/kamerplanter, repoURL: https://github.com/nolte/kamerplanter.git) statt auf eine veröffentlichte Chart-Version — um den unter Invariante: kein image.tag im Overlay beschriebenen Image-Pinning-Fix zu entsperren. In diesen fünf Tagen erreichte ein Commit auf develop diese Instanz tatsächlich. 405b43a2f beendete die Ausnahme und stellte den oben gezeigten OCI-Chart-Anker wieder her.
Der Preis des gewollten Zustands ist die Latenz: Zwischen „gemergt" und „läuft" liegen die beiden manuellen Sprünge aus So kommt eine neue Version in die Produktion. Wer nach einem Merge im Cluster den neuen Stand erwartet, sucht am falschen Ort — dort läuft das Release, auf das targetRevision zeigt, und das kann mehrere Releases hinterherhinken.
Wofür ein Release da ist¶
Ein Release ist gleichzeitig das Auslieferungsvehikel dieser Instanz und die Verpackung für alle anderen. Was daran hängt:
| Ergebnis | Für wen |
|---|---|
Der Anker, auf den targetRevision im GitOps-Repository gehoben werden kann | die Produktions-Instanz — der Sprung selbst bleibt ein Handgriff |
Chart-Paket oci://ghcr.io/nolte/charts/kamerplanter:<version>, mit Images auf <version>@sha256:<digest> gepinnt | Self-Hoster und fremde Cluster — siehe ArgoCD |
docker-compose-<version>.yml, .env.example-<version>, openapi.json als Release-Assets | Self-Hosting ohne Kubernetes, API-Clients |
MkDocs-Dokumentation auf GitHub Pages (release-cd-deliver-docs.yml) | Leser dieser Seite |
main auf den Release-Stand gesetzt (release-cd-refresh-master.yml) | alle, die einen stabilen Referenz-Branch brauchen |
Invariante: kein image.tag im Overlay¶
Ein Overlay, das diese Instanz konfiguriert, trägt keine per-Controller-image.tag-Overrides. Das ist keine Empfehlung, sondern die Bedingung, unter der der Digest-Pin überhaupt trägt.
Die Arbeitsteilung ist: targetRevision wählt die Chart-Version, das Chart wählt die Bytes. Ein image.tag im Overlay bricht die zweite Hälfte auf.
Die Invariante gilt auch unter einer Chart-Version
Der Digest-Pin ist keine Eigenschaft des Branches, sondern des Charts: scripts/ci/pin_chart_image_digests.sh schreibt die Digests in die Chart-Values, bevor das Release-Chart gepackt wird. Ein image.tag-Override gewinnt gegen diesen Default — unabhängig davon, ob targetRevision auf einen Branch, auf ein Release-Tag oder auf eine veröffentlichte Chart-Version zeigt. Er ersetzt den Digest durch eine bewegliche Referenz, und pullPolicy: IfNotPresent heißt dann: „nimm, was auf dem Node im Cache liegt". Damit hängt die Auslieferung wieder daran, was ein einzelner Knoten zufällig schon einmal gezogen hat — und das Anheben von targetRevision bewirkt nichts mehr.
Die Konsequenz ist zweimal gemessen worden, nicht befürchtet:
- Im ersten Vorfall (intern verfolgt als Ticket 1024) behielt ein
rollout restartstill ein altes Image; derinference-servicebediente wochenlang einen Stand ohne die/pest/*-Routen. - Im zweiten (intern verfolgt als Ticket 1210) verankerte das Overlay
targetRevision: v0.1.0mit sechsimage.tag-Overrides je Controller auf0.1.0— Images aus Releasev0.1.0, veröffentlicht am 06.08.2026. Der Fix aus #1163 mergte am 14.08.2026 nachdevelopund konnte in diesem Release nicht enthalten sein. Behoben wurde das am 16.08.2026 um 15:33 UTC durch3e53606c8, dastargetRevisionaufdevelopumstellte und alle Overrides entfernte.
Beide Male sah die Lage von außen gesund aus: Registry, Chart und Controller-Status waren einer Meinung. Nur der laufende Container war es nicht. Deshalb ist der Beweis immer der Blick in den Pod, nie in die Values-Datei.
Die Invariante steht im Klartext neben den Digests, die sie schützt — in helm/kamerplanter/values.yaml und im Overlay selbst. Ändere sie dort, nicht hier.
Prüfungen entlang der Auslieferungskette¶
Die sechs Sprünge fallen unabhängig voneinander aus. Eine Prüfung über einen Übergang sagt deshalb nichts über die anderen aus:
| Übergang | Frage | Prüfung |
|---|---|---|
| GHCR → Chart-Pin im Release | Trägt jedes Kamerplanter-Image im gepackten Chart einen Digest? | scripts/check_chart_image_digests.py im Release-Job, direkt nach dem Pinnen |
develop → veröffentlichtes Release | Trägt develop Commits, die kein veröffentlichtes Release enthält — und wie lange schon? | release-lag.yml, täglich 09:00 UTC |
Release → targetRevision im GitOps-Repo | Zeigt die Instanz auf das neue Release? | keine — der Wert liegt in einem anderen Repository |
| Chart-Pin → laufender Pod | Führt der Pod die Bytes aus, die das Chart nennt? | keine Automatik — von Hand, siehe Häufige Fragen |
Ein grüner Haken impliziert den nächsten nicht
Als der zweite Vorfall auffiel, war die damalige tägliche Digest-Prüfung (chart-image-digest-freshness.yml, bis 2026-09-10) grün — völlig zu Recht: Der Chart-Pin war aktuell. Genau das machte die Abweichung unsichtbar, denn die einzige Prüfung, die es gab, maß den Sprung, der funktionierte.
Was release-lag.yml leistet — und was nicht¶
Der Job vergleicht täglich um 09:00 UTC den Stand von develop mit dem jüngsten veröffentlichten Release. Ein Entwurf zählt ausdrücklich nicht; er wird in der Meldung sogar eigens benannt, weil genau er den Eindruck erzeugt, es sei etwas ausgeliefert. Gemeldet wird über ein einzelnes, deduppliziertes Issue mit dem Label release-lag. Der Messbericht release-lag-report.json entsteht nur im Arbeitsverzeichnis des Runners und wird nicht als Artefakt hochgeladen — er dient dem Issue-Schritt als Bedingung: fehlt er, war die Messung unbestimmt, der Lauf ist rot und es wird kein Issue geöffnet.
| Einstellung | Standard | Bedeutung |
|---|---|---|
RELEASE_LAG_THRESHOLD_DAYS | 3 | Karenzfenster. Gemeldet wird erst, wenn der älteste unveröffentlichte Commit mindestens so alt ist. |
RELEASE_LAG_BASE_BRANCH | develop | Der Branch, dessen Rückstand gemessen wird. |
RELEASE_LAG_THRESHOLD_DAYS ist eine Kadenz-Policy, kein unbegründetes Karenzfenster. Die Erwartung an dieses Repository lautet: Ein gemergter Fix soll spätestens binnen 3 Tagen ausgeliefert sein — veröffentlicht und mit gehobenem targetRevision in der Produktions-Instanz, die beiden manuellen Sprünge aus So kommt eine neue Version in die Produktion. Der Einstellungswert setzt nur die erste Hälfte dieser Kadenz durch: Er meldet, sobald der älteste unveröffentlichte Commit die 3 Tage überschritten hat, also sobald die Veröffentlichung überfällig ist. Die zweite Hälfte kann er nicht prüfen — siehe die Box „Ein Release beweist nicht, dass der Cluster es genommen hat" weiter unten, die genau diese Lücke behandelt.
Der Job ist ein Zeitplan-Job und kein Pull-Request-Gate, weil der Rückstand ohne jeden Commit wächst: Er nimmt mit jeder Stunde zu, in der niemand veröffentlicht, und schrumpft in dem Moment, in dem jemand es tut. Keines dieser beiden Ereignisse ist ein Push.
Mit dem ausgelieferten Standard hätte der Job den Vorfall nicht rechtzeitig gemeldet
Das ist nachgerechnet, nicht geschätzt. Der älteste unveröffentlichte Commit war am 13.08.2026 um 21:26 UTC entstanden; als am 16.08. um 12:00 UTC jemand erneut über denselben Fehler stolperte, war er 2,6 Tage alt — unter der Schwelle von 3 Tagen. Der erste Lauf, der Alarm geschlagen hätte, wäre der vom 17.08. um 09:00 UTC gewesen, also gut einen Tag nach dem Vorfall.
Die Schwelle ist bewusst ein Kompromiss: Ein engeres Fenster meldet gewöhnliche Wochenendentwicklung als Rückstand. Sie lässt sich ohne Codeänderung messen — beim manuellen Start des Workflows über den Eingabewert threshold_days.
Ein Release beweist nicht, dass der Cluster es genommen hat
release-lag.yml misst ausschließlich die Repository-Seite. Es sieht, dass ein Release existiert — nicht, dass targetRevision darauf gehoben wurde und schon gar nicht, welche Bytes ein Pod ausführt. Der Sprung 5 liegt in nolte/k8s-home-lab und ist für jeden Workflow dieses Repositories unerreichbar. Diese Lücke ist offen und darf nicht als geschlossen gelesen werden.
GHCR-Pakete abrufen¶
Alle Images sind öffentlich lesbar. Für lokale Tests:
Häufige Fragen¶
Warum schlägt der Backend-CI fehl, obwohl die Tests lokal laufen?
Stelle sicher, dass du Python 3.14 verwendest (python --version). Die CI verwendet explizit python-version: '3.14' mit allow-prereleases: true. Abweichende Python-Versionen können zu unterschiedlichem Verhalten führen. Prüfe auch, ob alle Abhängigkeiten mit uv sync --locked --extra dev installiert wurden.
Warum wird kein neues Image gebaut, obwohl ich auf develop gepusht habe?
Das Pfad-Filtern in docker-publish.yml stellt sicher, dass nur tatsächlich betroffene Komponenten gebaut werden. Wenn du z. B. nur eine Spec-Datei geändert hast, wird kein Image gebaut. Bei v*-Tags wird das Filtern umgangen.
Wie aktualisiere ich das Helm-Chart manuell?
Du kannst docker-publish.yml über workflow_dispatch manuell auslösen. Navigiere dazu in GitHub zu Actions → Build & Publish Container Images → Run workflow.
Wann wird main aktualisiert?
main wird ausschließlich automatisch durch release-cd-refresh-master.yml nach einem veröffentlichten Release aktualisiert. Direkte Pushes auf main sind nicht vorgesehen.
Wie sehe ich, welche Image-Version gerade läuft?
Frag die Instanz selbst:
{
"status": "healthy",
"version": "1.0.0",
"mode": "light",
"supported_majors": [1],
"build_revision": "37cbc06fcf0c7d69c07f7abbd3d485cb241070da"
}
build_revision ist der vollständige, 40-stellige Git-Commit des laufenden Builds — die Kennung, die du gegen git log hältst, um zu sehen, ob ein bestimmter Fix drin ist.
Nur über Betreiber-Konfiguration: build_revision
Das Feld ist standardmäßig abgeschaltet und erscheint erst, wenn die Instanz mit HEALTH_EXPOSE_BUILD_REVISION=true betrieben wird. Der Grund: /api/health ist unauthentifiziert. Öffentlich ist nicht der Commit-Hash — das Repository ist ohnehin offen —, sondern die Zuordnung dieser Host läuft auf jenem Commit, denn aus ihr folgt der exakte Rückstand gegenüber develop und damit die Liste der Fehlerbehebungen, die dieser Instanz fehlen. Details unter Umgebungsvariablen — Health-Endpunkt.
Für eine Produktionsinstanz, deren Auslieferungsstand von außen prüfbar sein soll, ist HEALTH_EXPOSE_BUILD_REVISION=true deshalb die empfohlene Einstellung — die Abwägung oben bleibt bestehen, sie kippt nur zugunsten der Prüfbarkeit, sobald ein Vorfall wie #1210 zeigt, was die Alternative kostet: ohne das Feld bleibt „läuft der Fix schon?" nur über kubectl-Zugriff auf den Cluster beantwortbar.
Seit #1236 setzt der Chart die Variable selbst. helm/kamerplanter/values.yaml führt HEALTH_EXPOSE_BUILD_REVISION: "true" als Chart-Default. Ein früherer Stand dieses Absatzes behauptete, die Variable sei „im GitOps-Overlay zu setzen, nicht in diesem Repository" — das war falsch: Produktion deployt genau diesen Chart (ArgoCD targetRevision gegen oci://ghcr.io/nolte/charts/kamerplanter), und Helm mischt die valuesObject-Env-Map des Betreibers in die des Charts, statt sie zu ersetzen. Nachgemessen durch Rendern des veröffentlichten Charts 0.2.1 mit dem valuesObject der Produktions-Application: Chart-Default und Betreiber-Overrides überleben beide. Eine Instanz aus einem Chart-Release nach dem 2026-09-01 antwortet also von selbst; ältere Releases, docker-compose-Installationen und ausdrückliche Overrides brauchen die Variable weiterhin gesetzt.
Wer nicht offenlegen will, überschreibt den Wert in den eigenen Values — und sollte dann scripts/ci/check_deployed_build.py nicht gegen diese Instanz laufen lassen, denn „nicht offengelegt" ist dort bewusst ein lautes unbestimmt und nie ein sauberes Ergebnis.
Die Antwort kennt drei unterscheidbare Zustände, und die Unterscheidung trägt:
| Antwort | Bedeutung |
|---|---|
| Der Schlüssel fehlt ganz | Die Instanz wurde bewusst so konfiguriert. Nichts ist kaputt — sie gibt nur keine Auskunft. |
"unknown" | Die Instanz will antworten, aber es ist keine Revision eingebacken (Entwicklungs-Image, ungestempelter Build). |
| Ein 40-stelliger Hexadezimal-Wert | Die echte Antwort. |
Der Wert wird vor der Ausgabe gegen das Muster ^[0-9a-f]{7,40}$ geprüft (nach dem Abschneiden von Leerraum, damit ein in YAML umbrochener oder in der Shell gequoteter Wert überlebt). Alles, was nicht passt, wird zu "unknown" — nie zu einem erfundenen oder abgeleiteten Wert.
build_revision ist ein Betriebssignal, keine Attestierung
Wer das Deployment kompromittiert hat, kann die Instanz jeden beliebigen Hash melden lassen. Der belastbare Nachweis bleibt gh attestation verify zusammen mit dem Digest, den der Pod tatsächlich ausführt:
kubectl get pod -n kamerplanter -l app.kubernetes.io/name=backend \
-o jsonpath='{range .items[*]}{.status.containerStatuses[0].imageID}{"\n"}{end}'
imageID nennt den Digest, den der Kubelet gestartet hat — die einzige Angabe, die weder von der Values-Datei noch vom Controller-Status abhängt.
version ist nicht die Build-Kennung
version ist die Anwendungs- und API-Version (dieselbe, die in der OpenAPI-Beschreibung unter info.version steht). Sie bleibt über viele Builds hinweg gleich und beantwortet die Frage „welche Bytes laufen hier?" nicht.
Für ein Image, das gerade nicht läuft. Die Labels org.opencontainers.image.* sind in jedes Image eingebettet; docker inspect <image> liest sie aus, der GHCR-Package-Tab auf GitHub zeigt sie ebenfalls. Damit prüfst du, was in einem Artefakt steckt — nicht, welches Artefakt dein Cluster gerade ausführt.
Zählt der Aufruf von /api/health gegen ein Rate-Limit?
Ja. /api/health ist unauthentifiziert und macht echte Arbeit — je nach Konfiguration synchrone Anfragen an TimescaleDB und den Knowledge Service —, war also ein billiger Verstärkungspunkt in interne Dienste hinein. Der Endpunkt ist deshalb pro Client-IP begrenzt; einstellbar über RATE_LIMIT_HEALTH (Standard 60/minute).
Die Kubernetes-Proben sind davon nicht betroffen: Sie zeigen auf /api/v1/health/live beziehungsweise /api/v1/health/ready und bleiben unbegrenzt.
Ist mein gemergter Fix schon ausgeliefert?
Drei Fragen — aber sie sind nicht gleichrangig, und sie widersprechen sich manchmal:
- Gibt es ein veröffentlichtes Release, das den Commit enthält?
gh release listzeigt Entwürfe alsDraft— ein Entwurf zählt nicht. Beobachtet wird das laufend vonrelease-lag.yml. - Zeigt
targetRevisionim GitOps-Repository auf dieses Release? Der Wert liegt innolte/k8s-home-labund wird von Hand gehoben. - Führt der Pod die passenden Bytes aus? Über
imageIDund, wo aktiviert,build_revision— siehe die Frage oben.
Ein image.tag-Override macht Schritt 2 unzuverlässig
Schritt 2 ist kein verlässlicher Ersatz für Schritt 3. Der Grund ist nicht hypothetisch: Es ist derselbe Override-Mechanismus, der unter Invariante: kein image.tag im Overlay beschrieben ist. Sobald ein Overlay einen per-Controller-image.tag- Override trägt, ist das laufende Image von targetRevision entkoppelt — den Anker anzuheben garantiert dann keine Änderung der laufenden Bytes mehr, weil der Override entscheidet, was gezogen wird, nicht der Digest-Pin des Charts.
Beide unter dieser Invariante dokumentierten Fälle sind genau das: der veraltete rollout restart (#1024) und die sechs per-Controller-Overrides, die die Produktions-Instanz vom 13.08.2026 19:41 UTC bis zum 16.08.2026 15:33 UTC auf das Chart v0.1.0 und seine 0.1.0-getaggten Images verankerten (#1210) — ein Zustand, der erst aufgehoben war, als ein einziger Commit sowohl targetRevision änderte als auch jeden Override entfernte, weil keine der beiden Änderungen allein genügt hätte.
Wenn sich Schritt 2 und Schritt 3 widersprechen, gewinnt immer die Beobachtung am Pod selbst — imageID, build_revision, oder ersatzweise jedes andere Feld, dessen Einführungscommit du kennst — nie das Manifest allein.
Die Reihenfolge oben ist keine UND-Verknüpfung, die alle drei Häkchen verlangt, bevor der Fix als ausgeliefert gilt — es ist eine Eskalationsleiter: Schritt 3 sticht Schritt 2, sobald beide vorliegen. Die vollständige Kette steht unter So kommt eine neue Version in die Produktion.
Steckt Commit Y in Image X — ganz ohne build_revision und ohne Cluster-Zugriff?
Ja. Diese Frage ist nicht dieselbe wie „was läuft gerade?" (siehe oben), aber sie lässt sich beantworten, ohne das Flag zu setzen und ohne überhaupt Zugriff auf einen Cluster zu haben — die Registry selbst kennt die Herkunft jedes Tags, öffentlich und ohne Login, weil das Paket öffentlich lesbar ist.
# 0. Eintragen, was du prüfen willst.
IMAGE="nolte/kamerplanter-backend"
TAG="0.0.23"
FIX_COMMIT="<fix-commit>"
# 1. Anonymen Pull-Token holen (das Paket ist öffentlich, kein Login nötig)
TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:${IMAGE}:pull&service=ghcr.io" \
| jq -r '.token')
# 2. Manifest des Tags abrufen und die OCI-Revision-Annotation entnehmen.
# Der Index-Medientyp muss im Accept-Header stehen bleiben: die Tags sind
# Multi-Arch, die Annotation hängt am Index, und wer nur das Image-Manifest
# anfragt, bekommt einen Body, in dem sie null ist.
REV=$(curl -s -H "Authorization: Bearer ${TOKEN}" \
-H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.oci.image.manifest.v1+json" \
"https://ghcr.io/v2/${IMAGE}/manifests/${TAG}" \
| jq -r '.annotations."org.opencontainers.image.revision"')
echo "${REV}"
# b40b3ccd8393a612876bdf8c48ad0144f81e32c3
# 3a. Prüfen, ob der Fix-Commit in dieser Revision steckt.
# Erst beide Commits absichern: `--is-ancestor` liefert Exit-Code 128,
# wenn *einer der beiden* lokal unbekannt ist (flacher Checkout,
# fehlender Tag, veralteter Fetch) — wer das im `||`-Zweig auffängt,
# meldet ein selbstbewusstes „nicht enthalten" für eine Frage, die gar
# nicht beantwortet wurde.
if ! git cat-file -e "${REV}^{commit}" 2>/dev/null \
|| ! git cat-file -e "${FIX_COMMIT}^{commit}" 2>/dev/null; then
echo "Revision oder Fix-Commit lokal nicht bekannt — erst fetchen, bevor du irgendetwas schlussfolgerst"
elif git merge-base --is-ancestor "${FIX_COMMIT}" "${REV}"; then
echo "enthalten"
else
echo "nicht enthalten"
fi
# 3b. Alternative: alle Tags nennen, die den Fix-Commit tragen
git tag --contains "${FIX_COMMIT}"
Setze IMAGE, TAG und FIX_COMMIT in Schritt 0 auf die Werte, die du prüfen willst — jeder spätere Schritt liest sie zurück, weiter unten ist nichts fest verdrahtet. Beide git-Befehle laufen lokal gegen deinen Checkout des Repositories; fehlt einer der beiden Commits, erst git fetch origin <commit> ausführen und die Prüfung danach wiederholen.
Das beantwortet eine andere Frage als Schritt 3 der vorigen Frage
Diese Kette identifiziert ein Artefakt — sie sagt, was in einem Image steckt, das irgendwo in einer Registry liegt. Sie sagt nicht, welches Artefakt dein Cluster gerade ausführt, und ersetzt Schritt 3 von „Ist mein gemergter Fix schon ausgeliefert?" nicht. Sie beantwortet eine andere Frage: „steckt Commit Y in Image X?" statt „läuft Image X gerade?". Für Letzteres bleibt der Blick in den laufenden Pod (imageID, build_revision) der einzige belastbare Weg.