モノレポでマージキューと必須ステータスチェックを運用するためのTips

ROUTE06 でソフトウェアエンジニアをしている @MH4GF です。
GitHub のマージキュー(Merge Queue)を私のチームでの開発フローに取り入れてから数ヶ月経ちました。マージキューは非常に便利ですが、挙動の理解やセットアップに難しさがあると感じています。いくつかの課題の対処ができ安定した運用ができてきたので、この記事ではセットアップでつまづきがちな点を紹介します。

マージキューとは

マージキューは 2023 年 7 月に一般公開された比較的新しい機能で、簡単に説明すると「プルリクエストのマージ前にマージ先ブランチを取り込んだ上で CI を実行し、通ることを確認してからマージする」機能です。
複数人で GitHub を利用した開発をしていると、main ブランチの取り込み漏れにより「プルリクエストでの CI は通るものの、マージ後の main ブランチの CI は失敗する」という問題が発生しがちです。この機能を使えば少ない労力でそのような問題を未然に防ぐことができるため、チーム開発では非常に効果的です。

詳しい仕組みや、この機能のメリットについては、それ自体で記事が一つ書けるほどの内容ですので、ここでは参考になった記事をいくつか紹介するにとどめます。

docs.github.com

公式ドキュメントです。まずはこちらを一読することをお勧めします。一時ブランチを作成するなどのマージキューの仕組みについても丁寧に解説されています。

bufferings.hatenablog.com

なぜマージキューが必要なのか?というモチベーションが順を追って理解できるわかりやすい記事です。

developer.mamezou-tech.com

実際にマージキューを有効化する方法と、どのような挙動をするのかについて理解が深められる記事です。

github.blog

GitHub がマージキューを作ることになった背景や、毎月 500 人以上のエンジニアが関わる GitHub 自身の大規模なモノレポで、どのようにリリースフローが変わったかについて述べられています。

マージキューと必須ステータスチェック

マージキューを利用する場合、ブランチ保護ルールの Require status checks to pass before merging をマージキューとセットで設定する必要があります。これは「ブランチをマージするために特定の CI ジョブの成功を必須とする機能」です。

ここで設定された CI ジョブがマージキュー上の CI で失敗した場合、そのプルリクエストはマージされずキューから外されます。
必須ステータスチェックを設定しないと、マージキューにキューが詰められても即時でマージされてしまいます。

そのため、マージキューを運用するには必須ステータスチェックの知識も必要です。既存の記事ではあまり語られていない重要な観点だと思います。

モノレポで必須ステータスチェックを運用する難しさ

私たちのチームは、プロダクトに関わる情報の全てを 1 つのモノレポで管理しています。フロントエンドやバックエンド、インフラのコードはもちろん、ドキュメントや PoC のコードも 1 つのリポジトリで運用しています。

モノレポで CI を運用する場合、ジョブのスキップは重要な論点です。コード差分がフロントエンドだけのプルリクエストでバックエンドの CI ジョブを動かすのは時間もコストも無駄なため、必要な時に必要な CI ジョブを実行できることが望ましいです。
私たちのリポジトリでは /frontend/api のように領域ごとにディレクトリを分けているので、以下のように paths を使ってフィルタリングすることで「フロントエンドのコード差分がない場合はジョブをスキップする」を実現できます。

on:
  pull_request:
    paths:
      - frontend/**

しかしここで問題になるのが、paths によるジョブのスキップをすると必須ステータスチェックが通らなくなることです。
必須ステータスチェックに設定したジョブが開始すらされない場合、保留中のまま待機し続けてしまいます。

GitHub のドキュメントにも以下のように記載されています:

警告: パス フィルターブランチ フィルター、またはコミット メッセージのためにワークフローがスキップされた場合、そのワークフローに関連付けられているチェックは "保留中" 状態のままになります。 これらのチェックを成功させる必要がある pull request は、マージが禁止されます。

ただし、ワークフロー内のジョブが条件付きのためにスキップされた場合、状態は "成功" として報告されます。 詳しくは、「条件を使用してジョブの実行を制御する」を参照してください。

docs.github.com

二文目の「ワークフロー内のジョブが条件付きのためにスキップされた場合、状態は "成功" として報告されます。」に解決の糸口がありそうです。少々ややこしいですが、以下のような違いがあります。

  • ワークフローの開始がスキップされた場合 → ジョブは実行されないため「保留中」となる
  • ワークフローが開始し、ジョブ内の条件によりスキップされた場合 → ジョブは実行されたため「成功」となる

つまり、ディレクトリによるジョブの実行スキップ処理を on.pull_request.paths ではなくジョブ内で行えば、ジョブ実行が不要なときにスキップしつつ成功として扱えそうです。

dorny/paths-filter を使ってジョブ内で paths の絞り込みを行う

ディレクトリによるジョブの実行スキップ処理をジョブ内で行うために使えるツールとして、 dorny/paths-filter があります。

github.com

これは指定したパスが変更されているかどうかをジョブの出力として返してくれます。
次のジョブがあったとします。フロントエンドの静的解析を行うジョブです:

on:
  pull_request:
    paths:
      - frontend/**

jobs:
  frontend-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - ...

これを dorny/paths-filter で置き換えると以下になります:

on:
  pull_request:

jobs:
  setup-job:
    runs-on: ubuntu-latest
    outputs:
      has-changes: ${{ steps.changes.outputs.has-changes }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            has-changes:
              - frontend/**

  frontend-lint:
    needs: [setup-job]
    if: ${{ needs.setup-job.outputs.has-changes == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - ...

lint の実行前に lint ジョブを実行するか判定するための setup-job を追加しています。
これで必須ステータスチェックに frontend-lint を設定し、スキップが起きた際も成功として扱うことができました。

デメリットとして、ワークフローのスキップとは違いジョブ内でのスキップなので、プルリクエストの Checks に表示されるジョブの数が多くなり見づらい問題はあります。

マトリックスによる並列実行でも必須ステータスチェックをサポートする

パスによるフィルタをジョブ内で実行することでモノレポでも必須ステータスチェックを運用できましたが、他にも必須ステータスチェックを運用する上で問題になるのがマトリックスによる並列実行です。
例えばマトリックスでテストの並列実行を行う場合、以下のように書くでしょう:

jobs:
  frontend-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - ...

このときジョブ名は frontend-test (1/4) となりますが、必須ステータスチェックはジョブ名を文字列で指定するので、マトリックスの数や組み合わせが増えた場合は設定が大変になってしまいます。

その際の解決策として、全てのマトリックスジョブが終了した後に実行するジョブを用意するとよいでしょう。以下が例となります:

jobs:
  frontend-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - ...

  status-checks:
    if: ${{ always() }}
    runs-on: ubuntu-latest
    needs: [frontend-test]
    steps:
      - run: exit 1
        if: >-
          ${{
               contains(needs.*.result, 'failure')
            || contains(needs.*.result, 'cancelled')
          }}

needs に設定したジョブ全てが終わった後に実行し、一つでも failurecancelled のジョブがあれば exit 1 とします。なければステップは実行されないため成功となります。
この手法は以下の Community の投稿を参考にさせていただきました。

github.com

また、マトリックスと前述したジョブのスキップを組み合わせることも可能です。以下のように設定します:

on:
  pull_request:
  merge_group:

jobs:
  setup-job:
    runs-on: ubuntu-latest
    outputs:
      has-changes: ${{ steps.changes.outputs.has-changes }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            has-changes:
              - frontend/**

  frontend-test:
    needs: [setup-job]
    if: ${{ needs.setup-job.outputs.has-changes == 'true' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - ...

  status-checks:
    if: ${{ always() }}
    runs-on: ubuntu-latest
    needs: [frontend-test]
    steps:
      - run: exit 1
        if: >-
          ${{
               contains(needs.*.result, 'failure')
            || contains(needs.*.result, 'cancelled')
          }}

必須ステータスチェックには status-checks を指定します。
これにより、プルリクエスト上の CI・マージキュー上の CI の両方で以下の内容が実現できました!

  • frontend/ ディレクトリ以下に変更差分がある場合、 frontend-test ジョブを 4 並列で実行し、成功したら必須ステータスチェックが通る
  • frontend/ ディレクトリ以下に変更差分がない場合、 frontend-test ジョブはスキップされつつ、必須ステータスチェックは通る

マージキューを運用し始めた感想

最後に、マージキューを運用してからの感想を述べたいと思います。

私たちのリポジトリではブランチ戦略を GitHub Flow で運用しており、main ブランチにマージすると自動でデプロイが走るようになっています。マージキューを導入するまでは安全のため main ブランチへのプッシュでも CI を動かしていましたが、マージキューを導入したことでそれを取り除きつつ、より安全にデプロイできるようになりました。

また、lint ルールを強化したりライブラリやミドルウェアのバージョンアップを行う際も、他のプルリクエストの影響を考えるコストを減らすことができました。 マージキュー上で CI が失敗した場合はキューから取り除かれマージ前の状態に戻るだけであり、以前のように main ブランチにマージしてしまった場合よりもリカバリーが容易です。

マージキューで気になる点として、 merge_group という新しいイベントを利用するためイベントに含まれる情報を利用するワークフローの設定が難しい場合があります。 ref を使って操作する処理や、deployment_url を利用する場合などが挙げられます。今のところ私たちのチームでは困っていませんが、今後の運用で課題になるかもしれません。

まとめ

この記事では、モノレポでマージキューと必須ステータスチェックを運用するための Tips を紹介しました。

  • マージキューの利用には必須ステータスチェックの知識も必要になること
  • ジョブのスキップと必須ステータスチェックを両立させるには dorny/paths-filter を使う
  • マトリックスによる並列実行では、全てのジョブが終わった後に成功可否を判断するジョブを追加すると良い