ready-for-review label workflowThe PR list is noisy. Maintainers cannot tell at a glance which PRs are actually ready for review versus draft, WIP, or still being iterated on after feedback. This wastes reviewer time and makes it hard to prioritize the queue. The rise of AI-assisted editing has also sharply increased commit volume, often producing many small follow-up commits for individual inline comments, which further amplifies the noise.
Introduce a ready-for-review label that is only present on a PR when the
author has explicitly signalled it is ready and no state has changed since
that signal. Maintainers can then filter the PR list to label:ready-for-review
and trust that every result deserves attention.
Author comments /ready-for-review on its own line in a PR comment. A single
workflow job (add-label) listens for PR comments, checks for an exact-line
match (not just a substring, so passing mentions of the command elsewhere in
a comment don't trigger it), verifies the commenter is the PR author or a
maintainer, and either applies the ready-for-review label or comments back
explaining why the command was rejected.
The label is removed automatically whenever the PR's state changes in a way
that invalidates the "ready" signal. In every case, the only action taken is
removing the ready-for-review label; the automation never changes the PR's
own draft/open state:
synchronize event).REQUEST_CHANGES or a COMMENT review
containing unresolved feedback (treat CHANGES_REQUESTED as the reliable
signal; treat plain comment reviews as optional/configurable).Each of these is a separate trigger condition in the same workflow, all converging on "remove label if present." Running removal in the same workflow as the gate check (below) is what lets removal finish before the gate re-evaluates, rather than racing it.
A job in the same workflow (check-label) runs on every relevant
pull_request_target event (push, open, reopen, label change) and verifies
the PR carries ready-for-review:
core.setFailed. This does not
post a PR comment; the explanation (what to run, and a link to docs) shows
up as the failure message on the check itself and in the workflow run log,
which is enough of a pointer without adding another comment to the thread.Because check-label declares needs: [remove-label] and both run within
the same pull_request_target event, check-label always executes after
remove-label completes on a push, and it re-fetches the PR's labels from
the API rather than trusting the event payload. This removes the race that
existed when the two lived in separate workflow files: previously a push
could trigger the gate and the label removal in parallel, with no guarantee
the gate would see the post-removal state.
Adding the label (via add-label) or removing it (via remove-label after a
change-requested review) each fire their own labeled/unlabeled event,
which re-triggers this same workflow and re-runs check-label on the
current commit, so the check reflects reality without waiting for a new
push.
This failing check is what actually reduces noise for maintainers, since it can be made a required check that keeps the PR out of default review views until the author opts in, without blocking merge mechanics for drafts.
.github/workflows/ready-for-review.yml)name: PR readiness
on:
issue_comment:
types: [created]
pull_request_review:
types: [submitted]
pull_request_target:
types: [synchronize, opened, reopened, labeled, unlabeled]
permissions:
pull-requests: write
issues: write
jobs:
add-label:
if: >
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/ready-for-review')
runs-on: ubuntu-latest
steps:
- name: Handle /ready-for-review command
uses: actions/github-script@v7
with:
script: |
const body = context.payload.comment.body || '';
const isCommand = /^\s*\/ready-for-review\s*$/m.test(body);
if (!isCommand) {
core.info('Comment does not contain the /ready-for-review command on its own line, skipping.');
return;
}
const commenter = context.payload.comment.user.login;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const isAuthor = commenter === pr.data.user.login;
const perms = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: commenter,
});
const isMaintainer = ['admin', 'write'].includes(perms.data.permission);
if (!isAuthor && !isMaintainer) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `@${commenter} only the PR author or a maintainer can run \`/ready-for-review\`.`,
});
core.setFailed(`${commenter} is not authorized to run /ready-for-review`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['ready-for-review'],
});
remove-label:
if: >
(github.event_name == 'pull_request_target' &&
github.event.action == 'synchronize') ||
(github.event_name == 'pull_request_review' &&
github.event.review.state == 'changes_requested')
runs-on: ubuntu-latest
steps:
- name: Remove label
uses: actions/github-script@v7
with:
script: |
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: 'ready-for-review',
});
} catch (e) {
if (e.status !== 404) throw e; // label already absent, fine
}
check-label:
name: Verify ready-for-review label
needs: [remove-label]
if: >
always() &&
github.event_name == 'pull_request_target' &&
github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Verify ready-for-review label
uses: actions/github-script@v7
with:
script: |
// Re-fetch fresh rather than trusting the event payload, since
// the remove-label job may have just changed the label set.
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
const labels = pr.labels.map(l => l.name);
if (!labels.includes('ready-for-review')) {
core.setFailed(
'This PR is not marked ready for review. Comment `/ready-for-review` once it is ready. See LINK_TO_DOCS for the contributor guide.'
);
}
Notes on the sketch:
pull_request_target is used for the whole workflow, not just label
mutation, so add-label, remove-label, and check-label all run with
full base-repo permissions and share one event context, even for PRs from
forks. None of the jobs check out PR code, so this remains safe.remove-label job only ever calls removeLabel. It does not touch
the PR's draft/ready state, mergeability, or any other field.check-label re-fetches labels via pulls.get instead of using
context.payload.pull_request.labels, so it reflects remove-label's
outcome rather than the stale state from when the event fired.check-label is skipped outright when pull_request.draft == true.check-label never comments; its failure message is visible on the check
itself and in the run log. add-label does comment, but only once, on a
rejected command, not repeatedly across pushes, so no deduplication logic
is needed there.check-label a required status check in branch protection
so it visibly blocks merge until addressed, or leave it non-blocking if
the intent is purely list hygiene rather than merge gating.check-label is given an explicit name: so its check run reads as
"Verify ready-for-review label" rather than the raw job id. This is what
makes the required-check string in branch protection predictable and
readable; see the Rollout plan for the exact context string.| Case | Options |
|---|---|
| Maintainer wants to force-mark a PR ready on author's behalf | Allow maintainers to run /ready-for-review too (sketch above already allows this) |
| Author pushes a trivial fix (typo, rebase) after being marked ready | Accept as a cost of simplicity, or add a /ready-for-review alias like a --skip flag for trivial pushes (adds complexity, probably not worth it initially) |
| Bots/automated pushes (e.g. Dependabot rebase) | Consider excluding pushes from known bot actors from label removal |
| Comment reviews without changes-requested state | Decide whether to treat these as removal triggers; recommend starting with only CHANGES_REQUESTED to avoid over-triggering |
| Stale ready PRs | Optional stale-bot integration to remove label after N days of inactivity |
CONTRIBUTING.md with the /ready-for-review instructions and a
short rationale, and link it from the gate check's failure message.<workflow name> / <job name>, so this should appear
as PR readiness / Verify ready-for-review label. GitHub only lists
checks in the branch protection picker once they've actually run against
the target branch, so trigger the workflow once (e.g. merge a pilot PR
or push a no-op commit) before trying to select it, and pick it from the
dropdown rather than typing it from memory since the match must be
exact.pull_request_review_comment), separate
from a formally submitted review, also trigger label removal, or is a
submitted CHANGES_REQUESTED review sufficient on its own?Most large CNCF projects implement this kind of thing through Prow, the Kubernetes-originated CI/chatops bot, rather than plain GitHub Actions — heavier infrastructure than what's proposed here, but it validates the underlying pattern:
[WIP] title prefix or a /hold
comment each drive a do-not-merge/* label that blocks merge automation
until removed. This is the reverse polarity of this proposal (default
mergeable, explicit block, rather than default blocked, explicit ready),
but it's the same idea of a bot-managed label as the accurate signal:
https://www.kubernetes.dev/docs/guide/pull-requests/lgtm plugin — the closest direct analog to our
removal-on-push trigger. A reviewer comments /lgtm, Prow adds the
lgtm label, and a new commit push automatically strips it again so the
PR has to be re-reviewed:
https://docs.prow.k8s.io/docs/components/plugins/lgtm/lgtm
plugin, including the same false-positive/spam tradeoffs called out in
the edge cases table above:
https://github.com/kubernetes/test-infra/issues/3795wip plugin on
its own repo, so this isn't just a kubernetes/kubernetes-specific
convention: https://prow.k8s.io/plugin-config