Man kann CI-Jobs nicht einfach blind zusammenlegen
Acht Lint-Jobs zu einem zusammenzulegen spart Runner-Slots auf einem kleinen Pool — verlockend, aber es wirft still Job-für-Job-Verhalten weg, von dem man gar nicht wusste, dass man sich darauf verlassen hatte. Teil 2 von „The Ops Log“.
TL;DR
TL;DR: N Jobs zu einem zusammenzulegen verliert per-Job allow_failure, per-Job Artefakte/JUnit und Rot/Grün pro Check in der UI. Den zusammengelegten Job additiv aufbauen und differenziell validieren — neu gegen alt auf einem echten Repo laufen lassen und die Verdikte vergleichen — bevor irgendetwas fleet-weit getauscht wird.
Was passiert ist
Die Lint-Stage lief mit acht separaten Jobs auf demselben PHP-Image: composer normalize / validate / psr, php-lint, json, yaml, typoscript, commit-msg. Acht Jobs bedeuten acht Pulls desselben Images und acht Runner-Slots. Auf einem Spot-Pool, der bei sechzehn gleichzeitigen Jobs deckelt, ist das der Flaschenhals. Sie zu einem Job zu falten → ein Pull, ein Slot. Klingt nach einem leichten Gewinn.
Was „ein Job“ leise kostet, fällt erst auf, wenn es ein konkretes Repo trifft:
- Per-Job
allow_failure. Ein Fork in der Flotte setztlint:composer:normalize: allow_failure: true, weil sein Upstream-Code das Normalize-Gate nicht erfüllen kann. Zusammengelegt hat dieses tolerierte Scheitern nirgends mehr Platz — der ganze Job wird rot. - Per-Job-Artefakte. Der eslint-Job liefert einen JUnit-Report, den die Merge-Request-UI inline rendert. Ein kombinierter Job bedeutet einen Artefakt-Vertrag.
- Rot/Grün pro Check. Acht Jobs geben acht Signale im Pipeline-Graph. Ein Job gibt eins — „Lint fehlgeschlagen“, ab ins Log.
- Parallelität. Acht Jobs laufen gleichzeitig; ein Job führt die Checks sequenziell aus.
Der zusammengelegte Job muss also die Semantik, die er einzieht, neu implementieren. Change-Gating wandert ins Skript (jeder Check läuft nur, wenn Dateien betroffen sind, die ihn angehen — fail-safe: Kann die Diff-Basis nicht aufgelöst werden, läuft alles statt still grün zu überspringen). Und allow_failure kommt als per-Check-soft-checks-Input zurück:
soft-checks — allow_failure pro Check
run() { # $1=key $2=label $3=fn
if ! ( set -eu; "$3" ); then
case " $SOFT " in
*" $1 "*) echo "⚠ $2 fehlgeschlagen — soft, blockiert den Job nicht" ;;
*) echo "✗ fehlgeschlagen: $2"; FAILED=1 ;;
esac
fi
}
Dann — und das ist der eigentliche Punkt — nicht darauf vertrauen, weil das YAML lintet und bash -n durchläuft. Differenziell validieren: den neuen Job additiv ausliefern (die alten acht unangetastet lassen), ihn bei einem echten Consumer neben die Originale stellen, und die Verdikte Check-für-Check auf demselben Commit vergleichen.
Genau dieser eine Test hat drei Dinge aufgedeckt, die keine noch so gründliche Lektüre gefunden hätte:
differenzieller Lauf — erster Consumer
lint:composer:normalize failed (allow_failure — toleriert)
lint:composer:validate success
lint:composer:psr-verify success
lint:php:all failed ← warum?
── composer normalize ⚠ soft soft-checks funktioniert ✓
── composer validate ok
── json lint ✗ jsonlint-Binary fehlt → siehe Teil 1- Die
allow_failure-Lücke — der Fork wäre bei einem blinden Swap gebrochen. (Behoben mitsoft-checks.) - Eine harte Abhängigkeit von einem
jsonlint-Binary, das nicht überall vorhanden ist. (Behoben mit einem Fallback.) - Der
register_argc_argv-Bug in genau diesem Fallback — der nur auffiel, weil ein echtes Repo ihn im echten Image ausführte. (Teil 1.)
Nichts davon ist der Happy Path. Eine grüne Pipeline auf dem neuen Job hätte „bestanden“, ohne irgendetwas über Äquivalenz zu beweisen. Die Schlussfolgerung, die ich mir immer wieder neu erarbeite: Die zusammengelegte Abstraktion bleibt Opt-in für Repos, die sauber durchlaufen, und der flottenweite Swap wird zu einer geplanten Migration — weil jeder Consumer, der eine Per-Job-Einstellung überschrieben hat, kartiert werden muss, nicht angenommen.
Die Lehre
Eine grüne Pipeline beweist den Happy Path, nicht dass Ihr neues Ding äquivalent zum alten ist. Differenzielle Validierung an einem echten Consumer ist die günstigste Versicherung, die Sie vor einer flottenweiten Änderung kaufen können.
Häufige Fragen
Warum wird die Migration pro Consumer statt fleet-weit auf einmal gemacht?+
Weil jeder Consumer, der eine Job-Einstellung individuell überschrieben hat (z. B. allow_failure), kartiert werden muss — ein blinder flottenweiter Swap würde genau diese Anpassungen verlieren.
Reicht ein grüner Testlauf auf einem Repo als Beweis?+
Nein — ein einzelner grüner Lauf zeigt nur den Happy Path. Erst der Vergleich Verdikt-für-Verdikt gegen die alten Jobs auf demselben Commit deckt Abweichungen wie fehlende allow_failure-Toleranz auf.
Wann lohnt sich Job-Konsolidierung trotzdem?+
Wenn der Runner-Pool tatsächlich der Flaschenhals ist (hier: 16 gleichzeitige Slots) und die verlorene Semantik — per-Job allow_failure, Artefakte, Parallelität — im zusammengelegten Job bewusst nachgebaut wird, statt stillschweigend zu verschwinden.
Fazit
Job-Konsolidierung spart Runner-Slots, aber nur auf dem Papier umsonst. Jede per-Job-Einstellung, die verschwindet, muss im zusammengelegten Job bewusst nachgebaut werden — und der einzige verlässliche Nachweis, dass das gelungen ist, ist ein differenzieller Lauf gegen einen echten Consumer, nicht ein grüner YAML-Lint.
Ich baue und validiere Ihre CI-Pipeline-Konsolidierung — additiv, differenziell getestet, ohne stille Verhaltensverluste.
Von der Job-Analyse (welche Per-Job-Einstellungen existieren wirklich?) über additive Migration bis zur differenziellen Validierung gegen reale Consumer, bevor irgendetwas flottenweit getauscht wird.
Plattform-Betrieb statt Beratung auf Papier: Ich baue und pflege Ihre CI/CD-Pipelines laufend.
Über den Autor

Kai Ole Hartwig
Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.
