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 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]}' 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.
Related Jump to heading
- Pull Request Automation & Bots β the parent topic and where labels sit in the routing chain.
- Assigning Reviewers Automatically β the first consumer of these labels.
- Path-Based CODEOWNERS in a Monorepo β the ownership data that routing depends on.