Ein gepinnter Wrapper pinnt nicht, was er intern auflöst
Ende August 2026 wurde in Linumed Base der CI-Job rot, der die Dokumentation baut. Die Meldung verlangte, die Architekturdiagramme neu zu rendern - nur hatte seit Tagen niemand ein Diagramm angefasst. Die Ursache war ein Versions-Pin, dessen Kommentar ausdrücklich versprach, genau diesen Ausfall zu verhindern. Die Absicht stimmte. Die Umsetzung legte nur die Verpackung fest, nicht den Inhalt. Diese Lektion gilt für jedes Werkzeug, das über einen Paketmanager aufgerufen wird, und sie ist in zwei Minuten überprüfbar.
Der Aufbau
Die Doku von Linumed Base enthielt drei Architekturdiagramme. Ihre Quellen lagen als Mermaid-Text im Repository, ein Skript renderte sie zu SVG, die SVGs wurden committet. Bei jedem Doku-Build renderte CI alle drei neu und brach ab, wenn das Ergebnis auch nur um ein Byte von den committeten Dateien abwich. Dieser Drift-Check sollte verhindern, dass eine geänderte Quelle ohne neues Bild durchrutscht.
Damit er nicht grundlos anschlägt, war der Renderer gepinnt:
MERMAID_CLI_VERSION="11.4.2"
npx -y "@mermaid-js/mermaid-cli@${MERMAID_CLI_VERSION}" ...
Und darüber stand die Begründung: "mermaid's layout engine changes between releases, and an unpinned renderer would produce a different SVG for an unchanged source - which the CI drift check would then report as a failure nobody caused." Genau das ist dann passiert.
Der Diagnoseweg
Der Weg zur Ursache ist lehrreicher als die Ursache selbst, weil er an jeder Stelle eine bequemere Abzweigung hatte.
1. Flake vermutet - und die Vermutung geprüft. Ein Check, der ohne
erkennbaren Anlass rot wird, sieht aus wie ein Flake. Statt es dabei zu belassen, haben
wir lokal nachgerendert: Das Skript erzeugte exakt die committeten Dateien,
git status blieb leer.
Lokal und Repository stimmten also überein, nur CI nicht. Das ist kein Zufall mehr,
sondern ein Unterschied zwischen zwei Umgebungen.
2. Byteweise verglichen statt Bilder angeschaut. Die Abweichung war ein einziges Zeichen, in allen drei SVGs an derselben Stelle der eingebetteten CSS:
lokal: #my-svg .edgePath .path{stroke:#4d7a75;stroke-width:1px;}
CI : #my-svg .edgePaths .path{stroke:#4d7a75;stroke-width:1px;}
Drei Dinge ließen sich daraus ablesen. Die Abweichung steckte auch in einem Diagramm,
dessen Quelle sich seit Wochen nicht geändert hatte, lag also nicht an den Quellen.
Maße und viewBox waren
identisch, es war also kein Schrift- oder Layoutproblem. Übrig blieb ein umbenannter
interner Klassenname, und das heißt: CI rendert mit einer anderen Mermaid-Version als
die Entwicklermaschine. Trotz Pin.
3. Die Abhängigkeit nachgesehen statt geraten. Die Registry sagt in zwei Zeilen, was der Pin tatsächlich festlegt:
$ npm view "@mermaid-js/mermaid-cli@11.4.2" dependencies.mermaid
^11.0.2
$ npm view mermaid version
11.17.2
Warum der Pin nicht griff
@mermaid-js/mermaid-cli
ist ein Kommandozeilen-Wrapper. Das eigentliche Rendern macht die Bibliothek
mermaid, und die zieht
der Wrapper nicht in einer festen Version nach, sondern über die Caret-Range
^11.0.2: jede
11er-Version ab 11.0.2. Ein Lockfile, das das festhalten könnte, liefert der Wrapper
nicht mit (npm würde dafür eine npm-shrinkwrap.json
beachten, die es in diesem Paket nicht gibt).
Den Rest erledigt der Cache. Auf einer Maschine, auf der
npx den Wrapper schon
einmal installiert hat, wird diese frühere Auflösung wiederverwendet. Ein frischer
CI-Runner hat keinen Cache und löst die Range neu auf, also zur neuesten passenden
Version. Gleicher Pin, gleiche Wrapper-Version, unterschiedlicher Renderer.
Für diesen Artikel haben wir nachträglich eingegrenzt, wann genau die Umbenennung kam,
indem wir die veröffentlichten Pakete aus der npm-Registry durchsucht haben. Bis
einschließlich mermaid@11.17.1
steht dort .edgePath .path,
ab 11.17.2
.edgePaths .path.
Version 11.17.2 erschien am 25. August 2026 - dem Tag des letzten grünen Laufs.
Das Detail, das man sich merken sollte: Es war ein Patch-Release. Aus
Sicht von Mermaid völlig legitim - ein interner CSS-Klassenname in der erzeugten
Ausgabe ist keine öffentliche Schnittstelle, und semantische Versionierung verspricht
dafür nichts. Eine engere Range wie ~
hätte deshalb nicht geholfen, sie lässt Patch-Releases ebenfalls durch. Wer die Ausgabe
eines Werkzeugs byte-genau vergleicht, macht jedes Detail dieser Ausgabe zur
Schnittstelle. Dann hilft nur eine exakte Version, und zwar für alles, was an der
Ausgabe beteiligt ist.
Das Muster, nicht der Einzelfall
Mermaid ist hier nur der Anlass. Dasselbe gilt für jedes Werkzeug, das über den Namen
und die Version des obersten Pakets aufgerufen wird:
npx paket@1.2.3,
pip install werkzeug==1.2.3
oder pipx install. Die
Versionsnummer in der Kommandozeile legt fest, welches Paket geholt wird. Was dieses
Paket seinerseits nachlädt, entscheiden seine eigenen Ranges und der Tag, an dem
installiert wird.
Und noch etwas ist lehrreich: Der Kommentar über dem Pin beschrieb die Absicht korrekt und klang überzeugend. Aber ein Kommentar wird nicht ausgeführt und von keinem Test geprüft. Dass der Pin nicht leistete, was der Kommentar versprach, fiel erst auf, als er versagte.
Was das für Sie heißt
Wenn in Ihren Build-Skripten oder CI-Jobs Werkzeuge per Versionsnummer gepinnt sind, lohnen sich ein paar Minuten Prüfung - bevor ein roter Lauf danach fragt:
-
Sehen Sie nach, was der Pin wirklich festlegt.
npm view <paket>@<version> dependencieszeigt die Abhängigkeiten eines Pakets mit ihren Ranges. Steht dort ein^oder~, ist Ihr Pin nur ein Etikett auf der Verpackung. -
Halten Sie die Auflösung im eigenen Repository fest. Eine kleine
package.jsonmit committetempackage-lock.jsonundnpm cistattnpxlegt auch die indirekten Abhängigkeiten fest. Das Lockfile muss in Ihrem Projekt liegen - das Lockfile eines fremden Pakets gilt für dessen Nutzer nicht. -
Oder pinnen Sie die ganze Werkzeugkette als Container-Image, dann
aber über den Digest (
@sha256:…), nicht über einen Tag. Ein Tag kann auf ein neues Image verschoben werden, ein Digest nicht. - Testen Sie den Pin gegen einen leeren Cache. Dass es auf der eigenen Maschine reproduzierbar ist, beweist wenig, wenn dort eine alte Auflösung im Cache liegt. Aussagekräftig ist ein Lauf in einer frischen Umgebung, also ein neuer Container oder ein CI-Job ohne Cache.
Keine dieser Maßnahmen ist aufwendig. Teuer wird es nur, wenn man sie erst ergreift, nachdem ein Check ohne ersichtlichen Grund rot geworden ist und niemand weiß, ob er dem Ergebnis noch trauen kann.
Wie es bei uns weiterging
Wir haben den Pin am Ende nicht repariert. Die naheliegende Frage war, wie man Mermaid
richtig festnagelt. Die bessere Frage war, ob drei Diagramme, die sich ein paarmal im
Jahr ändern, überhaupt eine Render-Pipeline brauchen. Die Antwort war nein: Die
Diagramme sind jetzt von Hand gezeichnet und liegen als PNG im Repository. Skript,
Renderer, Chromium im CI-Job und Drift-Check sind entfernt, nachzulesen im
Changelog von Linumed Base.
Die Lektion oben gilt unabhängig davon - sie gilt überall, wo noch etwas über
npx oder einen
vergleichbaren Aufruf gepinnt wird.
Der Umfang war am Ende der eigentliche Befund: Für drei Diagramme lief eine ganze Kette
aus Renderskript, gepinntem mermaid-cli,
einer Chromium-Installation mit vierzehn Runtime-Bibliotheken im CI-Job und einem
byte-genauen Drift-Check. Dieser Check verhinderte nur ein Problem, das ohne generierte
Bilder gar nicht erst existiert. Der Änderungssatz, der das alles entfernt hat, umfasst
34 neue und 227 gelöschte Zeilen.
Die Pipeline war bei ihrer Einführung nicht falsch begründet: Es gab drei Ausgabeflächen mit jeweils eigenem Mermaid-Theme, und ein Renderer lag nahe. Sie ist nur teurer geworden als ihr Nutzen. Beim kaputten Build ist der Reflex zu reparieren. Die seltenere Frage ist, ob das Kaputte überhaupt gebraucht wird.
Die Angaben zu Paketversionen, Abhängigkeiten und Veröffentlichungsdaten stammen aus der
öffentlichen npm-Registry (Metadaten von
@mermaid-js/mermaid-cli@11.4.2
und mermaid, dazu die
veröffentlichten Pakete mermaid
11.0.2 bis 11.17.2), abgerufen am 26. September 2026.