Como automatizar a compressão de imagens na sua pipeline de CI com o jpgboost-cli

Um repositório que acumula JPEG e PNG não comprimidos degrada o LCP e, ao mesmo tempo, torna cada clone mais pesado. O jpgboost-cli permite automatizar a sua compressão diretamente na pipeline de CI, sem depender da atenção de cada pessoa que contribui. Este artigo apresenta a sua integração com o GitLab CI, compara duas estratégias opostas e passa em revista as armadilhas que podem sair caras em produção.

O custo real das imagens não comprimidas

Cada imagem submetida sem compressão deixa um rasto no histórico do Git sob a forma de um blob que contém o seu conteúdo. Mesmo depois de a imagem ser substituída por uma versão mais leve, o blob antigo permanece no histórico. Um git clone baixa também esse histórico. O peso das imagens antigas continua, portanto, a pesar sobre o repositório, mesmo quando já não estão em uso.

Em um repositório que acumula capturas de tela, materiais de marketing ou exportações de design ao longo de vários meses, este peso morto atinge rapidamente várias centenas de megabytes. Isso torna os clones mais lentos na CI e aumenta o consumo de largura de banda de todas as pessoas que contribuem.

Do lado da produção, o problema não fica pelo repositório. Uma imagem sobredimensionada prejudica diretamente o Largest Contentful Paint (LCP), sobretudo quando a maior imagem acima da dobra passa a ser o elemento que determina a métrica. O ganho da compressão pode ser considerável: no próprio exemplo da documentação do jpgboost-cli, um JPEG de 4,2 MB desce para apenas 890 KB, uma redução de 79 %.

A compressão manual não é escalável. Depende do hábito individual. Em cada merge request, cada pessoa que contribui tem de se lembrar de otimizar as imagens antes de as submeter. Na prática, este passo é pulado com facilidade assim que a pressão do tempo ou a urgência aumentam. O problema costuma ser descoberto semanas mais tarde, quando uma auditoria ou um relatório de desempenho revela a dívida acumulada.

Porquê automatizar na CI em vez de localmente ou no servidor

Comprimir as imagens localmente, por exemplo com um hook pre-commit do Git, depende sempre da máquina e da disciplina de quem contribui. Um hook pode ser contornado, desinstalado ou simplesmente não existir na máquina de uma designer que submete os arquivos diretamente.

A compressão do lado do servidor, por seu turno, chega demasiado tarde. As imagens já foram indexadas e podem já ter sido servidas pelo menos uma vez. O problema é, portanto, visível para os primeiros visitantes, e a otimização tem depois de ser refeita em cada deploy.

A CI é o ponto de controlo mais fiável. É executada em cada merge request, não depende da configuração da máquina local e produz um resultado visível e rastreável nos registros do job. É este ponto de passagem único que torna a automatização vantajosa, mesmo que implique um custo de infraestrutura que é preciso contabilizar desde o início.

Esse custo pesa mais aqui porque o jpgboost-cli não é um binário autónomo que se possa instalar através de um gerenciador de pacotes. É distribuído com o JPGBoost.app, funciona apenas em macOS 15 ou posterior e exige uma licença Pro.

O resto deste artigo assenta, por isso, num pressuposto concreto: um runner macOS self-hosted e persistente, para dispor de um ambiente estável e manter a instalação do JPGBoost.app de um job para o seguinte.

Instalar o jpgboost-cli e testá-lo localmente

No runner, tal como no teste local, o binário vem dentro do pacote do aplicativo. Uma ligação simbólica criada uma única vez permite depois invocá-lo a partir de qualquer pasta.

# Ligação simbólica, uma única vez, para invocar o jpgboost-cli a partir de qualquer pasta
sudo ln -s /Applications/JPGBoost.app/Contents/MacOS/jpgboost-cli /usr/local/bin/jpgboost-cli

# Verificar que o comando responde
jpgboost-cli --help

A licença Pro é depois ativada uma única vez, manualmente, no runner. Isto nunca acontece dentro da própria pipeline. Se esta for a primeiríssima instalação a ativar esta licença, use o token que recebeu por e-mail após a compra.

# Identificador desta máquina, para partilhar com o suporte se necessário
jpgboost-cli --machine-id

# Primeira ativação, com o token recebido por e-mail após a compra
jpgboost-cli --activate ACT-XXXX-XXXX-XXXX-XXXX-XXXX

Se a licença já tiver sido ativada noutro sítio (tipicamente no aplicativo, para uso pessoal), --activate deixa de ser a abordagem correta. O runner tem de se juntar a essa licença existente em vez de ativar uma nova. A partir do dispositivo já ativado, gere um código e use-o depois no runner.

# A partir do dispositivo já ativado (a app, ou outra CLI), gera um código
jpgboost-cli --add-device

# No runner, se junta à licença existente com este código
jpgboost-cli --pair XXXX-XXXX

Esta ativação, por qualquer dos métodos, conta como uma das duas instalações cobertas pela licença Pro. Como o aplicativo e a CLI contam como duas instalações distintas, ativar em cada job esgotaria a cota rapidamente: com apenas duas execuções, a licença já estaria completamente consumida. É por isso que o runner tem de ser persistente, para conservar a ativação de um job para o seguinte.

Teste local rápido antes de prosseguir.

# Dois arquivos para WebP, qualidade 60
jpgboost-cli photo1.jpg photo2.png --quality 60 --format webp --output ./compressed

Configurar o GitLab (runner e token de push)

Antes de colar o arquivo .gitlab-ci.yml abaixo, há duas coisas a preparar uma única vez do lado do GitLab: registrar o runner e criar o token de que o job de correção precisa para fazer push.

Registrar o runner

No Mac escolhido como runner persistente (aquele onde o jpgboost-cli e a sua licença já estão instalados, conforme a seção anterior), registre o runner com a etiqueta macos usada pelos dois jobs.

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

O token de registro está em Settings > CI/CD > Runners > New project runner (a interface gera um token de utilização única para este registro, em vez do antigo token de registro compartilhado). --executor shell é determinante aqui: ao contrário de um executor Docker, executa os comandos diretamente no sistema do runner, o que é indispensável para encontrar o jpgboost-cli já instalado e a sua licença já ativada de um job para o seguinte, em vez de partir de um ambiente limpo de cada vez.

Página Create project runner do GitLab, com o campo Tags preenchido com macos e o botão Create runner em baixo
A etiqueta introduzida aqui é a mesma que o job mais abaixo referencia
Página Register runner do GitLab ao lado de um terminal executando gitlab-runner register, pedindo de forma interativa o nome do runner e o executor, com shell introduzido
O gitlab-runner register pede o nome do runner e depois o executor de forma interativa; responda shell a esta última pergunta
Configurações de CI/CD do GitLab, mostrando um runner de projeto atribuído, online e inativo, com a etiqueta macos
Uma vez registrado, o runner aparece online e inativo nas configurações de CI/CD do projeto

Criar o token de push

O job fix-image-weight faz push de um commit no ramo da merge request. O CI_JOB_TOKEN fornecido automaticamente a cada job não tem as permissões de escrita necessárias num ramo protegido, pelo que é preciso um token dedicado.

Um Project Access Token (Settings > Access Tokens do projeto) é, em teoria, a escolha mais limpa: ligado ao projeto e não a uma conta, sobrevive à saída de quem o criou. Mas num namespace pessoal no plano gratuito, o GitLab.com não oferece esta funcionalidade, reservada a grupos pagos: é o caso mais comum que leva a usar antes um Personal Access Token.

Um Personal Access Token se cria a partir das configurações da conta de usuário, não das do projeto (Avatar > Edit profile > Access Tokens), com:

  • Âmbito write_repository
  • Uma data de expiração coerente com a sua política de rotação de tokens
Página Personal access tokens do GitLab, criação de um token chamado push-images-ci com o âmbito Write repository e um ano de validade
O token só precisa do âmbito Write repository, mais nada

Uma contrapartida a conhecer antes de escolher este caminho: o token está ligado à conta que o criou. Se essa conta for desativada, perder o acesso ao projeto, ou se a pessoa sair da equipe, o job de correção deixa de conseguir fazer push, sem qualquer aviso prévio. Em um projeto de equipe, é preferível criá-lo a partir de uma conta de serviço dedicada em vez da conta pessoal de quem contribui.

O token gerado só é apresentado uma vez: copie-o de imediato e adicione-o depois em Settings > CI/CD > Variables do projeto com o nome PUSH_TOKEN, com as opções Masked e Protected sinalizadas se as suas merge requests apontarem para um ramo protegido.

Página CI/CD Settings do GitLab, painel de adição de uma variável de projeto com a chave PUSH_TOKEN, a opção Masked selecionada e o valor mascarado
A variável se chama PUSH_TOKEN, usada mais adiante no .gitlab-ci.yml

Se o ramo de origem das suas merge requests estiver ele próprio protegido, a conta associada ao token precisa ainda de permissão para fazer push diretamente para ele, em Settings > Repository > Protected branches > Allowed to push and merge; caso contrário, o git push do job falha com uma recusa de acesso apesar de o token ser válido.

Onde colocar o arquivo .gitlab-ci.yml

Por padrão, o GitLab só procura a pipeline num único sítio: um arquivo com o nome exato .gitlab-ci.yml (com o ponto inicial), na raiz do repositório, ao lado do README. Um arquivo colocado numa subpasta, ou com outro nome, é simplesmente ignorado: nenhum erro o sinaliza, o projeto se comporta como se não tivesse qualquer CI configurada.

Se for necessária outra localização, por exemplo um arquivo de pipeline compartilhado por vários repositórios ou uma estrutura de monorepo, Settings > CI/CD > General pipelines > CI/CD configuration file permite apontar para outro caminho, incluindo noutro projeto, com a sintaxe caminho/arquivo.yml@grupo/projeto:ramo. Fora deste caso específico, deixe este campo vazio e mantenha o arquivo na raiz.

Depois de o arquivo ser submetido e enviado, não é precisa qualquer ativação manual: o GitLab o detecta automaticamente no evento seguinte que corresponda a uma regra do arquivo, aqui a abertura ou atualização de uma merge request, através de $CI_PIPELINE_SOURCE == "merge_request_event". O resultado aparece no aba Pipelines do projeto e diretamente no aba com o mesmo nome da merge request. Para verificar a sintaxe antes de fazer push, em vez de a descobrir na primeira pipeline falhada, o editor integrado (Build > Pipeline editor no menu do projeto) inclui um botão Validate que chama o linter de CI do GitLab sem desencadear uma pipeline real.

Página de uma pipeline do GitLab com o estado Passed, mostrando os dois jobs check-image-weight e fix-image-weight bem-sucedidos
A pipeline arranca automaticamente na merge request, com os seus dois jobs verification e correction

Integração com o GitLab CI

O job abaixo aponta para um runner com a etiqueta macos, correspondente ao runner self-hosted persistente descrito acima e já ativado fora da pipeline. É executado apenas nas pipelines de merge request e combina os dois modos apresentados na seção seguinte: a verificação do peso e depois a correção automática.

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

variables:
  # Limiar acima do qual uma imagem comprimida faz o job falhar (em KB)
  THRESHOLD_KB: "6000"
  # Clone completo: sem isto, um clone superficial pode não conter
  # CI_MERGE_REQUEST_DIFF_BASE_SHA e fazer falhar o "git diff" abaixo.
  GIT_DEPTH: "0"

check-image-weight:
  stage: verification
  tags: [macos]
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # Verificar que o jpgboost-cli responde antes de prosseguir
      jpgboost-cli --help > /dev/null

      # Olhar apenas para as imagens adicionadas ou modificadas nesta merge request
      # (âmbito atual: .jpg e .png)
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "Nenhuma imagem modificada nesta merge request."
        exit 0
      fi

      # Compressão de teste para WebP, numa pasta descartável, para comparar tamanhos
      mkdir -p /tmp/check-images
      # (nada de "readarray": o macOS ainda inclui o bash 3.2, onde esta builtin
      # não existe, só foi introduzida no 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

      # Faz o job falhar se uma imagem continuar acima do limiar depois de comprimida
      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 imagem(ns) continuam acima de $THRESHOLD_KB KB depois de comprimidas:"
        jq --argjson threshold "$THRESHOLD_BYTES" -r '.[] | select(.compressedSizeBytes > $threshold) | .path' /tmp/report.json
        exit 1
      fi
      echo "Todas as imagens modificadas estão dentro do limiar de $THRESHOLD_KB KB."

fix-image-weight:
  stage: correction
  tags: [macos]
  # Independente da fase "verification": caso contrário, este job nunca é alcançado
  # no único caso em que é realmente preciso (o check-image-weight falhar).
  needs: []
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # Apenas .jpg/.png, ver o comentário equivalente em check-image-weight.
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "Nenhuma imagem modificada nesta merge request."
        exit 0
      fi

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

      # Converte todos os arquivos para JPEG (--format jpeg, o valor padrão):
      # para uma fotografia, um PNG comprime bastante pior do que um JPEG (ver
      # "Armadilhas frequentes"), e é esse o formato de destino esperado aqui. Não
      # serve para um PNG com transparência (o JPEG não tem canal alfa), o que não é
      # problema numa foto, mas deve ser reponderado se algum dia um logotipo ou um
      # ícone passar por esta pipeline.
      #
      # Vai baixando a qualidade por patamares enquanto o resultado continuar acima
      # do limiar: uma única passagem com qualidade fixa (60) nem sempre chega.
      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
        # O arquivo de saída é sempre <base>.jpg: se o original ainda não tinha
        # essa extensão (por exemplo .png), é preciso removê-lo do repositório,
        # caso contrário os dois coexistem e o antigo fica por comprimir
        # indefinidamente.
        case "$FILE" in
          *.jpg) : ;;
          *) git rm -q "$FILE" ;;
        esac
        if [ "$SIZE" -gt "$THRESHOLD_BYTES" ]; then
          echo "$FILE continua acima de $THRESHOLD_KB KB após a compressão (qualidade $QUALITY, $((SIZE / 1000)) KB): é preciso uma redução manual."
          REMAINING="$REMAINING $FILE"
        fi
      done <<< "$FILES"

      # Ganho de compressão, visível no registro do job (ver "Medir o ganho").
      # -s: /tmp/report.json contém um array JSON por arquivo (um ">>" por
      # iteração acima), o "add" funde-os num só antes de os somar.
      echo "--- Ganho de compressão ---"
      jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) bytes (\(.ratio))"' /tmp/report.json
      echo "Total antes: $(jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json) bytes"
      echo "Total depois: $(jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json) bytes"

      # O git diff --quiet não veria um .png removido (git rm) e substituído por um
      # .jpg não rastreado: o status --porcelain cobre também os arquivos não rastreados.
      if [ -z "$(git status --porcelain)" ]; then
        echo "Nada a submeter, todas as imagens já estavam comprimidas."
        exit 0
      fi

      git config user.name "jpgboost-ci"
      git config user.email "ci@example.com"
      git add -A
      # [skip ci]: sem isto, este push desencadeia uma nova pipeline merge_request_event,
      # que faz push de outro commit de correção, e assim sucessivamente (ciclo infinito).
      git commit -m "Comprimir as imagens modificadas com o jpgboost-cli [skip ci]"
      # PUSH_TOKEN é um personal access token (âmbito write_repository), guardado como
      # variável de CI/CD mascarada: o CI_JOB_TOKEN padrão não chega para fazer
      # push para um ramo protegido.
      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}"

      # O melhor resultado possível é enviado de qualquer forma (melhor do que nada),
      # mas o job falha se pelo menos um arquivo continuar acima do limiar na qualidade
      # mínima, para que fique visível em vez de ser aceite em silêncio.
      if [ -n "$REMAINING" ]; then
        echo "Ficheiro(s) ainda demasiado pesados apesar da compressão:$REMAINING"
        exit 1
      fi

Modo verificação ou modo correção

Os dois jobs da seção anterior ilustram duas formas opostas de ligar o jpgboost-cli a uma merge request do GitLab. Um bloqueia, o outro corrige em vez de quem contribui.

Em modo verificação, o job check-image-weight não altera nada. Comprime cada imagem modificada para uma pasta descartável, compara o tamanho resultante com o limiar escolhido e faz a CI falhar se esse limiar for ultrapassado. O repositório fica intacto. Cabe a quem contribui retrabalhar a imagem e voltar a submetê-la; a pipeline se limita a recusar o merge enquanto isso não estiver feito.

Em modo correção, o job fix-image-weight vai mais longe. Comprime cada imagem diretamente no lugar dentro do repositório, e depois submete e faz push do resultado para o ramo da merge request. Quem contribui já não tem nada a refazer, mas o histórico do Git ganha um commit que não escreveu.

CritérioModo verificaçãoModo correção
ObjetivoBloquear a CI se uma imagem ultrapassar um limiarComprimir e fazer push de um commit na merge request
Mecanismo--json + jq, comparação com um limiar escolhido, depois um exit 1 explícito (não há opção de limiar integrada)Compressão direta, escrita no lugar, depois push
Efeito no repositórioNenhum, o job apenas observaUm commit automático adicionado à merge request
Efeito em quem contribuiTem de corrigir e voltar a submeterNada mais a fazer
Autenticação necessáriaNenhumaProject access token ou deploy token numa variável mascarada
ContrapartidaAtrito, a correção manual fica a cargo de quem contribuiReescreve o histórico do Git automaticamente, risco de conflitos com um ramo protegido

Na prática, o modo verificação se adequa a uma equipe que quer manter o controlo editorial sobre as suas imagens (recorte, retoque, escolha do formato) antes de entrarem comprimidas no repositório. O modo correção se adequa a uma equipe que preferia nunca ter de pensar nisso, ao preço de mais um commit automático no histórico e de um token de push para gerenciar.

Otimizar o job

Os dois jobs já limitam o trabalho aos arquivos efetivamente modificados, através de git diff --name-only --diff-filter=ACM, em vez de voltar analisando todo o repositório em cada pipeline. Em um repositório que acumula centenas de imagens, a diferença no tempo de execução é considerável.

Com um runner self-hosted persistente, a cache funciona de outra forma. A diretiva cache: do GitLab CI existe sobretudo para restaurar dependências num runner efémero que começa do zero em cada job. Aqui, o JPGBoost.app já está instalado no runner e não precisa de ser baixado de novo. Acrescentar um bloco cache: não traria nada além do que o armazenamento local do runner já oferece.

A opção --json fornece a informação necessária para construir um mecanismo de idempotência. Guardando compressedSizeBytes, ou melhor ainda um hash do arquivo, de uma execução para a seguinte, seria possível detectar os arquivos inalterados e evitar recomprimi-los em cada pipeline.

O paralelismo, por seu lado, é tratado nativamente através de --jobs. Vários arquivos podem ser processados ao mesmo tempo, cada um descodificado e libertado de forma independente. A ocupação de memória depende, portanto, sobretudo do valor de --jobs, e não do número total de arquivos em espera. Num lote de 12 arquivos, a documentação indica um ganho de cerca de 4× entre --jobs 8 e --jobs 1, uma ordem de grandeza útil para dimensionar este valor no seu próprio runner, tendo presente que a codificação de AVIF e JPEG XL exige bastante mais CPU do que a de JPEG ou HEIC.

Armadilhas frequentes

A primeira armadilha diz respeito ao formato de saída. O script acima converte deliberadamente todas as imagens, PNG incluídos, para JPEG. Para uma fotografia, o JPEG oferece geralmente uma melhor relação entre qualidade e tamanho do que o PNG: forçar --format jpeg pode, portanto, colocar um arquivo abaixo do limiar de tamanho onde uma simples descida de qualidade não bastaria. A contrapartida é a perda da transparência, já que o JPEG não tem canal alfa, sem que apareça necessariamente qualquer erro nos registros. Esta escolha funciona para fotografias, mas pode destruir em silêncio um logotipo ou um ícone PNG com fundo transparente. Em um repositório que mistura ambos os usos, é preferível restringir o âmbito do job (a uma pasta dedicada, por exemplo) do que impor um único formato a todas as imagens.

No GitLab CI, por padrão um job de uma dada fase só é executado se todos os jobs da fase anterior tiverem sido bem-sucedidos. Mas o fix-image-weight só faz sentido quando o check-image-weight detecta uma ultrapassagem, precisamente o caso em que, com esse comportamento padrão, nunca seria executado. O needs: [] libera o job de correção dessa dependência implícita e permite que ele seja executado de forma independente, em paralelo com o job de verificação.

O job de correção faz depois push de um commit no ramo que desencadeou a pipeline. Sem precauções, esse push pode desencadear uma nova pipeline de merge request. Se o resultado continuar a ser considerado demasiado pesado, o job corrige outra vez, faz push de mais um commit e desencadeia ainda outra pipeline. Isso pode degenerar rapidamente num ciclo que gera dezenas de commits e pipelines em poucos minutos. Acrescentar [skip ci] à mensagem do commit automático diz ao GitLab para não desencadear uma nova pipeline para esse commit.

O git diff executado contra CI_MERGE_REQUEST_DIFF_BASE_SHA pressupõe também que esse commit de base está disponível localmente. Só que o GitLab clona com uma profundidade limitada por padrão. Em uma merge request que acumulou muitos commits, ou quando o ramo de destino divergiu bastante, o commit procurado pode simplesmente não estar lá. O git diff falha então por um motivo que nada tem a ver com o peso das imagens. Definir GIT_DEPTH: "0" força um clone completo e evita este problema, ao preço de um clone mais demorado em cada job.

Outra armadilha tem a ver com a detecção de alterações. O git diff --quiet só detecta alterações em arquivos que o Git já rastreia. Mas quando o script converte uma imagem para JPEG e remove o original com git rm, a alteração real é composta por duas operações: a remoção rastreada do .png e a criação de um .jpg novo e não rastreado. Um git diff simples pode então não reportar absolutamente nada. Na prática, o job pode mostrar nos seus registros um ganho de compressão perfeitamente real, concluir ainda assim "Nada a submeter" e nunca fazer push do resultado. O git status --porcelain, que cobre também os arquivos não rastreados, não se deixa enganar da mesma maneira.

As imagens já presentes no histórico do Git são também, por si só, um problema de tamanho. Mesmo depois de substituídas por uma versão comprimida num novo commit, as versões antigas permanecem no histórico como blobs do Git. O repositório não recupera automaticamente o espaço ocupado por esses arquivos. Só reescrever o histórico removeria esses dados antigos, e essa é uma operação destrutiva que não tem lugar numa pipeline automatizada.

O commit automático do modo correção pode ainda colidir com as próprias regras do repositório. Um ramo protegido pode proibir pushes diretos ou exigir uma revisão antes do merge. Do mesmo modo, um hook ou outro mecanismo de formatação pode tocar nos mesmos arquivos e entrar em conflito com o job de compressão. Estas interações têm de ser testadas explicitamente, em vez de se presumir que coexistem sem atritos.

Por fim, a pipeline só cobre as imagens que passam pelo repositório e, mais concretamente, pelo fluxo de merge request em causa. Uma designer que deixe uma imagem diretamente num CMS, numa pasta compartilhada ou em qualquer outro repositório de recursos fora do Git contorna por completo esta verificação. A pipeline melhora a qualidade das imagens versionadas no repositório, mas não é, por si só, uma política completa de gestão de recursos.

Medir o ganho

O fix-image-weight já apresenta este relatório no seu próprio registro, logo a seguir ao ciclo de compressão (ver a seção "Integração com o GitLab CI" acima), visível a partir do aba Pipelines da merge request, sem nada a acrescentar. O detalhe: o campo ratio da saída --json dá diretamente a porcentagem de redução por arquivo, ao lado de originalSizeBytes e compressedSizeBytes.

Registro do job fix-image-weight no GitLab, mostrando o relatório de ganho de compressão com o tamanho antes e depois de cada arquivo
O registro do job de correção mostra o ganho por arquivo e os totais antes e depois da compressão
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) bytes (\(.ratio))"' /tmp/report.json

Para o total agregado, o comando exato depende da forma como o job escreve o /tmp/report.json. O fix-image-weight escreve um array JSON por arquivo ao longo do seu ciclo (>>), pelo que o arquivo final contém vários arrays concatenados em vez de um só. É então preciso -s (slurp) para ler tudo, mas isso envolve esses arrays num array adicional: o map falha diretamente sobre ele (jq: error: Cannot index array with string) sem um add que os achate primeiro. É essa a forma usada no script acima.

# Total antes e depois da compressão, em todo o lote
# (arquivo escrito ao longo de várias chamadas: fix-image-weight)
jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json
jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json

Se construir o seu próprio relatório noutro sítio, localmente (ver "Instalar o jpgboost-cli") ou no check-image-weight, que comprime todos os arquivos numa única chamada (>), aí o /tmp/report.json já é um único array JSON, e o -s não é então nem necessário nem correto: produz o mesmo erro, pelo motivo oposto, ao envolver um array que já está completo.

# O mesmo cálculo, num relatório escrito numa única chamada
# (teste local, ou check-image-weight)
jq 'map(.originalSizeBytes) | add' /tmp/report.json
jq 'map(.compressedSizeBytes) | add' /tmp/report.json

O jpgboost-cli mede apenas aquilo que ele próprio produz: o tamanho antes e depois. As pontuações de desempenho (LCP, pontuação do Lighthouse) continuam a ter de ser obtidas em separado, com uma ferramenta dedicada. Nada na ferramenta calcula isso nativamente, e associá-lo artificialmente à porcentagem de compressão seria uma extrapolação não verificada.