monorepo ci GitHub Actions

集約ジョブパターンで解決するモノレポCIのrequired check問題

monorepo ci GitHub Actions

こんにちは!ロリポップ・ムームードメイン事業部ムームードメイングループの中村(@litencatt)です。

今回は、ムームードメインのモノレポで運用していたCIの統合ゲート(複数のCIの結果を1つの required check に集約する仕組み)を、GitHub Actionsネイティブの集約ジョブ方式に移行した話です。約500行のカスタムJavaScript + checks API + ポーリングで構成されていたプロキシ方式を廃止して、保守対象のコードは約335行に、障害モードは6種類まるごと消えました。

集約ジョブ方式は GitHub Actions の標準機能のみで構成されており、GitHub.com / GHES(GitHub Enterprise Server)を問わず、どのランナー環境でも導入できます。

TL;DR

  • モノレポで paths: フィルタ付き CI を required check(マージ前に成功が必須のチェック)にすると、該当パスに変更がない PR で skipped のまま永久ブロックされる(GitHub の既知制約)
  • これを解決するために checks API + ポーリングのプロキシを自前実装していたが、~500行のコードに6種の障害モードを抱える状態に
  • needs + if: always() の集約ジョブパターンに移行し、プラットフォームのネイティブ機能だけで required check 問題を解消
  • 既存 CI は workflow_call(再利用可能ワークフロー呼び出し)で再利用し、コードの重複なく移行
  • 移行時の注意点: concurrency group 衝突、workflow_run 非発火、check run 名の変化
  1. TL;DR
  2. 背景:モノレポCIの「required check問題」
  3. CI統合ゲートの変遷
    1. 第0世代:個別CIをrequired checkに登録(〜2026年3月)
    2. 第1世代:ポーリング方式のプロキシ(2026年3〜4月)
    3. 第2世代:イベント駆動化(2026年6月)
  4. 理想形の発見とPoC
    1. PoCで動作検証
  5. 新アーキテクチャ:CI オーケストレータ
    1. 主要な設計決定
  6. 実装で遭遇した落とし穴と解決
    1. concurrency group 衝突問題
    2. workflow_run が発火しなくなる問題
    3. check run 名の変化
    4. 変更検知の整合性
  7. 移行結果の比較
  8. 他プロジェクトへの導入ガイド
    1. 1. 各 CI の on:workflow_call に変更
    2. 2. オーケストレータを作成
    3. 3. required check を aggregate に切り替え
    4. 注意点
  9. まとめ

背景:モノレポCIの「required check問題」

ムームードメインのモノレポでも、ご多分に漏れずこの問題にぶつかりました。

GitHub Actions では paths: フィルタを使って「特定ディレクトリに変更があったときだけCIを実行する」ことができます。しかし、その CI をブランチ保護の required status check(PRをマージする前に成功が必須となるチェック)に登録すると、該当パスに変更がない PR では CI が起動せず skipped のまま永久にマージブロックされます。これは GitHub Community でも長く議論されている既知の制約です。

この制約に対する一般的な対処パターンは3つあります。

  1. 変更検知 + 集約ジョブ: 変更検知ジョブで対象サービスを判定し、条件付きで各CIを起動。末尾の集約ジョブ(needs + if: always())で全体の合否を出し、これを required check にする。今回採用した方式
  2. 常時起動するプロキシジョブ: 常に起動するジョブが checks API(GitHub が提供する、チェック結果を外部から作成・更新できる REST API)やポーリングで各CIの結果を収集し、1つのチェック結果にまとめる。柔軟だが複雑化しやすい
  3. 全CIを常時起動: 全CIを毎回起動し、内部で skip 判定する。シンプルだがランナーのリソースを無駄に消費する

ムームードメインのモノレポには複数の言語・フレームワークで構成されたサービスが共存しており、それぞれに独立した CI ワークフローがあります。この記事では、パターン2(プロキシ方式)を採用してから最終的にパターン1(集約ジョブ方式)に移行するまでの変遷を紹介します。

CI統合ゲートの変遷

この統合ゲートは一度にできたものではなく、問題が出るたびに直して、を繰り返してきました。

第0世代:個別CIをrequired checkに登録(〜2026年3月)

最初期は、各サービスの CI ワークフローをそれぞれ独立した required check として登録していました。

第0世代: 個別CIをrequired checkに登録した構成。service-a のみ変更した PR では service-b-ci と service-c-ci が skipped のまま永久ブロックされる

この構成はシンプルですが、前述の「required check問題」がそのまま発生します。service-a/ のみ変更した PR では service-b-ciservice-c-ci が起動せず、永久にマージブロックされます。

第1世代:ポーリング方式のプロキシ(2026年3〜4月)

required check 問題を解決するため、常時起動するプロキシジョブを導入しました。

第1世代: ポーリング方式のプロキシ。統合ゲートが常時起動し、変更パスを判定した上で各CIの完了をポーリングで最大36分間待機する

このジョブが変更パスを見て「どのCIが必要か」を判定し、checks API で該当 CI の完了をポーリングします。全ての必要な CI が成功すれば success、1つでも失敗すれば failure を返します。これを唯一の required check にすることで、不要な CI の skipped によるブロックを回避しました。

この時点での課題:

  • CI が完了するまで 最大約36分間ランナーを占有 する(ポーリングで待ち続けるため)
  • CI が36分以内に終わらないと タイムアウトで誤失敗 する(CI自体は成功しているのにゲートが失敗)
  • CI 完了からゲート反映まで 最大60秒の遅延 がある(ポーリング間隔)

第2世代:イベント駆動化(2026年6月)

ポーリングの課題を解消するため、workflow_run イベント(別のワークフローの完了をトリガーに起動する仕組み)を活用したイベント駆動方式に改修しました。

第2世代: イベント駆動化。各CIの完了時に workflow_run イベントで即座に Check Run を更新する。seed ジョブはバックストップとしてポーリングも実施

workflow_run イベントにより、各 CI の完了を即座に検知して Check Run を更新できるようになりました。ポーリングはバックストップに降格し、CI 完了からゲート反映までのラグがほぼゼロになりました。

しかし、この改修で新たな問題が発生しました。

  • checks API の cross-run update 禁止: GitHub が2025年2月に導入した仕様変更により、ある workflow run が作った Check Run を別の workflow run から更新できなくなり、本番で HttpError が発生しました。即日修正として、update ではなく常に create する方式に切り替えましたが、異なる run 間の通信に依存する設計自体が脆弱です
  • ブランチ保護の不整合: 自分たちの GHES 環境では、API で作成した独立 Check Run がブランチ保護の required check を満たさないケースがあり、commit status(もう1つのステータス報告手段)の併用が必要になりました。ゲートロジックがさらに複雑化しました
  • セキュリティ脆弱性の発見: レビュー中に、pull_request イベントが PR のブランチに含まれるワークフロー定義を実行するため、ゲートロジック自体を PR で改ざんできるバイパス経路が指摘されました。CODEOWNERS による保護を追加しましたが、補償的コントロールが増える一方でした

改修のたびに新たな問題が見つかり、補償的な対策が積み重なる一方でした。この時点でプロキシ方式は限界だと判断して、別のやり方を探し始めました。

理想形の発見とPoC

プロキシ方式の問題は、別ワークフローの結果を外部プロキシで集約するという設計そのものにありました。1つのワークフロー内で完結させられないか?と考えました。

調べていくと、GitHub Actions の標準機能だけで実現できることがわかりました。

  • workflow_call(再利用可能ワークフロー呼び出し): 既存の CI ワークフローを別のワークフローから uses: で呼び出せる。ジョブ定義をコピーせず再利用が可能
  • needs + if: always(): needs で依存する子ジョブの結果を needs.*.result で参照でき、if: always() を付ければ子ジョブが skipped や failure でも集約ジョブ自体は実行される
  • skipped は成功扱い: 条件付きで起動しなかった子ジョブは skipped になり、集約ジョブ側で成功として扱える

数百行のカスタム JavaScript + checks API + ポーリングでやっていたことが、プラットフォームの標準機能だけでできるわけです。

PoCで動作検証

実際に PoC を作成し、以下の4項目を検証しました。

  1. ローカルの reusable workflow(uses: ./.github/workflows/x.yml)が正常に呼び出せる
  2. 条件不成立で起動しなかった uses ジョブが needs.*.result == 'skipped' を返す
  3. 失敗した uses ジョブが needs.*.result == 'failure' を返す
  4. needs + if: always() の集約ジョブが上記を正しく判定できる

結果は全て成功。PoC の作成から検証完了まで1時間程度でした。

新アーキテクチャ:CI オーケストレータ

移行後の構成は以下の通りです。ci-orchestrator.yml という1つのワークフローに全てが収まっています。

新アーキテクチャ: CIオーケストレータ。changes ジョブで変更を検知し、該当サービスの CI のみ workflow_call で起動。aggregate ジョブが全体の合否を集約して required check になる。変更なしのサービスは skipped = 成功扱い

主要な設計決定

変更検知は git diff インラインchanges ジョブ内のシェルスクリプトで git diff --name-only を実行し、パスプレフィックスで判定しています。dorny/paths-filter のような外部 action は使わず、依存ゼロで構成しました。

steps:
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0
  - name: Detect changed service areas
    id: detect
    run: |
      BASE="${{ github.event.pull_request.base.sha }}"
      HEAD="${{ github.event.pull_request.head.sha }}"
      CHANGED=$(git diff --name-only "$BASE"..."$HEAD")

      # service-a: service-a/** から除外パスを引く
      EXCLUDED="service-a/docs/"
      if echo "$CHANGED" | grep -E "^service-a/" | grep -vE "^($EXCLUDED)" | grep -q .; then
        echo "service_a=true" >> "$GITHUB_OUTPUT"
      elif echo "$CHANGED" | grep -qF ".github/workflows/service-a-ci.yml"; then
        echo "service_a=true" >> "$GITHUB_OUTPUT"
      else
        echo "service_a=false" >> "$GITHUB_OUTPUT"
      fi
      # 他のサービスも同様...

secrets は secrets: inherit。子ワークフローが多数の secrets を参照している場合、明示的な mapping は現実的ではありません。secrets: inherit で呼び出し元の secrets を全て継承させています。

service-a:
  needs: changes
  if: needs.changes.outputs.service_a == 'true'
  uses: ./.github/workflows/service-a-ci.yml
  secrets: inherit

集約ジョブの判定は needs.*.result のシェル判定のみ。外部スクリプトは不要です。

aggregate:
  needs: [changes, service-a, service-b, service-c]
  if: always()
  runs-on: ubuntu-latest
  steps:
    - name: Evaluate gate
      run: |
        FAIL=0
        for PAIR in \
          "service-a:${{ needs.service-a.result }}" \
          "service-b:${{ needs.service-b.result }}" \
          "service-c:${{ needs.service-c.result }}"; do
          NAME="${PAIR%%:*}"
          R="${PAIR#*:}"
          case "$R" in
            success|skipped) ;;
            *) echo "BLOCKED: $NAME=$R"; FAIL=1 ;;
          esac
        done
        [ "$FAIL" = "1" ] && exit 1
        echo "aggregate: SUCCESS"

ラベル管理・通知は別ジョブに分離。CI 失敗時のラベル付与や PR コメントでの通知は ci-labels ジョブとして aggregate の後段に配置しています。外部 API(GitHub API でのラベル操作等)のエラーが required check の安定性に影響しないようにするためです。

実装で遭遇した落とし穴と解決

workflow_call 化は概念的にはシンプルですが、実際にやると予想外の挙動にいくつか遭遇しました。

concurrency group 衝突問題

GitHub Actions の concurrency 設定は、同じグループ名のジョブが同時に走らないよう制御する仕組みです。workflow_call で呼ばれた reusable workflow 内では、github.workflow(現在のワークフロー名を返す変数)が呼び出し元のワークフロー名に解決されます。そのため、子ワークフロー側に concurrency: group: ${{ github.workflow }}-${{ github.ref }} が残っていると、全ての子ワークフローが同じ concurrency group に入り、相互にキャンセルされるという事態が発生しました。

解決までに複数のアプローチを試しました。

  1. workflow_ref ベースの group 分離 → 不十分
  2. caller input を渡して group を分離 → 環境によっては効かない
  3. reusable workflow 側の concurrency を削除(最終解)

最終的に、concurrency 制御はオーケストレータ側の1箇所のみで行う構成に落ち着きました。

# ci-orchestrator.yml(呼び出し元)のみで制御
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

workflow_run が発火しなくなる問題

workflow_call 経由で実行された reusable workflow は独立した workflow run(ワークフローの個別の実行インスタンス)を生成しません。これは GitHub Actions の仕様です。

影響として、各CIの完了時に workflow_run トリガ(別ワークフローの完了を検知して起動する仕組み)で動いていた後続処理(CI失敗時のラベル管理、PR作者への通知等)が発火しなくなりました。

対応として、該当ロジックをオーケストレータ内の後段ジョブ(ci-labels)に移設しました。aggregate ジョブの outputs(どのCIが失敗したか)を受け取り、ラベルの付与・削除や通知コメントの投稿を行います。

check run 名の変化

workflow_call 経由で実行されたジョブの check run 名(PR のチェック一覧に表示される名前)は、「親ワークフロー名 / ジョブ名」 形式に変わります。例えば、従来 service-a-ci だった名前が CI / service-a になります。

これにより、旧来のジョブ名で完全一致の待機をしていた他のワークフロー(自動マージ等)が永久に pending になるという問題が発生しました。

対応として、他のワークフローの待機対象を個別の CI 名ではなく、集約ジョブ名の aggregate 1つに一本化しました。集約ジョブが成功 = 全CI成功なので、個別の CI 名を知る必要がなくなります。

変更検知の整合性

オーケストレータの git diff による変更検知と、各 CI の旧 paths: 条件は正確に整合させる必要があります。ずれがあると以下の問題が起きます。

  • 集約ジョブが待つのに CI が起動しない: デッドロック(集約ジョブが永久に完了しない)
  • CI が起動するのに集約ジョブが待たない: ゲートバイパス(CI の結果を無視してマージできてしまう)

また、レビューで指摘されて気づいたのですが、CI 定義ファイル自身(.github/workflows/*-ci.yml)の変更も検知に含める必要があります。含めないと、CI 定義のみを変更する PR で対応する CI がスキップされてしまいます。

移行結果の比較

指標 プロキシ方式 集約ジョブ方式
CI 完了→ゲート確定のラグ ~30〜100 秒 数秒
ランナー占有(ゲート側) 最大 ~36 分 ~1 分 + 数秒
保守対象コード ~500 行(JS + テスト + WF定義) ~335 行(WF定義のみ)
「CI 完了を待つ」仕組み 自前実装(ポーリング / イベント受信 / checks API) GitHub の needs 機構
攻撃面 checks API + 外部スクリプト + ワークフロー名同定 needs.*.result のシェル判定のみ
既知の障害モード 6種(HttpError / bootstrap / 偽装 / 改ざん / timeout / 遅延) 構造的に発生しない
新規 CI 追加時の作業 3箇所の同期が必要 1ファイルに追記するだけ

一番大きいのは、既知の障害モードが構造的に発生しなくなったことです。プロキシ方式の問題は全て「別ワークフローの結果を外部プロキシで集約する」設計から来ていたので、その設計をやめれば問題も消えました。

他プロジェクトへの導入ガイド

この方式は GitHub Actions の標準機能のみで構成されており、外部 action への依存もありません。以下の手順で導入できます。

1. 各 CI の on:workflow_call に変更

# before
on:
  pull_request:
    paths:
      - "service-a/**"

# after
on:
  workflow_call:

pull_request トリガと concurrency ブロックは削除します(前述の concurrency group 衝突を回避するため)。

2. オーケストレータを作成

変更検知(changes)→ 各 CI の条件付き呼び出し(uses:)→ 集約ジョブ(aggregate)の構成でオーケストレータを作成します。changes ジョブの検知パスは、各 CI の旧 paths: 条件と正確に整合させてください。

3. required check を aggregate に切り替え

ブランチ保護の required check を、旧来の個別 CI 名から aggregate に切り替えます。check run 名は checks API 上のジョブ名(UI 表示の「CI / aggregate」ではなく aggregate)です。

注意点

  • concurrency group の衝突: reusable workflow 側の concurrency を削除し、オーケストレータ側のみで制御する
  • workflow_run が発火しなくなる: workflow_run トリガで動いていた後続処理がある場合、オーケストレータ内に移設する必要がある
  • check run 名の変化: 他のワークフローで旧 CI 名に依存している箇所がないか確認する
  • 変更検知の整合性: CI 定義ファイル自身の変更も検知に含める

まとめ

CI の統合ゲートは以下の変遷をたどりました。

  1. 個別 CI を required check に登録 → skipped で永久ブロック
  2. ポーリング方式のプロキシ → ランナー長時間占有、タイムアウト誤失敗
  3. イベント駆動化(workflow_run + checks API) → GitHub 仕様変更で HttpError、補償的対策の積み重ね
  4. 集約ジョブ方式(needs + if: always()) → 構造的に問題が発生しない

課題を直すたびに新しい問題が出てきて、最終的にプラットフォームの標準機能に任せるのが一番シンプルだった、という結論になりました。

移行時に遭遇した落とし穴(concurrency group 衝突、workflow_run 非発火、check run 名の変化)は workflow_call 化で踏みやすいものなので、先に知っておくと手戻りが減ると思います。