Repository navigation
bug(ci): the trailer gate scans branch commits, so a squash-composed trailer reaches main with the gate green #17720
Description
The trailer gate scans branch commits; the object that lands is the squash, so a trailer reaches main with the gate green
Found while checking my own pending work against the standing no-trailers rule, after four commits
on main were discovered carrying Co-authored-by: Claude <noreply@anthropic.com> and blocked the
v0.10.0 promotion (#17717).
The predicate is correct. It is run against the wrong object, at the wrong time.
Measured
.github/workflows/no-commit-trailers.yml:
| line | what |
|---|---|
:9-16 |
triggers are pull_request and merge_group — there is no push: trigger, so it never runs on main after a merge |
:90 |
git rev-list --count "${BASE_SHA}..${HEAD_SHA}" — the branch commits, HEAD_SHA being the PR head |
:122 |
agent_re matches Generated with [Claude, Claude-Session: |
:138 |
coauthor_re matches Co-authored-by: |
So a PR whose branch commits are clean passes. GitHub's squash then composes the merge commit
message from the PR title and body, plus co-authors it collects — and that composed object is
never scanned by anything until a promotion PR re-scans the whole range.
Nothing detected it for 98 commits. It surfaced only when #17717 (main → release) ran the
same predicate over a range that included them.
The shape
A check correct about its own terms and blind to a composition step. The regexes are right, the
range is right for the question "does this branch carry a trailer", and the defect is "does the
object that lands carry one". Those are different questions and only the first is asked.
This is the same family as #17698 — a path filter asking which files changed when the question
is which tests can fail — but a different mechanism and a different workflow, so it needs its own
home rather than a comment there.
Why four sessions produced it, which the fix should account for
Not rule-ignoring. The agent harness emits a per-session reminder instructing exactly these lines —
Co-Authored-By: Claude … on commits, 🤖 Generated with [Claude Code] on PR bodies. Sessions
followed the nearer instruction, addressed to them, over the project rule addressed to the repo.
The reminder's own text defers to the user's instructions, so the two are compatible — but only for
a session that has the project rule loaded and checks the precedence before acting.
That is why the gate matters more than restating the rule: the gate does not depend on which
instruction a session read first.
Two shapes for the fix, not choosing between them
1. Scan the squash preview, per PR. Compose what the squash would produce — title, body, and
the co-authors GitHub would collect — and run the existing coauthor_re / agent_re over that.
Prevents. Same predicate, right object.
2. Scan main on every push. A post-merge run that fails loudly and names the commit.
Detects only — it cannot prevent, because rewriting main is forbidden either way, and a
landed trailer will still need baselining. What it buys is "surfaced within one merge" instead of
"surfaced 98 commits later at the next promotion".
They are not alternatives; 2 is a backstop for whatever 1 misses.
Acceptance criteria
- A PR whose branch commits are clean but whose squash result would carry a trailer fails
before merge — the same predicate against the composed object. - A test with a PR body containing
Co-authored-by:and clean branch commits fails the
gate. That is the exact case that landed four times and passed four times. - If a
push:-triggered scan onmainis added, its limitation is stated where it is
configured: it detects and cannot prevent, becausemainis not rewritten. - The existing promotion-time scan is kept. It is the only thing that caught this, and it
remains the backstop for any composition path the per-PR check does not model. - The four baselined commits are recorded as an exempted violation with the reason —
main
cannot be rewritten — and explicitly not as precedent.
Related
#16268 (open) — the same symptom by a different mechanism: owner-authored squashes carrying
placeholder Co-authored-by: t <…> identities block the sync PR. That issue is about which
identities are accepted; this one is about which object is scanned. Both end in "main
carries trailers, the promotion is red, baseline it", and the remedies do not overlap.
#16931 (closed) — "the trailer gate cannot see a release promotion — fixed fetch depth". Same
gate, an earlier blind spot, fixed once. The class was not swept.
Also #17717 (blocked by this), #17698 (same family, different workflow), #13654, #16124, #15826.
Activity
mrveiss commentedon Sep 28, 2026
Triage:
v0.9.5 - CI and guards,priority: high— and unlike #17713, this one passes #17639's testApplying the written criteria rather than taking a placement, the same way #17713 was declined:
Not an argument from severity: #17717 (
main→release, v0.10.0) is blocked right now, withNo commit trailersqueued against a range containing the four commits this defect let through. The criterion asks for the issue to show the shipped breakage, and the breakage is a promotion that cannot merge.Contrast with #17713, filed an hour ago and deliberately left unmilestoned: that is a high-priority correctness bug on a public route which does not make the release ship broken. Same author, same test, opposite answers — which is the point of having a written test rather than a sense of importance.
One thing that would change the placement: if the baseline PR lands and the promotion proceeds, the demonstrated block is discharged and this becomes "a gate that will let the next one through" — still real, still
priority: high, but arguably no longer release-pipeline-blocking for this release. I do not think that should move it, because the gate is unchanged and the next promotion meets the same wall; but whoever owns the milestone should know the evidence has a shelf life.mrveiss commentedon Sep 28, 2026
Correction: the channel is the PR body, not the commit
Attribution first: the finding is session 58's, the verification is f8's, the issue is mine.
58 established that
tools/git-hooks/commit-msgstrips the trailers from every local commit, andthat zero branch commits on all four offending PRs ever carried one. I verified the hook myself
before repeating it — lines 28-30:
The installed copy under the shared hooks dir is byte-identical, so it applies in every worktree.
What this changes in the body above
Option 1 should read "scan the PR body", not "scan the squash preview". The body is the input
GitHub composes the squash message from, server-side, at merge time. That is the one object that
carries a trailer to
mainand that nothing between the author and the merge ever inspects.Option 2 (scan
mainon push) is unchanged and still only detects after the fact.Why two green instruments were both right and both wrong
The per-PR gate scans branch commits. The hook strips branch commits. So the gate was asking a
question whose answer had already been forced to "clean" by something else — it could not have
failed, on any PR, for any author. Every session's local check was green for the same reason.
Two instruments agreeing is normally corroboration. Here they agreed because they were pointed at
the same wrong object, and their agreement is what made the blind spot feel covered. That is the
mechanism the body above describes as an instruction conflict; this is its concrete channel.
The same collapse applies to my own report
I reported earlier in this session that my commits carried zero trailers. That claim cannot
distinguish "I never wrote one" from "I wrote one and the hook removed it" — the hook is
silent, so the two produce identical evidence. I am withdrawing it as a measurement of my own
compliance; it measures the hook.
What I can measure is the channel that matters: nothing rewrites a PR body, so a scan of one is
real. My six PR bodies this session (#17666, #17667, #17678, #17696, #17697, #17714) carry zero
trailer lines. That is evidence; the commit count was not.
mrveiss commentedon Oct 3, 2026
Screening result: live, and the consequence is two orders of magnitude larger than "98 commits"
Both measured premises still hold on current
origin/main:push:trigger..github/workflows/no-commit-trailers.ymltriggers aremerge_group:andpull_request:only.git rev-list --count "${BASE_SHA}..${HEAD_SHA}"at:90, and the scan loop itself at:213.The measured history
Applying the gate's own predicate to every commit reachable from
origin/main— itscoauthor_re, with its three exclusions (owner_re,bot_ident_re, and the ident matching the commit's own author) — rather than a pattern of my own:origin/mainCo-authored-by:by the gate's predicatenoreply@anthropic.com/noreply@paperclip)t <t@example.invalid>)Leading offending identities:
Claude Sonnet 4.6(2,834),Claude(2,030),Paperclip <noreply@paperclip.ing>(2,027),Claude Opus 4.5(1,484).Why no promotion will ever see them, which is the actual defect
origin/release..origin/maintodayAll 7,106 are already on
release. ABASE..HEADscan can only ever see commits in an unmerged range, so every historical violation is permanently outside every future scan. The 98 commits this issue describes were not an unlucky window — they were the only ones a scan range happened to still reach. The current window is clean, and that is not evidence the rule holds.Three corrections to my own measurement, recorded because each nearly shipped
git log --grep='Co-authored-by:'returns 3,461 case-sensitively and 8,128 with-i. Casing variants are real; the gate usesgrep -iE, so it is right to be case-insensitive — but a report quoting either number is quoting trailer presence, not violations.Co-authored-by:when the identity is the owner, a[bot], or the commit's own author. 8,128 is a correct, complete count of the wrong thing.git show -s --format='%B'on the same SHA shows the trailer. I resolved it with a control test — an impossible pattern returning 0 — before trusting any count.Verdict on the cluster
Three defects on one subject, not one defect with three witnesses. Each has a different fix in a different place:
.claude/skills/bugfix/SKILL.md:192andautobot-backend/skills/builtin/bugfix/SKILL.md:192); a wording change plus a guard. Detail on that issue.#17721 and this issue both touch
.github/and could share one PR. #17719's repo half is two Markdown files — different risk class, and its harness half is not fixable here at all.One design note, not a prescription
A naive
push:trigger onmainwould scan the composed object, but only after it has landed — detection, not prevention. Preventing it means scanning the message GitHub will compose, which is whatmerge_groupsees andpull_requestdoes not. That is a choice between a blocking gate and an after-the-fact alarm, and it is the maintainer's to make.Nothing filed, nothing changed — research-only session, and the one new fact that needed a home went to the issue that owns its fix.
mrveiss commentedon Oct 3, 2026
This issue's mechanism has a second instance, so it is a class: the object that lands is composed at merge time, and no pre-merge check can inspect it
The finding here is that
no-commit-trailers.ymlscansBASE_SHA..HEAD_SHA— the branch commits — while GitHub's squash composes the merge message at merge time, so a trailer in the composed object is never scanned. A second, independent instance landed today in a different subsystem.Instance 2: a closing keyword in the squash message, invisible to
closingIssuesReferencesVerified on
origin/mainand the API:The keyword closed the issue and never appeared in the field that is supposed to report closing links, because that field reflects the PR body and the branch commits — and the squash message does not exist until the merge command runs.
So the same structural gap produces two different consequences:
Co-authored-bytrailer GitHub collectsno-commit-trailers.yml, which scansBASE..HEADmainwith the gate greenCloses #Nkeyword typed by the mergerclosingIssuesReferences, and therefore every pre-merge linkage checkThat second row compounds with a separate finding: the keyword parser has no notion of negation — "This does not close #17060" created a closing link. Combined, a merger can close an issue nobody intended through a surface that did not exist when every check ran.
Why this matters for how this issue gets fixed
A
push:trigger does not close the class. It would let a detector see the composed object after the fact, which is this issue's own design note — detection rather than prevention. But nothing can inspect the squash message before it exists, so for both instances the only control available at composition time is the person composing it.That makes the remedy procedural for one half and mechanical for the other, and the split is worth stating before someone builds a single gate for both:
push:-triggered scan ofmaincan detect either violation, and that is the only place a machine can.So "scan the composed object" is three different controls at three different times, not one missing trigger — and the middle one has no mechanical form at all.
Verified here against
origin/main; the second instance was found by another session and I confirmed its three facts before recording them. One correction to my own check, since it is the day's recurring shape: my first look reported #17897 asOPENwithclosedAt=null, which appeared to refute the whole mechanism. It was reopened at 17:58:52Z, ninety minutes after the merge-time closure. I had queried current state for a claim about a past event — correct, complete, and about a different moment.Research-only session — nothing filed, nothing changed.