Table of Contents
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 Path | Reason |
|---|---|
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.lock | Adding 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
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.