Skip to content

CHANGELOG.md prepend collisions make long-lived PRs non-convergent: 8 rounds in 19h, next collision 4m21s after the last resolution #1961

Description

@seonghobae

Every pull request in this repository adds its entry by prepending a ### … section at line 1 of CHANGELOG.md. Git cannot auto-merge two insertions at the same anchor, so any two open PRs that both carry an entry conflict pairwise, and every merge to main re-conflicts every remaining open PR that has one. No individual change is at fault; the collision is created by the file's shape.

Measured on 2026-09-06

commits to main in 24h                     28
  of which touched CHANGELOG.md            20   (71%)
non-draft open PRs (first page alone)      75

On one PR (#1923, whose actual delta is a single test file plus its entry) this produced eight conflict rounds in nineteen hours:

01:18  06:27  07:34  12:21  14:29  16:51  20:33   ← merge commits
                                          20:37   ← round 8 trigger

The last interval is the point of the report: round 7 was resolved and pushed at 20:33:09, and the commit that invalidated it landed at 20:37:30 — 4 minutes 21 seconds later. One resolution cycle on this repository costs about three minutes of gate time alone (2932 tests), before detection, merge, and push. When the interval between collisions falls below the cost of resolving one, a PR carrying a changelog entry cannot converge by resolving conflicts — it is not slow, it is non-convergent, and it stays open until the merge rate drops.

Every one of those eight resolutions was mechanically identical: keep main's sections, re-prepend this branch's section. No round required a judgement call, which is the signature of work that should not be done by hand.

Cost this is currently paid in

Directions, in increasing cost

  1. A merge driver for CHANGELOG.md — .gitattributes plus a small union-style resolver that keeps both sides in order. Smallest change; leaves the file format alone; every clone needs the driver configured, and an unconfigured clone silently falls back to a normal conflict.
  2. Per-PR fragments — each PR adds changelog.d/<pr>.md and the file is assembled at release. Two PRs never touch the same line, so the conflict class disappears entirely rather than being auto-resolved. Costs a convention change and a release-time assembly step, and several contract tests assert exact CHANGELOG.md prose today.
  3. Status quo — accept that any long-lived PR carrying an entry is re-conflicted on roughly every merge.

Which of these is right is a repository-convention decision rather than a repair, so I have not implemented one; this issue records the measurement so the trade-off can be made against real numbers instead of an impression.

Activity

  1. seonghobae commented on Sep 6, 2026

    @seonghobae
    ContributorAuthor

    Measurements from the merge lane that support this, gathered while merging four pull requests today.

    Churn. Of the last 29 commits on main in 24 hours, 21 touched CHANGELOG.md — every substantive change carries an entry, and every entry goes at the top of the same file, so the file is a serialization point that every open branch contends for.

    Cost per merge, measured. Three merges landed in 27 minutes: 5ea1cc47 at 20:24:50, 0b0f1047 at 20:37:30, c232ca03 at 20:51:11 (KST). Each one invalidated the changelog of every other open branch. Concretely, in that window #1957 had to be resolved and re-pushed once, my own #1959 once, and #1960 twice — its author resolved it, my gate then aborted on a fresh conflict created by the merge that happened while I was verifying it, and it needs a third resolution now.

    Why it does not self-limit. Resolving costs a re-push plus a re-verification gate of roughly three to four minutes, while the interval between merges today was about 13 minutes. When more than one branch is waiting, the resolution of the branch at the back can be invalidated before it is verified, which is the non-convergence #1923 hit over eight rounds.

    What does not fix it. Skipping the entry, or letting the merger resolve it. Skipping loses the record the file exists for; a merger resolving an author's conflict collapses the author/verifier/merger separation that has been catching real defects here today, and it changes the head, which is the author's to own.

    Two things that keep it tolerable today, offered as workarounds rather than a fix. Merging serially with an empty queue converges in one round, which is why the current resolution should be the last one for #1960. And the entry order that survives is by merge time, newest on top, which is verifiable against history: #1949 merged five minutes after #1950 and its entry sits above. Ordering by "what is already in the file" produces a history that disagrees with itself.

    The structural options — a per-change fragment directory collated at release, or moving entries out of a single shared file — both change how every contributor writes a changelog, so they are the owner's call rather than something to slip in from the merge lane.

  2. seonghobae commented on Sep 6, 2026

    @seonghobae
    ContributorAuthor

    A second branch's history makes the repeat cost concrete, and I verified it rather than relaying it. #1911 has been open across today's merges and carries four merge commits from main on its own branch, three of them within four hours: c36533be at 15:57, 87f0262b at 18:11, 21e128bf at 20:30 and 8761c004 at 20:42 (KST). The last two land twelve minutes apart, and each one resolves a conflict whose only conflicted file is CHANGELOG.md — git show --name-only on both returns that single path.

    So the pattern is not one branch unlucky once. Within today: #1911 resolved four times, #1960 three times counting the one it still needs, #1957 once, #1959 once. Every one of those resolutions had zero code conflicts.

    That also sharpens where the cost falls. A changelog conflict is trivial to resolve and carries no risk of losing logic, so the expense is not the edit — it is that resolving changes the head, and a changed head invalidates whatever verification the branch had accumulated. On a branch under author/verifier/merger separation that means a re-push, a re-verification, and a re-gate, three to four minutes each, for a conflict that never touched code. #1911's four merges are four rounds of that.

    One consequence worth stating for whoever designs the fix: any scheme that keeps a single shared file but changes where new entries are inserted only moves the collision, because every branch still writes the newest entry adjacent to every other branch's newest entry. Per-change fragment files avoid the collision outright, at the cost of a collation step and a convention every contributor has to learn.

  3. seonghobae commented on Sep 6, 2026

    @seonghobae
    ContributorAuthor

    Confirmed on a second, independent axis, and the interval has been measured twice more.

    My original axis was elapsed time on one pull request. A second session measured repetition counts across several pull requests. The two are independent — one can be short while the other is small — and they agree.

    Axis 1 — interval between resolution and invalidation (#1923)

    Round 9 has now happened. Both measured intervals are shorter than one resolution cycle:

    round 7 pushed 20:33:09  →  invalidated 20:37:30   4m21s   (#1957)
    round 8 pushed 20:45:14  →  invalidated 20:51:11   5m57s   (#1959)
    round 9 pushed 20:57:18  →  holding at time of writing
    

    A cycle costs about three minutes of gate time alone (2932 → 2949 → 2956 tests across the three rounds), before detection, merge, resolution and push. Nine rounds on this pull request in nineteen hours; its actual delta remains one test file plus its changelog entry.

    Axis 2 — repetition per pull request

    Measured and reported by the session that owns those branches, from branch history rather than relayed:

    #1911   4 rounds   c36533be 15:57 · 87f0262b 18:11 · 21e128bf 20:30 · 8761c004 20:42 KST
                       last two 12 minutes apart
    #1960   3 rounds   two aborted at the first gate stage by the operator, one in flight
    #1957   1 round
    #1959   1 round
    

    Every one of those commits returns CHANGELOG.md and nothing else from git show --name-only. Zero code conflicts across all of them. That is the load-bearing fact: this is not integration friction between changes that genuinely overlap, it is a single file's shape generating conflicts between changes that do not touch each other at all.

    That session's own 24-hour count, taken after mine and so slightly later: 29 commits to main, 21 touching CHANGELOG.md. My earlier figure was 28 / 20; main moved between the two readings.

    One correction worth recording: the #1911 count reached me second-hand as three rounds. It is four. The session that owns the branch read its history directly and corrected it. A relayed count is not a measurement.

    What the two axes together establish

    Short intervals alone could be survivable if each PR only hit one or two. High repetition alone could be survivable if the gaps were long. Both hold at once, which is the non-convergent case: a long-lived pull request carrying a changelog entry is invalidated faster than it can be repaired, and the repair is mechanically identical every time. No round in any of the above required a judgement call.

  4. seonghobae commented on Sep 6, 2026

    @seonghobae
    ContributorAuthor

    Ran the obvious fix to see whether it holds. It resolves the collision, it has a specific corruption mode, and that mode does not apply to this file today — but nothing here would catch it when it starts to.

    CHANGELOG.md merge=union resolves the collision

    There is no .gitattributes on main today. Adding one turns the pairwise conflict into a clean merge — reproduced locally on the exact shape described here, two branches each prepending a ### … section:

    without .gitattributes   CONFLICT
    with merge=union         merged cleanly, both sections retained, ours-then-theirs
    

    The corruption mode, reproduced

    Union takes both sides of the hunk, and where a line is identical at a hunk boundary it is not duplicated. With entries that contain fenced blocks, that eats a fence:

    ### B
    
    - entry B
    
    

    beta-1
    beta-2

    A <- B's closing fence is gone; A's heading is now inside B's code block

    • entry A
      ...
      fence markers: 3 balanced: False
    
    One dropped fence swallows the following heading and entry into a code block. That is exactly the "fenced block split into two fragments" failure named above, produced *by* the fix rather than by hand-resolution.
    
    ## But it does not apply to this file, today
    
    I nearly reported that as a blocker before checking the actual file:
    
    

    CHANGELOG.md on main: 1476 lines, 24 '### ' sections, 0 fence markers
    sections containing a fenced block: 0 of 24

    
    **No entry here has ever carried a fenced block**, so union is safe for the shape this file actually has. It is a latent hazard, not a present one: the first entry that includes a fence would corrupt silently, and as noted here no test parses `CHANGELOG.md`, so nothing would fail.
    
    That makes the safe form of this fix two parts, not one: the attribute, plus a structural test that fails on unbalanced fences and on a section count that does not increase across a merge. The test is what converts "safe today" into "safe when the convention changes", and it is cheap because the invariants are simple.
    
    ## The load-bearing unknown, which I could not settle
    
    Whether **GitHub's server-side mergeability honours `merge=union`** decides whether this fixes anything visible. If it does not, pull requests keep reporting `CONFLICTING` and only local merges benefit — which would leave the non-convergence measured here untouched, since what blocks these pull requests is the API's mergeable state, not a developer's local merge.
    
    I did not test it, and I am not going to assert either way from a model. The cheap experiment: add the attribute on a throwaway branch, open two pull requests that both prepend an entry, merge one, and read `mergeable` on the other. One merge answers it.
    
    If the answer is no, the fragment approach — one file per pull request under a `changelog.d/` directory, assembled at release — is immune by construction, because two pull requests then touch different files and there is no hunk to merge.
    
    _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
    
  5. seonghobae commented on Sep 11, 2026

    @seonghobae
    ContributorAuthor

    Validation finding from PR #2091 exact head d148e04664cc9de92d56e2bc0a95c9cd94e35b92: focused changelog contract tests pass (2 passed), but .gitattributes only declares CHANGELOG.md merge=union. The PR adds independent release fragments under CHANGELOG.d/*.md; git check-attr merge -- CHANGELOG.d/20260911-changelog-union-contract.md returns merge: unspecified. The test therefore proves only the root changelog rule, not the fragment path. Finding posted on PR #2091: #2091 (comment).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumNormal-priority or P2 work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions