--- source_sha: "9fe09e5e598b" --- # Branches und was sie schützt { #branches-and-what-protects-them } Ein einziger langlebiger Branch. ``` feat/… fix/… ──pull request──▶ main ``` `main` ist das, was ein Leser dieses Repositorys klont, und das, woraus Tags geschnitten werden. Alles, was dorthin gelangt, tut das als gequetschter Commit aus einem kurzlebigen Branch, nachdem die CI am Pull Request gelaufen ist. Ein Push auf `main` und ein `v*`-Tag veröffentlichen außerdem jeweils die beiden Container-Images - `ghcr.io/vstorm-co/agenticos-backend` und `-frontend`, `edge` und `sha-` aus dem Branch, die Version und `latest` aus dem Tag - über `.github/workflows/images.yml`. Der Workflow hat keinen Pull-Request-Trigger, also kann ein Fork nichts unter dem Namen der Organisation veröffentlichen, und er weist einen Commit ab, der nicht auf `main` liegt, sodass auch ein aus einem Branch gepushter Tag `latest` nicht verschieben kann; [Deploy](deploy.md#the-images) sagt, was sie zieht. Es gibt kein `dev`. Kurzzeitig gab es das: Arbeit landete dort und erreichte `main` in Release-Pull-Requests. In dieser Größenordnung brachte es einen Staging-Branch, den niemand brauchte, und kostete einen zweiten Ort, an dem jede Änderung liegen musste, also wurde es entfernt. ## Was erzwungen wird, und wodurch { #what-is-enforced-and-by-what } | Regel | Erzwungen durch | |---|---| | Kein direkter Push auf `main` | Ruleset — ein Pull Request ist erforderlich | | CI grün vor dem Merge | Erforderliche Status-Checks: `lint`, `test`, `test-frontend`, `e2e`, `docs`, `Security Scan` | | Kein neuer Code-Scanning-Alert wird gemergt | Merge-Schutz durch Code Scanning im Ruleset. **Nicht** der Status der `analyze`-Jobs selbst, der grün ist, was die Analyse auch gefunden hat — siehe [CodeQL](#codeql-runs-on-the-pull-request) unten | | Squash beim Merge | Ruleset — die einzige erlaubte Merge-Methode | | Konversationen aufgelöst | Ruleset | | Veraltete Freigaben werden bei einem neuen Push verworfen | Ruleset | | Kein Force-Push, keine Löschung | Ruleset | | Kein Commit, während man auf `main` steht | `no-commit-to-branch` in `.pre-commit-config.yaml` | | Rechtschreibung, über jede versionierte Datei | codespell — als Hook auf den Dateien, die ein Commit berührt, und als `make lint-spelling` im `lint`-Job der CI über den gesamten Baum | | Routen halten nur Router, keine Banner-Kommentare, keinen toten Code | `check_routes.py`, `check_comments.py` und `vulture` — Hooks in `.pre-commit-config.yaml` (jeder scannt den gesamten Baum, `pass_filenames: false`) und Schritte von `make lint-backend` im `lint`-Job der CI | | Keine deklarierte Abhängigkeit, die nichts importiert | `deptry` — ein Schritt von `make lint-backend` im `lint`-Job der CI, der auf DEP002 und DEP004 prüft. Kein Pre-Commit-Hook: es liest das gesamte Manifest gegen den gesamten Baum, es gibt also keine Variante der Frage pro Datei | | YAML-Formatierung, Workflow-Sicherheit, die Pre-Commit-Grundlagen, über jede versionierte Datei | yamlfmt, zizmor und `pre-commit-hooks` (`end-of-file-fixer`, `trailing-whitespace`, `check-yaml/json/toml`, `detect-private-key` …) — als Hooks auf den Dateien, die ein Commit berührt, und als `make lint-precommit` im `lint`-Job der CI über den gesamten Baum. Wie bei der Rechtschreibung sind diese ihrer Natur nach dateiweise, sodass ein `rev:`-Sprung, der eine neue Regel mitbringt, jede bestehende Datei bricht, ohne dass es jemand bemerkt, bis eine unbeteiligte Änderung daran scheitert | Ein Hook liest immer nur, was ein Commit berührt, was ihn für sich genommen zu einem schlechten Tor macht: ein Tippfehler, der mit seiner Datei gemergt wird, liegt dort, bis jemand diese Datei aus einem anderen Grund bearbeitet, und dessen Commit wird an einem Wort abgewiesen, das er nicht geschrieben hat. Deshalb steht die Rechtschreibprüfung zweimal in der Tabelle — der Hook ist die schnelle Rückmeldung, `make lint-spelling` hält die Aussage für den gesamten Baum wahr. Die Status-Checks sind heute einzeln aufgeführt. Sie sollten zu einem einzigen aggregierenden Job `All Checks Passed` zusammenfallen, damit das Hinzufügen eines CI-Jobs nicht mehr bedeutet, "daran zu denken, ein Ruleset zu bearbeiten" — eine Liste erforderlicher Checks, die vom Workflow abdriftet, ist der Weg, auf dem ein Build am Ende über nichts grün wird. ### Ein erforderlicher Check darf berechtigterweise `skipped` melden { #a-required-check-may-legitimately-report-skipped } !!! info "Ein erforderlicher Check mit `skipped` ist ein Pass, kein Problem" GitHub erfüllt einen erforderlichen Status-Check mit `success`, `skipped` **oder** `neutral`. Ein Branch, der nur das Backend berührt, bekommt also gar keine Antwort vom Frontend - was heißt, dass "grün" auf so einem Branch eine Aussage über weniger Jobs ist, als `make check` ausführt. Drei dieser sechs laufen nicht bei jedem Pull Request. `test`, `test-frontend` und `e2e` kosten 8,2, 5,3 und 5,1 abgerechnete Minuten, und ein `changes`-Job entscheidet, welche davon ein Änderungssatz nachweislich nicht beeinflussen kann — `scripts/ci_changed_scope.py`, sodass die Regel prüfbar ist statt ein Glob in einer YAML-Datei ([#317](https://github.com/vstorm-co/agenticos/issues/317)). Deshalb ist das Tor ein `if:` auf Job-Ebene und **kein** `paths:`-Filter auf dem Workflow: ein herausgefilterter Workflow meldet seine Checks überhaupt nie, sodass das Ruleset auf sechs Kontexte wartet, die niemals eintreffen, und der Merge-Button für immer grau bleibt. Der Klassifizierer ist in der zurückhaltenden Richtung geschrieben: **ein Job wird nur dann übersprungen, wenn jeder geänderte Pfad nachweislich für ihn irrelevant ist**, sodass ein unbekannter Pfad alles ausführt. Die freizügige Schreibweise derselben Idee würde ein neues Verzeichnis stillschweigend eine Suite vom Laufen abhalten lassen — was kein roter Build ist, sondern ein grüner, bei dem ein Tor fehlt, und dieses Repository hat dafür schon zweimal bezahlt (#143, #165). Es gibt nur zwei Ausnahmen, beide geprüft statt angenommen: - `docs/**`, `mkdocs.yml` und eine `*.md` auf oberster Ebene, weil kein Test eine davon liest; - die jeweils andere Hälfte des Baums, für jede der beiden Unit-Suiten. `e2e` ist von keiner der beiden Hälften ausgenommen, und `lint` wird überhaupt nie durch ein Tor geführt — weil `make lint-spelling` und `make lint-precommit` jede versionierte Datei lesen. Die zweite Ausnahme macht vor einem Verzeichnis halt. `frontend/src/app/api/**` ist das BFF, und `backend/tests/api/test_bff_forwarded_paths.py` prüft die `/api/v1/…`-Pfade, die diese Handler fest verdrahten, gegen die Routentabelle des Backends — eine Änderung an einem Proxy führt also auch die Backend-Suite aus. Sie dort zu überspringen wäre derselbe Fehler "grün mit fehlendem Tor" wie oben, an genau dem Test, der geschrieben wurde, um ihn zu fangen. Zwei Details braucht die zurückhaltende Richtung, damit sie tatsächlich hält, und die erste Fassung hat beide falsch gemacht: - Jeder torgeführte Job trägt `!cancelled()` neben der Ausgabeprüfung. Ohne das würde ein `changes`-Job, der **fehlschlägt** — ein 502 von der API, ein Rate Limit — alle drei Suiten überspringen, ohne dass ihre Bedingungen je gelesen würden, und da `changes` selbst kein erforderlicher Kontext ist, würde der Merge-Button über einem Branch grün, auf dem keine Suite gelaufen ist. - Der Job füttert `previous_filename` ebenso ein wie `filename`. Eine Umbenennung meldet nur den Pfad, an dem sie angekommen ist, sodass ein aus `backend/` verschobenes Modul sonst ein Frontend-Pfad wäre und die Backend-Suite für eine Änderung überspringen würde, die ein Backend-Modul gelöscht hat. Was ein Änderungssatz überspringt, steht im Log des `changes`-Jobs. Lokal wird nichts übersprungen: `make check` führt den gesamten Satz aus. ### Ein gestapelter Pull Request führt die CI ebenfalls aus { #a-stacked-pull-request-runs-ci-too } Zwei Branches, die dieselbe Datei bearbeiten, sollen gestapelt werden — der zweite wird gegen den ersten geöffnet statt gegen `main` — deshalb trägt der `pull_request`-Trigger in `ci.yml` **keinen `branches:`-Filter**. Dieser Filter greift auf der *Basis*, und solange er da war, passte ein gestapelter Pull Request auf keinen Trigger und führte überhaupt nichts aus ([#359](https://github.com/vstorm-co/agenticos/issues/359)). Die gefährliche Hälfte war nicht der fehlende Lauf, sondern wie er sich las. Ein Pull Request ohne Jobs zeigt eine **leere** Check-Liste, keine rote: `gh pr checks` antwortet "no checks reported" und das Rollup ist leer, was aussieht wie ein Lauf, der noch nicht begonnen hat. Vier Pull Requests wurden an einem Tag so gemergt, jeder nur auf einem Laptop verifiziert. Nichts schloss die Lücke, bis das Kind nach dem Merge seines Elternteils auf `main` umgehängt wurde, und genau dann wartet niemand auf einen frischen Lauf von sieben Minuten. Es kostet wenig: der `changes`-Job klassifiziert ein gestapeltes Kind anhand seines eigenen Diffs — er liest `pulls/{n}/files`, was dem Vergleich gegen die eigene Basis dieses Pull Requests entspricht — und die Concurrency-Gruppe weiter unten bricht die überholten Läufe des Kindes ab wie bei jedem anderen auch. Dass der Trigger keinen Basisfilter trägt, wird geprüft statt angenommen, in `backend/tests/test_ci_workflow.py`. Das muss so sein: ein Workflow, der nicht auslöst, erzeugt keinen Beleg dafür, dass er es nicht getan hat, also kann nichts an einem Lauf die Regression zeigen. Dieselbe Datei prüft die andere Eigenschaft, die kein Lauf zeigen kann — dass jeder Job seine eigene Laufzeit begrenzt, siehe unten. Eine Grenze, die klar gesagt gehört: **ein grüner gestapelter Pull Request wurde gegen sein Elternteil geprüft, nicht gegen `main`** — Checks gehören zu einem Head-Commit, also trägt das Umhängen das alte Ergebnis unverändert weiter; das liegt am Stapeln selbst und nicht an etwas, das ein Trigger beheben könnte, und es ist ein Grund, Stapel kurz zu halten. ### CodeQL läuft am Pull Request { #codeql-runs-on-the-pull-request } CodeQL war hier die zweite Grenze. Es lief aus GitHubs Standardeinrichtung auf einem wöchentlichen Plan, deren Trigger nicht in diesem Repository liegen, also traf ein Fund erst nach dem Merge, der ihn eingeführt hatte, auf `main` ein. `.github/workflows/codeql.yml` ersetzt das ([#1415](https://github.com/vstorm-co/agenticos/issues/1415)): die Analyse läuft am Pull Request, auf demselben Trigger wie alles andere hier, und der wöchentliche vollständige Lauf bleibt für die Query-Packs, die sich zwischen Merges aktualisieren. Zum Workflow gehören zwei Repository-Einstellungen, und keine davon kann es selbst vornehmen. Die erste: die Standardeinrichtung muss abgeschaltet werden — GitHub weist den Upload einer erweiterten Konfiguration ab, solange sie konfiguriert ist, die beiden können also nicht nebeneinander bestehen: ```bash gh api -X DELETE repos/vstorm-co/agenticos/code-scanning/default-setup ``` Die zweite ist das, was einen Merge tatsächlich verweigert. **Ein grüner `analyze`-Job heißt, dass die Analyse gelaufen ist, nicht dass sie nichts gefunden hat**: `codeql-action/analyze` lädt seine Ergebnisse hoch und endet mit 0, was auch immer darin steht. Den Pull Request blockiert der [Merge-Schutz durch Code Scanning](https://docs.github.com/en/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts), eine Regel im Ruleset von `main` neben den sechs erforderlichen Status-Checks, mit einem Werkzeug und einer Schwellenwert-Stufe konfiguriert. Ohne ihn ist der Alert am Pull Request und im Security-Tab sichtbar, und nichts hält den Merge auf — also dort, wo dieses Repository vorher war, nur langsamer. ### Jeder Job begrenzt seine eigene Laufzeit { #every-job-bounds-its-own-runtime } `changes` war der einzige Job in `ci.yml`, der ein `timeout-minutes` trug, also erbten die anderen sieben GitHubs Standardwert von **360 Minuten** ([#364](https://github.com/vstorm-co/agenticos/issues/364)), sodass ein hängender Job seinen erforderlichen Status-Check sechs Stunden lang gehalten hätte, ohne dass irgendetwas in diesem Repository ihn früher beendet. Das war als Vorsichtsmaßnahme gegen etwas geschrieben, das niemand gesehen hatte. Vierzehn `e2e`-Läufe erreichten die Grenze in den vier Tagen bis zum 18. August ([#879](https://github.com/vstorm-co/agenticos/issues/879)) — und wie ein Job dabei aussieht, steht weiter unten. | Job | Grenze | Gemessen | |---|---|---| | `changes` | 5 | 7s | | `lint` | 10 | 22s | | `Security Scan` | 10 | 14s | | `docs` | 15 | 4m34s | | `test-frontend` | 45 | 20m07s | | `test` | 90 | 22m25s | | `e2e` | 25 | 8m01s | Die meisten Zeiten stammen aus Lauf 31116003994, einer vollen Matrix auf `main`. Die Backend-Zeit stammt aus Lauf 41102882122, die Frontend-Zeit aus 41108431368, beide vom 30. Oktober 2026. Images werden separat in `images.yml` gebaut. Jede Grenze liegt beim Mehrfachen ihres Jobs statt knapp darüber: das Timeout existiert, um einen Hänger zu beenden, und eines, das eng genug ist, um einen berechtigt kalten Cache zu kappen, ist ein roter Build aus einem Grund, der nichts mit dem Diff zu tun hat. ### Ein Lauf pro Branch { #one-run-per-branch } `ci.yml` trägt eine Concurrency-Gruppe mit Schlüssel `github.ref`, sodass ein erneuter Push auf einen Branch dessen vorigen Lauf abbricht. Das ist wichtig, weil `AGENTS.md` einen Commit und einen Push pro fertigem Teilstück verlangt: ohne Abbruch wurden 75 der 369 Läufe in den ersten sechs Augusttagen überholt, während sie noch liefen — etwa 1.800 abgerechnete Minuten, die Fragen zu Commits beantworteten, auf die niemand wartete. **Ein Push auf `main` ist ausgenommen, und wie er ausgenommen wird, ist der interessante Teil.** Der Lauf des Merges selbst ist das, was Historie und Badge überhaupt bedeutsam macht, also darf ein `main`-Lauf weder abgebrochen noch eingereiht werden. `cancel-in-progress: false` liefert nur das erste davon. `false` heißt *einreihen*, und GitHub bricht jeden zuvor **wartenden** Lauf einer Gruppe ab, wenn ein neuerer eingereiht wird. Bei einer einzigen Gruppe für `main` — Merge A läuft, B wartet — würde ein landendes C B rundheraus abbrechen, und Bs Commit bekäme überhaupt keine CI. Bei vierzehn Releases in sechs Tagen gegen einen `main`-Lauf von rund 10 Minuten sind zwei Merges innerhalb eines Fensters keine seltene Form. Also trägt die Gruppe bei einem Push `github.run_id`, das pro Lauf eindeutig ist: jeder Merge bekommt eine eigene Gruppe und kollidiert mit nichts. Pull Requests lösen alle zum selben Suffix auf und brechen einander weiterhin pro `github.ref` ab. ### Zwei Dinge melden `cancelled`, und nur eines davon ist das { #two-things-report-cancelled-and-only-one-of-them-is-that } Der Abschnitt oben beschreibt den Abbruch, der wie entworfen funktioniert, und er ist die Erklärung, nach der alle greifen. **Das andere ist ein Job, dem seine `timeout-minutes` ausgegangen sind** — GitHub verzeichnet einen Job, den es an der Grenze beendet hat, als `cancelled` und nicht als Fehlschlag — und ein erforderlicher Check mit `cancelled` gilt *nicht* als Pass, wie ein `skipped` es tut, sodass der Merge über einem Diff blockiert bleibt, mit dem alles in Ordnung ist. Sie auseinanderzuhalten kostet einen Blick: | | Überholt (#317) | An der Grenze beendet (#879) | |---|---|---| | Was sonst im Lauf ist | jeder laufende Job gemeinsam abgebrochen | **ein** Job; der Rest ist grün | | Der Schluss des Laufs selbst | `cancelled` | `success`, abzüglich des einen Jobs | | Dauer des abgebrochenen Jobs | was immer er erreicht hatte | seine `timeout-minutes`, auf die Sekunde | | Ein neuerer Push auf dem Branch | ja — das ist die Ursache | nein | | Die letzte Zeile des Logs | `The operation was canceled.` | dieselbe Zeile, und das ist die Falle | Die Dauer ist das Erkennungszeichen. `gh api repos/vstorm-co/agenticos/actions/runs//attempts//jobs` liefert `started_at`, `completed_at` und Schlüsse pro Schritt — **und es muss die Form `attempts/` sein**, denn ein Re-Run schreibt um, was der schlichte Endpunkt `runs//jobs` antwortet, sodass ein ins Grüne wiederholter Job dort `success` meldet und der ursprüngliche Schluss verschwunden ist. Was die vierzehn gemeinsam hatten, war ein Schritt: `playwright install --with-deps`, das nach `apt-get` ausweicht, was unbegrenzt hängt, wenn der Azure-Mirror des Runners nicht erreichbar ist. Der e2e-Job installiert überhaupt keine Systempakete mehr, und `backend/tests/test_ci_workflow.py` weist einen Schritt ab, der das täte. Die allgemeine Lehre überlebt diesen Schritt jedoch: **ein Schritt, der zu einem Dritten greift, ist ein Schritt, der ohne eigene Grenze hängen kann**, und einer, der das tut, verbraucht das gesamte Budget des Jobs und meldet sich dann als der Abbruch von jemand anderem. ## Squash, und warum der Titel des Pull Requests zählt { #squash-and-why-the-pull-request-title-matters } !!! important "Die Beschreibung des Pull Requests *ist* die Commit-Nachricht, die überlebt" `main` behält einen Commit pro Pull Request, gebaut aus Titel und Rumpf statt aus den eigenen Commits des Branches. `AGENTS.md` hat das Format. Also erreichen `wip`, `fixup` und `try again` es nie — und die Beschreibung ist keine Höflichkeit. ## Die Notluke { #the-escape-hatch } !!! warning "Es gibt keine Bypass-Akteure" Ein Owner, der jetzt etwas mergen muss, deaktiviert das Ruleset, mergt und schaltet es wieder ein - drei Klicks und ein Audit-Eintrag, was genau das richtige Maß an Reibung für etwas ist, das selten sein sollte. Das ist Absicht: ein Bypass, der immer verfügbar ist, ist ein Bypass, der wöchentlich genutzt wird, und ein Release-Weg, den niemand beschreiben kann. ## Aktualisierung von Abhängigkeiten { #dependency-updates } Das Backend läuft wöchentlich, wobei die Agent-Frameworks getrennt von allem anderen gruppiert sind — sie bewegen sich schnell, und diese Codebasis soll ihnen folgen. Das Frontend läuft monatlich, nach einer Abkühlzeit von sieben Tagen. Dependabot schlägt Aktualisierungen für **direkte** Abhängigkeiten vor. Alles darunter bewegt sich nur, wenn eine direkte es mitzieht, und genau dafür gibt es `.github/workflows/dependency-freshness.yml`: einmal pro Woche hebt es das gesamte Lock an — transitive Pakete eingeschlossen —, führt die ganze Suite dagegen aus und eröffnet ein Issue, wenn das etwas bricht. Nichts wird committet; die Aktualisierung wird mit dem Runner verworfen. `make deps-upgrade-all` ist dasselbe lokal und der Weg, auf dem sich ein rotes Issue daraus reproduzieren lässt. Zwei Dinge daran sind nicht offensichtlich, und beide haben Zeit gekostet, bevor sie verstanden waren: - **Ein Gruppenmuster muss ein abschließendes `*` tragen, um eine mit Extras geschriebene Abhängigkeit zu treffen.** `pydantic-ai-slim[openrouter,…]` wird von `pydantic-ai-slim` nicht getroffen. Dieses Schweigen kostete Monate: die Gruppe `agent-frameworks` eröffnete keinen einzigen Pull Request, und die Laufzeit ritt mit ihren Majors in `backend-everything-else` mit. Umgekehrt bleibt `fastapi` exakt, weil es ohne Extras deklariert ist und keine Wildcard braucht; früher musste es eine vermeiden, da `fastapi*` auch `fastapi-cache2` fing, bis diese Abhängigkeit in #155 entfernt wurde. - **Dependabot kann `frontend/bun.lock` nicht aktualisieren.** Sein npm-Ökosystem kennt `package-lock.json`, `yarn.lock` und `pnpm-lock.yaml`, und nicht das von bun. Ein Frontend-Sprung kommt also als `package.json` allein an, und `bun install --frozen-lockfile` weist die Abweichung ab, wodurch `test-frontend` und `e2e` aus einem Grund rot werden, der nichts mit der Abhängigkeit zu tun hat. **Erzeugen Sie es von Hand neu** auf dem Branch des Pull Requests: ```bash cd frontend && bun install --lockfile-only && git commit -am "build(deps): sync bun.lock" ``` Das zu automatisieren ist schwerer, als es aussieht: ein Workflow auf `pull_request` bekommt ein schreibgeschütztes Token, wenn Dependabot ihn ausgelöst hat, ganz gleich was sein `permissions`-Block sagt, also kann er das Ergebnis nicht zurückpushen. **Dependabot kann das Backend nicht lesen, solange es Python 3.15 verlangt.** Sein uv-Job bricht an der Zeile `requires-python` ab, öffnet also keine Pull Requests für das Backend - den Lock bewegt `make deps-upgrade` von Hand -, und seine Alerts würden weiter die zuvor gesperrten Versionen abgleichen. Deshalb übermittelt `.github/workflows/dependency-graph.yml` den Graphen selbst: Bei jedem Push auf `main`, der `backend/uv.lock` ändert, listet `scripts/dependency_snapshot.py` jede gesperrte Version, und Alerts öffnen und schließen sich an diesen. ## Reviews { #reviews } Der [automatisierte Reviewer](code-review.md) läuft bei jedem Pull Request. Er ist nie ein erforderlicher Check, kann also keinen Build zum Scheitern bringen — aber seine Befunde sind Review-Threads, und das Ruleset oben verlangt, dass diese aufgelöst sind. Antworten reicht nicht — jemand muss den Thread als aufgelöst markieren, bevor der Merge-Button zurückkommt. Siehe [code-review.md](code-review.md). Die Qualitätshälfte von CodeQL eröffnet Threads unter denselben Bedingungen, als `github-code-quality[bot]`. Sie lässt sich nicht nach Regel oder Pfad filtern — der einzige Schalter ist aus, für eine ganze Sprache, was kein lohnender Tausch ist — deshalb listet [code-review.md](code-review.md#codeql-and-the-findings-that-block-a-merge) stattdessen die bereits entschiedenen Befunde auf, und einen davon aufzulösen kostet einen Klick statt eines Aufsatzes. ## Zusammenfassung { #recap } - **Ein langlebiger Branch.** Branch, Pull Request, Squash beim Merge. - Ein Push bricht den laufenden Lauf ab, ein erneuter Push ist also auch die Entscheidung, sich für die vorige Antwort nicht mehr zu interessieren. - Die CI führt **weniger Jobs aus als `make check`** — ein erforderlicher Check mit `skipped` ist ein Pass, kein Problem. - `main` ist vom Abbruch ausgenommen, und die Art der Ausnahme ist eine Concurrency-Gruppe, die `github.run_id` trägt — eindeutig pro Lauf, sodass kein `main`-Lauf einen anderen abbricht. ## Release-Cuts und gemeinsame Releases { #release-cuts-and-release-trains } Zuerst die Feature-PRs mergen, danach einen Cut für die Gruppe vorbereiten. Der größte enthaltene Versionssprung entscheidet: `make release-cut BUMP=patch` oder `BUMP=minor` zeigt einen Plan vom aktualisierten `origin/main`. `CREATE_PR=1` erstellt einen eigenen Worktree, ändert Changelog und drei Anwendungsversionen, committet, pusht und öffnet den PR mit Ihrer `gh`-Anmeldung. Leere Unreleased-Abschnitte und gleichzeitig offene Cut-PRs werden abgelehnt. Das Werkzeug mergt und veröffentlicht nichts. Nach dem Merge Release und Tag gemeinsam auf dem Cut-Merge-Commit erstellen, wie in `AGENTS.md` beschrieben. CI und CodeQL erkennen Cuts anhand der tatsächlichen Git-Bäume. Nur CHANGELOG und Anwendungsversionen dürfen abweichen; Abhängigkeiten, Konfiguration, Dateimodi und übriger Quellcode müssen einem erfolgreichen vollständigen Lauf entsprechen. CI verlangt drei erfolgreiche große Testsuiten, CodeQL alle vier Analysen. Übersprungene Jobs zählen nicht als Nachweis. Bei PRs wird die tatsächlich geprüfte synthetische Merge-SHA gespeichert, nicht die Branch-Head-SHA der Run-API. Geprüft werden die letzten 30 erfolgreichen Läufe; fehlende Nachweise oder API-Fehler starten vollständige Prüfungen. Das gilt für Cut-PR und Merge auf `main`. Normale Änderungen behalten ihre Prüfungen, geplante CodeQL-Läufe bleiben vollständig. Ruleset und Code-Scanning-Mergeschutz bleiben aktiv. CodeQL-Wiederverwendung verlangt zusätzlich alle vier gespeicherten SARIF-Berichte einer vollständigen Analyse. Sie werden für den neuen Commit hochgeladen und ihre Verarbeitung wird abgewartet, damit der Mergeschutz Ergebnisse für diesen Commit erhält. Wiederverwendende Läufe dienen nicht als neue Quelle; geplante Analysen bleiben vollständig. Backend und Frontend werden weiter für die Cut-SHA gebaut, weil sie die Anwendungsversion enthalten. Ein Tag verwendet die Manifeste derselben SHA; Scans und alle sechs SBOMs laufen trotzdem und die SBOMs werden dem vorhandenen Release angehängt. Jedes fehlende Image wird einzeln gebaut. Browser-Manifeste können anhand des gesamten Docker-Kontexts, des festgelegten Debian-Basis-Digests und der ISO-Woche wiederverwendet werden. Der erste Build einer neuen Woche aktualisiert Pakete. SHA- und Versions-Aliase bleiben für Deployment und Rollback. Ein Produktionsdeployment wird beim Veröffentlichen eines stabilen Releases angeboten, nicht bei Entwürfen oder Vorabversionen. Produktionsfreigabe, Main-Abstammungsprüfung und manueller `workflow_dispatch` bleiben erhalten; für Rollbacks Commit oder Tag explizit wählen. Der Queue-Wächter vergleicht mit dem neuesten stabilen Release statt unveröffentlichten Main-Commits und meldet nach einem manuellen Deployment einen unbekannten Rückstand.