Как автоматизировать сжатие изображений в вашем CI-пайплайне с помощью jpgboost-cli

Репозиторий, в котором накапливаются несжатые JPEG и PNG, ухудшает LCP и одновременно утяжеляет каждое клонирование. jpgboost-cli позволяет автоматизировать их сжатие прямо в CI-пайплайне, не полагаясь на внимательность каждого участника. В этой статье разбирается интеграция с GitLab CI, сравниваются две противоположные стратегии и рассматриваются подводные камни, которые в продакшене могут дорого обойтись.

Во что на самом деле обходятся несжатые изображения

Каждое изображение, закоммиченное без сжатия, оставляет след в истории Git в виде блоба с его содержимым. Даже после того, как изображение позже заменят более лёгкой версией, старый блоб остаётся в истории. git clone скачивает эту историю целиком. Так что вес старых изображений продолжает давить на репозиторий, даже когда они уже не используются.

В репозитории, где месяцами копятся скриншоты, маркетинговые материалы или экспорты из дизайн-инструментов, этот мёртвый груз быстро достигает нескольких сотен мегабайт. Это замедляет клонирование в CI и увеличивает расход трафика для всех участников.

Со стороны продакшена проблема не заканчивается на репозитории. Изображение завышенного размера напрямую ухудшает Largest Contentful Paint (LCP), особенно когда именно самое большое изображение в первом экране становится элементом, определяющим метрику. Выигрыш от сжатия может быть существенным: в примере из документации самого jpgboost-cli JPEG размером 4,2 МБ уменьшается до 890 КБ, то есть на 79 %.

Ручное сжатие не масштабируется. Оно держится на индивидуальной привычке. В каждом merge request каждому участнику нужно не забыть оптимизировать изображения перед коммитом. На практике этот шаг легко пропускается, как только появляется нехватка времени или срочность. Обычно проблему обнаруживают неделями позже, когда аудит или отчёт о производительности вскрывает накопившийся долг.

Почему автоматизировать в CI, а не локально или на сервере

Сжатие изображений локально, например через Git-хук pre-commit, всегда зависит от машины и дисциплины участника. Хук можно обойти, удалить, или его может просто не быть на машине дизайнера, который коммитит файлы напрямую.

Сжатие на стороне сервера, наоборот, срабатывает слишком поздно. Изображения уже проиндексированы и, возможно, уже были отданы хотя бы раз. То есть проблема видна первым посетителям, а оптимизацию затем приходится повторять при каждом деплое.

CI — самая надёжная контрольная точка. Она запускается при каждом merge request, не зависит от настройки локальной машины и даёт видимый, отслеживаемый результат в логах job. Именно это единственное обязательное узкое место и делает автоматизацию оправданной, даже с учётом инфраструктурных затрат, которые нужно заложить с самого начала.

Здесь эти затраты весомее, потому что jpgboost-cli — не самостоятельный бинарник, который можно поставить через пакетный менеджер. Он поставляется вместе с JPGBoost.app, работает только на macOS 15 и новее и требует лицензии Pro.

Поэтому вся дальнейшая часть статьи строится на одном конкретном допущении: постоянный self-hosted раннер на macOS, чтобы иметь стабильное окружение и сохранять установленный JPGBoost.app от одного job к другому.

Установка jpgboost-cli и локальная проверка

И на раннере, и при локальной проверке бинарник находится внутри бандла приложения. Символическая ссылка, созданная один раз, позволяет затем вызывать его из любой папки.

# Символическая ссылка, один раз, чтобы вызывать jpgboost-cli из любой папки
sudo ln -s /Applications/JPGBoost.app/Contents/MacOS/jpgboost-cli /usr/local/bin/jpgboost-cli

# Проверяем, что команда отвечает
jpgboost-cli --help

Лицензия Pro активируется затем один раз, вручную, на раннере. Внутри самого пайплайна это не происходит никогда. Если это самая первая установка, активирующая эту лицензию, используйте токен, полученный по электронной почте после покупки.

# Идентификатор этой машины, чтобы при необходимости передать в поддержку
jpgboost-cli --machine-id

# Первая активация, с токеном, полученным по почте после покупки
jpgboost-cli --activate ACT-XXXX-XXXX-XXXX-XXXX-XXXX

Если лицензия уже активирована в другом месте (обычно в приложении, для личного использования), --activate больше не подходит. Раннеру нужно присоединиться к этой существующей лицензии, а не активировать новую. На уже активированном устройстве сгенерируйте код, затем используйте его на раннере.

# На уже активированном устройстве (приложение или другой CLI) генерирует код
jpgboost-cli --add-device

# На раннере присоединяется к существующей лицензии по этому коду
jpgboost-cli --pair XXXX-XXXX

Эта активация, любым из двух способов, засчитывается как одна из двух установок, покрываемых лицензией Pro. Поскольку приложение и CLI считаются двумя отдельными установками, активация на каждом job быстро исчерпала бы квоту: уже после двух запусков лицензия была бы полностью израсходована. Именно поэтому раннер должен быть постоянным — чтобы сохранять активацию от одного job к другому.

Быстрая локальная проверка, прежде чем идти дальше.

# Два файла в WebP, качество 60
jpgboost-cli photo1.jpg photo2.png --quality 60 --format webp --output ./compressed

Настройка GitLab (раннер и токен для push)

Прежде чем вставлять файл .gitlab-ci.yml ниже, на стороне GitLab нужно один раз настроить две вещи: зарегистрировать раннер и создать токен, который нужен job исправления для push.

Регистрация раннера

На Mac, выбранном в качестве постоянного раннера (том самом, где jpgboost-cli и его лицензия уже установлены по предыдущему разделу), зарегистрируйте раннер с тегом macos, который используют оба job.

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

Токен регистрации находится в Settings > CI/CD > Runners > New project runner (интерфейс генерирует одноразовый токен для этой регистрации вместо старого общего регистрационного токена). --executor shell здесь принципиален: в отличие от Docker-исполнителя, он выполняет команды прямо в системе раннера, а это необходимо, чтобы от одного job к другому находить jpgboost-cli уже установленным и его лицензию уже активированной, вместо того чтобы каждый раз стартовать с чистого окружения.

Страница Create project runner в GitLab, поле Tags заполнено значением macos, внизу кнопка Create runner
Указанный здесь тег — тот самый, на который ссылается job ниже
Страница Register runner в GitLab рядом с терминалом, где выполняется gitlab-runner register и интерактивно запрашиваются имя раннера и исполнитель, введено shell
gitlab-runner register интерактивно спрашивает имя раннера, затем исполнителя; на последний вопрос ответьте shell
Настройки CI/CD в GitLab: назначенный раннер проекта, в сети и простаивает, с тегом macos
После регистрации раннер отображается в сети и простаивающим в настройках CI/CD проекта

Создание токена для push

Job fix-image-weight делает push коммита в ветку merge request. У CI_JOB_TOKEN, который автоматически выдаётся каждому job, нет нужных прав на запись в защищённую ветку, поэтому требуется отдельный токен.

Project Access Token (Settings > Access Tokens проекта) в теории — самый чистый вариант: он привязан к проекту, а не к учётной записи, и переживает уход того, кто его создал. Но в личном пространстве имён на бесплатном тарифе GitLab.com не предлагает эту возможность, она доступна только платным группам: это и есть самая частая причина использовать вместо него Personal Access Token.

Personal Access Token создаётся в настройках учётной записи пользователя, а не проекта (Avatar > Edit profile > Access Tokens), со следующими параметрами:

  • Область write_repository
  • Срок действия, согласованный с вашей политикой ротации токенов
Страница Personal access tokens в GitLab: создание токена с именем push-images-ci, областью Write repository и сроком действия один год
Токену нужна только область Write repository, и ничего больше

О чём стоит знать, прежде чем выбирать этот путь: токен привязан к учётной записи, которая его создала. Если эту запись отключат, она потеряет доступ к проекту, или человек уйдёт из команды, job исправления перестанет делать push — без всякого предупреждения заранее. В командном проекте его лучше создавать из отдельной сервисной учётной записи, а не из личной записи участника.

Сгенерированный токен показывается только один раз: скопируйте его сразу же, затем добавьте в Settings > CI/CD > Variables проекта под именем PUSH_TOKEN, отметив опции Masked и Protected, если ваши merge request нацелены на защищённую ветку.

Страница CI/CD Settings в GitLab, панель добавления переменной проекта с ключом PUSH_TOKEN, выбранной опцией Masked и скрытым значением
Переменная называется PUSH_TOKEN и используется дальше в .gitlab-ci.yml

Если исходная ветка ваших merge request сама защищена, учётной записи, связанной с токеном, дополнительно нужно право пушить в неё напрямую — в Settings > Repository > Protected branches > Allowed to push and merge, иначе git push из job завершится отказом в доступе несмотря на действительный токен.

Где размещать файл .gitlab-ci.yml

По умолчанию GitLab ищет пайплайн только в одном месте: файл с точным именем .gitlab-ci.yml (с начальной точкой), в корне репозитория, рядом с README. Файл, помещённый в подпапку или названный иначе, просто игнорируется: об этом не сообщает никакая ошибка, проект просто ведёт себя так, будто у него вообще не настроен CI.

Если нужно другое расположение, например общий файл пайплайна для нескольких репозиториев или структура монорепозитория, в Settings > CI/CD > General pipelines > CI/CD configuration file можно указать другой путь, в том числе в другом проекте, с синтаксисом путь/файл.yml@группа/проект:ветка. Вне этого частного случая оставьте это поле пустым и держите файл в корне.

После того как файл закоммичен и запушен, ручная активация не нужна: GitLab обнаружит его автоматически при первом же событии, подходящем под правило из файла — здесь это открытие или обновление merge request через $CI_PIPELINE_SOURCE == "merge_request_event". Результат появляется во вкладке Pipelines проекта и прямо во вкладке с тем же названием в merge request. Чтобы проверить синтаксис до push, а не узнать о нём на первом упавшем пайплайне, во встроенном редакторе (Build > Pipeline editor в меню проекта) есть кнопка Validate, которая вызывает CI-линтер GitLab, не запуская реальный пайплайн.

Страница пайплайна GitLab со статусом Passed и двумя успешными job check-image-weight и fix-image-weight
Пайплайн запускается автоматически на merge request, с двумя своими job — verification и correction

Интеграция с GitLab CI

Job ниже нацелен на раннер с тегом macos, то есть на описанный выше постоянный self-hosted раннер, уже активированный вне пайплайна. Он выполняется только в пайплайнах merge request и объединяет два режима, представленных в следующем разделе: проверку веса, а затем автоматическое исправление.

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

variables:
  # Порог, выше которого сжатое изображение роняет job (в КБ)
  THRESHOLD_KB: "6000"
  # Полное клонирование: без этого поверхностный клон может не содержать
  # CI_MERGE_REQUEST_DIFF_BASE_SHA, и "git diff" ниже упадёт.
  GIT_DEPTH: "0"

check-image-weight:
  stage: verification
  tags: [macos]
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # Проверяем, что jpgboost-cli отвечает, прежде чем идти дальше
      jpgboost-cli --help > /dev/null

      # Смотрим только на изображения, добавленные или изменённые в этом merge request
      # (текущая область: .jpg и .png)
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "В этом merge request изображения не менялись."
        exit 0
      fi

      # Пробное сжатие в WebP, в одноразовую папку, чтобы сравнить размеры
      mkdir -p /tmp/check-images
      # (никакого "readarray": в macOS до сих пор поставляется bash 3.2, где этой
      # встроенной команды нет, она появилась только в 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

      # Роняет job, если изображение и после сжатия превышает порог
      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 "Изображений, всё ещё превышающих $THRESHOLD_KB КБ после сжатия: $OVER_LIMIT"
        jq --argjson threshold "$THRESHOLD_BYTES" -r '.[] | select(.compressedSizeBytes > $threshold) | .path' /tmp/report.json
        exit 1
      fi
      echo "Все изменённые изображения укладываются в порог $THRESHOLD_KB КБ."

fix-image-weight:
  stage: correction
  tags: [macos]
  # Независимо от стадии "verification": иначе этот job никогда не будет достигнут
  # ровно в том единственном случае, когда он и нужен (когда check-image-weight падает).
  needs: []
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # Только .jpg/.png, см. аналогичный комментарий в check-image-weight.
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "В этом merge request изображения не менялись."
        exit 0
      fi

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

      # Конвертирует каждый файл в JPEG (--format jpeg, значение по умолчанию): для
      # фотографии PNG сжимается заметно хуже, чем JPEG (см. "Типичные подводные
      # камни"), и именно JPEG здесь ожидаемый целевой формат. Не подходит для PNG
      # с прозрачностью (у JPEG нет альфа-канала) — для фотографии это не проблема,
      # но к этому стоит вернуться, если через пайплайн когда-нибудь пойдёт логотип
      # или иконка.
      #
      # Понижает качество ступенями, пока результат превышает порог: одного прохода
      # с фиксированным качеством (60) не всегда достаточно.
      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
        # Выходной файл всегда <имя>.jpg: если у оригинала не было такого расширения
        # (например .png), оригинал нужно удалить из репозитория, иначе оба
        # сосуществуют, а старый так и остаётся несжатым сколь угодно долго.
        case "$FILE" in
          *.jpg) : ;;
          *) git rm -q "$FILE" ;;
        esac
        if [ "$SIZE" -gt "$THRESHOLD_BYTES" ]; then
          echo "$FILE и после сжатия превышает $THRESHOLD_KB КБ (качество $QUALITY, $((SIZE / 1000)) КБ): нужно уменьшить вручную."
          REMAINING="$REMAINING $FILE"
        fi
      done <<< "$FILES"

      # Выигрыш от сжатия, виден в логе job (см. "Измерение выигрыша").
      # -s: /tmp/report.json содержит по одному JSON-массиву на файл (одно ">>" на
      # итерацию выше), "add" объединяет их в один перед суммированием.
      echo "--- Выигрыш от сжатия ---"
      jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) байт (\(.ratio))"' /tmp/report.json
      echo "Всего до: $(jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json) байт"
      echo "Всего после: $(jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json) байт"

      # git diff --quiet не увидел бы удалённый (git rm) .png, заменённый
      # неотслеживаемым .jpg: status --porcelain охватывает и неотслеживаемые файлы.
      if [ -z "$(git status --porcelain)" ]; then
        echo "Коммитить нечего, все изображения уже были сжаты."
        exit 0
      fi

      git config user.name "jpgboost-ci"
      git config user.email "ci@example.com"
      git add -A
      # [skip ci]: без этого push запускает новый пайплайн merge_request_event,
      # который пушит ещё один коммит исправления, и так далее (бесконечный цикл).
      git commit -m "Сжать изменённые изображения с помощью jpgboost-cli [skip ci]"
      # PUSH_TOKEN — это personal access token (область write_repository), хранящийся
      # как замаскированная переменная CI/CD: стандартного CI_JOB_TOKEN недостаточно,
      # чтобы пушить в защищённую ветку.
      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}"

      # Лучший из возможных результатов пушится в любом случае (лучше, чем ничего),
      # но job падает, если хотя бы один файл на минимальном качестве всё ещё выше
      # порога, чтобы это оставалось заметным, а не принималось молча.
      if [ -n "$REMAINING" ]; then
        echo "Файлы, всё ещё слишком тяжёлые несмотря на сжатие:$REMAINING"
        exit 1
      fi

Режим проверки или режим исправления

Два job из предыдущего раздела показывают два противоположных способа подключить jpgboost-cli к merge request в GitLab. Один блокирует, другой исправляет за участника.

В режиме проверки job check-image-weight ничего не меняет. Он сжимает каждое изменённое изображение в одноразовую папку, сравнивает получившийся размер с выбранным порогом и роняет CI, если порог превышен. Репозиторий остаётся нетронутым. Переработать изображение и закоммитить его заново — задача участника; пайплайн просто не даёт смержить, пока это не сделано.

В режиме исправления job fix-image-weight идёт дальше. Он сжимает каждое изображение прямо на месте в репозитории, затем коммитит и пушит результат в ветку merge request. Участнику ничего переделывать не нужно, но в истории Git появляется коммит, который он не писал.

КритерийРежим проверкиРежим исправления
ЦельЗаблокировать CI, если изображение превышает порогСжать и запушить коммит в merge request
Механизм--json + jq, сравнение с выбранным порогом, затем явный exit 1 (встроенного параметра порога нет)Прямое сжатие, запись на месте, затем push
Влияние на репозиторийНикакого, job только наблюдаетАвтоматический коммит, добавленный в merge request
Влияние на участникаПриходится исправлять и коммитить самомуБольше ничего делать не нужно
Требуемая аутентификацияНе нужнаProject access token или deploy token в замаскированной переменной
КомпромиссТрение, ручное исправление остаётся на участникеАвтоматически дописывает историю Git, риск конфликтов с защищённой веткой

На практике режим проверки подходит команде, которая хочет сохранить редакторский контроль над своими изображениями (кадрирование, ретушь, выбор формата) до того, как они попадут в репозиторий сжатыми. Режим исправления подходит команде, которая предпочла бы вообще об этом не думать, ценой одного лишнего автоматического коммита в истории и токена для push, который надо сопровождать.

Оптимизация job

Оба job уже ограничивают работу реально изменёнными файлами через git diff --name-only --diff-filter=ACM, вместо повторного сканирования всего репозитория на каждом пайплайне. В репозитории, где накопились сотни изображений, разница во времени выполнения существенна.

С постоянным self-hosted раннером кеширование работает иначе. Директива cache: в GitLab CI существует в основном для восстановления зависимостей на эфемерном раннере, который на каждом job начинает с нуля. Здесь JPGBoost.app уже установлен на раннере и его не нужно скачивать заново. Блок cache: не добавил бы ничего сверх того, что и так даёт локальное хранилище раннера.

Опция --json даёт информацию, нужную для построения механизма идемпотентности. Сохраняя compressedSizeBytes, а лучше хеш файла, от запуска к запуску, можно было бы определять неизменившиеся файлы и не пересжимать их на каждом пайплайне.

Параллелизм, в свою очередь, поддерживается нативно через --jobs. Несколько файлов могут обрабатываться одновременно, каждый декодируется и освобождается независимо. Поэтому потребление памяти зависит в основном от значения --jobs, а не от общего числа файлов в очереди. На пакете из 12 файлов документация сообщает о выигрыше примерно в 4× между --jobs 8 и --jobs 1 — полезный порядок величины, чтобы подобрать это значение на своём раннере, с учётом того, что кодирование AVIF и JPEG XL заметно требовательнее к процессору, чем JPEG или HEIC.

Типичные подводные камни

Первый подводный камень касается выходного формата. Скрипт выше намеренно конвертирует каждое изображение, включая PNG, в JPEG. Для фотографии JPEG обычно даёт лучшее соотношение качества и размера, чем PNG: принудительный --format jpeg поэтому может увести файл ниже порога размера там, где простого снижения качества не хватило бы. Плата за это — потеря прозрачности, поскольку у JPEG нет альфа-канала, причём в логах не обязательно появится хоть какая-то ошибка. Для фотографий этот выбор работает, но может незаметно испортить PNG-логотип или иконку с прозрачным фоном. В репозитории, где смешаны оба сценария, лучше ограничить область действия job (например, отдельной папкой), чем навязывать единый формат всем изображениям.

В GitLab CI job определённой стадии по умолчанию выполняется, только если все job предыдущей стадии завершились успешно. Но fix-image-weight имеет смысл ровно тогда, когда check-image-weight обнаруживает превышение — то есть именно в том случае, когда при таком поведении по умолчанию он бы никогда не запустился. needs: [] освобождает job исправления от этой неявной зависимости и позволяет ему выполняться независимо, параллельно с job проверки.

Затем job исправления пушит коммит в ветку, запустившую пайплайн. Без мер предосторожности этот push может запустить новый пайплайн merge request. Если результат по-прежнему сочтён слишком тяжёлым, job снова исправляет, пушит ещё один коммит и запускает ещё один пайплайн. Это быстро вырождается в цикл, порождающий десятки коммитов и пайплайнов за считаные минуты. Добавление [skip ci] в сообщение автоматического коммита говорит GitLab не запускать новый пайплайн для этого коммита.

git diff, выполняемый относительно CI_MERGE_REQUEST_DIFF_BASE_SHA, к тому же предполагает, что этот базовый коммит доступен локально. Однако GitLab по умолчанию клонирует с ограниченной глубиной. В merge request, где накопилось много коммитов, или когда целевая ветка заметно разошлась, искомого коммита может просто не оказаться. Тогда git diff падает по причине, никак не связанной с весом изображений. GIT_DEPTH: "0" заставляет делать полный клон и снимает эту проблему ценой более долгого клонирования на каждом job.

Ещё один подводный камень — обнаружение изменений. git diff --quiet замечает изменения только в файлах, которые Git уже отслеживает. Но как только скрипт конвертирует изображение в JPEG и удаляет оригинал через git rm, реальное изменение складывается из двух операций: отслеживаемого удаления .png и создания нового, неотслеживаемого .jpg. Обычный git diff тогда может не сообщить вообще ничего. На практике job может показать в логах совершенно настоящий выигрыш от сжатия, всё равно заключить «Коммитить нечего» и так и не запушить результат. git status --porcelain, который охватывает и неотслеживаемые файлы, на это не попадается.

Изображения, уже находящиеся в истории Git, — это к тому же отдельная проблема размера. Даже когда их заменяют сжатой версией в новом коммите, старые версии остаются в истории как блобы Git. Репозиторий не освобождает занятое этими файлами место автоматически. Убрать эти старые данные могла бы только перезапись истории, а это разрушительная операция, которой нет места в автоматизированном пайплайне.

Автоматический коммит режима исправления может также столкнуться с собственными правилами репозитория. Защищённая ветка может запрещать прямые push или требовать ревью перед слиянием. Точно так же хук или другой механизм форматирования может трогать те же файлы и конфликтовать с job сжатия. Эти взаимодействия нужно проверять явно, а не исходить из того, что они уживутся без трения.

Наконец, пайплайн охватывает только изображения, проходящие через репозиторий, а точнее — через сам поток merge request. Дизайнер, который кладёт изображение прямо в CMS, в общую папку или любое другое хранилище ресурсов вне Git, обходит эту проверку полностью. Пайплайн улучшает качество изображений, версионируемых в репозитории, но сам по себе не является полноценной политикой управления ресурсами.

Измерение выигрыша

fix-image-weight уже выводит этот отчёт в собственном логе, сразу после цикла сжатия (см. раздел «Интеграция с GitLab CI» выше), и его видно во вкладке Pipelines в merge request — добавлять ничего не нужно. Деталь: поле ratio в выводе --json сразу даёт процент уменьшения по каждому файлу, рядом с originalSizeBytes и compressedSizeBytes.

Лог job fix-image-weight в GitLab с отчётом о выигрыше от сжатия и размером до и после для каждого файла
Лог job исправления показывает выигрыш по каждому файлу и суммы до и после сжатия
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) байт (\(.ratio))"' /tmp/report.json

Для суммарного итога точная команда зависит от того, как job пишет /tmp/report.json. fix-image-weight по ходу цикла пишет по одному JSON-массиву на файл (>>), поэтому итоговый файл содержит несколько склеенных массивов, а не один. Тогда нужен -s (slurp), чтобы прочитать всё целиком, но он оборачивает эти массивы в дополнительный массив: map на нём падает сразу же (jq: error: Cannot index array with string) без add, который сперва их расплющит. Именно эта форма и используется в скрипте выше.

# Итог до и после сжатия, по всему пакету
# (файл записан за несколько вызовов: fix-image-weight)
jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json
jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json

Если вы строите собственный отчёт в другом месте — локально (см. «Установка jpgboost-cli») или в check-image-weight, который сжимает все файлы одним вызовом (>), — там /tmp/report.json уже представляет собой один JSON-массив, и -s тогда не нужен и неверен: он выдаёт ту же ошибку по противоположной причине, оборачивая уже готовый массив.

# Тот же расчёт для отчёта, записанного одним вызовом
# (локальная проверка или check-image-weight)
jq 'map(.originalSizeBytes) | add' /tmp/report.json
jq 'map(.compressedSizeBytes) | add' /tmp/report.json

jpgboost-cli измеряет только то, что производит сам: размер до и после. Показатели производительности (LCP, оценка Lighthouse) по-прежнему нужно получать отдельно, специальным инструментом. Ничто в инструменте не считает их нативно, а искусственно привязывать их к проценту сжатия было бы непроверенной экстраполяцией.