Problem
CONTRIBUTING.md and AGENTS.md both open the work loop with "open a GitHub issue first — this is the unit of work." Nothing checks it. The rule holds when someone remembers, and quietly fails when they do not — most often on changes that felt too small to deserve an issue, which is exactly when the reasoning is most likely to be lost.
It happens to agents especially. An agent working from a conversation has the why in its context, opens a PR describing the change, and never notices that the reasoning now exists only in a chat log nobody else can read. Two of these shipped this week across the org's repos.
Proposal
A reusable workflow that fails when a pull request has no linked issue, wired in as a required status check so the failure blocks merge.
Detection
A pull request passes when it references at least one issue. Referencing is not closing — forty-two PRs can reference the same issue and close none of it, and every one of them passes. The check asks that a change is attached to a recorded reason, never that it finishes one.
GitHub records the relationship, but only on the issue. PullRequest exposes closingIssuesReferences — closing keywords and Development-sidebar links — and nothing else; there is no referencedIssues field, and a PR's own timelineItems do not carry its outgoing references. Verified against a live case: swimblocks/officials-admin#24 mentions rowswimming/row-officials-admin#3 with no closing keyword, the issue's timeline shows a CrossReferencedEvent with willCloseTarget: false, and the PR's timeline shows nothing at all.
So the check runs in two steps — text to find candidates, the API to confirm them:
- Gather candidates from the PR title and body:
#12, owner/repo#12, and full issue URLs.
- Confirm each by querying that issue's timeline for a
CrossReferencedEvent whose source is this pull request. Union with closingIssuesReferences to cover Development-sidebar links. One confirmation is enough.
The confirmation step is the rule, not a sanity check on the parsing. A CrossReferencedEvent is exactly the property being asked for: it lives on the issue, so the link is visible from both ends, and it survives branch deletion and squash-merge, so it is still there in a year when someone asks why a line of code exists. Text that merely looks like a reference — a number copy-pasted from another PR's description, a stale link — produces no event and does not pass.
This is also why the branch name does not count, though the org mandates <issue-number>-<short-slug>. A branch name registers nothing on the issue, and the branch itself is usually deleted on merge. It is a useful convention for humans mid-flight, not a durable link.
Cross-repo references count; the two-tracker split makes them routine. Confirming one needs a token that can read the other repo, and where the check cannot see across, it should accept the candidate rather than fail. A false failure on a correct pull request is what teaches people to switch a check off.
Escape hatch, and what it should cost
Some pull requests genuinely should not need an issue, and a rule with no exit is one people learn to bypass wholesale. But a bare label is too cheap — it becomes a reflex, applied without thought.
Require both:
-
The no-issue label.
-
A line in the PR description matching ^NO-ISSUE:\s*(.{15,})$ — for example:
NO-ISSUE: Fixes a typo in the deck-eval-parser README.
Neither alone, because they do different jobs. The label is machine-queryable: is:pr label:no-issue audits every bypass the org has ever taken, in one search, which is what makes it possible to notice the hatch being overused. The line is the reason, readable next to the change by anyone who wonders why this one was exempt. A label with no line is unaccountable; a line with no label is invisible to any audit.
The length floor rejects NO-ISSUE: n/a. It will not stop someone determined to write NO-ISSUE: because I said so, and it should not try — the goal is to make the bypass deliberate and visible, not to adjudicate it.
Bots keep a plain author-based bypass, since Dependabot cannot be asked to explain itself.
The org's bulk mechanical changes across many repos are already carved out of the agent-handoff rule in CONTRIBUTING.md; those can use the same NO-ISSUE: line rather than needing a separate mechanism.
The check should say why it failed and how to fix it, quoting the exact line format, in the check output. A red X with no explanation teaches people to click "merge anyway" wherever they can.
What makes this harder than it looks
Required status checks cannot block merge on private repos under GitHub Free. settings.yml says so directly — the legacy branches: protection block "is expected to fail on GitHub Free" and sets required_status_checks: null, while the swimblocks-default ruleset that does work is documented as applying to public repos only. Every repo where this rule was actually broken recently is private.
So the honest scope is two-phase:
- The workflow, everywhere. It runs on public and private repos alike and reports a visible failure. On private repos that is advisory — a red X somebody has to consciously ignore, which is most of the value at this org's size.
- Merge-blocking, where the plan allows it. Add the check to the ruleset's
required_status_checks for public repos. Blocking on private repos needs GitHub Team, which is a spending decision, not a technical one.
Worth deciding explicitly rather than discovering after the workflow is written and appears not to work.
Related
Acceptance criteria
- A reusable workflow in this repo that fails a PR with no linked issue.
- A PR that references an issue without closing it passes. Worth an explicit test case: it is the easiest part of this to get wrong, and getting it wrong turns a reference rule into a one-PR-per-issue rule.
- The bypass requires the
no-issue label and a conforming NO-ISSUE: line; either one alone still fails, and the check says which is missing.
- Bot authors bypass without either.
- Failure output states the rule and how to satisfy it, quoting the line format.
CONTRIBUTING.md documents the bypass, so it is a known affordance rather than folklore.
- Adopted by at least one repo end to end, and the private-repo enforcement gap either closed or written down as accepted.
Problem
CONTRIBUTING.mdandAGENTS.mdboth open the work loop with "open a GitHub issue first — this is the unit of work." Nothing checks it. The rule holds when someone remembers, and quietly fails when they do not — most often on changes that felt too small to deserve an issue, which is exactly when the reasoning is most likely to be lost.It happens to agents especially. An agent working from a conversation has the why in its context, opens a PR describing the change, and never notices that the reasoning now exists only in a chat log nobody else can read. Two of these shipped this week across the org's repos.
Proposal
A reusable workflow that fails when a pull request has no linked issue, wired in as a required status check so the failure blocks merge.
Detection
A pull request passes when it references at least one issue. Referencing is not closing — forty-two PRs can reference the same issue and close none of it, and every one of them passes. The check asks that a change is attached to a recorded reason, never that it finishes one.
GitHub records the relationship, but only on the issue.
PullRequestexposesclosingIssuesReferences— closing keywords and Development-sidebar links — and nothing else; there is noreferencedIssuesfield, and a PR's owntimelineItemsdo not carry its outgoing references. Verified against a live case:swimblocks/officials-admin#24mentionsrowswimming/row-officials-admin#3with no closing keyword, the issue's timeline shows aCrossReferencedEventwithwillCloseTarget: false, and the PR's timeline shows nothing at all.So the check runs in two steps — text to find candidates, the API to confirm them:
#12,owner/repo#12, and full issue URLs.CrossReferencedEventwhosesourceis this pull request. Union withclosingIssuesReferencesto cover Development-sidebar links. One confirmation is enough.The confirmation step is the rule, not a sanity check on the parsing. A
CrossReferencedEventis exactly the property being asked for: it lives on the issue, so the link is visible from both ends, and it survives branch deletion and squash-merge, so it is still there in a year when someone asks why a line of code exists. Text that merely looks like a reference — a number copy-pasted from another PR's description, a stale link — produces no event and does not pass.This is also why the branch name does not count, though the org mandates
<issue-number>-<short-slug>. A branch name registers nothing on the issue, and the branch itself is usually deleted on merge. It is a useful convention for humans mid-flight, not a durable link.Cross-repo references count; the two-tracker split makes them routine. Confirming one needs a token that can read the other repo, and where the check cannot see across, it should accept the candidate rather than fail. A false failure on a correct pull request is what teaches people to switch a check off.
Escape hatch, and what it should cost
Some pull requests genuinely should not need an issue, and a rule with no exit is one people learn to bypass wholesale. But a bare label is too cheap — it becomes a reflex, applied without thought.
Require both:
The
no-issuelabel.A line in the PR description matching
^NO-ISSUE:\s*(.{15,})$— for example:Neither alone, because they do different jobs. The label is machine-queryable:
is:pr label:no-issueaudits every bypass the org has ever taken, in one search, which is what makes it possible to notice the hatch being overused. The line is the reason, readable next to the change by anyone who wonders why this one was exempt. A label with no line is unaccountable; a line with no label is invisible to any audit.The length floor rejects
NO-ISSUE: n/a. It will not stop someone determined to writeNO-ISSUE: because I said so, and it should not try — the goal is to make the bypass deliberate and visible, not to adjudicate it.Bots keep a plain author-based bypass, since Dependabot cannot be asked to explain itself.
The org's bulk mechanical changes across many repos are already carved out of the agent-handoff rule in
CONTRIBUTING.md; those can use the sameNO-ISSUE:line rather than needing a separate mechanism.The check should say why it failed and how to fix it, quoting the exact line format, in the check output. A red X with no explanation teaches people to click "merge anyway" wherever they can.
What makes this harder than it looks
Required status checks cannot block merge on private repos under GitHub Free.
settings.ymlsays so directly — the legacybranches:protection block "is expected to fail on GitHub Free" and setsrequired_status_checks: null, while theswimblocks-defaultruleset that does work is documented as applying to public repos only. Every repo where this rule was actually broken recently is private.So the honest scope is two-phase:
required_status_checksfor public repos. Blocking on private repos needs GitHub Team, which is a spending decision, not a technical one.Worth deciding explicitly rather than discovering after the workflow is written and appears not to work.
Related
settings.ymlchange will not propagate on its own. Enforcement rollout depends on it.Acceptance criteria
no-issuelabel and a conformingNO-ISSUE:line; either one alone still fails, and the check says which is missing.CONTRIBUTING.mddocuments the bypass, so it is a known affordance rather than folklore.