Klon-Aktualität prüfen — Standard-Branch ermitteln, nicht main annehmen:
B=$(git ls-remote --symref origin HEAD | sed -n 's|^ref: refs/heads/\([^[:space:]]*\).*|\1|p')
git fetch origin "${B:?Standard-Branch nicht ermittelbar}" &&
git rev-list --count HEAD..FETCH_HEADDrei Server im Portfolio heissen ihren Standard-Branch master
(openlex-mcp, swiss-courts-mcp, swisstopo-mcp); dort scheitert ein fest
verdrahtetes origin/main mit «couldn't find remote ref main». Wer das für ein
Netzproblem hält, arbeitet weiter auf genau dem veralteten Klon, vor dem dieser
Absatz warnt. Den :?-Schutz nicht weglassen: Bei leerem B fetcht git still
den Remote-HEAD und endet mit 0.
Ein veralteter Klon erzeugt eine rote CI, deren Ursache nicht im Diff steht. Am 3.8.2026 zweimal passiert — beide Male fehlten genau die Commits, die das Gate einführten, an dem der Branch scheiterte.
Gates lokal fahren, mit der GEPINNTEN ruff-Version aus der CI. Eine andere Version meldet Abweichungen, die niemand verursacht hat.
Gegenprobe ist Pflicht. Ein Test, der grün bleibt, wenn man die Implementierung entfernt, prüft nichts. Jede neue Zusicherung einzeln neutralisieren und zeigen, dass genau die zugehörigen Tests fallen.
Zwei Fallen, die beide grün blieben:
- Eine Fake-Uhr, die nur beim Schlafen vorrückt, kann eine Zusicherung über echte Zeit nicht widerlegen.
monkeypatch.setattr(modul.asyncio, "sleep", ...)greift ins Modulasyncioselbst und entschärft die Mechanik im ganzen Prozess. Patche einen Modul-Alias (_sleep = asyncio.sleep), nicht das fremde Modul.
Handgeschriebene Fixtures kodieren die Annahme des Autors und können sie nicht widerlegen. Mindestens eine aufgezeichnete Antwort pro externem Endpunkt, mit Aufnahmedatum.
Roter Live-Test: erst die Quelle abfragen, dann einordnen. Nicht aus der Fehlermeldung schliessen. Am 3.8.2026 hiess "nicht gefunden" nicht, dass der Datensatz weg war, sondern dass die Quelle die Schreibweise ihrer Kopfzeile gewechselt hatte — vier von sechs Datensätzen produktiv kaputt, alle Unit-Tests grün.
Ein 4xx ist kein Nein. Am 29.8.2026 antwortete past-publications in
swiss-procurement-mcp auf jede Publikation mit Losen mit HTTP 400. Daraus war
geschlossen worden, die Quelle verweigere diese Auskunft; der Befund stand
datiert im Fixture-Nachweis, ein Test bestätigte ihn, alles blieb grün. Die
Spec desselben Endpunkts führt einen als optional deklarierten Parameter
lotId — für Publikationen mit Losen ist er Pflicht. Mit ihm antwortet
dieselbe Publikation mit 200. Ein Projekt trug sieben Vorgängerpublikationen,
die der Server als «Quelle nicht erreichbar» wegwarf.
Drei Handgriffe daraus:
- Die Parameterliste der Spec durchgehen, bevor ein Statuscode eingeordnet wird. «Optional» heisst dort oft «optional für die Mehrheit».
- Einer deterministischen Absage keinen Wiederholungsrat geben. «Nicht erreichbar, bitte später erneut» ist bei einem 400 falsch und liest sich für das Modell wie eine Störung. Den Status mitführen und den fehlenden Parameter benennen — den Status, nicht den Antwortkörper.
- Beide Antworten aufzeichnen, mit und ohne den Parameter. Eine Aufzeichnung nur des Fehlschlags kann nicht zeigen, dass er vermeidbar war; dass nur der 400er aufgezeichnet war, ist der Grund, warum der falsche Befund nicht auffiel.
Und ein 403 ist gar keine Auskunft. Am 29.8.2026 sollten für 42 Repos die Dependabot-Labels nachgemessen werden. Alle 13 Abfragen des ersten Stapels kamen zurück als:
Failed to find label: API rate limit already exceeded for user ID 8864492.
Der gefährliche Teil steht vorn: Das Werkzeug verpackt eine Sperre als Fund-Fehlschlag. Wer die Zeile überfliegt oder nur auf ein leeres Ergebnis prüft, zählt 39 Repos als «Label fehlt» und hat seine eigene Erschöpfung gemessen. Das Limit hängt am Konto, nicht am Repo — derselbe Vormittag hatte es mit 42 eröffneten und 42 gemergten PRs verbraucht.
Das ist der Absatz darüber, andersherum gelesen: dort war ein 400 eine echte, wiederholbare Antwort und galt als Störung; hier ist eine Störung als Antwort verpackt. Entscheidend ist nie der Statuscode, sondern ob die Quelle überhaupt geantwortet hat.
-
Positivkontrolle im selben Repo. Ein «nicht gefunden» wird erst dadurch zur Messung, dass eine gleichzeitige Abfrage etwas findet.
-
Die Messung entlang der Sperre teilen.
raw.githubusercontent.comist ein CDN und nicht die REST-API. Um 11:19:27 UTC lieferte es fürregister-mcpHTTP 200, während die Label-Abfrage desselben Repos in derselben Minute die Sperre meldete. Alle 42dependabot.ymlkamen so durch, während die Label-Hälfte stand. -
Am Token vorbei geht es nicht. Beide Umwege enden am Agent-Proxy, und jeder mit einer eigenen irreführenden Begründung.
api.github.comohne Zugangsdaten:GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization.Das ist keine Aussage über die Organisation, sondern das, was ohne Token kommt. Wer ihr folgt, sucht einen Admin für ein Problem, das keiner hat. Die HTML-Seite
github.com/<owner>/<repo>/labelsfällt ebenfalls, aber anders:This GitHub API path is not available: sessions are bound to their configured repositories. Use repository-scoped endpoints (repos/{owner}/{repo}/...).Der Proxy behandelt also auch
github.comals API-Pfad; die zweite Meldung klingt nach einem Scope-Problem und ist doch nur dieselbe Sackgasse. Den Token aus der Umgebung in einen curl-Header zu setzen, blockiert der Klassifikator. Ob es überhaupt hülfe, ist offen: die Sperre nennt ein Nutzerkonto, und ob der Token zu diesem gehört, wurde nie geprüft. -
Die Sperre gilt nicht dem Dienst, sondern dem Zugangspfad. Unmittelbar nachdem eine Abfrage der Checks eines PR sauber durchlief, meldete die Label-Abfrage weiter die Sperre. Von einem blockierten Werkzeug also nicht auf «GitHub ist zu» schliessen — und umgekehrt eine gelungene Abfrage nicht als Entwarnung für die gesperrte nehmen.
Wann die Sperre fällt, geben diese Beobachtungen nicht her. Die Meldung nennt
keinen Zeitpunkt, und die X-RateLimit-Kopfzeilen sind hinter dem Proxy nicht
zu sehen. Belegt sind drei gesperrte Zeitpunkte — 11:14, 11:16 und 11:19 UTC.
Wer daraus eine Dauer macht, hat sie erfunden.
Dieselbe Falle bei einer Konfigurationsoption: die Vorgabe lesen, bevor man
einen Schlüssel für wirkungslos hält. Am 29.8.2026 fielen die
labels:-Zeilen aus den dependabot.yml des Portfolios, begründet mit
«Dependabot legt Labels nicht an». Eine Messung danach zeigte, dass
dependencies in 36 von 42 Repos sehr wohl existiert, 35 davon mit GitHubs
Standardbeschreibung. Das las sich zuerst wie ein Beleg, dass die Aktion
falsch war.
Die Optionsreferenz kehrt es um:
Dependabot creates these default labels automatically, as necessary in
your repository.
If you define more than one package manager, an additional label for the
ecosystem or language is added to each pull request.
The labels specified are used instead of the default labels.
Ohne labels: vergibt Dependabot also dependencies — und, sobald mehr als
ein Paketmanager deklariert ist, zusätzlich ein Ökosystem-Label — und legt sie
selbst an; eine eigene Liste ersetzt diesen Satz, und «if any of these
labels is not defined in the repository, it is ignored». Die Zeile war nicht
wirkungslos — sie tauschte einen sich selbst pflegenden Vorgabesatz gegen eine
starre Liste.
Die Bedingung nicht weglassen. Bei nur einem Paketmanager steht das
Ökosystem-Label gar nicht zu; wer es dort trotzdem erwartet, schreibt genau
den Fehlbefund auf, gegen den dieser Abschnitt geschrieben ist — der Abschnitt
liefe an sich selbst vorbei. Im Portfolio deklariert jede dependabot.yml
zwei (pip und github-actions), die Bedingung ist hier also überall
erfüllt; anderswo nicht unbedingt. Aufgefallen ist die fehlende Bedingung
nicht beim Schreiben, sondern durch einen Codex-Review auf
swiss-environment-mcp PR #113 — vierzehn Sekunden vor dem Merge desselben
PR.
Was das kostet, ist an openlex-mcp gemessen: zwei Ökosysteme deklariert,
also stünden dependencies und ein Ökosystem-Label zu; vorhanden ist nur
das erste, github-actions und github_actions fehlen beide (Kontrolle bug
vorhanden). register-mcp ist die Gegenprobe: dort existieren alle vier
deklarierten Namen mit handgeschriebener Beschreibung, die Liste ist gewollt
und vollständig.
Dreimal falsch eingeordnet, in drei Richtungen. Erst die Zeile für bloss wirkungslos gehalten. Dann die gefundenen Labels für einen Widerspruch. Dann, auf denselben Fund gestützt, einen richtigen PR geschlossen mit dem Argument, das Label existiere ja — obwohl es existiert, weil die Vorgabe es anlegt. Der dritte Fehler ist der teuerste, weil er wie eine Messung aussah.
Was die Messung nicht hergibt: wer die 36 Labels angelegt hat. Die Referenz sagt, Dependabot tue es; die Objekt-IDs liegen aber so dicht beieinander, dass sie eher aus einem Stapellauf stammen. Beides passt zum Befund, keines ist belegt — die Herkunft blieb ungemessen.
Beim Aufräumen gilt deshalb dieselbe Frage wie bei lotId: Was ist die
Vorgabe, wenn man das Ding weglässt — nicht bloss, ob der aktuelle Wert
etwas bewirkt.
results[0] ist nur so verlässlich wie die Zusicherung danach. Pinnt die
Abfrage einen bekannten Datensatz, ist der erste Treffer eine Drift-Wache und
in Ordnung. Hängt die Zusicherung dagegen davon ab, welche Variante die
Quelle heute zuoberst hat, prüft der Test den Tag: am 25.8.2026 rot, weil die
neueste Zürcher Publikation zufällig Lose hatte, am 26.8. grün, ohne dass sich
etwas geändert hätte. Den Fall gezielt wählen und beide Zweige fahren.
PR ohne jeden Check ist selten ein Repo ohne CI, meistens ein Merge-Konflikt: GitHub berechnet dafür keinen Merge-Commit und startet nichts.
Bei einem blockierten PR nennt der Merge-Versuch den Blocker, jede Ableitung
rät. mergeable_state: blocked bei grüner CI heisst: ein required Kontext
fehlt oder steht nicht auf grün. Welcher, sagt die Einstellung — und die sperrt
der Agent-Proxy mit HTTP 403, ein MCP-Werkzeug dafür gibt es nicht. Der Ausweg
ist nicht Indizienarbeit, sondern ein Merge-Versuch über die API:
PUT /repos/<owner>/<repo>/pulls/<n>/merge
405 Required status check "Codex hat diesen Head geprueft" is expected.
Der Name steht dort wörtlich so, wie er in der Branch Protection eingetragen ist. Scheitert der Versuch, kostet er nichts.
Am 24./25.9.2026 über drei Repos vermessen, nachdem ein Gate-Workflow entfernt worden war und seinen required Kontext ohne Berichterstatter zurückliess:
| Repo | eingetragener Kontext | Art |
|---|---|---|
register-mcp |
Codex hat den PR angesehen |
Check-Run |
srgssr-mcp |
review-abgeschlossen |
Check-Run |
fedlex-mcp |
Codex hat diesen Head geprueft |
Check-Run |
Warum Ableiten hier systematisch fehlgeht. GitHub nimmt als Check-Run-Name
den Job-Namen, nicht den des Workflows. Zwei der drei Kontexte enthalten die
Zeichenfolge «codex-gate» nicht, obwohl sie aus codex-gate.yml stammen; wer in
den Einstellungen danach sucht, findet nichts und hält die Regel für abwesend.
Trug der Job kein name:, nimmt GitHub die Job-ID — daher review-abgeschlossen.
Zwei Fehlschlüsse sind dabei belegt, beide aus einer Beobachtung gezogen:
- Aus einem Commit-Status auf den required Kontext geschlossen. In
fedlex-mcpstand der Statuscodex-gateauf dem Head aufsuccessund blockierte nichts, während der fehlende Check-Run den Merge hielt. Am Kontroll-PR waren beide rot — dort ist nicht zu unterscheiden, welcher von beiden eingetragen ist. Genommen wurde der auffälligere. - Aus einer Check-Run-Liste auf den required Kontext geschlossen. Die Liste zeigt, was berichtet wurde; eingetragen sein kann ein Name, der gerade gar nicht erscheint. Genau das ist der Fall, um den es geht.
Ein Vorbehalt, der zur Methode gehört: Die Absage nennt immer nur den ersten fehlenden Kontext. Ist ein zweiter eingetragen, zeigt ihn erst der nächste Versuch. Nach jeder Änderung an der Einstellung also erneut versuchen, bis der Merge durchgeht oder ein neuer Name fällt.
Die Kosten der Ableitung sind gemessen: ein Arbeitstag, an dem der PR-Text den falschen Namen trug und in den Einstellungen nach einer Zeichenfolge gesucht wurde, die dort nicht steht.
Vor dem Anlegen eines Branches mit vorgegebenem Namen prüfen, ob es ihn schon gibt:
git ls-remote --heads origin claude/<name> | wc -lSteht dort 1, arbeitet jemand anderes daran — mit Schreibrecht auf denselben
Ref.
Ein PR mit leerem Diff wird geschlossen, nicht gemergt. Der Test ist
get_files auf dem PR: kommt [] zurück, ändert er nichts. Ein grüner Check
sagt dazu nichts — die CI prüft den Head, nicht die Differenz zur Basis.
Am 21.8.2026 liefen zwei Sessions dieselbe Aufgabe über 45 Repos, auf den
Branches claude/codex-review-audit-templates-9sn6mx und
claude/codex-review-audit-7ioh56. Wo die eine zuerst nach main kam, wurde
main in den Branch der anderen gemergt und der add/add-Konflikt zugunsten
von main aufgelöst. Übrig blieben 14 PRs, die durch sämtliche Gates grün
liefen und nichts enthielten; sie wurden gemergt und hinterliessen leere
Merge-Commits. Mit den zwei Folge-PRs, die aus demselben Grund gegenstandslos
waren, waren 16 der 59 PRs jenes Tages reine Reibung.
Dieselbe Klasse wie der handgeschriebene Stub, der denselben Feldnamen annahm wie der Code: Nichts ist rot, weil nichts geprüft wird, worauf es ankommt.
Der Default-Branch heisst master, nicht main. PRs gehen dorthin.
Werkzeug-Versionen: ruff und mypy, beide exakt und beide nur im
[dev]-Extra von pyproject.toml; die Versionen dort nachlesen, nicht hier.
Ein Install des Extras reicht also, lokal wie in der CI. Keine zweite Version
in die Workflows schreiben: ein solcher Schritt läuft nach dem Install und
überstimmt den Pin still — für ruff stand er dort
(test_werkzeug_versionen.py hält beides fest). Eine
.pre-commit-config.yaml gibt es nicht — die andere Stelle, an der ein
abweichender ruff-Pin schlummern kann. Wer eine anlegt, nimmt die Version aus
pyproject.toml.
Lokale Läufe trotzdem nur mit den Versionen aus einem frischen [dev]-Install
bewerten. Ein Werkzeug aus der Umgebung meldet Fehler, die das Projekt nicht
hat — gemessen: das ambiente mypy 1.19.1 findet in api_client.py einen
no-any-return, den 2.3.1 nicht sieht.
Gates, wörtlich aus der CI:
pytest tests/ -m "not live"
python scripts/check_ruff_pin.py
ruff check src/ tests/ scripts/
ruff format --check src/ tests/ scripts/
mypy src/
python scripts/snapshot_tool_hashes.py --check
python scripts/render_egress_acl.py --check
python scripts/check_version_sync.pyKein include unter [tool.ruff] setzen — der Umfang sind die drei Pfade im
Gate-Befehl selbst. Wer ihn prüfen will, zählt nach statt hier abzulesen:
ruff check src/ tests/ scripts/ --show-files | wc -l. ruff format meldet
dabei eine Datei mehr als ruff check, weil 0.16 auch Markdown formatiert und
damit tests/fixtures/PROVENANCE.md mitnimmt — zwei Zahlen, kein Fehler.
Der Versions-Sync prüft fünf Stellen, nicht vier. Neben pyproject.toml
(der Quelle), server.json zweimal und den beiden README-Badges zählt
uv.lock mit — die Version des Wurzelpakets. Sie blieb beim Release 0.5.0 auf
0.4.1 stehen, während die vier damals geprüften Stellen einvernehmlich richtig
waren und das Gate grün meldete; uv tree --frozen --package swisstopo-mcp gab
0.4.1 aus, und wer die Arbeitskopie über uv sync --locked oder
uv run --frozen konsumiert, bekam die alte Nummer. Gefunden hat es ein
Codex-Review, behoben hat es nicht der Check, sondern nebenbei ein
Dependency-Bump, der die Lockfile neu schrieb.
Die Lockfile nie von Hand editieren — uv lock neu laufen lassen. Repos ohne
uv.lock überspringt der Check.
Ein achtes Gate hängt an jedem PR, ausserhalb von ci.yml:
security.yml fährt gitleaks. Sein Trigger nennt branches: [master, main] —
beide, damit er eine Umbenennung des Default-Branchs überlebt. Lokal braucht
er gitleaks und läuft deshalb nicht nebenbei mit.
Die Matrix setzt kein fail-fast: false: Eine rote 3.11 bricht 3.12 und 3.13
ab, bevor sie etwas sagen.
Live-Tests: .github/workflows/live-test.yml, nächtlich per Cron
(0 4 * * *). Sie sind hier also nicht bloss per -m "not live"
ausgeschlossen; DRIFT-005 ist erfüllt.
Release-Distanz: .github/workflows/release-distance.yml, montags 05:40
UTC. Kein Gate — es blockiert nichts und kann es nicht: «pyproject-Version ist
auf PyPI schon vergeben und HEAD liegt hinter keinem Tag» trifft auf jeden
PR nach einem Release zu, ein blockierender Check wäre dauerhaft rot. Der Lauf
öffnet stattdessen ein Issue mit dem Label release-distance, sobald Commits
an src/ oder pyproject.toml jenseits des Tags stehen und die letzte
Publikation älter als 14 Tage ist. Alles andere — tests/, docs/,
.github/ — erreicht über PyPI niemanden und zählt nicht.
Lokal nachfahren mit python scripts/check_release_distance.py; das Skript ist
stdlib-only und braucht Netz nur für PyPI. --min-age-days 0 zeigt, was der
Montagslauf melden würde, wenn die Schwelle fiele.
Der Lauf hat drei Ausgänge. unknown heisst: die Messung hat nicht
stattgefunden — dann wird der Job rot, ohne ein Issue anzulegen. Wer den
Workflow anfasst, lässt fetch-depth: 0 stehen: ohne Tags im Checkout ist die
Distanz nicht messbar, und ein fehlender Tag sähe sonst aus wie «nichts
unveröffentlicht» — genau die Verwechslung, gegen die der dritte Ausgang da
ist.