Zum Inhalt springen

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> dependencies zeigt 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.json mit committetem package-lock.json und npm ci statt npx legt 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.