Come automatizzare la compressione delle immagini nella tua pipeline CI con jpgboost-cli

Un repository che accumula JPEG e PNG non compressi peggiora l'LCP e allo stesso tempo appesantisce ogni clone. jpgboost-cli permette di automatizzarne la compressione direttamente nella pipeline CI, senza dipendere dall'attenzione di ogni singola persona che contribuisce. Questo articolo presenta la sua integrazione con GitLab CI, mette a confronto due strategie opposte e ripercorre le insidie che in produzione possono costare care.

Il costo reale delle immagini non compresse

Ogni immagine committata senza compressione lascia una traccia nella cronologia Git sotto forma di blob che ne contiene il contenuto. Anche quando l'immagine viene poi sostituita da una versione più leggera, il vecchio blob resta nella cronologia. Un git clone scarica anche quella cronologia. Il peso delle vecchie immagini continua quindi a gravare sul repository, anche quando non sono più in uso.

In un repository che accumula screenshot, materiali di marketing o export di design nell'arco di diversi mesi, questo peso morto raggiunge rapidamente diverse centinaia di megabyte. Questo rallenta i cloni nella CI e aumenta il consumo di banda per tutte le persone che contribuiscono.

Sul fronte della produzione, il problema non si ferma al repository. Un'immagine sovradimensionata peggiora direttamente il Largest Contentful Paint (LCP), soprattutto quando l'immagine più grande sopra la piega diventa l'elemento che determina la metrica. Il guadagno della compressione può essere notevole: nell'esempio riportato dalla documentazione di jpgboost-cli, un JPEG da 4,2 MB scende ad appena 890 KB, una riduzione del 79 %.

La compressione manuale non è scalabile. Dipende dall'abitudine individuale. A ogni merge request, ogni persona che contribuisce deve ricordarsi di ottimizzare le immagini prima di committarle. Nella pratica, questo passaggio salta facilmente non appena aumentano la fretta o l'urgenza. Il problema di solito viene scoperto settimane dopo, quando un audit o un report sulle prestazioni rivela il debito accumulato.

Perché automatizzare nella CI anziché in locale o lato server

Comprimere le immagini in locale, per esempio con un hook Git pre-commit, dipende sempre dalla macchina e dalla disciplina di chi contribuisce. Un hook può essere aggirato, disinstallato o semplicemente mancare sulla macchina di una designer che committa i file direttamente.

La compressione lato server, invece, interviene troppo tardi. Le immagini sono già state indicizzate e potrebbero essere già state servite almeno una volta. Il problema è quindi visibile ai primi visitatori, e l'ottimizzazione va poi rifatta a ogni deploy.

La CI è il punto di controllo più affidabile. Viene eseguita a ogni merge request, non dipende dalla configurazione della macchina locale e produce un risultato visibile e tracciabile nei log del job. È proprio questo passaggio obbligato a rendere l'automazione conveniente, anche se comporta un costo infrastrutturale da mettere in conto fin dall'inizio.

Qui quel costo pesa di più perché jpgboost-cli non è un binario autonomo installabile tramite un gestore di pacchetti. Viene distribuito con JPGBoost.app, funziona solo su macOS 15 o versioni successive e richiede una licenza Pro.

Il resto di questo articolo si basa quindi su un presupposto preciso: un runner macOS self-hosted e persistente, per avere un ambiente stabile e mantenere l'installazione di JPGBoost.app da un job all'altro.

Installare jpgboost-cli e provarlo in locale

Sul runner, come nel test in locale, il binario è contenuto nel bundle dell'applicazione. Un collegamento simbolico creato una volta sola permette poi di richiamarlo da qualsiasi cartella.

# Collegamento simbolico, una volta sola, per richiamare jpgboost-cli da qualsiasi cartella
sudo ln -s /Applications/JPGBoost.app/Contents/MacOS/jpgboost-cli /usr/local/bin/jpgboost-cli

# Verificare che il comando risponda
jpgboost-cli --help

La licenza Pro viene poi attivata una volta sola, manualmente, sul runner. Questo non avviene mai all'interno della pipeline stessa. Se questa è la primissima installazione con cui attivi questa licenza, usa il token ricevuto via email dopo l'acquisto.

# Identificativo di questa macchina, da comunicare al supporto se necessario
jpgboost-cli --machine-id

# Prima attivazione, con il token ricevuto via email dopo l'acquisto
jpgboost-cli --activate ACT-XXXX-XXXX-XXXX-XXXX-XXXX

Se la licenza è già stata attivata altrove (in genere sull'app, per uso personale), --activate non è più l'approccio corretto. Il runner deve aggiungersi a quella licenza esistente anziché attivarne una nuova. Dal dispositivo già attivato genera un codice, poi usalo sul runner.

# Dal dispositivo già attivato (l'app, o un'altra CLI), genera un codice
jpgboost-cli --add-device

# Sul runner, si aggiunge alla licenza esistente con questo codice
jpgboost-cli --pair XXXX-XXXX

Questa attivazione, con entrambi i metodi, conta come una delle due installazioni coperte dalla licenza Pro. Poiché l'app e la CLI contano come due installazioni distinte, attivare a ogni job esaurirebbe in fretta la quota: con appena due esecuzioni la licenza sarebbe già completamente consumata. È proprio per questo che il runner deve essere persistente, così da conservare l'attivazione da un job all'altro.

Rapido test in locale prima di proseguire.

# Due file in WebP, qualità 60
jpgboost-cli photo1.jpg photo2.png --quality 60 --format webp --output ./compressed

Configurare GitLab (runner e token di push)

Prima di incollare il file .gitlab-ci.yml qui sotto, sul lato GitLab vanno predisposte due cose una volta sola: registrare il runner e creare il token di cui il job di correzione ha bisogno per fare push.

Registrare il runner

Sul Mac scelto come runner persistente (quello dove jpgboost-cli e la sua licenza sono già installati dalla sezione precedente), registra il runner con il tag macos usato da entrambi i job.

gitlab-runner register \
  --url https://gitlab.com \
  --token <PROJECT_REGISTRATION_TOKEN>

Il token di registrazione si trova in Settings > CI/CD > Runners > New project runner (l'interfaccia genera un token monouso per questa registrazione, anziché il vecchio token di registrazione condiviso). --executor shell è determinante qui: a differenza di un executor Docker, esegue i comandi direttamente sul sistema del runner, cosa indispensabile per ritrovare jpgboost-cli già installato e la sua licenza già attivata da un job all'altro, invece di ripartire ogni volta da un ambiente pulito.

Pagina Create project runner di GitLab, con il campo Tags impostato su macos e il pulsante Create runner in basso
Il tag inserito qui è quello a cui fa riferimento il job più avanti
Pagina Register runner di GitLab accanto a un terminale che esegue gitlab-runner register, con la richiesta interattiva del nome del runner e dell'executor e shell digitato
gitlab-runner register chiede il nome del runner e poi l'executor in modo interattivo; rispondi shell a quest'ultima domanda
Impostazioni CI/CD di GitLab, con un runner di progetto assegnato, online e inattivo, con tag macos
Una volta registrato, il runner compare online e inattivo nelle impostazioni CI/CD del progetto

Creare il token di push

Il job fix-image-weight effettua il push di un commit sul branch della merge request. Il CI_JOB_TOKEN fornito automaticamente a ogni job non ha i permessi di scrittura necessari su un branch protetto, quindi serve un token dedicato.

Un Project Access Token (Settings > Access Tokens del progetto) è in teoria la scelta più pulita: legato al progetto anziché a un account, sopravvive all'uscita di chi lo ha creato. Ma su un namespace personale con il piano gratuito, GitLab.com non offre questa funzionalità, riservata ai gruppi a pagamento: è il caso più comune che spinge a usare invece un Personal Access Token.

Un Personal Access Token si crea dalle impostazioni dell'account utente, non da quelle del progetto (Avatar > Edit profile > Access Tokens), con:

  • Scope write_repository
  • Una data di scadenza coerente con la tua politica di rotazione dei token
Pagina Personal access tokens di GitLab, creazione di un token chiamato push-images-ci con lo scope Write repository e una scadenza di un anno
Al token serve solo lo scope Write repository, niente di più

Un compromesso da conoscere prima di scegliere questa strada: il token è legato all'account che lo ha creato. Se quell'account viene disattivato, perde l'accesso al progetto, o se la persona lascia il team, il job di correzione smette di poter fare push, senza alcun preavviso. In un progetto di squadra è meglio crearlo da un account di servizio dedicato piuttosto che dall'account personale di chi contribuisce.

Il token generato viene mostrato una volta sola: copialo subito, poi aggiungilo in Settings > CI/CD > Variables del progetto con il nome PUSH_TOKEN, con le opzioni Masked e Protected selezionate se le tue merge request puntano a un branch protetto.

Pagina CI/CD Settings di GitLab, pannello di aggiunta di una variabile di progetto con la chiave PUSH_TOKEN, l'opzione Masked selezionata e il valore mascherato
La variabile si chiama PUSH_TOKEN, usata più avanti nel .gitlab-ci.yml

Se il branch di origine delle tue merge request è a sua volta protetto, l'account associato al token deve inoltre avere il permesso di fare push direttamente su di esso, in Settings > Repository > Protected branches > Allowed to push and merge; in caso contrario il git push del job fallisce con un rifiuto di accesso nonostante un token valido.

Dove mettere il file .gitlab-ci.yml

Per impostazione predefinita GitLab cerca la pipeline in un solo posto: un file chiamato esattamente .gitlab-ci.yml (con il punto iniziale), nella radice del repository, accanto al README. Un file collocato in una sottocartella, o con un nome diverso, viene semplicemente ignorato: nessun errore lo segnala, il progetto si comporta come se non avesse alcuna CI configurata.

Se serve una posizione diversa, per esempio un file di pipeline condiviso tra più repository o una struttura monorepo, Settings > CI/CD > General pipelines > CI/CD configuration file permette di puntare a un altro percorso, anche in un progetto diverso, con la sintassi percorso/file.yml@gruppo/progetto:branch. Al di fuori di questo caso particolare, lascia il campo vuoto e tieni il file nella radice.

Una volta che il file è stato committato e pushato, non serve alcuna attivazione manuale: GitLab lo rileva automaticamente al primo evento che corrisponde a una regola del file, qui l'apertura o l'aggiornamento di una merge request, tramite $CI_PIPELINE_SOURCE == "merge_request_event". Il risultato compare nella scheda Pipelines del progetto e direttamente nella scheda con lo stesso nome della merge request. Per controllare la sintassi prima di fare push, anziché scoprirla alla prima pipeline fallita, l'editor integrato (Build > Pipeline editor nel menu del progetto) include un pulsante Validate che richiama il linter CI di GitLab senza avviare una pipeline reale.

Pagina di una pipeline GitLab con stato Passed, con i due job check-image-weight e fix-image-weight riusciti
La pipeline si avvia automaticamente sulla merge request, con i suoi due job verification e correction

Integrazione con GitLab CI

Il job qui sotto punta a un runner con tag macos, corrispondente al runner self-hosted persistente descritto sopra e già attivato al di fuori della pipeline. Viene eseguito solo sulle pipeline di merge request e combina le due modalità presentate nella sezione successiva: la verifica del peso e poi la correzione automatica.

# .gitlab-ci.yml
stages:
  - verification
  - correction

variables:
  # Soglia oltre la quale un'immagine compressa fa fallire il job (in KB)
  THRESHOLD_KB: "6000"
  # Clone completo: senza questo, un clone superficiale potrebbe non contenere
  # CI_MERGE_REQUEST_DIFF_BASE_SHA e far fallire il "git diff" qui sotto.
  GIT_DEPTH: "0"

check-image-weight:
  stage: verification
  tags: [macos]
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # Verifica che jpgboost-cli risponda prima di andare avanti
      jpgboost-cli --help > /dev/null

      # Considera solo le immagini aggiunte o modificate in questa merge request
      # (ambito attuale: .jpg e .png)
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "Nessuna immagine modificata in questa merge request."
        exit 0
      fi

      # Compressione di prova in WebP, in una cartella usa e getta, per confrontare le dimensioni
      mkdir -p /tmp/check-images
      # (niente "readarray": macOS include ancora bash 3.2, dove questa builtin
      # non esiste, introdotta solo in 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

      # Fa fallire il job se un'immagine supera ancora la soglia una volta compressa
      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 immagine/i superano ancora $THRESHOLD_KB KB una volta compresse:"
        jq --argjson threshold "$THRESHOLD_BYTES" -r '.[] | select(.compressedSizeBytes > $threshold) | .path' /tmp/report.json
        exit 1
      fi
      echo "Tutte le immagini modificate rientrano nella soglia di $THRESHOLD_KB KB."

fix-image-weight:
  stage: correction
  tags: [macos]
  # Indipendente dallo stage "verification": altrimenti questo job non viene mai
  # raggiunto nell'unico caso in cui serve davvero (check-image-weight che fallisce).
  needs: []
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # Solo .jpg/.png, vedi il commento equivalente 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 "Nessuna immagine modificata in questa merge request."
        exit 0
      fi

      rm -f /tmp/report.json
      THRESHOLD_BYTES=$((THRESHOLD_KB * 1000))
      MIN_QUALITY=20
      REMAINING=""

      # Converte ogni file in JPEG (--format jpeg, il valore predefinito): per una
      # fotografia un PNG comprime nettamente peggio di un JPEG (vedi "Insidie
      # ricorrenti"), ed è questo il formato di destinazione atteso qui. Non adatto
      # a un PNG con trasparenza (JPEG non ha canale alfa), il che non è un problema
      # per una foto, ma va riconsiderato se un logo o un'icona dovesse mai passare
      # da questa pipeline.
      #
      # Abbassa la qualità a scalini finché il risultato supera ancora la soglia:
      # un singolo passaggio a qualità fissa (60) non sempre basta.
      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
        # Il file di output è sempre <base>.jpg: se l'originale non aveva già
        # questa estensione (per esempio .png), va rimosso dal repository,
        # altrimenti i due coesistono e il vecchio resta non compresso a tempo
        # indeterminato.
        case "$FILE" in
          *.jpg) : ;;
          *) git rm -q "$FILE" ;;
        esac
        if [ "$SIZE" -gt "$THRESHOLD_BYTES" ]; then
          echo "$FILE resta sopra $THRESHOLD_KB KB dopo la compressione (qualità $QUALITY, $((SIZE / 1000)) KB): serve una riduzione manuale."
          REMAINING="$REMAINING $FILE"
        fi
      done <<< "$FILES"

      # Guadagno di compressione, visibile nel log del job (vedi "Misurare il guadagno").
      # -s: /tmp/report.json contiene un array JSON per file (un ">>" per
      # iterazione sopra), "add" li unisce in uno solo prima di sommarli.
      echo "--- Guadagno di compressione ---"
      jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) byte (\(.ratio))"' /tmp/report.json
      echo "Totale prima: $(jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json) byte"
      echo "Totale dopo: $(jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json) byte"

      # git diff --quiet non vedrebbe un .png rimosso (git rm) e sostituito da un
      # .jpg non tracciato: status --porcelain copre anche i file non tracciati.
      if [ -z "$(git status --porcelain)" ]; then
        echo "Niente da committare, tutte le immagini erano già compresse."
        exit 0
      fi

      git config user.name "jpgboost-ci"
      git config user.email "ci@example.com"
      git add -A
      # [skip ci]: senza questo, il push avvia una nuova pipeline merge_request_event,
      # che pusha un altro commit di correzione, e così via (ciclo infinito).
      git commit -m "Comprimi le immagini modificate con jpgboost-cli [skip ci]"
      # PUSH_TOKEN è un personal access token (scope write_repository), memorizzato come
      # variabile CI/CD mascherata: il CI_JOB_TOKEN predefinito non basta per fare push
      # su un branch protetto.
      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}"

      # Il miglior risultato possibile viene comunque pushato (meglio di niente), ma
      # il job fallisce se almeno un file supera ancora la soglia alla qualità minima,
      # così resta visibile invece di essere accettato in silenzio.
      if [ -n "$REMAINING" ]; then
        echo "File ancora troppo pesanti nonostante la compressione:$REMAINING"
        exit 1
      fi

Modalità verifica o modalità correzione

I due job della sezione precedente illustrano due modi opposti di collegare jpgboost-cli a una merge request GitLab. Uno blocca, l'altro corregge al posto di chi contribuisce.

In modalità verifica, il job check-image-weight non cambia nulla. Comprime ogni immagine modificata in una cartella usa e getta, confronta la dimensione risultante con la soglia scelta e fa fallire la CI se quella soglia viene superata. Il repository resta intatto. Spetta a chi contribuisce rilavorare l'immagine e ricommittarla; la pipeline si limita a rifiutare il merge finché non è stato fatto.

In modalità correzione, il job fix-image-weight va oltre. Comprime ogni immagine direttamente sul posto nel repository, poi committa e pusha il risultato sul branch della merge request. Chi contribuisce non ha più nulla da rifare, ma la cronologia Git guadagna un commit che non ha scritto.

CriterioModalità verificaModalità correzione
ObiettivoBloccare la CI se un'immagine supera una sogliaComprimere e pushare un commit nella merge request
Meccanismo--json + jq, confronto con una soglia scelta, poi un exit 1 esplicito (nessuna opzione di soglia integrata)Compressione diretta, scrittura sul posto, poi push
Effetto sul repositoryNessuno, il job si limita a osservareUn commit automatico aggiunto alla merge request
Effetto su chi contribuisceDeve correggere e ricommittare da séNulla da fare
Autenticazione necessariaNessunaProject access token o deploy token in una variabile mascherata
CompromessoAttrito, la correzione manuale resta a carico di chi contribuisceRiscrive automaticamente la cronologia Git, rischio di conflitti con un branch protetto

Nella pratica, la modalità verifica si adatta a un team che vuole mantenere il controllo editoriale sulle proprie immagini (ritaglio, ritocco, scelta del formato) prima che entrino compresse nel repository. La modalità correzione si adatta a un team che preferirebbe non doverci pensare mai, al prezzo di un commit automatico in più nella cronologia e di un token di push da gestire.

Ottimizzare il job

Entrambi i job limitano già il lavoro ai file effettivamente modificati, tramite git diff --name-only --diff-filter=ACM, invece di riscansionare l'intero repository a ogni pipeline. In un repository che accumula centinaia di immagini, la differenza nei tempi di esecuzione è notevole.

Con un runner self-hosted persistente, la cache funziona in modo diverso. La direttiva cache: di GitLab CI esiste soprattutto per ripristinare le dipendenze su un runner effimero che riparte da zero a ogni job. Qui JPGBoost.app è già installato sul runner e non va riscaricato. Aggiungere un blocco cache: non aggiungerebbe nulla rispetto a quanto già offre l'archiviazione locale del runner.

L'opzione --json fornisce le informazioni necessarie per costruire un meccanismo di idempotenza. Conservando compressedSizeBytes, o meglio ancora un hash del file, da un'esecuzione all'altra, sarebbe possibile individuare i file invariati ed evitare di ricomprimerli a ogni pipeline.

Il parallelismo, dal canto suo, è gestito nativamente tramite --jobs. Più file possono essere elaborati contemporaneamente, ciascuno decodificato e rilasciato in modo indipendente. L'impronta di memoria dipende quindi soprattutto dal valore di --jobs, non dal numero totale di file in attesa. Su un lotto di 12 file, la documentazione riporta un guadagno di circa 4× tra --jobs 8 e --jobs 1, un ordine di grandezza utile per dimensionare questo valore sul proprio runner, tenendo presente che la codifica di AVIF e JPEG XL è nettamente più impegnativa per la CPU rispetto a JPEG o HEIC.

Insidie ricorrenti

La prima insidia riguarda il formato di output. Lo script qui sopra converte deliberatamente ogni immagine, PNG compresi, in JPEG. Per una fotografia, JPEG offre in genere un rapporto qualità/dimensione migliore del PNG: forzare --format jpeg può quindi portare un file sotto la soglia di dimensione dove un semplice calo di qualità non basterebbe. Il prezzo da pagare è la perdita della trasparenza, dato che JPEG non ha canale alfa, senza che compaia necessariamente un errore nei log. Questa scelta funziona per le foto, ma può rompere in silenzio un logo o un'icona PNG con sfondo trasparente. In un repository che mescola entrambi gli usi, è meglio restringere l'ambito del job (a una cartella dedicata, per esempio) che imporre un unico formato a tutte le immagini.

In GitLab CI, per impostazione predefinita un job di un dato stage viene eseguito solo se tutti i job dello stage precedente sono andati a buon fine. Ma fix-image-weight ha senso solo quando check-image-weight rileva un superamento, esattamente il caso in cui, con quel comportamento predefinito, non verrebbe mai eseguito. needs: [] libera il job di correzione da questa dipendenza implicita e gli permette di essere eseguito in modo indipendente, in parallelo al job di verifica.

Il job di correzione pusha poi un commit sul branch che ha avviato la pipeline. Senza precauzioni, quel push può avviare una nuova pipeline di merge request. Se il risultato viene ancora giudicato troppo pesante, il job corregge di nuovo, pusha un altro commit e avvia un'altra pipeline ancora. La cosa può degenerare rapidamente in un ciclo che genera decine di commit e pipeline nel giro di pochi minuti. Aggiungere [skip ci] al messaggio del commit automatico dice a GitLab di non avviare una nuova pipeline per quel commit.

Il git diff eseguito rispetto a CI_MERGE_REQUEST_DIFF_BASE_SHA presuppone inoltre che quel commit di base sia disponibile in locale. GitLab però clona con una profondità limitata per impostazione predefinita. Su una merge request che ha accumulato molti commit, o quando il branch di destinazione è divergito parecchio, il commit cercato può semplicemente non esserci. Il git diff fallisce allora per un motivo che non ha nulla a che vedere con il peso delle immagini. Impostare GIT_DEPTH: "0" forza un clone completo ed evita questo problema, al prezzo di un clone più lungo a ogni job.

Un'altra insidia riguarda il rilevamento delle modifiche. git diff --quiet rileva solo le modifiche ai file che Git già traccia. Ma quando lo script converte un'immagine in JPEG e rimuove l'originale con git rm, la modifica reale è composta da due operazioni: la rimozione tracciata del .png e la creazione di un nuovo .jpg non tracciato. Un semplice git diff può allora non segnalare nulla. Nella pratica, il job può mostrare nei suoi log un guadagno di compressione perfettamente reale, concludere comunque "Niente da committare" e non pushare mai il risultato. git status --porcelain, che copre anche i file non tracciati, non si lascia ingannare allo stesso modo.

Anche le immagini già presenti nella cronologia Git sono un problema di dimensioni a sé stante. Anche una volta sostituite da una versione compressa in un nuovo commit, le vecchie versioni restano nella cronologia come blob Git. Il repository non recupera automaticamente lo spazio occupato da quei file. Solo una riscrittura della cronologia rimuoverebbe quei vecchi dati, ed è un'operazione distruttiva che non ha posto in una pipeline automatizzata.

Il commit automatico della modalità correzione può inoltre scontrarsi con le regole del repository stesso. Un branch protetto può vietare i push diretti o richiedere una revisione prima del merge. Allo stesso modo, un hook o un altro meccanismo di formattazione potrebbe toccare gli stessi file ed entrare in conflitto con il job di compressione. Queste interazioni vanno testate esplicitamente, anziché dare per scontato che convivano senza attriti.

Infine, la pipeline copre solo le immagini che passano dal repository e, più precisamente, dal flusso di merge request in questione. Una designer che deposita un'immagine direttamente in un CMS, in una cartella condivisa o in qualsiasi altro archivio di risorse esterno a Git aggira completamente questo controllo. La pipeline migliora la qualità delle immagini versionate nel repository, ma non è, da sola, una politica completa di gestione delle risorse.

Misurare il guadagno

fix-image-weight mostra già questo report nel proprio log, subito dopo il ciclo di compressione (vedi la sezione "Integrazione con GitLab CI" più sopra), visibile dalla scheda Pipelines della merge request, senza nulla da aggiungere. Il dettaglio: il campo ratio dell'output --json fornisce direttamente la percentuale di riduzione per file, accanto a originalSizeBytes e compressedSizeBytes.

Log del job fix-image-weight in GitLab, con il report del guadagno di compressione e la dimensione prima e dopo per ciascun file
Il log del job di correzione mostra il guadagno per file e i totali prima e dopo la compressione
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) byte (\(.ratio))"' /tmp/report.json

Per il totale aggregato, il comando esatto dipende da come il job scrive /tmp/report.json. fix-image-weight scrive un array JSON per file nel corso del suo ciclo (>>), quindi il file finale contiene più array concatenati anziché uno solo. Serve allora -s (slurp) per leggere il tutto, ma questo racchiude quegli array in un array aggiuntivo: map fallisce direttamente su di esso (jq: error: Cannot index array with string) senza un add che li appiattisca prima. È la forma usata nello script qui sopra.

# Totale prima e dopo la compressione, sull'intero lotto
# (file scritto nel corso di più chiamate: fix-image-weight)
jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json
jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json

Se costruisci il tuo report altrove, in locale (vedi "Installare jpgboost-cli") o in check-image-weight, che comprime tutti i file in un'unica chiamata (>), lì /tmp/report.json è già un unico array JSON, e -s non è allora né necessario né corretto: produce lo stesso errore, per il motivo opposto, racchiudendo un array già completo.

# Stesso calcolo, su un report scritto in un'unica chiamata
# (test in locale, o check-image-weight)
jq 'map(.originalSizeBytes) | add' /tmp/report.json
jq 'map(.compressedSizeBytes) | add' /tmp/report.json

jpgboost-cli misura solo ciò che produce esso stesso: la dimensione prima e dopo. I punteggi di prestazione (LCP, punteggio Lighthouse) restano da ottenere separatamente, con uno strumento dedicato. Nulla nello strumento lo calcola in modo nativo, e associarlo artificialmente alla percentuale di compressione sarebbe un'estrapolazione non verificata.