Last updated: March 22, 2026
How to Automate Pull Request Labeling
Labels tell your team what a pull request is before they open it. Without automation, labels get applied inconsistently or not at all. With a few workflow files, every PR gets labeled the moment it’s opened by changed files, size, and title convention.
Approach 1: GitHub’s Official Labeler Action
.github/labeler.yml
frontend:
- changed-files:
- any-glob-to-any-file:
- "src/frontend/**"
- "app/assets/**"
- "*.css"
backend:
- changed-files:
- any-glob-to-any-file:
- "src/api/**"
- "app/controllers/**"
- "app/models/**"
infrastructure:
- changed-files:
- any-glob-to-any-file:
- "terraform/**"
- "kubernetes/**"
- "docker-compose*.yml"
- "Dockerfile*"
documentation:
- changed-files:
- any-glob-to-any-file:
- "docs/**"
- "**/*.md"
tests:
- changed-files:
- any-glob-to-any-file:
- "**/*.test.*"
- "**/*.spec.*"
- "tests/**"
dependencies:
- changed-files:
- any-glob-to-any-file:
- "package.json"
- "package-lock.json"
- "yarn.lock"
- "go.mod"
- "requirements*.txt"
.github/workflows/labeler.yml
name: Label Pull Request
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
sync-labels: true
Approach 2: Label by PR Size
name: PR Size Label
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request;
const total = pr.additions + pr.deletions;
const sizeLabels = {
"size/XS": total <= 10,
"size/S": total > 10 && total <= 100,
"size/M": total > 100 && total <= 500,
"size/L": total > 500 && total <= 1000,
"size/XL": total > 1000,
};
const allSizeLabels = Object.keys(sizeLabels);
const newLabel = Object.entries(sizeLabels)
.find(([, matches]) => matches)?.[0];
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
});
for (const label of currentLabels.map(l => l.name).filter(l => allSizeLabels.includes(l))) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label,
});
}
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: newLabel,
color: total <= 100 ? "0e8a16" : total <= 500 ? "e4e669" : "b60205",
});
} catch (e) { /* Label already exists */ }
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [newLabel],
});
Approach 3: Label by Conventional Commit Title
name: Conventional PR Labels
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request;
const title = pr.title.toLowerCase();
const typeMap = {
"feat": { label: "feature", color: "0075ca" },
"fix": { label: "bug", color: "d73a4a" },
"chore": { label: "chore", color: "e4e669" },
"docs": { label: "documentation", color: "0075ca" },
"refactor": { label: "refactor", color: "7057ff" },
"perf": { label: "performance", color: "e4e669" },
"test": { label: "tests", color: "0e8a16" },
"ci": { label: "ci/cd", color: "0052cc" },
};
const labelsToAdd = [];
for (const [prefix, { label, color }] of Object.entries(typeMap)) {
if (title.startsWith(prefix + ":") || title.startsWith(prefix + "(")) {
labelsToAdd.push({ name: label, color });
break;
}
}
if (title.includes("!:") || title.includes("breaking")) {
labelsToAdd.push({ name: "breaking-change", color: "b60205" });
}
for (const { name, color } of labelsToAdd) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name, color,
});
} catch (e) { /* exists */ }
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [name],
});
}
Bootstrap Labels in a New Repo
#!/bin/bash
REPO="${GITHUB_REPOSITORY:-yourorg/yourrepo}"
create_label() {
gh label create "$1" --color "$2" --description "$3" --repo "$REPO" --force 2>/dev/null || true
}
create_label "feature" "0075ca" "New feature"
create_label "bug" "d73a4a" "Bug fix"
create_label "chore" "e4e669" "Maintenance task"
create_label "documentation" "0075ca" "Documentation update"
create_label "refactor" "7057ff" "Code refactoring"
create_label "tests" "0e8a16" "Test additions or fixes"
create_label "ci/cd" "0052cc" "CI/CD pipeline changes"
create_label "breaking-change" "b60205" "Breaking API or behavior change"
create_label "size/XS" "0e8a16" "< 10 lines"
create_label "size/S" "0e8a16" "10-100 lines"
create_label "size/M" "e4e669" "100-500 lines"
create_label "size/L" "d93f0b" "500-1000 lines"
create_label "size/XL" "b60205" "> 1000 lines"
create_label "dependencies" "0075ca" "Dependency updates"
echo "Labels created in $REPO"
Approach 4: Composite Labeling (Files + Size + Convention)
Combine all three approaches in one workflow that runs a single script:
name: Smart PR Labels
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
sync-labels: false # don't remove size labels
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request;
const title = pr.title.toLowerCase();
const total = pr.additions + pr.deletions;
const labelsToAdd = [];
// Size label
const sizeMap = [
["size/XS", total <= 10],
["size/S", total > 10 && total <= 100],
["size/M", total > 100 && total <= 500],
["size/L", total > 500 && total <= 1000],
["size/XL", total > 1000],
];
const sizeLabel = sizeMap.find(([, match]) => match)?.[0];
if (sizeLabel) labelsToAdd.push(sizeLabel);
// Type label from title
const typeMap = {
feat: "feature", fix: "bug", chore: "chore",
docs: "documentation", refactor: "refactor",
perf: "performance", test: "tests", ci: "ci/cd",
};
for (const [prefix, label] of Object.entries(typeMap)) {
if (title.startsWith(prefix + ":") || title.startsWith(prefix + "(")) {
labelsToAdd.push(label);
break;
}
}
// Breaking change
if (title.includes("!:") || title.includes("breaking")) {
labelsToAdd.push("breaking-change");
}
// Apply labels (ignore duplicates)
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: labelsToAdd,
});
}
Enforcing Labels as a PR Merge Requirement
Require at least one type label before a PR can merge using a status check:
name: Label Check
on:
pull_request:
types: [opened, labeled, unlabeled, synchronize]
jobs:
check-labels:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const requiredTypes = [
"feature", "bug", "chore", "documentation",
"refactor", "tests", "ci/cd", "performance"
];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
});
const labelNames = labels.map(l => l.name);
const hasType = requiredTypes.some(t => labelNames.includes(t));
if (!hasType) {
core.setFailed(
`PR must have a type label. Add one of: ${requiredTypes.join(", ")}`
);
}
Add this as a required status check in your repo’s branch protection rules (Settings > Branches > Require status checks to pass before merging).
Using Labels in Changelog Generation
# .github/release-please.yml
changelog-sections:
- type: feature
section: "Features"
- type: bug
section: "Bug Fixes"
- type: breaking-change
section: "Breaking Changes"
- type: performance
section: "Performance"
- type: dependencies
section: "Dependencies"
Related Reading
- How to Create Automated Security Scan Pipelines
- Best Tools for Remote Team Changelog Review
- How to Create Automated Dependency Audit
Built by theluckystrike — More at zovo.one