So automatisierst du die Bildkomprimierung in deiner CI-Pipeline mit jpgboost-cli
Ein Repository, das unkomprimierte JPEGs und PNGs ansammelt, verschlechtert den LCP und macht zugleich jeden Klon schwerer. Mit jpgboost-cli lässt sich ihre Komprimierung direkt in der CI-Pipeline automatisieren, ohne auf die Wachsamkeit jedes einzelnen Beitragenden angewiesen zu sein. Dieser Artikel zeigt die Integration mit GitLab CI, vergleicht zwei gegensätzliche Strategien und geht auf die Fallstricke ein, die in der Produktion teuer werden können.
Was unkomprimierte Bilder wirklich kosten
Jedes ohne Komprimierung committete Bild hinterlässt in der Git-Historie eine Spur als Blob mit seinem Inhalt. Selbst wenn das Bild später durch eine leichtere Version ersetzt wird, bleibt der alte Blob in der Historie. Ein git clone holt diese Historie mit. Das Gewicht alter Bilder lastet also weiter auf dem Repository, auch wenn sie längst nicht mehr verwendet werden.
In einem Repository, das über mehrere Monate Screenshots, Marketing-Visuals oder Design-Exporte ansammelt, erreicht dieser tote Ballast schnell mehrere Hundert Megabyte. Das verlangsamt Klone in der CI und erhöht den Bandbreitenverbrauch für alle Beitragenden.
Auf der Produktionsseite hört das Problem nicht beim Repository auf. Ein überdimensioniertes Bild verschlechtert den Largest Contentful Paint (LCP) unmittelbar, besonders wenn das größte Bild oberhalb der Falzkante zu dem Element wird, das die Metrik bestimmt. Der Gewinn durch Komprimierung kann erheblich sein: Im Beispiel aus der Dokumentation von jpgboost-cli schrumpft ein JPEG von 4,2 MB auf 890 KB, eine Reduktion um 79 %.
Manuelle Komprimierung skaliert nicht. Sie hängt an der Gewohnheit Einzelner. Bei jedem Merge Request muss jede beitragende Person daran denken, Bilder vor dem Commit zu optimieren. In der Praxis fällt dieser Schritt leicht unter den Tisch, sobald Zeitdruck aufkommt. Entdeckt wird das Problem meist erst Wochen später, wenn ein Audit oder ein Performance-Report die aufgelaufenen Schulden sichtbar macht.
Warum in der CI automatisieren statt lokal oder serverseitig
Bilder lokal zu komprimieren, etwa mit einem pre-commit-Hook, hängt immer von der Maschine und der Disziplin der beitragenden Person ab. Ein Hook lässt sich umgehen, deinstallieren oder fehlt schlicht auf dem Rechner einer Designerin, die Dateien direkt committet.
Serverseitige Komprimierung wiederum greift zu spät. Die Bilder sind bereits indexiert und möglicherweise schon einmal ausgeliefert worden. Das Problem ist für die ersten Besucher also sichtbar, und die Optimierung muss bei jedem Deploy erneut laufen.
Die CI ist der zuverlässigste Kontrollpunkt. Sie läuft bei jedem Merge Request, hängt nicht von der Einrichtung der lokalen Maschine ab und liefert ein sichtbares, nachvollziehbares Ergebnis in den Job-Logs. Genau dieser eine Engpass macht die Automatisierung lohnenswert, auch wenn sie Infrastrukturkosten mit sich bringt, die man von Anfang an einplanen muss.
Diese Kosten wiegen hier schwerer, weil jpgboost-cli keine eigenständige Binärdatei ist, die sich über einen Paketmanager installieren ließe. Sie wird mit JPGBoost.app ausgeliefert, läuft nur unter macOS 15 oder neuer und setzt eine Pro-Lizenz voraus.
Der Rest dieses Artikels baut deshalb auf einer konkreten Annahme auf: einem dauerhaften, selbst gehosteten macOS-Runner, um eine stabile Umgebung zu haben und die JPGBoost.app-Installation von Job zu Job zu erhalten.
jpgboost-cli installieren und lokal testen
Auf dem Runner wie beim lokalen Test steckt die Binärdatei im App-Bundle. Ein einmal angelegter Symlink erlaubt anschließend den Aufruf aus jedem Ordner.
# Symlink einmalig anlegen, um jpgboost-cli aus jedem Ordner aufzurufen
sudo ln -s /Applications/JPGBoost.app/Contents/MacOS/jpgboost-cli /usr/local/bin/jpgboost-cli
# Prüfen, ob der Befehl antwortet
jpgboost-cli --help
Die Pro-Lizenz wird anschließend einmalig und manuell auf dem Runner aktiviert. Das geschieht nie innerhalb der Pipeline selbst. Handelt es sich um die allererste Installation für diese Lizenz, verwende den Token, den du nach dem Kauf per E-Mail erhalten hast.
# Kennung dieser Maschine, bei Bedarf für den Support
jpgboost-cli --machine-id
# Erstaktivierung mit dem nach dem Kauf per E-Mail erhaltenen Token
jpgboost-cli --activate ACT-XXXX-XXXX-XXXX-XXXX-XXXX
Wurde die Lizenz bereits anderswo aktiviert (typischerweise in der App, für den persönlichen Gebrauch), ist --activate nicht mehr der richtige Weg. Der Runner muss der bestehenden Lizenz beitreten, statt eine neue zu aktivieren. Erzeuge dazu auf dem bereits aktivierten Gerät einen Code und verwende ihn auf dem Runner.
# Auf dem bereits aktivierten Gerät (App oder eine andere CLI): erzeugt einen Code
jpgboost-cli --add-device
# Auf dem Runner: tritt der bestehenden Lizenz mit diesem Code bei
jpgboost-cli --pair XXXX-XXXX
Diese Aktivierung zählt bei beiden Verfahren als eine der zwei Installationen, die die Pro-Lizenz abdeckt. Da App und CLI als zwei getrennte Installationen zählen, wäre das Kontingent bei einer Aktivierung pro Job schnell erschöpft: Nach nur zwei Durchläufen wäre die Lizenz bereits vollständig aufgebraucht. Genau deshalb muss der Runner dauerhaft sein, damit die Aktivierung von Job zu Job erhalten bleibt.
Kurzer lokaler Test, bevor es weitergeht.
# Zwei Dateien nach WebP, Qualität 60
jpgboost-cli photo1.jpg photo2.png --quality 60 --format webp --output ./compressed
GitLab einrichten (Runner und Push-Token)
Bevor du die untenstehende .gitlab-ci.yml einfügst, sind auf GitLab-Seite zwei Dinge einmalig einzurichten: den Runner registrieren und den Token anlegen, den der Korrektur-Job zum Pushen braucht.
Den Runner registrieren
Registriere auf dem Mac, den du als dauerhaften Runner ausgewählt hast (dort, wo jpgboost-cli und seine Lizenz aus dem vorigen Abschnitt bereits installiert sind), den Runner mit dem Tag macos, das beide Jobs verwenden.
gitlab-runner register \
--url https://gitlab.com \
--token <PROJECT_REGISTRATION_TOKEN>
Den Registrierungs-Token findest du unter Settings > CI/CD > Runners > New project runner (die Oberfläche erzeugt einen Einmal-Token für diese Registrierung statt des alten geteilten Registrierungs-Tokens). --executor shell ist hier entscheidend: Anders als ein Docker-Executor führt er Befehle direkt auf dem System des Runners aus, was unerlässlich ist, um jpgboost-cli bereits installiert und seine Lizenz bereits aktiviert von einem Job zum nächsten vorzufinden, statt jedes Mal in einer frischen Umgebung zu starten.
gitlab-runner register fragt interaktiv nach dem Runner-Namen und dann nach dem Executor; antworte auf die letzte Frage mit shell
Den Push-Token anlegen
Der Job fix-image-weight pusht einen Commit auf den Branch des Merge Requests. Der CI_JOB_TOKEN, den jeder Job automatisch erhält, hat auf einem geschützten Branch nicht die nötigen Schreibrechte, deshalb braucht es einen eigenen Token.
Ein Project Access Token (Settings > Access Tokens im Projekt) ist theoretisch die sauberste Wahl: An das Projekt statt an ein Konto gebunden, überdauert er den Weggang der Person, die ihn erstellt hat. In einem persönlichen Namespace im kostenlosen Tarif bietet GitLab.com diese Funktion allerdings nicht an, sie bleibt bezahlten Gruppen vorbehalten. Das ist der häufigste Grund, stattdessen auf einen Personal Access Token auszuweichen.
Ein Personal Access Token wird in den Einstellungen des Benutzerkontos angelegt, nicht in denen des Projekts (Avatar > Edit profile > Access Tokens), mit:
- Scope
write_repository - Einem Ablaufdatum, das zu deiner Token-Rotationsrichtlinie passt
Ein Nachteil, den man vor dieser Entscheidung kennen sollte: Der Token ist an das Konto gebunden, das ihn erstellt hat. Wird dieses Konto deaktiviert, verliert es den Zugriff auf das Projekt, oder verlässt die Person das Team, kann der Korrektur-Job nicht mehr pushen, ohne Vorwarnung. In einem Teamprojekt legst du ihn besser aus einem dedizierten Service-Konto an statt aus dem persönlichen Konto einer beitragenden Person.
Der erzeugte Token wird nur ein einziges Mal angezeigt: Kopiere ihn sofort und trage ihn dann unter Settings > CI/CD > Variables im Projekt als PUSH_TOKEN ein, mit den Optionen Masked und Protected, falls deine Merge Requests auf einen geschützten Branch zielen.
PUSH_TOKEN und wird weiter unten in der .gitlab-ci.yml verwendetIst der Quell-Branch deiner Merge Requests selbst geschützt, braucht das mit dem Token verbundene Konto zusätzlich die Berechtigung, direkt dorthin zu pushen, unter Settings > Repository > Protected branches > Allowed to push and merge. Sonst scheitert der git push des Jobs trotz gültigem Token an einer Zugriffsverweigerung.
Wo die Datei .gitlab-ci.yml hingehört
GitLab sucht die Pipeline standardmäßig nur an einer einzigen Stelle: in einer Datei mit exakt dem Namen .gitlab-ci.yml (mit führendem Punkt), im Wurzelverzeichnis des Repositorys, neben der README. Eine Datei in einem Unterordner oder mit abweichendem Namen wird schlicht ignoriert: Kein Fehler weist darauf hin, das Projekt verhält sich einfach so, als wäre keine CI konfiguriert.
Ist ein anderer Ort nötig, etwa eine über mehrere Repositorys geteilte Pipeline-Datei oder eine Monorepo-Struktur, kannst du unter Settings > CI/CD > General pipelines > CI/CD configuration file auf einen anderen Pfad verweisen, auch in einem anderen Projekt, mit der Syntax pfad/datei.yml@gruppe/projekt:branch. Außerhalb dieses Sonderfalls lässt du das Feld leer und behältst die Datei im Wurzelverzeichnis.
Ist die Datei einmal committet und gepusht, braucht es keine manuelle Aktivierung: GitLab erkennt sie automatisch beim nächsten Ereignis, das auf eine Regel in der Datei passt, hier das Öffnen oder Aktualisieren eines Merge Requests über $CI_PIPELINE_SOURCE == "merge_request_event". Das Ergebnis erscheint im Tab Pipelines des Projekts und direkt im gleichnamigen Tab des Merge Requests. Um die Syntax vor dem Pushen zu prüfen, statt sie bei der ersten fehlgeschlagenen Pipeline zu entdecken, bietet der eingebaute Editor (Build > Pipeline editor im Projektmenü) eine Schaltfläche Validate, die den CI-Linter von GitLab aufruft, ohne eine echte Pipeline auszulösen.
verification und correctionIntegration in GitLab CI
Der folgende Job zielt auf einen Runner mit dem Tag macos, also den oben beschriebenen dauerhaften, selbst gehosteten Runner, der bereits außerhalb der Pipeline aktiviert wurde. Er läuft ausschließlich in Merge-Request-Pipelines und vereint die beiden im nächsten Abschnitt vorgestellten Modi: Prüfung des Gewichts und anschließende automatische Korrektur.
# .gitlab-ci.yml
stages:
- verification
- correction
variables:
# Schwellenwert, ab dem ein komprimiertes Bild den Job fehlschlagen lässt (in KB)
THRESHOLD_KB: "6000"
# Vollständiger Klon: Ohne dies enthält ein flacher Klon womöglich nicht
# CI_MERGE_REQUEST_DIFF_BASE_SHA und lässt das "git diff" unten fehlschlagen.
GIT_DEPTH: "0"
check-image-weight:
stage: verification
tags: [macos]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
script:
- |
# Prüfen, ob jpgboost-cli antwortet, bevor es weitergeht
jpgboost-cli --help > /dev/null
# Nur Bilder betrachten, die in diesem Merge Request hinzugefügt oder
# geändert wurden (aktueller Umfang: .jpg und .png)
FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
if [ -z "$FILES" ]; then
echo "Kein Bild in diesem Merge Request geändert."
exit 0
fi
# Testkomprimierung nach WebP in einem Wegwerf-Ordner, um Größen zu vergleichen
mkdir -p /tmp/check-images
# (kein "readarray": macOS liefert noch bash 3.2 aus, wo diese Builtin
# fehlt, sie kam erst mit bash 4)
FILES_ARR=()
while IFS= read -r LINE; do
FILES_ARR+=("$LINE")
done <<< "$FILES"
jpgboost-cli "${FILES_ARR[@]}" --format webp --quality 75 --jobs 4 --output /tmp/check-images --json > /tmp/report.json
# Lässt den Job fehlschlagen, wenn ein Bild komprimiert noch über dem Schwellenwert liegt
THRESHOLD_BYTES=$((THRESHOLD_KB * 1000))
OVER_LIMIT=$(jq --argjson threshold "$THRESHOLD_BYTES" '[.[] | select(.compressedSizeBytes > $threshold)] | length' /tmp/report.json)
if [ "$OVER_LIMIT" -gt 0 ]; then
echo "$OVER_LIMIT Bild(er) überschreiten komprimiert weiterhin $THRESHOLD_KB KB:"
jq --argjson threshold "$THRESHOLD_BYTES" -r '.[] | select(.compressedSizeBytes > $threshold) | .path' /tmp/report.json
exit 1
fi
echo "Alle geänderten Bilder liegen unter dem Schwellenwert von $THRESHOLD_KB KB."
fix-image-weight:
stage: correction
tags: [macos]
# Unabhängig von der Stage "verification": sonst wird dieser Job in dem
# einen Fall nie erreicht, in dem er gebraucht wird (check-image-weight schlägt fehl).
needs: []
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
script:
- |
# Nur .jpg/.png, siehe den entsprechenden Kommentar in check-image-weight.
FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
if [ -z "$FILES" ]; then
echo "Kein Bild in diesem Merge Request geändert."
exit 0
fi
rm -f /tmp/report.json
THRESHOLD_BYTES=$((THRESHOLD_KB * 1000))
MIN_QUALITY=20
REMAINING=""
# Konvertiert jede Datei nach JPEG (--format jpeg, der Standardwert): Ein PNG
# komprimiert bei einem Foto deutlich schlechter als ein JPEG (siehe "Häufige
# Fallstricke"), und JPEG ist hier das erwartete Zielformat. Ungeeignet für
# ein PNG mit Transparenz (JPEG hat keinen Alphakanal), bei einem Foto kein
# Problem, aber neu zu bewerten, falls je ein Logo oder Icon durch diese
# Pipeline läuft.
#
# Senkt die Qualität schrittweise, solange das Ergebnis über dem Schwellenwert
# liegt: Ein einziger Durchlauf mit fester Qualität (60) reicht nicht immer.
while IFS= read -r FILE; do
FOLDER=$(dirname "$FILE")
QUALITY=60
while :; do
RESULT=$(jpgboost-cli "$FILE" --format jpeg --quality "$QUALITY" --output "$FOLDER" --json)
SIZE=$(echo "$RESULT" | jq '.[0].compressedSizeBytes // 0')
if [ "$SIZE" -le "$THRESHOLD_BYTES" ] || [ "$QUALITY" -le "$MIN_QUALITY" ]; then
break
fi
QUALITY=$((QUALITY - 15))
if [ "$QUALITY" -lt "$MIN_QUALITY" ]; then
QUALITY=$MIN_QUALITY
fi
done
echo "$RESULT" >> /tmp/report.json
# Die Ausgabedatei heißt immer <basis>.jpg: Hatte das Original nicht schon
# diese Endung (z. B. .png), muss das Original aus dem Repository entfernt
# werden, sonst existieren beide nebeneinander und das alte bleibt auf
# unbestimmte Zeit unkomprimiert.
case "$FILE" in
*.jpg) : ;;
*) git rm -q "$FILE" ;;
esac
if [ "$SIZE" -gt "$THRESHOLD_BYTES" ]; then
echo "$FILE liegt nach der Komprimierung weiterhin über $THRESHOLD_KB KB (Qualität $QUALITY, $((SIZE / 1000)) KB): manuelle Verkleinerung nötig."
REMAINING="$REMAINING $FILE"
fi
done <<< "$FILES"
# Komprimierungsgewinn, sichtbar im Log des Jobs (siehe "Den Gewinn messen").
# -s: /tmp/report.json enthält ein JSON-Array pro Datei (ein ">>" pro
# Durchlauf oben), "add" führt sie vor dem Summieren zu einem zusammen.
echo "--- Komprimierungsgewinn ---"
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) Bytes (\(.ratio))"' /tmp/report.json
echo "Gesamt vorher: $(jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json) Bytes"
echo "Gesamt nachher: $(jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json) Bytes"
# git diff --quiet würde ein per git rm entferntes .png nicht sehen, das durch
# ein nicht verfolgtes .jpg ersetzt wurde: status --porcelain erfasst auch
# nicht verfolgte Dateien.
if [ -z "$(git status --porcelain)" ]; then
echo "Nichts zu committen, alle Bilder waren bereits komprimiert."
exit 0
fi
git config user.name "jpgboost-ci"
git config user.email "ci@example.com"
git add -A
# [skip ci]: Ohne dies löst dieser Push eine neue merge_request_event-Pipeline
# aus, die einen weiteren Korrektur-Commit pusht, und so weiter (Endlosschleife).
git commit -m "Geänderte Bilder mit jpgboost-cli komprimiert [skip ci]"
# PUSH_TOKEN ist ein Personal Access Token (Scope write_repository), abgelegt als
# maskierte CI/CD-Variable: Der standardmäßige CI_JOB_TOKEN reicht nicht aus, um
# auf einen geschützten Branch zu pushen.
git remote set-url origin "https://gitlab-ci-token:${PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
git push origin "HEAD:${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"
# Das bestmögliche Ergebnis wird in jedem Fall gepusht (besser als nichts), aber
# der Job schlägt fehl, wenn mindestens eine Datei bei minimaler Qualität noch
# über dem Schwellenwert liegt, damit das sichtbar bleibt statt still akzeptiert
# zu werden.
if [ -n "$REMAINING" ]; then
echo "Datei(en) trotz Komprimierung weiterhin zu schwer:$REMAINING"
exit 1
fi
Prüfmodus oder Korrekturmodus
Die beiden Jobs aus dem vorigen Abschnitt zeigen zwei gegensätzliche Wege, jpgboost-cli in einen GitLab-Merge-Request einzubinden. Der eine blockiert, der andere korrigiert stellvertretend für die beitragende Person.
Im Prüfmodus verändert der Job check-image-weight nichts. Er komprimiert jedes geänderte Bild in einen Wegwerf-Ordner, vergleicht die resultierende Größe mit dem gewählten Schwellenwert und lässt die CI fehlschlagen, wenn dieser überschritten wird. Das Repository bleibt unangetastet. Es ist Sache der beitragenden Person, das Bild zu überarbeiten und neu zu committen; die Pipeline verweigert lediglich den Merge, bis das erledigt ist.
Im Korrekturmodus geht der Job fix-image-weight weiter. Er komprimiert jedes Bild direkt an Ort und Stelle im Repository, committet dann das Ergebnis und pusht es auf den Branch des Merge Requests. Die beitragende Person muss nichts mehr nacharbeiten, dafür erhält die Git-Historie einen Commit, den sie nicht selbst geschrieben hat.
| Kriterium | Prüfmodus | Korrekturmodus |
|---|---|---|
| Ziel | CI blockieren, wenn ein Bild einen Schwellenwert überschreitet | Komprimieren und einen Commit in den Merge Request pushen |
| Mechanismus | --json + jq, Vergleich mit einem gewählten Schwellenwert, dann ein explizites exit 1 (kein eingebauter Schwellenwert-Schalter) | Direkte Komprimierung, Schreiben an Ort und Stelle, dann Push |
| Wirkung auf das Repo | Keine, der Job beobachtet nur | Ein automatischer Commit im Merge Request |
| Wirkung auf die beitragende Person | Muss selbst korrigieren und neu committen | Nichts weiter zu tun |
| Erforderliche Authentifizierung | Keine | Project Access Token oder Deploy-Token in einer maskierten Variablen |
| Kompromiss | Reibung, die manuelle Korrektur bleibt bei der beitragenden Person | Schreibt die Git-Historie automatisch fort, Konfliktrisiko mit einem geschützten Branch |
In der Praxis passt der Prüfmodus zu einem Team, das die redaktionelle Kontrolle über seine Bilder behalten will (Beschnitt, Retusche, Formatwahl), bevor sie komprimiert ins Repository gelangen. Der Korrekturmodus passt zu einem Team, das lieber nie darüber nachdenken möchte, um den Preis eines zusätzlichen automatischen Commits in der Historie und eines zu verwaltenden Push-Tokens.
Den Job optimieren
Beide Jobs beschränken die Arbeit bereits auf tatsächlich geänderte Dateien, über git diff --name-only --diff-filter=ACM, statt bei jeder Pipeline das gesamte Repository erneut zu scannen. In einem Repository, das Hunderte von Bildern ansammelt, ist der Unterschied in der Laufzeit erheblich.
Mit einem dauerhaften, selbst gehosteten Runner funktioniert Caching anders. Die Direktive cache: von GitLab CI existiert vor allem, um Abhängigkeiten auf einem flüchtigen Runner wiederherzustellen, der bei jedem Job bei null anfängt. Hier ist JPGBoost.app bereits auf dem Runner installiert und muss nicht erneut geladen werden. Ein cache:-Block würde nichts hinzufügen, was der lokale Speicher des Runners nicht ohnehin schon liefert.
Die Option --json liefert die Informationen, die für einen Idempotenz-Mechanismus nötig wären. Indem man compressedSizeBytes oder besser noch einen Hash der Datei von einem Lauf zum nächsten aufbewahrt, ließen sich unveränderte Dateien erkennen und ihre erneute Komprimierung bei jeder Pipeline vermeiden.
Parallelität wiederum ist über --jobs nativ abgedeckt. Mehrere Dateien lassen sich gleichzeitig verarbeiten, jede wird unabhängig dekodiert und wieder freigegeben. Der Speicherbedarf hängt daher hauptsächlich vom Wert von --jobs ab, nicht von der Gesamtzahl anstehender Dateien. Bei einem Stapel von 12 Dateien nennt die Dokumentation einen Gewinn von rund 4× zwischen --jobs 8 und --jobs 1, eine brauchbare Größenordnung, um diesen Wert auf dem eigenen Runner zu dimensionieren, wobei zu bedenken ist, dass die Kodierung von AVIF und JPEG XL deutlich CPU-intensiver ist als die von JPEG oder HEIC.
Häufige Fallstricke
Der erste Fallstrick betrifft das Ausgabeformat. Das obige Skript konvertiert bewusst jedes Bild, PNG eingeschlossen, nach JPEG. Bei einer Fotografie bietet JPEG in der Regel ein besseres Verhältnis von Qualität zu Größe als PNG: --format jpeg zu erzwingen kann eine Datei also unter den Schwellenwert bringen, wo ein bloßes Absenken der Qualität nicht genügen würde. Der Preis dafür ist der Verlust der Transparenz, da JPEG keinen Alphakanal hat, ohne dass zwangsläufig ein Fehler in den Logs auftaucht. Diese Wahl funktioniert für Fotos, kann aber ein PNG-Logo oder -Icon mit transparentem Hintergrund stillschweigend zerstören. In einem Repository, das beide Verwendungen mischt, schränkt man den Umfang des Jobs besser ein (etwa auf einen eigenen Ordner), statt allen Bildern ein einziges Format aufzuzwingen.
In GitLab CI läuft ein Job einer Stage standardmäßig nur, wenn alle Jobs der vorigen Stage erfolgreich waren. fix-image-weight ergibt aber nur dann Sinn, wenn check-image-weight eine Überschreitung feststellt, also genau in dem Fall, in dem er mit diesem Standardverhalten nie laufen würde. needs: [] befreit den Korrektur-Job von dieser impliziten Abhängigkeit und lässt ihn unabhängig laufen, parallel zum Prüf-Job.
Der Korrektur-Job pusht anschließend einen Commit auf den Branch, der die Pipeline ausgelöst hat. Ohne Vorkehrungen kann dieser Push eine neue Merge-Request-Pipeline auslösen. Wird das Ergebnis weiterhin als zu schwer bewertet, korrigiert der Job erneut, pusht einen weiteren Commit und löst noch eine Pipeline aus. Das kann binnen Minuten in eine Schleife mit Dutzenden Commits und Pipelines ausarten. Ein [skip ci] in der Nachricht des automatischen Commits weist GitLab an, für diesen Commit keine neue Pipeline zu starten.
Das git diff gegen CI_MERGE_REQUEST_DIFF_BASE_SHA setzt außerdem voraus, dass dieser Basis-Commit lokal verfügbar ist. GitLab klont standardmäßig aber mit begrenzter Tiefe. In einem Merge Request, der viele Commits angesammelt hat, oder wenn der Ziel-Branch deutlich divergiert ist, kann der gesuchte Commit schlicht fehlen. Das git diff scheitert dann aus einem Grund, der nichts mit dem Bildgewicht zu tun hat. GIT_DEPTH: "0" erzwingt einen vollständigen Klon und umgeht dieses Problem, um den Preis eines längeren Klons bei jedem Job.
Ein weiterer Fallstrick betrifft das Erkennen von Änderungen. git diff --quiet erkennt nur Änderungen an Dateien, die Git bereits verfolgt. Sobald das Skript ein Bild nach JPEG konvertiert und das Original mit git rm entfernt, besteht die eigentliche Änderung aber aus zwei Vorgängen: dem verfolgten Entfernen des .png und dem Anlegen eines neuen, nicht verfolgten .jpg. Ein einfaches git diff meldet dann womöglich gar nichts. In der Praxis kann der Job in seinen Logs einen völlig realen Komprimierungsgewinn anzeigen, trotzdem auf "Nichts zu committen" schließen und das Ergebnis nie pushen. git status --porcelain, das auch nicht verfolgte Dateien erfasst, lässt sich davon nicht täuschen.
Bilder, die bereits in der Git-Historie liegen, sind zudem ein Größenproblem für sich. Auch wenn sie in einem neuen Commit durch eine komprimierte Fassung ersetzt werden, bleiben die alten Versionen als Git-Blobs in der Historie. Das Repository gibt den Platz dieser Dateien nicht automatisch wieder frei. Nur ein Umschreiben der Historie würde diese Altdaten entfernen, und das ist ein destruktiver Vorgang, der in einer automatisierten Pipeline nichts zu suchen hat.
Der automatische Commit des Korrekturmodus kann außerdem mit den eigenen Regeln des Repositorys kollidieren. Ein geschützter Branch kann direkte Pushes untersagen oder ein Review vor dem Merge verlangen. Ebenso könnte ein Hook oder ein anderer Formatierungsmechanismus dieselben Dateien anfassen und mit dem Komprimierungs-Job in Konflikt geraten. Diese Wechselwirkungen sind ausdrücklich zu testen, statt anzunehmen, dass sie reibungslos koexistieren.
Schließlich erfasst die Pipeline nur Bilder, die durch das Repository laufen, genauer gesagt durch den betreffenden Merge-Request-Fluss. Wer als Designerin ein Bild direkt in ein CMS, einen geteilten Ordner oder einen anderen Asset-Speicher außerhalb von Git legt, umgeht diese Prüfung vollständig. Die Pipeline verbessert die Qualität der Bilder, die im Repository versioniert sind, ist aber für sich genommen keine vollständige Asset-Management-Richtlinie.
Den Gewinn messen
fix-image-weight gibt diesen Bericht bereits in seinem eigenen Log aus, direkt nach der Komprimierungsschleife (siehe Abschnitt "Integration in GitLab CI" weiter oben), sichtbar über den Pipelines-Tab des Merge Requests, ohne dass etwas hinzukommen müsste. Das Detail: Das Feld ratio in der --json-Ausgabe liefert den Reduktionsprozentsatz pro Datei direkt, neben originalSizeBytes und compressedSizeBytes.
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) Bytes (\(.ratio))"' /tmp/report.json
Für die Gesamtsumme hängt der genaue Befehl davon ab, wie der Job /tmp/report.json schreibt. fix-image-weight schreibt im Verlauf seiner Schleife ein JSON-Array pro Datei (>>), die fertige Datei enthält also mehrere aneinandergehängte Arrays statt eines einzigen. -s (slurp) ist dann nötig, um alles zu lesen, verpackt diese Arrays aber in ein zusätzliches Array: map scheitert daran unmittelbar (jq: error: Cannot index array with string), ohne ein vorangestelltes add, das sie zuerst zusammenführt. Genau diese Form verwendet das Skript oben.
# Summe vor und nach der Komprimierung, über den gesamten Stapel
# (Datei über mehrere Aufrufe geschrieben: fix-image-weight)
jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json
jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json
Wenn du deinen eigenen Bericht anderswo erstellst, lokal (siehe "jpgboost-cli installieren") oder in check-image-weight, das alle Dateien in einem einzigen Aufruf komprimiert (>), ist /tmp/report.json dort bereits ein einziges JSON-Array, und -s ist dann weder nötig noch richtig: Es erzeugt denselben Fehler aus dem umgekehrten Grund, indem es ein bereits vollständiges Array noch einmal einpackt.
# Dieselbe Rechnung, auf einem in einem einzigen Aufruf geschriebenen Bericht
# (lokaler Test oder check-image-weight)
jq 'map(.originalSizeBytes) | add' /tmp/report.json
jq 'map(.compressedSizeBytes) | add' /tmp/report.json
jpgboost-cli misst nur, was es selbst erzeugt: die Größe vorher und nachher. Performance-Werte (LCP, Lighthouse-Score) sind weiterhin separat zu ermitteln, mit einem dafür vorgesehenen Werkzeug. Nichts im Werkzeug berechnet das nativ, und es künstlich an den Komprimierungsprozentsatz zu koppeln wäre eine nicht belegte Extrapolation.