Enforce Path-Based Approvals with GitHub Actions

Enforce Path-Based Approvals with GitHub Actions

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

  1. πŸ“ First, the workflow detects changed files in the PR using the GitHub CLI.
  2. 🧭 Next, it matches those files to paths listed in .github/approval.rules.
  3. πŸ‘₯ Then, it assigns required reviewers automatically.
  4. 🚫 After that, it blocks merging if required approvals are missing.
  5. πŸ” 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-team member 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

  1. πŸ“ Detect changed files in the PR using the GitHub CLI.
  2. 🧭 Match those files to paths listed in .github/approval.rules.
  3. πŸ‘₯ Assign required reviewers automatically.
  4. 🚫 Block merging if required approvals are missing.
  5. πŸ” 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.rules updated 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

πŸ› οΈ 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