A GitHub Actions Workflow to Restrict File Change Scope by Branch in a Monorepo

2026-02-26 hit count image

Learn how to implement a GitHub Actions workflow that automatically validates whether changed files in a PR fall within the corresponding service directory based on the branch name in a monorepo.

github_actions

Background: Why Restrict File Change Scope?

In a monorepo structure where multiple services coexist in a single repository, changes to one service can unintentionally affect another. Consider a repository with the following structure:

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

In a monorepo, each service team can adopt a convention of including the service name in their branch names, such as feature/service-a/... or feature/service-b/.... For more details on such conventions, please refer to the following blog post:

While a branch name validation workflow can enforce naming conventions, branch naming alone cannot fully prevent accidental modifications to other services’ code. For example, even if you’re working on a service-a branch, you could accidentally modify files in the service-b directory, and the branch name validation workflow would not catch this.

To solve this problem, we created a workflow that automatically validates whether changed files in a PR belong to the service directory corresponding to the branch. When used together with PR title validation, you can automatically verify both PR metadata and change scope.

Overall Workflow Structure

name: Validate File Changes by Branch

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

The workflow runs whenever a PR is opened or updated. The overall flow consists of 4 steps.

Step 1: Extract Service Name from Branch Name

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

A custom action extract_branch_and_service_name, built as a Composite Action, splits the branch name by / and extracts the second part as the service name.

We created a .github/actions/extract_branch_and_service_name/action.yml file with the following content:

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

For example, from the branch feature/service-a/some-feature, the extracted service name would be service-a.

Step 2: Collect the List of Changed Files

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

The tj-actions/changed-files action retrieves the list of changed files in the PR. This action was also used in the workflow for running ESLint only on changed files.

The key here is the write_output_files: true option. It saves the list of changed files to /tmp/all_changed_files.txt instead of environment variables. This is because when there are many changed files, you may hit the environment variable length limit (ARG_MAX).

Step 3: Map Service Name to Directory

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

Since the service identifier in the branch name may differ from the actual directory name under apps/, we use a mapping table for conversion. For example, the branch name uses service-a, but the actual directory name might be serviceA.

If the service name doesn’t exist in the mapping (e.g., a common branch), the validation is skipped. Since non-service common modules can affect multiple services, there’s no need to restrict the file change scope.

Step 4: Validate Each File

For each changed file, the workflow checks whether the second part of the path (the service directory) matches the expected service directory.

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;
  }
}

However, some files are allowed as exceptions:

Allowed PathReason
packages/lib/shared-api/Shared API modules have no cross-product impact, so any service can freely modify them
packages/config/cspell/Spell check dictionaries are a shared resource
yarn.lockAdding a library from any service causes this common file to change

Execution Conditions

The workflow doesn’t need to run on every PR. Certain branches such as deployment branches and the main branch are excluded from validation:

if: >
  github.head_ref != 'main' &&
  !startsWith(github.head_ref, 'release/') &&
  !startsWith(github.head_ref, 'merge/')
  • main: As the integration branch, all directory changes are permitted
  • release/*: Multiple services’ files may change during release preparation
  • merge/*: Changes spanning multiple directories are expected during branch merges

Examples

Success case — Only the following files changed on the feature/service-a/add-login branch:

apps/serviceA/src/pages/Login.tsx          ✅ serviceA directory
apps/serviceA/src/api/auth.ts              ✅ serviceA directory
packages/lib/shared-api/src/auth.ts        ✅ Allowed exception
yarn.lock                                  ✅ Allowed exception

Failure case — Files from another service changed on the feature/service-a/add-login branch:

apps/serviceA/src/pages/Login.tsx    ✅ serviceA directory
apps/serviceB/src/utils/auth.ts     ❌ Cannot change serviceB files on service-a branch!

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

Conclusion

This workflow automatically checks “did this PR only modify files within the correct scope?” before code review. While human mistakes can never be completely prevented, setting up guardrails at the CI level allows you to catch errors before they make it into the codebase.

If you’re running a monorepo and want to clearly define boundaries between services, I recommend introducing this kind of file change scope validation.

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS