Bilder in GitHub Actions komprimieren
Jemand legt ein 4 MB großes Hero-Bild in assets/ ab, niemand bemerkt es, und zwei
Wochen später fällt der Lighthouse-Score. Die Lösung ist kein weiterer Punkt auf
der Code-Review-Checkliste, sondern ein Schritt in der Pipeline, der das Bild
verkleinert, bevor es ausgeliefert wird.
Das ist der ganze Workflow:
# .github/workflows/optimize-images.yml
name: Optimize images
on:
pull_request:
paths:
- 'assets/**.png'
- 'assets/**.jpg'
- 'assets/**.jpeg'
- 'assets/**.webp'
- 'assets/**.avif'
permissions:
contents: write
jobs:
compress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Compress
env:
PIPIC_TOKEN: ${{ secrets.PIPIC_TOKEN }}
run: npx @pipic/cli ./assets --replace --json
- name: Commit if anything shrank
run: |
if git diff --quiet; then
echo "Nothing to commit — images were already optimal."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: compress images"
git push
Der paths-Filter leistet dort echte Arbeit. Ohne ihn läuft der Job bei jedem Pull
Request, und jeder Lauf geht das gesamte assets/-Verzeichnis durch — warum das
zählt, kommt gleich.
Der Token
CI hat keinen Browser, pipic login fällt also aus. Erstelle einen Token auf
deiner Kontoseite, speichere ihn als Repository-Secret
namens PIPIC_TOKEN, und die CLI holt ihn sich aus der Umgebung — er hat Vorrang
vor der Anmeldedatei, es gibt also nichts, wo man sich einloggen müsste.
Beachte, dass pipic token create dich bewusst auf die Webseite schickt, statt
einen Token im Terminal auszustellen. Eine gestohlene CI-Anmeldeinformation sollte
keine neuen ausstellen können.
Das Flag, das du nicht weglassen kannst
--replace überschreibt an Ort und Stelle, -o <dir> schreibt woanders hin. Beide
schließen sich gegenseitig aus, und eines von beiden ist Pflicht:
npx @pipic/cli ./assets ohne beide bricht mit einem Nutzungsfehler ab, bevor auch
nur eine Anfrage rausgeht.
In einem Workflow, der die Ergebnisse zurückcommittet, willst du --replace: die
Dateien im Arbeitsverzeichnis sind genau die, die du gleich committest.
Das Ergebnis lesen
--json gibt pro Datei ein NDJSON-Objekt aus. In einem Workflow musst du es meist
nicht parsen — git diff --quiet sagt dir bereits, ob sich etwas geändert hat —
aber es landet im Job-Log, und dort schaust du nach, wenn ein Lauf dich überrascht:
{"file":"assets/hero.png","status":"ok","before":2411233,"after":486201,"saved":1925032}
{"file":"assets/logo.webp","status":"skipped","before":18944,"after":18944,"saved":0,"error":"not smaller — original kept"}
skipped ist kein Fehler. Dateien werden nur zurückgeschrieben, wenn das Ergebnis
tatsächlich kleiner ist — ein bereits optimiertes Bild meldet also skipped mit
Begründung, und git diff bleibt dafür sauber.
Worauf man wirklich aufbauen sollte, sind die Exit-Codes:
| Code | Bedeutung | Im Workflow |
|---|---|---|
| 0 | Alles erfolgreich | Schritt läuft durch |
| 1 | Einige Dateien fehlgeschlagen | Schritt schlägt fehl — im Log nachsehen, welche |
| 2 | Nutzungsfehler | Dein Befehl ist falsch; Workflow korrigieren |
| 3 | Anmeldung erforderlich | PIPIC_TOKEN fehlt, ist falsch oder widerrufen |
| 4 | Monatskontingent aufgebraucht | Siehe unten |
Eine 3 in CI heißt fast immer, dass das Secret den Schritt nicht erreicht hat —
ein Tippfehler im env:-Block oder ein Secret, das für Pull Requests aus Forks
nicht verfügbar ist.
Die Kontingentrechnung
Das ist der Teil, über den Leute stolpern, deshalb klar heraus: das monatliche Kontingent zählt Anfragen, nicht geänderte Dateien. Jedes Bild, das die CLI an den Server übergibt, kostet eine — auch die, die als „schon klein genug" zurückkommen.
Es gibt kein lokales Gedächtnis darüber, was vorher komprimiert wurde. Zeig auf ein Verzeichnis, und es verarbeitet bei jedem Lauf alles darin.
Ein Job, der bei jedem Pull Request einen assets/-Ordner mit 200 Bildern
durchgeht, verbraucht also 200 Einheiten pro Lauf — und der kostenlose Tarif hat
100 im Monat. Zwei praktische Konsequenzen:
- Halte den
paths-Filter eng. Er verhindert, dass der Job läuft, wenn sich keine Bilder geändert haben — und das ist meistens der Fall. - Richte den Befehl auf das engste mögliche Verzeichnis.
./assets/heroesschlägt./assets, wenn dort die Bewegung stattfindet.
Wenn der Job nur die in diesem Pull Request geänderten Bilder anfassen soll, übergib sie explizit, statt ein Verzeichnis zu reichen:
- name: Compress changed images
env:
PIPIC_TOKEN: ${{ secrets.PIPIC_TOKEN }}
run: |
FILES=$(git diff --name-only --diff-filter=d origin/${{ github.base_ref }}... \
-- 'assets/**.png' 'assets/**.jpg' 'assets/**.jpeg' 'assets/**.webp' 'assets/**.avif')
if [ -z "$FILES" ]; then
echo "No image changes."
exit 0
fi
echo "$FILES" | xargs npx @pipic/cli --replace --json
Damit wird aus festen Kosten pro Lauf ein Kostenanteil proportional zum Diff — in den meisten Repositories eine Handvoll Dateien.
Exit 4 heißt, das Kontingent für diesen Monat ist aufgebraucht. Es wird nichts automatisch berechnet: Pro hebt das Kontingent auf 5.000, und das Komprimieren auf pipic.cc bleibt in jedem Fall kostenlos und unbegrenzt.
Was dieser Workflow nicht tut
Er blockiert den Pull Request nicht. Es gibt keinen Modus „nur prüfen, bei
unoptimierten Bildern fehlschlagen" — die CLI komprimiert, und du entscheidest, was
mit dem Ergebnis passiert. Wenn du eine harte Schranke willst, sieht sie so aus:
Komprimierung ausführen, den Schritt fehlschlagen lassen, wenn git diff nicht
leer ist, und die optimierten Dateien vom Autor selbst committen lassen.
- name: Fail if images weren't optimised
run: |
git diff --quiet || {
echo "::error::These images can be compressed further. Run pipic locally and commit."
git diff --stat
exit 1
}
Das tauscht Bequemlichkeit gegen Kontrolle: nichts wird im Namen des Autors committet, aber irgendjemand muss es erledigen.