$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

GitHub ActionsでRuboCopのキャッシュを利用する

RuboCopはキャッシュファイルを作成し、2度目以降の実行を高速化するのですが、GitHub Actionsでキャッシュを利用するために工夫が必要だったのでメモしておきます。

小規模なプロジェクトの場合

小規模なプロジェクトでRuboCopの実行時間がある程度ありキャッシュを利用したい場合は、キャッシュのvalidityチェックは完全にRuboCopにお任せし、すべてのGitHub Actionsでキャッシュを作ってしまうやり方がオススメです。理由は、小規模な場合キャッシュサイズが問題になるケースは少なく、全部キャッシュした方がシンプルかつメンテナンス性も高いためです。

参考の設定はこちら。

    steps:
      ...
      - name: Cache rubocop
        uses: actions/cache@v3
        env:
          cache-name: cache-rubocop-v1
        with:
          path: ~/.cache/rubocop_cache
          key: ${{ env.cache-name }}-${{ github.head_ref }}
          restore-keys: ${{ env.cache-name }}-
      - run: bundle exec rubocop --parallel
      ...

古いキャッシュが残り続け、キャッシュサイズが肥大化する場合は、https://docs.rubocop.org/rubocop/usage/caching.html を参考に、AllCops: MaxFilesInCache を調整すると良いと思います。

大規模でキャッシュサイズが問題になる場合

大規模なプロジェクト場合、一度のRuboCopのキャッシュが500KB〜1MBに到達するケースがあります。かつ毎回のGitHub Actions実行でキャッシュを保持してしまうと、GitHub Actionsのキャッシュ上限に達してしまう場合があります。その場合はキャッシュの保持の仕方を工夫しなければなりません。ただし保持の仕方を間違えると以下の問題に遭遇します。

  • cache-rubocop-hashhhhdummmmmmy1のようなキャッシュが作られているとする
  • RuboCopのバージョンを更新すると、これまでのすべてのキャッシュが無効になる
  • すべてのキャッシュが使えないため、RuboCopのチェックがフルで実行される
  • 新しくできたキャッシュをActions側のキャッシュに入れようとするが、そちらのキャッシュキーが変わっていないとキャッシュ保存がスキップされてしまう
  • 結果、古いキャッシュを使い続け、ずっとフル実行のままになってしまう

この問題にならないためには、RuboCopで全キャッシュが無効になるタイミングでは、必ずGitHub Actionsのキャッシュキーも変えておく必要があります。https://docs.rubocop.org/rubocop/usage/caching.html#cache-validity を参考にすると、Rubyのバージョンを上げる・RuboCopのバージョンを上げる・RuboCopの設定を変える・rubocopコマンドへ渡すオプションを変えるケースだとすべてのキャッシュが無効になりそうです。

これらを考慮した参考例は次の通り。RuboCopのキャッシュが無効にならないケースでもGitHub Actions側のキャッシュキーが変わる時もありますが、キャッシュサイズの制御のためなので厳密なチェックはしていません。

    steps:
      ...
      - name: Cache rubocop
        uses: actions/cache@v3
        env:
          cache-name: cache-rubocop-v1
        with:
          path: ~/.cache/rubocop_cache
          # RuboCopのcacheのvalidityに合わせてキャッシュキーを決める
          # * RuboCopの設定変更の時にキャッシュを分けるため、.rubocop.ymlと.rubocop_todo.ymlを見る
          # * RuboCopのバージョン更新の時にキャッシュを分けるため、Gemfile.lockを見る
          # * Rubyのバージョンが変更された時にキャッシュを分けるため、.ruby-versionを見る
          key: ${{ env.cache-name }}-${{ hashFiles('.rubocop.yml', '.rubocop_todo.yml', 'Gemfile.lock', '.ruby-version') }}
          restore-keys: ${{ env.cache-name }}-
      - run: bundle exec rubocop --parallel
      ...

このチェックではrubocopコマンドに渡すオプションを変えたときにキャッシュキーを変えていないため、必要があればhashFilesに関係するファイルを入れておくと良さそうです。

またこのやり方の場合、hashFilesに指定したファイルをずっと更新しなかった場合に、徐々にキャッシュが古くなっていき実行が遅くなるという問題があります。しかし、大規模な場合、Gemfile.lockは1~2週に一度は更新されるであろうと考え、あまり気にしていません。様子を見て、もし問題になってきた場合は対策を考える予定です。例えばhttps://jpdebug.com/p/2821562のように、数日に一度はキャッシュがクリアされるように日付をキャッシュキーに入れるやり方を取ると良さそうです。

まとめ

今回はGitHub ActionsでRuboCopのキャッシュを保持する設定について書きました。大規模なプロジェクトの場合、工夫が必要なので難しいですね。

調査メモ: RuboCopのキャッシュ構造について

RuboCopは以下のようなキャッシュ構造になっている。

{cache_root}/{rubocop_checksum}/{context_checksum}/{file_checksum}

たとえば ~/.cache/rubocop_cache/48495939d6d5ca59d7f0a191fd9c11432a988b9d/eb1638cf9f9405f1dfcb03f4d43f86ec4b3f6af5/bab558f367464a4c3a65c3c112fae2e3168613cd のようなファイルに、どんな違反があったかの情報が書かれている。現在RuboCopチェックをかけようとしているファイルのキャッシュがあれば、違反内容は変わらないためキャッシュファイル内に記載された違反内容をそのまま返せば良い。

Rubyのバージョン変更、RuboCopの設定変更、RuboCopのオプション変更はrubocop_checksumやcontext_checksumへ影響を及ぼすので、全キャッシュが無効になるようだ。

まったく何も変更がない場合、checksumの計算 + RuboCopチェックをかける分のキャッシュ読み込みのみ行うため、ほぼ一瞬でRuboCopの結果が返ってくるようだ。