🛞

モノレポで不要なGithub Actions実行を最小にしつつマージブロック機能(マージ前必須チェックステータス)を使う方法の検討

2023/10/01に公開

免責

以下は2023/09時点での情報をもとに書いています。
今後、GitHubのアップデートにより以下の問題を解決する機能が出てきたり、仕組みが変更されてこの問題自体が起こらないようになる可能性もあります。

状況が変わった場合は高確率でGithub actions and required checks in a monorepo (using paths to limit execution) · community · Discussion #26251に書き込みがなされると思いますので、時間が空いてこの記事を開いた方はまずそちらで状況が変わっていないかの確認をされることをおすすめします。

本題前に用語の確認

ステータスチェックとは

PRの画面で画像のように表示される、コミットもしくはPRに紐付けられる主にCIの実行結果を表すものです。

ステータスチェックについて - GitHub Docs

厳密には、ステータスチェックは次の2種類の集合です。

名前 機能 制約
(コミット)ステータス 状態とtitle・descriptionのみ 特になし
チェック(チェックスイート・チェックラン) 上記に加えてコード行単位でのコメントなど GitHub Appからのみ利用可能

ややこしいのですが、ステータスチェックはときに省略してチェックと表記されます。
例えば必須ステータスチェックのトラブルシューティング - GitHub Docsのページではステータスチェックの省略の意味でチェックと書いていますが、GitHub アプリを使用した CI チェックの構築 - GitHub Docsのページではチェックスイート・チェックランの意味でチェックと書いています。
この2つはコミットステータスを含むかどうかが大きく違います。これを区別する簡単な方法はありませんので、基本的に文脈から判断するしかありません。

Require status checks to pass before merging とは

Branch protection ruleの中の1項目です。

これを有効にした後、その下のフォームからジョブ名を選択すると、そのジョブのステータスチェックがSuccessにならない限り、プルリクエストがマージできなくなります。

問題概要

みなさん、ブランチプロテクションの Require status checks to pass before merging は使っていますか?
そうです、PRのマージ前に特定のCIが通っていることを強制する設定です。
これがあると、間違ってテストが通っていないのにデプロイしてしまうといった事故が防げます。

多くの場合、非常に便利で使いやすい機能なのですが、これと相性の悪い設定があります。
それが、GitHub Actionsのpaths / bnrachesフィルターです。

paths / branchesフィルターと Require status checks to pass before merging の相性の悪さ

GitHub Actionsの発火条件(on)の中には、ブランチ名や変更されたファイルのパスで発火するかどうかを決められる、branchesフィルター / pathsフィルターというものがあります。
これは不要なCI実行を省くために非常に有用で、例えば1つのリポジトリにTypeScriptのコードとRailsのコードが別々に収められている場合、Railsのコードが変更された場合のみRailsコードをテスト・デプロイする(逆も同じ)といったことができます。

このようにとても便利なpaths/branchesフィルターですが、実は Require status checks to pass before merging との相性がとても悪いです。
Require status checks to pass before merging の対象にpaths/branchesフィルターを含んだワークフローを指定したとします。こうした場合、普通に考えるとフィルターによりスキップされたワークフロー(=ステータスチェック)についてはチェックをパスしたものとして扱ってほしいところです。明示的にpathsフィルタで実行不要と判断したのだから、そのほうが自然と言えます。
しかし、実際にはこのスキップされたワークフローはチェックに通っていないものと判断されます。その結果、ステータスチェックはイエローのまま、永遠にCIは実行されず、いつまで経ってもマージできないということになります。

前述のTypeScript / Rails混在型のリポジトリを例にすると、それぞれのために1つずつテストを実行するワークフローを定義したとします。ブランチプロテクションルールでそれぞれのテストが通らない限りマージできないよう Require status checks to pass before merging からその2つのワークフロー(正確にはその中のジョブ)をSuccess必須にしましょう。
この状態でTypeScriptのコードだけを変えるPRを作ったとします。この際、実行されるワークフローはTypeScriptのテスト用のものだけです。これは期待通りですが、マージ前ステータスチェックではRails側のステータスチェックが通っていないため、このPRはこの状態ではどうやってもマージできません。

GitHub公式の提案するソリューション

GitHub公式もこの問題を認知しており、次のような注意書きが必須ステータスチェックのトラブルシューティング - GitHub Docsに書かれています。

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

このため、ワークフローが必須の場合は、パスまたはブランチ フィルターを使用してワークフローの実行をスキップしないでください。 詳細については、「ワークフロー実行をスキップする」と「必要なワークフロー」を参照してください。

このようにGitHubの提案はシンプルです。しかし、このガイドに従ってpaths/branchesフィルターを外すと、不要なCIが増え、時間もお金も余計にかかってしまいます。

モノレポで発生する地獄

この問題が特に顕著になるのが、多くの異なる種類のプログラムが1つのリポジトリにまとめられた、いわゆるモノレポ構成の場合です。
モノレポ構成の場合、ワークフローの種類が数百以上になることは珍しくありません。
そのような構成ではpaths/branchesフィルターを使わない場合と使う場合ではコストが100倍以上違うことになります。

模索される解決手段

この問題はGitHubの公式コミュニティでも議論されています。

Github actions and required checks in a monorepo (using paths to limit execution) · community · Discussion #26251

また、そこ以外でも同じ問題に対する解決手段が取られている資料があります。
以下では、それらから見つかった解決手段について列挙します。

1. paths / branches フィルターを使用しない

参考元: 必須ステータスチェックのトラブルシューティング - GitHub Docs

GitHub公式が提案する方法です。

利点

シンプルな解決策で余計な記述がいりません。必然、間違いが発生しづらいです。

欠点

GitHub Actionsの実行時間 = コストが上がります。
もしリポジトリ内がpathsやbranchesで100分割されているとしたら、フィルターを使った場合と比べてコストが100倍になります。
また、PRのステータスチェック一覧でも本来の変更内容に関係しないチェックが大量に並んでしまいます。

2. 各ワークフローの内部で jobs.<job_id>.if を使って実行をスキップする

参考元: https://github.com/orgs/community/discussions/26251#discussioncomment-3250970

実はGitHubによるこの問題についての注意書きには続けて次の1文が書かれていました。

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

これを利用すると、次のように on で記述していたフィルターを jobs.<job_id>.if に移すことでステータスチェックをグリーンにすることができます。

name: example-workflow
on: [push]
jobs:
  test:
    if: github.ref_name == 'branch_name_a'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: bin/rails test

pathsフィルターについてはこれよりはややこしいですが、dorny/paths-filter: Conditionally run actions based on files modified by PR, feature branch or pushed commitsを使えば同様のことができます。

利点

branches/pathsフィルターと同等のことができるため、GitHub Actionsの実行時間は必要最低限とほぼおなじになります(dorny/paths-filterの実行時間分だけが追加で必要になります)。

欠点

それぞれのワークフロー記述が複雑になります。
具体的には、純正のbranches/pathsフィルターと異なる書き方をする必要がある点、かつその中で新たなジョブの追加・依存関係やoutputによるフラグ引き回しをもとのワークフローに合わせて書き換えなければならない点です。

また、この方法でも「paths / branches フィルターを使用しない」パターンと同じく本来は不要なステータスチェックがたくさん並んでしまいます。

3. Success必須としたいジョブと同じ名前のダミージョブを使う

参考元: https://github.com/orgs/community/discussions/26251#discussioncomment-3250964

Require status checks to pass before merging は、実はワークフロー名は一切チェックしておらず、ジョブ名のみでステータスチェックとマッチングします。
かつ、ドキュメントには明記されていないようですが複数のワークフロー内に同じ名前のジョブがあった場合、つまり複数マッチングした場合、乗算的な振る舞いをします。挙動をまとめるとこうです:

マッチングしたジョブ数(ワークフロー数) 結果
0 Expected(つまりnot Success)
1 そのジョブの結果
2以上、かつ1つでもFailure Failure
2以上、かつすべてSuccess Success

今回の問題はマッチングしたジョブ数が0の場合です。
そこで、中身が空っぽで一律Successを返すJobを定義し、そのワークフローは毎回実行するようにすると、もし本来のワークフローが実行されない場合はSuccessになり、実行される場合でもその本来のワークフローが失敗すれば全体の判定もFailureになります。

利点

GitHub Actionsの実行時間は必要最低限にかなり近くなります。ダミーのジョブを含んだワークフローの実行時間分のみが追加で必要になります。
また、本来のワークフローファイルを編集する必要がない点もメリットです。

欠点

ジョブの種類が増えた場合、その度にダミーを含んだワークフローファイルを編集する必要があります。ただし、これについては追記を忘れた場合でもPRでExpectedが出ることで必ず気づくことができる点、それにより間違ってチェックを通さずマージしてしまうようなリスクがない点からそれほど大きな欠点ではありません。

一方、ダミーの不必要なジョブまで一覧に含まれてしまう欠点は今までと同じです。

4. ステータスチェックの乗算的なワークフローを使う

参考元:

他のステータスチェックを監視して、それらが1つでも失敗すれば自分も失敗終了し、すべてが成功した場合のみ自身も成功にするようなワークフローを使う方法です。
Require status checks to pass before merging ではこの乗算的ワークフローのみを必須ジョブにします。
(この方法を取る場合、merge-gatekeeperが非常に使いやすくセットアップ方法も簡単であるためおすすめです)

利点

乗算的ワークフロー以外に手を加える必要がありません。
また、乗算的ワークフロー自体も最初に作成して以降は手を加える必要がなく、ワークフローファイルの運用コストは低いです。

欠点

他のワークフローの失敗を待つだけのGitHub Actionsを長時間実行する必要があるため、コストが上がります。
paths/branchesフィルターを使わない場合に比べればマシですが、すべてのワークフローが成功する場合、一番長く実行されるワークフローと同じ時間だけ乗算的ワークフローが実行されます。ですので、ワークフローが毎回1個しか実行されない場合はコストが2倍になります。3,4個実行される場合でも30%程度のコスト増は覚悟しなければなりません。

5. 各ワークフローのジョブ名を同じにする

上の方でも書きましたが、Require status checks to pass before merging はワークフロー名は一切見ておらず、ジョブ名のみでマッチングします。
そして、同じく上で書いたとおり、複数のジョブがマッチングした場合、その最終的なステータスチェックは乗算された結果(つまり1つでも失敗すると最終的な結果は失敗になる)になります。

ですので、すべてのワークフローでジョブの名前を統一し、そのジョブ名を Require status checks to pass before merging で必須ステータスにすれば、paths/branchesフィルターを通して実行されたワークフローについては、CIのSuccessを必須にする、ということができます。

(この場合、どのワークフローも発火しない場合にマージできなくなりますが、それは「3. Success必須としたいジョブと同じ名前のジョブを使う」を併用することで解決できます)。

メリット

実行時間は必要最小限とほぼ一致します(ダミーが必要な場合はその実行時間分増える)。
また、ステータスチェック一覧に実際には不要なジョブが並ぶようなこともありません。

デメリット

ジョブ名に制約がかかります。各ワークフローのジョブ内容がおおよそ同じ性質を持つならば、test deploy など共通の性質で名前を統一できますが、性質の違うジョブがあった場合、強引な名前の統一は複雑さやわかりにくさを増加させます。

蛇足: 結局うちではどれにしたのか

私のところではTerraformのモノレポ化を現在進めているのですが、5と3を組み合わせた方法を取ることにしました。これは、リポジトリのコードがほぼTerraformであるため、ワークフローファイルの構成がほぼ同じで、ジョブ名も共通化して問題なかった( plan, apply )ためです。
README.mdのように共通のテスト・デプロイジョブを発火させないケースについては、ダミーワークフローを発火させることで対処しました。

Discussion