jpgboost-cliでCIパイプラインの画像圧縮を自動化する方法

未圧縮のJPEGやPNGが溜まっていくリポジトリは、LCPを悪化させると同時に、クローンのたびに重さを増していきます。jpgboost-cliを使えば、その圧縮をCIパイプラインの中で直接自動化でき、貢献者ひとりひとりの注意力に頼らずに済みます。この記事ではGitLab CIとの統合を紹介し、正反対の2つの戦略を比較したうえで、本番環境で高くつきかねない落とし穴を取り上げます。

未圧縮画像が実際に招くコスト

圧縮せずにコミットされた画像は、その内容を持つblobとしてGitの履歴に痕跡を残します。あとでその画像をより軽いバージョンに置き換えたとしても、古いblobは履歴に残ったままです。git cloneはその履歴もまとめて取得します。つまり古い画像の重さは、すでに使われていなくてもリポジトリにのしかかり続けます。

スクリーンショットやマーケティング素材、デザインの書き出しが数か月にわたって溜まっていくリポジトリでは、この死んだ重さはあっという間に数百メガバイトに達します。これはCIでのクローンを遅くし、すべての貢献者の帯域消費を増やします。

本番環境側では、問題はリポジトリだけにとどまりません。サイズ過大な画像はLargest Contentful Paint(LCP)を直接悪化させます。とりわけファーストビューで最も大きい画像が、その指標を決める要素になっている場合はなおさらです。圧縮による効果は大きくなり得ます。jpgboost-cliのドキュメント自身の例では、4.2 MBのJPEGがわずか890 KBまで下がり、79 %の削減になっています。

手作業の圧縮はスケールしません。個人の習慣に依存するからです。マージリクエストのたびに、貢献者それぞれがコミット前に画像を最適化することを覚えていなければなりません。実際には、時間の余裕がなくなったり急ぎの状況になったりすると、この手順は簡単に飛ばされます。問題が見つかるのはたいてい数週間後、監査やパフォーマンスレポートが積み上がった負債を明らかにしたときです。

ローカルやサーバー側ではなくCIで自動化する理由

ローカルでの圧縮、たとえばGitのpre-commitフックによる圧縮は、常にマシンと貢献者の規律に左右されます。フックは回避できますし、アンインストールもできますし、ファイルを直接コミットするデザイナーのマシンにはそもそも入っていないこともあります。

一方、サーバー側での圧縮は手遅れです。画像はすでにインデックスされ、少なくとも一度は配信されている可能性があります。つまり問題は最初の訪問者には見えてしまいますし、その後もデプロイのたびに最適化をやり直すことになります。

CIは最も信頼できるチェックポイントです。マージリクエストのたびに実行され、ローカルマシンの構成に左右されず、ジョブのログに目に見える追跡可能な結果を残します。この一点集中の関門があるからこそ自動化は割に合います。ただし最初から見込んでおくべきインフラコストは伴います。

ここではそのコストがより重くのしかかります。jpgboost-cliはパッケージマネージャーでインストールできる単独のバイナリではないからです。JPGBoost.appに同梱され、macOS 15以降でのみ動作し、Proライセンスを必要とします。

そのため、この記事の以降の内容は1つの具体的な前提に立っています。すなわち永続的なセルフホストのmacOSランナーです。安定した環境を確保し、JPGBoost.appのインストール状態をジョブからジョブへ引き継ぐためです。

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ライセンスが対象とする2つのインストールのうちの1つとして数えられます。アプリとCLIは別々のインストールとして数えられるため、ジョブごとに有効化していては枠はすぐ尽きます。わずか2回の実行でライセンスは使い切られてしまう計算です。だからこそランナーは永続的である必要があり、有効化の状態をジョブからジョブへ保つのです。

先に進む前に、ローカルで簡単に試しておきます。

# 2つのファイルを品質60でWebPに変換
jpgboost-cli photo1.jpg photo2.png --quality 60 --format webp --output ./compressed

GitLabの設定(ランナーとプッシュ用トークン)

以下の.gitlab-ci.ymlを貼り付ける前に、GitLab側で一度だけ用意しておくものが2つあります。ランナーの登録と、修正ジョブがプッシュするために必要なトークンの作成です。

ランナーを登録する

永続的なランナーとして選んだMac(前のセクションでjpgboost-cliとそのライセンスをすでにインストールしたマシン)で、両方のジョブが使うmacosタグを付けてランナーを登録します。

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

登録トークンはSettings > CI/CD > Runners > New project runnerにあります(画面上では、従来の共有登録トークンではなく、この登録専用の使い捨てトークンが生成されます)。ここで--executor shellが重要です。Dockerエグゼキューターと違い、コマンドをランナーのシステム上で直接実行するため、毎回まっさらな環境から始めるのではなく、jpgboost-cliがインストール済みでライセンスも有効化済みの状態をジョブからジョブへ引き継げます。

GitLabのCreate project runner画面。Tagsフィールドにmacosが入力され、下部にCreate runnerボタンがある
ここで入力したタグが、後述のジョブから参照されるタグです
GitLabのRegister runner画面と、gitlab-runner registerを実行してランナー名とエグゼキューターを対話的に尋ねているターミナル。shellと入力されている
gitlab-runner registerはランナー名、続いてエグゼキューターを対話的に尋ねます。最後の質問にはshellと答えてください
GitLabのCI/CD設定画面。macosタグの付いたプロジェクトランナーが1つ割り当てられ、オンラインかつアイドル状態で表示されている
登録が済むと、ランナーはプロジェクトのCI/CD設定にオンラインかつアイドル状態で表示されます

プッシュ用トークンを作成する

fix-image-weightジョブは、マージリクエストのブランチにコミットをプッシュします。すべてのジョブに自動で渡されるCI_JOB_TOKENには、保護ブランチへの書き込みに必要な権限がないため、専用のトークンが必要です。

Project Access Token(プロジェクトのSettings > Access Tokens)は理論上もっともきれいな選択肢です。アカウントではなくプロジェクトに紐づくため、作成者が抜けても生き残ります。ただし無料プランの個人ネームスペースでは、GitLab.comはこの機能を提供しておらず、有料グループ限定です。代わりにPersonal Access Tokenを使うことになる最も一般的な理由がこれです。

Personal Access Tokenはプロジェクトの設定ではなく、ユーザーアカウントの設定から作成します(Avatar > Edit profile > Access Tokens)。設定内容は次のとおりです。

  • スコープ write_repository
  • トークンのローテーション方針に合った有効期限
GitLabのPersonal access tokens画面。push-images-ciという名前のトークンを、Write repositoryスコープと有効期限1年で作成しているところ
トークンに必要なのはWrite repositoryスコープだけで、それ以上は不要です

この方法を選ぶ前に知っておくべき代償があります。トークンは作成したアカウントに紐づいている点です。そのアカウントが無効化されたり、プロジェクトへのアクセス権を失ったり、その人がチームを離れたりすると、修正ジョブはプッシュできなくなります。しかも事前の警告は何もありません。チームのプロジェクトであれば、貢献者個人のアカウントではなく、専用のサービスアカウントから作成するほうが安全です。

生成されたトークンは一度しか表示されません。すぐにコピーし、プロジェクトのSettings > CI/CD > VariablesPUSH_TOKENという名前で追加します。マージリクエストの向き先が保護ブランチであれば、MaskedProtectedのオプションにチェックを入れてください。

GitLabのCI/CD Settings画面。キーがPUSH_TOKENのプロジェクト変数を追加するパネルで、Maskedオプションが選択され値がマスクされている
この変数の名前はPUSH_TOKENで、後述の.gitlab-ci.ymlで使われます

マージリクエストのソースブランチ自体が保護されている場合は、トークンに紐づくアカウントにそのブランチへ直接プッシュする権限も必要です(Settings > Repository > Protected branches > Allowed to push and merge)。これがないと、トークンが有効でもジョブのgit pushはアクセス拒否で失敗します。

.gitlab-ci.ymlファイルを置く場所

GitLabは既定では1か所しかパイプラインを探しません。リポジトリのルート、READMEと同じ階層にある、正確に.gitlab-ci.ymlという名前のファイル(先頭のドットを含む)です。サブフォルダに置かれたファイルや、名前の違うファイルは単純に無視されます。エラーで知らせてくれることもなく、プロジェクトはCIがまったく設定されていないかのように振る舞うだけです。

別の場所が必要な場合、たとえば複数リポジトリで共有するパイプラインファイルやモノレポ構成では、Settings > CI/CD > General pipelines > CI/CD configuration fileで別のパスを指定できます。パス/ファイル.yml@グループ/プロジェクト:ブランチという構文で、別プロジェクトを指すことも可能です。この特殊なケース以外では、この欄は空のままにして、ファイルはルートに置いてください。

ファイルをコミットしてプッシュすれば、手動での有効化は不要です。GitLabはファイル内のルールに合致する次のイベントで自動的に検知します。ここでは$CI_PIPELINE_SOURCE == "merge_request_event"による、マージリクエストの作成または更新です。結果はプロジェクトのPipelinesタブと、マージリクエストの同名タブに直接表示されます。最初のパイプライン失敗で気づくのではなく、プッシュ前に構文を確認したい場合は、内蔵エディター(プロジェクトメニューのBuild > Pipeline editor)にValidateボタンがあり、実際のパイプラインを起動せずにGitLabのCIリンターを呼び出せます。

ステータスがPassedのGitLabパイプライン画面。check-image-weightとfix-image-weightの2つのジョブがどちらも成功している
パイプラインはマージリクエストで自動的に起動し、verificationcorrectionの2つのジョブを実行します

GitLab CIとの統合

以下のジョブはmacosタグの付いたランナー、つまり上で説明した永続的なセルフホストランナー(パイプラインの外ですでに有効化済み)を対象にしています。マージリクエストのパイプラインでのみ実行され、次のセクションで説明する2つのモード、すなわちサイズの検証とその後の自動修正を組み合わせています。

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

variables:
  # 圧縮後の画像がこの値を超えるとジョブを失敗させるしきい値(KB)
  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

      # このマージリクエストで追加・変更された画像だけを対象にする
      # (現在の対象範囲: .jpg と .png)
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "このマージリクエストで変更された画像はありません。"
        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

      # 圧縮してもしきい値を超える画像があればジョブを失敗させる
      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 KB を超える画像が $OVER_LIMIT 件あります:"
        jq --argjson threshold "$THRESHOLD_BYTES" -r '.[] | select(.compressedSizeBytes > $threshold) | .path' /tmp/report.json
        exit 1
      fi
      echo "変更されたすべての画像が $THRESHOLD_KB KB のしきい値に収まっています。"

fix-image-weight:
  stage: correction
  tags: [macos]
  # "verification" ステージから独立させる: そうしないと、このジョブが本当に必要な
  # 唯一のケース (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 "このマージリクエストで変更された画像はありません。"
        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 KB を超えています (品質 $QUALITY、$((SIZE / 1000)) KB): 手動での縮小が必要です。"
          REMAINING="$REMAINING $FILE"
        fi
      done <<< "$FILES"

      # 圧縮による削減量。ジョブのログで確認できる (「効果を測定する」を参照)。
      # -s: /tmp/report.json にはファイルごとに 1 つの JSON 配列が入っている
      # (上のループの ">>" 1 回につき 1 つ)。"add" が合計前にそれらを 1 つにまとめる。
      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]: これがないと、このプッシュが新たな 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}"

      # 可能な限り最良の結果はいずれにせよプッシュする (何もしないよりはよい) が、
      # 最低品質でもしきい値を超えるファイルが 1 つでも残っていればジョブを失敗させ、
      # 黙って受け入れられるのではなく見えるようにしておく。
      if [ -n "$REMAINING" ]; then
        echo "圧縮してもまだ重すぎるファイル:$REMAINING"
        exit 1
      fi

検証モードか修正モードか

前のセクションの2つのジョブは、jpgboost-cliをGitLabのマージリクエストに組み込む正反対の2つのやり方を示しています。一方はブロックし、もう一方は貢献者の代わりに直します。

検証モードでは、check-image-weightジョブは何も変更しません。変更された画像を使い捨てフォルダに圧縮し、その結果のサイズを選んだしきい値と比較して、超えていればCIを失敗させます。リポジトリはそのままです。画像を作り直してコミットし直すのは貢献者の役目で、パイプラインはそれが終わるまでマージを許さないだけです。

修正モードでは、fix-image-weightジョブはさらに踏み込みます。リポジトリ内で各画像をその場で直接圧縮し、その結果をコミットしてマージリクエストのブランチにプッシュします。貢献者にやり直すことは何も残りませんが、Gitの履歴には自分で書いたのではないコミットが1つ増えます。

基準検証モード修正モード
目的画像がしきい値を超えたらCIをブロックする圧縮してマージリクエストにコミットをプッシュする
仕組み--json + jq、選んだしきい値との比較、その後の明示的なexit 1(しきい値用のオプションは組み込まれていない)直接圧縮し、その場に書き込み、プッシュ
リポジトリへの影響なし。ジョブは観察するだけマージリクエストに自動コミットが1つ追加される
貢献者への影響自分で直してコミットし直す必要があるそれ以上やることはない
必要な認証不要マスクされた変数に入れたproject access tokenまたはdeploy token
トレードオフ摩擦が生じ、手作業の修正が貢献者に残るGitの履歴を自動で書き足す。保護ブランチと衝突するリスク

実務では、検証モードは画像がリポジトリに圧縮済みで入る前に、自分たちで編集上の判断(トリミング、レタッチ、フォーマットの選択)を握っておきたいチームに向いています。修正モードは、履歴に自動コミットが1つ増えることと、プッシュ用トークンを管理する手間を受け入れてでも、そもそも考えずに済ませたいチームに向いています。

ジョブの最適化

どちらのジョブも、パイプラインのたびにリポジトリ全体を再スキャンするのではなく、git diff --name-only --diff-filter=ACMによって実際に変更されたファイルだけに作業を限定しています。何百もの画像が溜まったリポジトリでは、実行時間の差は大きくなります。

永続的なセルフホストランナーでは、キャッシュの考え方が変わります。GitLab CIのcache:ディレクティブは、主にジョブごとにゼロから始まる使い捨てランナーで依存関係を復元するために存在します。ここではJPGBoost.appはすでにランナーにインストールされており、再ダウンロードは不要です。cache:ブロックを足しても、ランナーのローカルストレージがすでに提供しているもの以上のものは得られません。

--jsonオプションは、冪等性の仕組みを作るのに必要な情報を提供します。compressedSizeBytesを、できればファイルのハッシュを実行ごとに保持しておけば、変わっていないファイルを検出して、パイプラインのたびに再圧縮するのを避けられるでしょう。

並列処理のほうは--jobsによってネイティブに扱われます。複数のファイルを同時に処理でき、それぞれ独立してデコードと解放が行われます。したがってメモリ使用量は主に--jobsの値に左右され、待機中のファイル総数には左右されません。12ファイルのバッチでは、ドキュメントは--jobs 8--jobs 1の間でおよそ4倍の差を報告しています。自分のランナーでこの値を決めるうえで有用な目安ですが、AVIFやJPEG XLのエンコードはJPEGやHEICよりCPU負荷が明らかに高い点は念頭に置いてください。

よくある落とし穴

1つ目の落とし穴は出力フォーマットに関するものです。上のスクリプトは、PNGも含めてすべての画像を意図的にJPEGへ変換します。写真であれば、JPEGは一般にPNGより品質とサイズの比率が優れています。したがって--format jpegを強制することで、単に品質を下げるだけでは足りない場合でもファイルをサイズのしきい値以下に収められることがあります。その代償は透過の喪失です。JPEGにはアルファチャンネルがないためですが、ログに必ずしもエラーが出るわけではありません。この選択は写真では有効ですが、透過背景のPNGロゴやアイコンを何も言わずに壊してしまう可能性があります。両方の用途が混在するリポジトリでは、すべての画像に単一のフォーマットを押し付けるより、ジョブの対象範囲を(たとえば専用フォルダに)絞るほうが安全です。

GitLab CIでは、あるステージのジョブは既定では前のステージのすべてのジョブが成功した場合にのみ実行されます。ところがfix-image-weightが意味を持つのはcheck-image-weightが超過を検出したときであり、その既定の挙動のままでは決して実行されないケースがまさにそれです。needs: []は修正ジョブをこの暗黙の依存関係から解放し、検証ジョブと並行して独立に実行できるようにします。

修正ジョブはその後、パイプラインを起動したブランチにコミットをプッシュします。対策をしないと、そのプッシュが新しいマージリクエストのパイプラインを起動しかねません。結果がまだ重すぎると判断されれば、ジョブはまた修正し、さらにコミットをプッシュし、またパイプラインを起動します。これは数分のうちに数十のコミットとパイプラインを生むループへと容易に暴走します。自動コミットのメッセージに[skip ci]を加えると、そのコミットに対して新しいパイプラインを起動しないようGitLabに伝えられます。

CI_MERGE_REQUEST_DIFF_BASE_SHAに対して実行されるgit diffは、そのベースコミットがローカルで利用できることも前提にしています。しかしGitLabは既定では深さを制限してクローンします。コミットが多く積み上がったマージリクエストや、ターゲットブランチが大きく分岐している場合、探しているコミットが単に存在しないことがあります。するとgit diffは、画像のサイズとはまったく関係のない理由で失敗します。GIT_DEPTH: "0"を設定すると完全クローンが強制され、この問題は回避できますが、ジョブごとにクローンが長くなる代償を伴います。

もう1つの落とし穴は変更の検出に関するものです。git diff --quietは、Gitがすでに追跡しているファイルの変更しか検出しません。ところがスクリプトが画像をJPEGに変換し、git rmで元ファイルを削除すると、実際の変更は2つの操作から成ります。追跡されている.pngの削除と、新しい未追跡の.jpgの作成です。素のgit diffでは、このとき何も報告されないことがあります。実際には、ジョブがログにまったく本物の圧縮効果を表示しながら、それでも「コミットするものはありません」と結論し、結果を一度もプッシュしないということが起こり得ます。未追跡ファイルもカバーするgit status --porcelainは、同じようには騙されません。

すでにGitの履歴にある画像も、それ自体が別のサイズ問題です。新しいコミットで圧縮版に置き換えたとしても、古いバージョンはGitのblobとして履歴に残ります。リポジトリがそれらのファイルの占める領域を自動で回収することはありません。この古いデータを取り除けるのは履歴の書き換えだけですが、それは自動化されたパイプラインに居場所のない破壊的な操作です。

修正モードの自動コミットは、リポジトリ自身のルールとぶつかることもあります。保護ブランチは直接のプッシュを禁じていたり、マージ前のレビューを必須にしていたりします。同様に、フックやその他のフォーマット処理の仕組みが同じファイルを触り、圧縮ジョブと衝突する可能性もあります。これらの相互作用は、摩擦なく共存するはずだと決めてかからず、明示的にテストする必要があります。

最後に、パイプラインが対象にできるのはリポジトリを通る画像、より正確に言えば当のマージリクエストのフローを通る画像だけです。デザイナーが画像をCMSや共有フォルダ、その他Git外のアセット置き場に直接置いた場合、このチェックは完全に迂回されます。パイプラインはリポジトリでバージョン管理される画像の品質を高めますが、それ単体で完全なアセット管理ポリシーになるわけではありません。

効果を測定する

fix-image-weightは圧縮ループの直後に、このレポートを自身のログにすでに出力しています(上の「GitLab CIとの統合」セクションを参照)。マージリクエストのPipelinesタブから確認でき、追加で何かする必要はありません。細かい点として、--json出力のratioフィールドが、originalSizeBytescompressedSizeBytesと並んで、ファイルごとの削減率を直接示します。

GitLabのfix-image-weightジョブのログ。ファイルごとの圧縮前後のサイズを示す圧縮削減レポートが表示されている
修正ジョブのログには、ファイルごとの削減量と圧縮前後の合計が出力されます
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) バイト (\(.ratio))"' /tmp/report.json

合計値については、正確なコマンドはジョブが/tmp/report.jsonをどう書くかによります。fix-image-weightはループの中でファイルごとに1つのJSON配列を書く(>>)ため、最終的なファイルには1つではなく複数の配列が連結された状態になります。そのため全体を読むには-s(slurp)が必要ですが、これはそれらの配列をさらに1つの配列で包んでしまいます。先にaddで平坦化しないと、mapはそこで直接失敗します(jq: error: Cannot index array with string)。上のスクリプトで使っているのがこの形です。

# バッチ全体の圧縮前後の合計
# (複数回の呼び出しで書かれたファイル: fix-image-weight)
jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json
jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json

別の場所で自分のレポートを作る場合、つまりローカル(「jpgboost-cliのインストール」を参照)や、すべてのファイルを1回の呼び出しで圧縮するcheck-image-weight>)の場合、そこでの/tmp/report.jsonはすでに単一のJSON配列です。この場合-sは不要であり、正しくもありません。すでに完成している配列をさらに包んでしまうため、逆の理由で同じエラーが出ます。

# 1回の呼び出しで書かれたレポートに対する同じ計算
# (ローカルでの確認、または check-image-weight)
jq 'map(.originalSizeBytes) | add' /tmp/report.json
jq 'map(.compressedSizeBytes) | add' /tmp/report.json

jpgboost-cliが測るのは、それ自身が生み出すもの、つまり圧縮前後のサイズだけです。パフォーマンススコア(LCPやLighthouseのスコア)は、専用のツールで別途取得する必要があります。このツールにそれをネイティブに算出する機能はなく、圧縮率と人為的に結びつけるのは検証されていない外挿にすぎません。