
TL;DR
I recently started notifycat, an open-source Go project that posts Slack threads for pull requests. Small repo, just me pushing code, but it quickly developed all the friction of a real team project without the tooling to match. So I added four GitHub Actions workflows for routine management:
- Automatic path-based labeling with actions/labeler
- PR size labels with pr-size-labeler
- Auto-assigning PR authors with auto-assign-action
- Stale issue and PR cleanup with actions/stale
It helps me with:
- Knowing what part of the codebase a PR touches without reading the diff
- Knowing how big a change is at a glance
- Never forgetting who owns an open PR
- Keeping the issue tracker from turning into a graveyard
So here's what I built, what problem each one solved, and where I got burned.
Label by path
The problem. A week into notifycat I had six open PRs and couldn't tell which ones touched the Slack integration versus the core event loop. I was reading diffs just to orient myself. That's the kind of thing a machine should do.
actions/labeler is the official one, maintained by GitHub. It needs two files: a config that maps labels to file globs, and a workflow that runs it. For this blog the config looks like this:
# .github/labeler.yml
post:
- changed-files:
- any-glob-to-any-file:
- 'content/**'
- 'static/images/**'
theme:
- changed-files:
- any-glob-to-any-file:
- 'templates/**'
- 'static/css/**'
A PR touching content/ gets post. A PR touching templates or styles gets theme. The workflow that applies them is four lines of actual content:
# .github/workflows/labeler.yml
name: Label PRs by path
on: [pull_request_target]
permissions:
contents: read
pull-requests: write
jobs:
labeler:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
The trigger deserves a sentence. pull_request_target runs the workflow from the base branch with full token access, which is what lets it label PRs from forks. It's safe here because the job never checks out the PR's code; it only reads file paths and applies labels. The same trigger has a side effect that confused me for a minute: the PR that introduces the workflow won't label itself, because the workflow doesn't exist on the base branch yet. Labels start flowing one PR later.
Label by size
The problem. I'd merged what felt like a small refactor and then spent forty minutes in review because I hadn't noticed it touched seven packages. Size labels would have set my expectations before I opened the diff.
pr-size-labeler counts changed lines and sorts the PR into a bucket, Kubernetes-style:
# .github/workflows/pr-size.yml
name: Label PRs by size
on: [pull_request]
permissions:
issues: write
pull-requests: write
jobs:
pr-size:
runs-on: ubuntu-latest
steps:
- uses: codelytv/pr-size-labeler@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_max_size: '10'
s_max_size: '100'
m_max_size: '500'
l_max_size: '1000'
fail_if_xl: 'false'
files_to_ignore: 'static/images/*'
files_to_ignore is the input worth pausing on. A blog post with five screenshots is not an XL change, so image files don't count toward the size here. On an application repo you'd exclude lock files and generated code instead. And if your team enforces small diffs, flip fail_if_xl to true and giant PRs fail the check with a comment asking to split them.
Review time follows size. A size/xs PR gets picked up between two meetings; a size/xl PR needs a calendar slot. Once the labels exist, the PR list sorts itself by reviewability, and "I'll grab the small ones first" becomes a strategy instead of guesswork.
Assign an owner
The problem. I kept opening GitHub, seeing an unassigned PR, and wondering for a second whether I'd already looked at it or whether it was still waiting. It was always waiting. It was always mine. The question was just overhead.
auto-assign-action sets reviewers and assignees the moment a PR opens. The config is the smallest file of the bunch:
# .github/auto_assign.yml
addReviewers: false
addAssignees: author
And the workflow:
# .github/workflows/auto-assign.yml
name: Auto-assign PR author
on:
pull_request:
types: [opened, ready_for_review]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- uses: kentaro-m/auto-assign-action@v2.0.2
On a team you'd list a reviewer rotation under reviewers: and let the action spread the load. On a solo repo, reviewers stay off (GitHub won't let you request a review from yourself) and the author becomes the assignee. That sounds cosmetic until you notice that "Assigned to me" works as a cross-repo to-do list, and now every open PR shows up on it without anyone clicking.
Look at that permissions block again. Three scopes to assign one person to one PR? There's a story there, and it's the next section.
Sweep the dead
The problem. I had twelve open notifycat issues from the first month of the project. Half of them were stale questions I'd since answered in code. They weren't worth closing manually, but they made the tracker look abandoned.
actions/stale, also official, is the only one of the four that runs on a schedule instead of reacting to PRs:
# .github/workflows/stale.yml
name: Close stale issues and PRs
on:
schedule:
- cron: '30 4 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
days-before-stale: 30
days-before-close: 14
stale-issue-message: >
This issue has been quiet for 30 days. It will close in 14
days unless something happens here.
stale-pr-message: >
This PR has been quiet for 30 days. It will close in 14
days unless something happens here.
exempt-issue-labels: 'pinned'
exempt-pr-labels: 'pinned'
Thirty days of silence earns a stale label and a comment. Fourteen more and the thing closes. Any activity in between removes the label and resets the clock, and a pinned label opts an issue out entirely. The workflow_dispatch trigger is there so you can run it by hand from the Actions tab instead of waiting for the cron.
One warning: this is the only workflow of the four that talks to humans. The other three quietly decorate PRs; this one posts comments on other people's issues and closes them. Tune the message before you tune the windows.
One error, two causes
Time for the part the READMEs don't cover. I pushed all four workflows in a single PR and watched the checks. pr-size went green and the PR collected its size/m label within seconds (115 changed lines, for the record). Then auto-assign failed:
##[error]Resource not accessible by integration
If you've spent any time with GitHub Actions, you've met this error. It means the token is missing a permission. It never says which one.
My first theory held up: assignees ride the Issues API. Every pull request is secretly an issue underneath (the REST endpoint for assignees lives under /issues/), so writing an assignee needs issues: write, and my workflow only had pull-requests: write. Added the scope, pushed, re-ran.
Same error. Same message. Different cause.
The second one took the run log to find. The action reads its own config file, .github/auto_assign.yml, through the API rather than from a checkout. Reading repo contents needs contents: read. And I had taken that permission away myself, without ever typing the word "contents". Here's the rule that makes it possible, straight from the GitHub docs:
When the
permissionskey is used, all unspecified permissions are set to no access, with the exception of themetadatascope, which always gets read access. (GitHub docs)
The default token would have had contents: read all along. The moment I wrote an explicit permissions: block to add issues: write, the block became an allowlist, and every scope I didn't mention dropped to none. My fix for the first failure created the second.
A permissions: block doesn't patch the defaults, it replaces them. When an action fails with "Resource not accessible by integration", open the failed run, expand "Set up job", and read the GITHUB_TOKEN Permissions group:
##[group]GITHUB_TOKEN Permissions
Issues: write
Metadata: read
PullRequests: write
##[endgroup]
That's what the token actually got. Compare it against what the action's README asks for, and the missing scope is usually staring back at you.
Testing without merging
A few things I learned while verifying the stack on its own PR:
- Workflows triggered by
pull_requestrun the workflow file from the PR itself, so the PR that introduces them tests them live. That's howpr-sizeandauto-assignran before anything was merged. - The labeler won't do that, because
pull_request_targettakes the file from the base branch. Silence from the labeler on its own PR is by design, not a bug to debug. - You can't replay an
openedevent, butready_for_reviewis in the trigger list for a reason: flip the PR to draft and back (gh pr ready --undo, thengh pr ready) and the workflow fires again. Beats closing and reopening. - Labels that don't exist get created on the fly with random colors. Pre-create them and the PR list gets a readable gradient instead of confetti:
gh label create size/xs --color 3cbf00 --description "Up to 10 changed lines"
gh label create size/s --color 5d9801 --description "Up to 100 changed lines"
gh label create size/m --color 7f6c00 --description "Up to 500 changed lines"
gh label create size/l --color a14f00 --description "Up to 1000 changed lines"
gh label create size/xl --color c32f00 --description "More than 1000 changed lines"
Green for tiny, red for huge. You can read a PR list from across the room.
The cheat sheet
The part to bookmark. Six files, one label setup command, done.
File tree
.github/
├── labeler.yml # path → label mapping
├── auto_assign.yml # assignee config
└── workflows/
├── labeler.yml # trigger: pull_request_target
├── pr-size.yml # trigger: pull_request
├── auto-assign.yml # trigger: pull_request (opened, ready_for_review)
└── stale.yml # trigger: schedule (cron)
Step 1 — create labels first
Size labels (green to red):
gh label create size/xs --color 3cbf00 --description "Up to 10 changed lines"
gh label create size/s --color 5d9801 --description "Up to 100 changed lines"
gh label create size/m --color 7f6c00 --description "Up to 500 changed lines"
gh label create size/l --color a14f00 --description "Up to 1000 changed lines"
gh label create size/xl --color c32f00 --description "More than 1000 changed lines"
Path labels (add one per area of the codebase):
# blog example
gh label create post --color 0075ca --description "Content or images changed"
gh label create theme --color e4e669 --description "Templates or styles changed"
# Go app example
gh label create api --color 0075ca --description "API handlers"
gh label create core --color d93f0b --description "Core business logic"
gh label create infra --color e4e669 --description "Docker, CI, config"
gh label create docs --color 0e8a16 --description "Documentation"
Step 2 — add the config files
.github/labeler.yml — adapt the globs to your layout:
# blog
post:
- changed-files:
- any-glob-to-any-file: ['content/**', 'static/images/**']
theme:
- changed-files:
- any-glob-to-any-file: ['templates/**', 'static/css/**']
# Go app
api:
- changed-files:
- any-glob-to-any-file: ['internal/api/**', 'cmd/**']
core:
- changed-files:
- any-glob-to-any-file: ['internal/core/**', 'pkg/**']
infra:
- changed-files:
- any-glob-to-any-file: ['.github/**', 'Dockerfile', 'docker-compose*.yml']
docs:
- changed-files:
- any-glob-to-any-file: ['docs/**', '*.md']
.github/auto_assign.yml:
# solo repo: assign the author
addReviewers: false
addAssignees: author
# team repo: round-robin reviewers
# addReviewers: true
# reviewers:
# - alice
# - bob
# - charlie
# numberOfReviewers: 1
Step 3 — add the workflow files
All four are in the body above. Quick reference with permissions:
| Workflow | Trigger | permissions: block |
|---|---|---|
labeler.yml | pull_request_target | contents: read + pull-requests: write |
pr-size.yml | pull_request | issues: write + pull-requests: write |
auto-assign.yml | pull_request | contents: read + issues: write + pull-requests: write |
stale.yml | schedule | issues: write + pull-requests: write |
Customize pr-size.yml per project type
# blog or docs repo — images and markdown don't count
files_to_ignore: 'static/images/* *.md'
# Go app — ignore generated code and vendor
files_to_ignore: 'vendor/* **/*.pb.go'
# Node project — ignore lockfile churn
files_to_ignore: 'package-lock.json yarn.lock pnpm-lock.yaml'
# Strict teams: fail the check instead of just labeling
fail_if_xl: 'true'
Debugging shortlist
- "Resource not accessible by integration" means a missing scope. The run log's "Set up job" section shows what the token got; the README says what it wanted.
- A
permissions:block replaces the defaults. List everything the action needs, not just the scope you came to add. - Labeler does nothing on its own PR.
pull_request_targetruns from the base branch; merge first, then it wakes up. - Scheduled workflows only fire from the default branch. Keep
workflow_dispatchon the stale workflow so you can test it by hand. - Re-trigger PR workflows with the draft toggle instead of close-and-reopen (
gh pr ready --undothengh pr ready).
Where this is overkill
A stale bot can be rude. Someone wrote a thoughtful issue, came back two months later, and found it closed by a robot. If your repo has outside contributors, use generous windows, write a warm message, and document the pinned escape hatch. On a repo that's mostly an archive, skip the stale bot entirely; there's nothing to triage.
Auto-assign on a solo repo borders on bookkeeping. I still run it for the cross-repo "Assigned to me" view, but I won't pretend it changed my life. It's one file and zero maintenance, so the bar is low.
Path labels rot. The globs describe today's directory layout. Restructure the project and the labeler silently stops matching anything. Glance at labeler.yml whenever directories move.
At some scale you outgrow ten-line workflows. Review rotations with load balancing, merge queues, slash commands in comments: that's Mergify rules or full Prow territory. Four files is the right size for a repo owned by one person or one team. It's the wrong size for Kubernetes.
Wrapping up
The whole stack took about an hour, and most of that hour went into the two permission failures you've now read about, which means your install should take ten minutes. Full disclosure: right after verifying everything worked on notifycat, I realized I'd been testing in the wrong branch and had to roll back once before committing the final versions. The configs above are the survivors: tested on a live PR, then written down so the next install goes in clean.
Don't start with all four. Pick the question that annoys you most. If it's "how big is this PR", start with the size labeler; if your issue tracker is a graveyard, start with stale. The first PR that shows up pre-labeled, pre-sized, and pre-assigned makes the argument better than this post can.
Links
- actions/labeler: path-based PR labeling, official
- pr-size-labeler: size buckets from changed lines
- auto-assign-action: reviewers and assignees on PR open
- actions/stale: scheduled cleanup of inactive issues and PRs, official
- GITHUB_TOKEN permissions: the allowlist rule that bit me
- Prow: what triage automation looks like at Kubernetes scale
- Mergify: the YAML rules engine between four files and Prow
- notifycat: the project where I set this up