Branches und was sie schützt¶
Ein einziger langlebiger Branch.
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-<short> 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 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¶
| 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 |
| 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¶
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).
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.ymlund eine*.mdauf 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 einchanges-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 dachangesselbst 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_filenameebenso ein wiefilename. Eine Umbenennung meldet nur den Pfad, an dem sie angekommen ist, sodass ein ausbackend/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¶
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).
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.
Zwei Grenzen, die klar gesagt gehören. 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. Und CodeQL ist hier nicht
konfiguriert: es läuft aus GitHubs Standardeinrichtung, deren Trigger nicht in
diesem Repository liegen, also ist es nicht unsere Entscheidung, ob es einen
gestapelten Pull Request liest.
Jeder Job begrenzt seine eigene Laufzeit¶
changes war der einzige Job in ci.yml, der ein timeout-minutes trug, also
erbten die anderen sieben GitHubs Standardwert von 360 Minuten
(#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) — 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 |
20 | 5m08s |
docker |
20 | 2m30s |
test |
25 | 7m43s |
e2e |
25 | 8m01s |
Die gemessenen Zeiten stammen aus Lauf 31116003994, einer vollen Matrix auf
main. 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¶
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 CLAUDE.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¶
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/<id>/attempts/<n>/jobs liefert
started_at, completed_at und Schlüsse pro Schritt — und es muss die Form
attempts/<n> sein, denn ein Re-Run schreibt um, was der schlichte Endpunkt
runs/<id>/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¶
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. CLAUDE.md hat das Format.
Also erreichen wip, fixup und try again es nie — und die Beschreibung ist
keine Höflichkeit.
Die Notluke¶
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¶
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 vonpydantic-ai-slimnicht getroffen. Dieses Schweigen kostete Monate: die Gruppeagent-frameworkseröffnete keinen einzigen Pull Request, und die Laufzeit ritt mit ihren Majors inbackend-everything-elsemit. Umgekehrt bleibtfastapiexakt, weil es ohne Extras deklariert ist und keine Wildcard braucht; früher musste es eine vermeiden, dafastapi*auchfastapi-cache2fing, bis diese Abhängigkeit in #155 entfernt wurde. - Dependabot kann
frontend/bun.locknicht aktualisieren. Sein npm-Ökosystem kenntpackage-lock.json,yarn.lockundpnpm-lock.yaml, und nicht das von bun. Ein Frontend-Sprung kommt also alspackage.jsonallein an, undbun install --frozen-lockfileweist die Abweichung ab, wodurchtest-frontendunde2eaus 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:
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.
Reviews¶
Der automatisierte Reviewer 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.
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
stattdessen die bereits entschiedenen Befunde auf, und einen davon aufzulösen
kostet einen Klick statt eines Aufsatzes.
Zusammenfassung¶
- 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 mitskippedist ein Pass, kein Problem. mainist vom Abbruch ausgenommen, und die Art der Ausnahme ist eine Concurrency-Gruppe, diegithub.run_idträgt — eindeutig pro Lauf, sodass keinmain-Lauf einen anderen abbricht.