モノレポでブランチごとにファイル変更範囲を制限するGitHub Actionsワークフロー

2026-02-26 hit count image

モノレポでブランチ名に基づいて、PRの変更ファイルが該当サービスのディレクトリ範囲内かを自動で 検証するGitHub Actionsワークフローの実装方法を紹介します。

github_actions

背景: なぜファイル変更範囲を制限すべきか?

複数のサービスが1つのリポジトリに共存するモノレポ構成では、あるサービスの変更が別のサービスに意図しない影響を与える可能性があります。例えば、以下のような構成のリポジトリを考えてみましょう。

apps/
├── service-a/
├── service-b/
├── service-c/
└── ...

モノレポでは、各サービスチームがfeature/service-a/...feature/service-b/...のように、ブランチ名にサービス名を含めるコンベンションを作って使用できます。このようなコンベンションについては、以下のブログ記事をご参照ください。

ブランチ名を検査するワークフローでネーミングコンベンションを強制することはできますが、ブランチ名のコンベンションだけでは他のサービスのコードを修正してしまうミスを完全に防ぐことはできません。例えば、service-aブランチで作業中に誤ってservice-bディレクトリのファイルを修正しても、ブランチ名検査ワークフローではこれを検出できません。

この問題を解決するために、PRの変更ファイルがブランチに対応するサービスディレクトリ内のファイルかどうかを自動検証するワークフローを作りました。PRタイトル検査と併用すれば、PRのメタデータと変更内容の両方を自動的に検証できます。

ワークフロー全体構成

name: Validate File Changes by Branch

on:
  pull_request:
    types: [opened, reopened, edited, synchronize]

ワークフローはPRがオープンまたは更新されるたびに実行されます。全体の流れは4つのステップで構成されています。

Step 1: ブランチ名からサービス名を抽出

feature/service-a/some-feature  →  SERVICE_NAME = "service-a"
fix/service-b/bug-fix           →  SERVICE_NAME = "service-b"

Composite Actionで作成したカスタムアクションextract_branch_and_service_nameが、ブランチ名を/で分割して2番目の部分をサービス名として抽出します。

.github/actions/extract_branch_and_service_name/action.ymlファイルを作成し、以下のように記述して使用しています。

name: 'Extract branch and service name'
description: 'Extract branch and service name from branch'

outputs:
  BRANCH_NAME:
    description: 'Branch name'
    value: ${{ steps.extract_branch_and_service_name.outputs.BRANCH_NAME }}
  SERVICE_NAME:
    description: 'Service name'
    value: ${{ steps.extract_branch_and_service_name.outputs.SERVICE_NAME }}
  FULL_BRANCH_NAME:
    description: 'Full branch name'
    value: ${{ steps.extract_branch_and_service_name.outputs.FULL_BRANCH_NAME }}

runs:
  using: 'composite'
  steps:
    - id: extract_branch_and_service_name
      shell: bash
      run: |
        FULL_BRANCH_NAME=${{ github.event.pull_request.head.ref }}

        BRANCH_NAME=""
        SERVICE_NAME=""

        if [[ $FULL_BRANCH_NAME == "main" || $FULL_BRANCH_NAME == "develop" ]]; then
          BRANCH_NAME="$FULL_BRANCH_NAME"
          SERVICE_NAME="$FULL_BRANCH_NAME"
        else
          IFS='/' read -ra BRANCH_PARTS <<< "$FULL_BRANCH_NAME"

          if [ "${#BRANCH_PARTS[@]}" -gt 1 ]; then
            BRANCH_NAME=${BRANCH_PARTS[0]}
            SERVICE_NAME=${BRANCH_PARTS[1]}
          else
            echo "[Error] Branch name does not contain a valid service name."
            exit 1
          fi
        fi

        echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
        echo "SERVICE_NAME=$SERVICE_NAME" >> $GITHUB_OUTPUT
        echo "FULL_BRANCH_NAME=$FULL_BRANCH_NAME" >> $GITHUB_OUTPUT

例えば、feature/service-a/some-featureというブランチからは、service-aがサービス名として抽出されます。

Step 2: 変更ファイル一覧の収集

- name: Get changed files
  uses: tj-actions/[email protected]
  with:
    files: |
      **
    write_output_files: true
    output_dir: /tmp

tj-actions/changed-filesアクションでPRの変更ファイル一覧を取得します。このアクションは変更ファイルにのみESLintを適用するワークフローでも活用しています。

ここでのポイントはwrite_output_files: trueオプションです。変更ファイル一覧を環境変数ではなく/tmp/all_changed_files.txtファイルに保存します。ファイルに保存する理由は、変更ファイルが多い場合に環境変数の長さ制限(ARG_MAX)に引っかかる可能性があるためです。

Step 3: サービス名とディレクトリのマッピング

const serviceDirectoryMap = {
  'service-a': 'serviceA',
  'service-b': 'serviceB',
  'service-c': 'serviceC',
  // ...
};

ブランチ名のサービス識別子と実際のapps/配下のディレクトリ名が異なる場合があるため、マッピングテーブルで変換します。例えば、ブランチ名ではservice-aと書きますが、実際のディレクトリ名はserviceAかもしれません。

マッピングに存在しないサービス名(例:commonブランチ)の場合は検証をスキップします。サービスではない共通モジュールは複数のサービスに影響する可能性があるため、ファイル変更範囲を制限する必要がありません。

Step 4: ファイルごとの検証

変更された各ファイルについて、パスの2番目の部分(サービスディレクトリ)が該当サービスのディレクトリと一致するか確認します。

for (const filename of files) {
  const pathParts = filename.split('/');
  const fileServiceType = pathParts[1]; // apps/serviceA/... → "serviceA"

  if (fileServiceType !== serviceDirectory) {
    core.setFailed(
      `The file ${filename} is not allowed to be changed in the ${serviceName} branch.`
    );
    return;
  }
}

ただし、一部のファイルは例外として許可しています:

許可パス理由
packages/lib/shared-api/共有APIモジュールはプロダクト間の影響がないため、各サービスから自由に修正可能
packages/config/cspell/スペルチェック辞書は共通リソース
yarn.lockどのサービスからでもライブラリを追加すると共通で変更される

実行条件

すべてのPRで実行する必要はありません。以下のように、デプロイ用ブランチやメインブランチなどの特定のブランチは検証対象から除外されます:

if: >
  github.head_ref != 'main' &&
  !startsWith(github.head_ref, 'release/') &&
  !startsWith(github.head_ref, 'merge/')
  • main: 統合ブランチのため、すべてのディレクトリ変更が許可
  • release/*: リリース準備過程で複数サービスのファイルが変更される可能性がある
  • merge/*: ブランチ統合時に複数ディレクトリにまたがる変更が想定される

動作例

成功ケースfeature/service-a/add-loginブランチで以下のファイルのみ変更:

apps/serviceA/src/pages/Login.tsx          ✅ serviceAディレクトリ
apps/serviceA/src/api/auth.ts              ✅ serviceAディレクトリ
packages/lib/shared-api/src/auth.ts        ✅ 許可例外
yarn.lock                                  ✅ 許可例外

失敗ケースfeature/service-a/add-loginブランチで他のサービスのファイルを変更:

apps/serviceA/src/pages/Login.tsx    ✅ serviceAディレクトリ
apps/serviceB/src/utils/auth.ts     ❌ service-aブランチでserviceBのファイル変更不可!

The file apps/serviceB/src/utils/auth.ts is not allowed to be changed in the service-a branch.

まとめ

このワークフローは、コードレビューの前に「このPRが正しい範囲のファイルのみを修正したか」を自動的に確認してくれます。人間のミスを完全に防ぐことはできませんが、CIレベルでガードレールを設けておけば、ミスがコードベースに反映される前にキャッチできます。

モノレポを運用しながらサービス間の境界を明確にしたい場合は、このようなファイル変更範囲の検証を導入することをお勧めします。

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。



SHARE
Twitter Facebook RSS