モノレポのCIを高速化!GitHub Actionsで変更があったプロジェクトのみ実行する方法

こんにちは!いわむらです。

モノレポを採用しているプロジェクトで、「ちょっとした修正なのに、CIが全サービス分走って時間がかかるのもったいない」って思ったので変更があったやつだけ走るようにして節約できると良さそうと思い色々調べてみました。

今回は、dorny/paths-filterを使い、変更が加えられたサービスやパッケージに関連するジョブだけを実行することで、CI/CDのパイプラインを高速化・効率化する方法をご紹介します。

モノレポの課題
モノレポはコードの共有や依存関係の管理に優れていますが、プロジェクトが大きくなるにつれてCIの実行時間が長くなりがちかなと思います。例えば、複数のサービス(service-a, service-b)と共通パッケージ(shared-ui)を持つモノレポを考えてみましょう。service-a のフロントエンドコードを少し変更しただけでも、service-b や shared-ui も含めたすべてのリント、テスト、ビルドが実行されると、貴重な時間とリソースが無駄になってしまいます。

改善方法:変更差分に基づいたCI実行
この問題を改善する鍵は、「変更があった箇所」だけに関連するCIを実行することで改善できそうだなと。具体的には、プルリクエストやプッシュで変更されたファイルを検知し、そのファイルが含まれるサービスやパッケージに対応するジョブのみをトリガーします。

想定するディレクトリ構成
今回は、以下のようなディレクトリ構成を例に説明します。各サービスのディレクトリ直下に、そのサービスのフロントエンドコードがあると想定します。(APIなどは共通化されているか、別リポジトリなどで管理されているイメージです)

.
├── services/
│   ├── service-a/       <-- サービスA (フロントエンド)
│   │   ├── package.json
│   │   └── src/
│   ├── service-b/       <-- サービスB (フロントエンド)
│   │   ├── package.json
│   │   └── src/
│   └── service-c-backend/ <-- サービスC (バックエンドのみなど)
│       └── ...
├── packages/
│   ├── shared-ui/       <-- 共通UIコンポーネント
│   │   └── package.json
│   └── shared-utils/    <-- 共通ユーティリティ
│       └── package.json
└── pnpm-workspace.yaml  <-- pnpmを使う場合

GitHub Actions ワークフロー
この構成で、変更があったフロントエンドサービスや共通パッケージに対してのみリントとビルドを実行するワークフローは以下のようになります。

name: CI

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  # 1. 変更検出ジョブ
  changes:
    name: Detect changes
    runs-on: ubuntu-latest
    outputs:
      # 変更があったサービス/パッケージ名のリストを出力 (例: ["services/service-a", "packages/shared-ui"])
      projects: ${{ steps.filter.outputs.changes || '[]' }}
      # いずれかのサービス/パッケージに変更があったか (true/false)
      # filtersで定義したキー名に合わせてOR条件を記述
      any_changed: ${{ steps.filter.outputs['services/service-a'] == 'true' || steps.filter.outputs['services/service-b'] == 'true' || steps.filter.outputs['packages/shared-ui'] == 'true' || steps.filter.outputs['packages/shared-utils'] == 'true' }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          # paths-filterが変更を正しく比較するために全履歴を取得
          fetch-depth: 0

      - name: Check for file changes using paths-filter
        uses: dorny/paths-filter@v3
        id: filter
        with:
          # 変更があったファイルリストをJSON形式で出力
          list-files: json
          # フィルターのキー名を、そのまま作業ディレクトリとして使えるようにパス形式にする
          filters: |
            services/service-a:      # サービスAのディレクトリ
              - 'services/service-a/**'
            services/service-b:      # サービスBのディレクトリ
              - 'services/service-b/**'
            packages/shared-ui:    # 共通UIパッケージ
              - 'packages/shared-ui/**'
            packages/shared-utils:   # 共通ユーティリティパッケージ
              - 'packages/shared-utils/**'

  # 2. Node.jsベースのチェックジョブ (フロントエンドや共通パッケージ用)
  node-checks:
    name: ${{ matrix.project }} - Checks
    needs: changes # changesジョブの完了を待つ
    # changesジョブの出力any_changedがtrueの場合のみ実行
    if: needs.changes.outputs.any_changed == 'true' && (startsWith(matrix.project, 'services/') || startsWith(matrix.project, 'packages/')) # Node.js関連のプロジェクトのみ対象とする例
    runs-on: ubuntu-latest
    container:
      image: node:22-bullseye

    strategy:
      fail-fast: false # 1つが失敗しても他は続行
      matrix:
        # changesジョブの出力projects (変更があったサービス/パッケージ名のリスト) を元にジョブを並列実行
        project: ${{ fromJson(needs.changes.outputs.projects) }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # pnpmを使う場合
      - name: Setup pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 9
          run_install: false

      # pnpmキャッシュの設定
      - name: Get pnpm store directory
        id: pnpm-cache
        shell: bash
        run: |
          echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

      - name: Setup pnpm Cache
        uses: actions/cache@v4
        with:
          path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ matrix.project }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', matrix.project)) }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-${{ matrix.project }}-

      # 依存関係のインストール
      - name: Install dependencies
        # matrix.projectがそのまま作業ディレクトリになる
        working-directory: ${{ matrix.project }}
        run: pnpm install --frozen-lockfile

      # リント実行
      - name: Run ESLint
        working-directory: ${{ matrix.project }}
        run: pnpm run lint

      # ビルド実行
      - name: Run Build
        working-directory: ${{ matrix.project }}
        run: pnpm run build

ワークフロー解説
1. changes ジョブ:
actions/checkout@v4:
コードをチェックアウト ( workspace-depth: 0 で全履歴取得)。
dorny/paths-filter@v3:
・filters:で監視対象のディレクトリとフィルター名を定義。ここではフィルター名を作業ディレクトリパスと同じにしています ( services/service-a, packages/shared-ui など)。これが後々便利になります。
・変更があったフィルター名のリストと、各フィルターに変更があったかのフラグ ( services/service-a, packages/shared-ui など) を出力します。
outputs: :
後続ジョブで使えるように、変更リストと、いずれかに変更があったかを示す any_changed を出力します。any_changed の条件式は filters で定義したキー名に合わせて調整してください。


2. node-checks ジョブ:
needs :changes: changes ジョブの完了を待ちます。
if: needs.changes.outputs.any_changed == 'true' && ...:
changes ジョブで変更が検知され、かつ、Matrix で展開されるプロジェクト名 ( matrix.project) がNode.js関連(例: services/ または packages/ で始まる)の場合のみ実行します。これにより、例えばバックエンド専用のディレクトリが変更されても、このNode.js用ジョブは実行されません。
strategy.matrix.project: ${{ fromJson(needs.changes.outputs.projects) }}:
changes ジョブからの変更リストを元に、変更があったサービス/パッケージごとにジョブを並列実行します。
・pnpm関連ステップ: pnpmのセットアップとキャッシュを行います。
・working-directory: ${{ matrix.project }}:
ここがポイントです。 filters でキー名をパスと同じにしたため、 matrix.projectの値 ( services/service-a など) をそのまま working-directoryに指定できます。これにより、各ジョブは正しいディレクトリで pnpm install, lint, build を実行します。

まとめ
dorny/paths-filter を活用し、ディレクトリ構造に合わせてフィルターを設定することで、モノレポ内の変更があった箇所だけを対象にCIを実行できます。これにより、CIの実行時間を大幅に短縮し、開発プロセス全体の効率を向上させることが可能です。

今回の例はフロントエンドがメインでしたが、バックエンドや他の種類のパッケージが含まれる場合でも、同様の考え方でワークフローを拡張できると思います。ぜひ、あなたのモノレポプロジェクトにも導入してみてください!