Skip to content

bug(ci): the trailer gate scans branch commits, so a squash-composed trailer reaches main with the gate green #17720

Open

Description

@mrveiss

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 on main is added, its limitation is stated where it is
    configured: it detects and cannot prevent, because main is 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

  1. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Triage: v0.9.5 - CI and guards, priority: high — and unlike #17713, this one passes #17639's test

    Applying the written criteria rather than taking a placement, the same way #17713 was declined:

    "v0.9.0 ships something broken or missing without it: a dead user-facing path, a security exposure in shipped code, data loss, a broken install or update path, or a release-pipeline defect."

    criterion this issue
    dead user-facing path no
    security exposure in shipped code no
    data loss no
    broken install or update path no
    release-pipeline defect yes — demonstrated

    Not an argument from severity: #17717 (main → release, v0.10.0) is blocked right now, with No commit trailers queued 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.

  2. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    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-msg strips the trailers from every local commit, and
    that zero branch commits on all four offending PRs ever carried one. I verified the hook myself
    before repeating it — lines 28-30:

    grep -viE '^[[:space:]]*Co-authored-by:[[:space:]]' "$msg_file" \
      | grep -viE '^[[:space:]]*(🤖[[:space:]]*)?Generated with \[?Claude' \
      | grep -viE '^[[:space:]]*Claude-Session:' \
    

    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 main and that nothing between the author and the merge ever inspects.
    Option 2 (scan main on 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.

  3. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    Screening result: live, and the consequence is two orders of magnitude larger than "98 commits"

    Both measured premises still hold on current origin/main:

    • No push: trigger. .github/workflows/no-commit-trailers.yml triggers are merge_group: and pull_request: only.
    • Branch-range scan. 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 — its coauthor_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:

    count
    commits reachable from origin/main 14,342
    carrying at least one third-party Co-authored-by: by the gate's predicate 7,106
    of those, whose only offending identity is an agent-attribution trailer 6,818
    offending trailer lines that are agent attribution (noreply@anthropic.com / noreply@paperclip) 10,831
    offending trailer lines that are something else (e.g. t <t@example.invalid>) 288

    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

    count
    commits in origin/release..origin/main today 44
    of the 7,106 that fall in that window 0

    All 7,106 are already on release. A BASE..HEAD scan 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

    1. git log --grep='Co-authored-by:' returns 3,461 case-sensitively and 8,128 with -i. Casing variants are real; the gate uses grep -iE, so it is right to be case-insensitive — but a report quoting either number is quoting trailer presence, not violations.
    2. Neither number is the violation count. The gate permits 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.
    3. My first spot-check printed a commit body and found no trailer, appearing to contradict the scan. That was my command passing two revisions at once; 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:

    #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 on main would scan the composed object, but only after it has landed — detection, not prevention. Preventing it means scanning the message GitHub will compose, which is what merge_group sees and pull_request does 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.

  4. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    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.yml scans BASE_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 closingIssuesReferences

    Verified on origin/main and the API:

    merge commit 1edec3064c, line 7:   Closes #17897
    PR #17912  mergedAt                2026-10-03T16:28:14Z
    #17897     timeline: closed        2026-10-03T16:28:16Z      <- 2s after the merge
    PR #17912  closingIssuesReferences EMPTY (0 entries)
    

    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:

    what is composed at merge time what cannot see it consequence
    this issue a Co-authored-by trailer GitHub collects no-commit-trailers.yml, which scans BASE..HEAD a forbidden trailer reaches main with the gate green
    new a Closes #N keyword typed by the merger closingIssuesReferences, and therefore every pre-merge linkage check an issue closes with no recorded closing link — or, with a negation the parser does not understand, one closes that nobody intended

    That 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:

    • Before the merge: the branch commits are inspectable, and that is where a gate belongs. This issue's existing predicate is correct there.
    • At the merge: the squash body is authored, not derived. The only check is the merger reading what they are about to compose — the trailer rule and the closing keyword both.
    • After the merge: a push:-triggered scan of main can 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 as OPEN with closedAt=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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions