Auto-labelling pull requests by changed path Jump to heading

Hand-applied labels are wrong within a month. Someone forgets, someone uses a synonym, someone labels the change they intended rather than the one they made β€” and every dashboard built on those labels inherits the error. Labels derived from the diff cannot drift, because they are recomputed from the tree on every push. This recipe builds that, and then uses it as the routing table for review assignment and pipeline decisions, within pull request automation and bots.

When to use this approach Jump to heading

  • Your repository has more than one clearly separable area.
  • Reviewers filter by label, or you want them to be able to.
  • Some paths carry more risk than others β€” migrations, infrastructure, security config.
  • You already run path-filtered pipelines and want the two to agree.
  • If the repository is one small application with no internal boundaries, labels add ceremony without information.

Step 1 β€” Derive the rules from the tree, not from memory Jump to heading

Start by listing the directories that actually receive changes, ordered by how often.

# Top-level areas by change frequency over the last year
git log --since='1 year ago' --name-only --format='' \
  | grep -v '^$' | cut -d/ -f1-2 | sort | uniq -c | sort -rn | head -15
# And the paths that change together, which suggests where the boundaries are
git log --since='6 months ago' --name-only --format='---' \
  | awk '/^---/{if(n)print s; s="";n=0;next}{s=s" "$0;n++}' \
  | tr ' ' '\n' | grep -v '^$' | cut -d/ -f1-2 | sort | uniq -c | sort -rn | head
Three kinds of label, in the order they earn their placeArea labels say which part of the system changed and are read by routing. Type labels say what kind of change it is and are read by release notes. Risk labels mark paths that need extra care and are read by both people and pipelines.what each label family is read byarea/…routing and reviewer assignmenttype/…release notes and changelogsrisk/…extra approvals and human attentiona label nothing reads is a label nobody maintains

Step 2 β€” Write globs that survive a rename Jump to heading

The common failure is a rule matching src/api/** in a repository where the directory moved to services/api/ two years ago. The rule still exists, matches nothing, and the label silently stops appearing.

# .github/labeler.yml
area/api:
  - changed-files:
      - any-glob-to-any-file:
          - 'services/api/**'
          - 'packages/api-*/**'
area/infra:
  - changed-files:
      - any-glob-to-any-file:
          - 'infra/**'
          - '**/*.tf'
          - '.github/workflows/**'
risk/migration:
  - changed-files:
      - any-glob-to-any-file:
          - '**/migrations/**'
          - '**/*.sql'
type/docs:
  - changed-files:
      - all-globs-to-all-files:
          - 'docs/**'
# Verification: every glob must match at least one file in the current tree
python3 - <<'PY'
import pathlib, fnmatch, sys, re
rules = pathlib.Path('.github/labeler.yml').read_text()
globs = re.findall(r"- '([^']+)'", rules)
files = [str(p) for p in pathlib.Path('.').rglob('*') if p.is_file() and '.git/' not in str(p)]
for g in sorted(set(globs)):
    if not any(fnmatch.fnmatch(f, g) for f in files):
        print('DEAD GLOB:', g)
PY

Note the difference between any-glob-to-any-file and all-globs-to-all-files. The documentation label uses the second because a change that touches docs and code is not a documentation change, and labelling it as one sends it to the wrong reviewer.

Step 3 β€” Wire the labeller into the pipeline Jump to heading

# .github/workflows/label.yml
name: label
on:
  pull_request_target:
    types: [opened, synchronize, reopened]
permissions:
  contents: read
  pull-requests: write
jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
        with:
          sync-labels: true
# Verification: labels update when paths are added and removed
gh pr view "$PR" --json labels --jq '[.labels[].name] | sort'

This is the one place pull_request_target is defensible: the job checks out nothing from the contributor’s branch and runs only the labelling action, so untrusted code never executes with the elevated token. Everything else that needs write access should still avoid it, for the reasons in linting commit messages for forked pull requests.

sync-labels: true is what keeps labels honest: when the author removes the migration file, the risk label disappears too.

Step 4 β€” Use the labels to make decisions Jump to heading

Labels only pay for themselves when something reads them. Three consumers are worth wiring up immediately.

# Require a second approval on risky paths
      - name: Extra approval for migrations
        if: contains(github.event.pull_request.labels.*.name, 'risk/migration')
        run: |
          approvals=$(gh pr view "$PR" --json reviews \
            --jq '[.reviews[] | select(.state=="APPROVED")] | length')
          [ "$approvals" -ge 2 ] || { echo "::error::migrations need two approvals"; exit 1; }
# Release notes grouped by label rather than by guesswork
gh pr list --state merged --search 'merged:>=2026-09-01' \
  --json number,title,labels \
  --jq 'group_by(.labels[0].name // "other")[] | {group: (.[0].labels[0].name // "other"),
        items: [.[] | .title]}'
One derived label, three consumersA single risk label computed from the diff is read by the reviewer assignment step, by a policy job that requires a second approval, and by the release-notes generator. None of them needs its own path rules.Changed pathsmigrations/**risk/migrationderived labelThree consumersroutingapproval policyrelease notesdefine the boundary once; every consumer inherits it Any-glob against all-globs matchingAny-glob labels a change when it touches at least one matching file, which is right for area labels. All-globs labels it only when every changed file matches, which is what a documentation-only label needs to stay accurate.Should the label apply when the change also touches other areas?yes β€” it is one area of severalany-glob-to-any-filearea and risk labelsno β€” the label claims exclusivityall-globs-to-all-filesdocs-only, deps-onlya docs label on a change that also edits code sends it to the wrong reviewer

Step 5 β€” Keep the rules from rotting Jump to heading

Add the dead-glob check from Step 2 to the pipeline so a directory move fails loudly instead of silently disabling a label.

      - name: Label rules must match the current tree
        run: python3 scripts/check-labeler-globs.py
# And a periodic look at which labels are actually appearing
gh pr list --state merged --limit 200 --json labels \
  --jq '[.[].labels[].name] | group_by(.)[] | {label: .[0], count: length}' | head -20

A label that has not appeared in two hundred pull requests is either a dead glob or a boundary that no longer exists. Both are worth knowing about.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should authors be able to add labels by hand? Jump to heading

Yes, for labels the diff cannot know about β€” needs-design-input, blocked. Keep those in a separate namespace from the derived ones, and never let the synchronisation step strip them, or people will stop bothering.

How do these relate to path-filtered pipeline triggers? Jump to heading

They express the same boundaries and should be generated from one source if you can manage it. When they drift, a change runs the API pipeline but arrives labelled as web work, and the resulting confusion is hard to attribute β€” the trigger design is covered in optimizing CI triggers for path-specific changes.

What about very large pull requests that touch everything? Jump to heading

They collect every label, which is accurate and unhelpful β€” and that is itself the signal. A change that is genuinely in six areas at once is a change that should have been several, which is the argument in splitting a branch into reviewable pull requests.