
Code reviews are one of the most important parts of a strong engineering workflow. However, in large projects, not all files are created equal. Some paths (like infrastructure, security, or frontend code) need specific team members to approve changes before merging.
Therefore, enforcing structured approvals is crucial. GitHub provides basic reviewer assignment, but it doesnβt give granular control out of the box. Fortunately, we can solve this problem elegantly with GitHub Actions.
In this guide, youβll learn how to automatically enforce path-based approvals in pull requests. As a result, your team will ensure that the right people approve changes in the right places β without slowing down development.
π§° What This Workflow Does
This GitHub Action:
- β Detects changed files in a pull request
- π Matches changed paths to defined approvers
- π₯ Automatically requests reviews from required users or teams
- π« Blocks merging until required approvals are received
- π Re-runs checks automatically after approvals
This is a lightweight, free alternative to CODEOWNERS enforcement, perfect for open-source projects or teams without GitHub Enterprise.
π§ How It Works Behind the Scenes
- π First, the workflow detects changed files in the PR using the GitHub CLI.
- π§ Next, it matches those files to paths listed in
.github/approval.rules. - π₯ Then, it assigns required reviewers automatically.
- π« After that, it blocks merging if required approvals are missing.
- π Finally, it re-runs checks automatically when approvals are added.
Consequently, no sensitive or critical paths get merged without the right set of eyes on them.
π§ͺ Example Use Case
Letβs imagine a scenario:
- Global approvers:
@carol - Frontend approvers:
@alice - Backend approvers:
@bob
A pull request changes frontend/navbar.js.
Therefore, at least two approvals are required:
- β
@carol(global) - β
@alice(frontend path)
If only one of them approves, the merge remains blocked.
Eventually, when both approvals are in place, the PR becomes mergeable.
π Full GitHub Actions Workflow: enforce-path-approvals.yml
name: Enforce Path-Based Approvals
on:
pull_request:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted, dismissed]
permissions:
contents: read
pull-requests: write
checks: write
actions: write
jobs:
setup-reviewers:
if: github.event_name == 'pull_request' && github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install jq
run: sudo apt-get update && sudo apt-get install -y jq
- name: Collect changed files
run: |
{
echo "CHANGED_FILES<<EOF"
gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \
--paginate --jq '.[].filename' | sort -u
echo "EOF"
} >> $GITHUB_ENV
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Assign reviewers and post requirements comment
run: |
RULE_FILE=".github/approval.rules"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
if [[ ! -f "$RULE_FILE" ]]; then
echo "β $RULE_FILE not found"
exit 1
fi
GLOBAL_USERS=""
declare -A PATH_USERS
MATCHED_PATHS=""
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -z "$line" || "$line" =~ ^# ]] && continue
if [[ "$line" =~ ^[a-zA-Z0-9._/-]+/ ]]; then
path=$(echo "$line" | awk '{print $1}')
users=$(echo "$line" | cut -d' ' -f2-)
PATH_USERS["$path"]="$users"
else
GLOBAL_USERS="$GLOBAL_USERS $line"
fi
done < "$RULE_FILE"
if [[ -z "$GLOBAL_USERS" ]]; then
echo "β No global approvers defined in $RULE_FILE"
exit 1
fi
while IFS= read -r file; do
[[ -z "$file" ]] && continue
for p in "${!PATH_USERS[@]}"; do
if [[ "$file" == "$p"* ]]; then
if ! echo "$MATCHED_PATHS" | grep -q "$p"; then
MATCHED_PATHS="$MATCHED_PATHS $p"
fi
break
fi
done
done <<< "$CHANGED_FILES"
REQUIRED="$GLOBAL_USERS"
if [[ -n "$MATCHED_PATHS" ]]; then
for p in $MATCHED_PATHS; do
REQUIRED="$REQUIRED ${PATH_USERS[$p]}"
done
fi
USERS=$(echo "$REQUIRED" | tr ' ' '\n' | sed 's/@//g' | grep -v '^$' | tr '[:upper:]' '[:lower:]' | sort -u)
PR_AUTHOR_LOWER=$(echo "$PR_AUTHOR" | tr '[:upper:]' '[:lower:]')
USERS=$(echo "$USERS" | grep -v "^${PR_AUTHOR_LOWER}$" | tr '\n' ' ')
if [[ -n "$USERS" ]]; then
JSON_ARRAY=$(echo "$USERS" | tr ' ' '\n' | jq -R -s -c 'split("\n") | map(select(length>0))')
gh api -X POST repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers \
--input - <<< "{\"reviewers\": $JSON_ARRAY}" || true
fi
echo "**Approval Requirements:**" > /tmp/msg.txt
echo "" >> /tmp/msg.txt
echo "β οΈ **Important**: This PR will NOT be mergable until all requirements below are satisfied." >> /tmp/msg.txt
echo "" >> /tmp/msg.txt
echo "1. **Global approval required**: $(echo "$GLOBAL_USERS" | tr ' ' ', ')" >> /tmp/msg.txt
if [[ -n "$MATCHED_PATHS" ]]; then
echo "2. **Path-specific approval also required**:" >> /tmp/msg.txt
for p in $MATCHED_PATHS; do
echo " - \`$p\` β ${PATH_USERS[$p]}" >> /tmp/msg.txt
done
echo "" >> /tmp/msg.txt
echo "β
**Total: 2 approvals needed** (1 global + 1 path)" >> /tmp/msg.txt
else
echo "" >> /tmp/msg.txt
echo "β
**Total: 1 approval needed** (global only)" >> /tmp/msg.txt
fi
gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
-f body="$(cat /tmp/msg.txt)"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
approval-check:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || github.event_name == 'pull_request_review'
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install jq
run: sudo apt-get update && sudo apt-get install -y jq
- name: Collect changed files
run: |
{
echo "CHANGED_FILES<<EOF"
gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \
--paginate --jq '.[].filename' | sort -u
echo "EOF"
} >> $GITHUB_ENV
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Validate global + path-specific approvals
run: |
RULE_FILE=".github/approval.rules"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
PR_AUTHOR_LOWER=$(echo "$PR_AUTHOR" | tr '[:upper:]' '[:lower:]')
if [[ ! -f "$RULE_FILE" ]]; then
echo "β $RULE_FILE not found"
exit 1
fi
APPROVERS=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews \
--paginate --jq '[.[] | select(.state=="APPROVED" and .user.login != "'"$PR_AUTHOR"'") | .user.login] | unique | .[]' \
| tr '[:upper:]' '[:lower:]' | tr '\n' ' ')
echo "Current approvers: [$APPROVERS]"
if [[ -z "$APPROVERS" ]]; then
echo "β No approvals yet"
exit 1
fi
GLOBAL_USERS=""
declare -A PATH_USERS
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -z "$line" || "$line" =~ ^# ]] && continue
if [[ "$line" =~ ^[a-zA-Z0-9._/-]+/ ]]; then
path=$(echo "$line" | awk '{print $1}')
users=$(echo "$line" | cut -d' ' -f2-)
PATH_USERS["$path"]="$users"
else
GLOBAL_USERS="$GLOBAL_USERS $line"
fi
done < "$RULE_FILE"
GLOBAL_LIST=$(echo "$GLOBAL_USERS" | tr ' ' '\n' | sed 's/@//g' | tr '[:upper:]' '[:lower:]' | grep -v '^$' | grep -v "^${PR_AUTHOR_LOWER}$" | sort -u)
PATH_NEEDED=false
while IFS= read -r file; do
[[ -z "$file" ]] && continue
for path in "${!PATH_USERS[@]}"; do
if [[ "$file" == "$path"* ]]; then
PATH_NEEDED=true
GLOBAL_APPROVED=false
for user in $GLOBAL_LIST; do
if echo "$APPROVERS" | grep -qw "$user"; then
GLOBAL_APPROVED=true
break
fi
done
if [[ "$GLOBAL_APPROVED" == "false" ]]; then
echo "β Missing global approval"
exit 1
fi
PATH_APPROVERS=$(echo "${PATH_USERS[$path]}" | tr ' ' '\n' | sed 's/@//g' | tr '[:upper:]' '[:lower:]' | grep -v '^$' | grep -v "^${PR_AUTHOR_LOWER}$")
PATH_APPROVED=false
for user in $PATH_APPROVERS; do
if echo "$APPROVERS" | grep -qw "$user"; then
PATH_APPROVED=true
break
fi
done
if [[ "$PATH_APPROVED" == "false" ]]; then
echo "β Missing path approval for $path"
exit 1
fi
break
fi
done
done <<< "$CHANGED_FILES"
if [[ "$PATH_NEEDED" == "false" ]]; then
GLOBAL_APPROVED=false
for user in $GLOBAL_LIST; do
if echo "$APPROVERS" | grep -qw "$user"; then
GLOBAL_APPROVED=true
break
fi
done
if [[ "$GLOBAL_APPROVED" == "false" ]]; then
echo "β Missing global approval"
exit 1
fi
fi
echo "π All requirements satisfied!"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
rerun-on-approval:
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
steps:
- name: Find and rerun failed approval-check
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "π Finding failed runs for PR #$PR_NUMBER (SHA: $HEAD_SHA)..."
RUNS=$(gh api "repos/${{ github.repository }}/actions/workflows/enforce-path-approvals.yml/runs?per_page=50" \
--jq '.workflow_runs[] | select(.conclusion=="failure" and .head_sha=="'"$HEAD_SHA"'") | .id')
if [[ -n "$RUNS" ]]; then
for RUN_ID in $RUNS; do
echo "π Re-running failed workflow: $RUN_ID"
gh api -X POST "repos/${{ github.repository }}/actions/runs/$RUN_ID/rerun-failed-jobs" || true
done
else
echo "βΉοΈ No failed runs found for this commit"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
π .github/approval.rules Example
# Global approvers (always required)
@techlead @project-manager
# Path-specific approvers
frontend/ @frontend-team
backend/ @backend-team
infra/ @security-team @infra-team
β How it works:
- Every PR requires at least one global approver.
- If files inside
frontend/are changed β at least one@frontend-teammember must approve. - If no path matches, only the global approverβs approval is required.
You can use individual users (@username) or GitHub Teams (@org/team).
π§ How It Works Behind the Scenes
- π Detect changed files in the PR using the GitHub CLI.
- π§ Match those files to paths listed in
.github/approval.rules. - π₯ Assign required reviewers automatically.
- π« Block merging if required approvals are missing.
- π Re-run checks automatically when approvals are added.
This ensures no sensitive or critical paths get merged without the right set of eyes on them.
π§ͺ Example Use Case
- Global approvers:
@carol - Frontend approvers:
@alice - Backend approvers:
@bob
PR changes: frontend/navbar.js
Required approvals:
- β
@carol(global) - β
@alice(frontend path)
β Merge is allowed only when both have approved.
β If only one approves β merge is blocked.
π‘οΈ Why This Is Useful
- π§ Granular review control β assign reviewers per folder.
- π No enterprise plan required β works in free repos.
- π§βπ» Improves accountability β specific teams own their code.
- π§ͺ Flexible β can be updated anytime by editing
approval.rules. - π Secure β ensures critical code canβt slip through unreviewed.
π§ Best Practices
- π Keep
approval.rulesupdated as your team structure changes. - π₯ Use GitHub Teams instead of individual users for scalability.
- π§ͺ Test in a dev branch before enforcing in production.
- π Combine with branch protection rules for stronger security.
- π¨ Optional: integrate notifications (Slack / Teams / email).
π Conclusion
By setting up this path-based approval workflow, you can:
- Automatically tag the right reviewers
- Enforce structured review rules
- Protect sensitive code paths
- Maintain a clean, auditable approval process
This approach is perfect for growing teams, monorepos, and open-source projects alike π
π Resources
You Might Also Like
- π n8n vs Make.com: Why I Went Self-Hosted
- π n8n Google Places API: Auto-Save Businesses to Sheets
π οΈ Recommended Tools for Developers & Tech Pros
Save time, boost productivity, and work smarter with these AI-powered tools I personally use and recommend:
1οΈβ£ CopyOwl.ai β Research & Write Smarter
Write fully referenced reports, essays, or blogs in one click.
β
97% satisfaction β’ β
10+ hrs saved/week β’ β
Academic citations
2οΈβ£ LoopCV.pro β Build a Job-Winning Resume
Create beautiful, ATS-friendly resumes in seconds β perfect for tech roles.
β
One-click templates β’ β
PDF/DOCX export β’ β
Interview-boosting design
3οΈβ£ Speechify β Listen to Any Text
Turn articles, docs, or PDFs into natural-sounding audio β even while coding.
β
1,000+ voices β’ β
Works on all platforms β’ β
Used by 50M+ people
4οΈβ£ Jobright.ai β Automate Your Job Search
An AI job-search agent that curates roles, tailors resumes, finds referrers, and can apply for jobsβget interviews faster.
β
AI agent, not just autofill – β
Referral insights – β
Faster, personalized matching