🔧 fix(release): stop merge commits rendering into CHANGELOG.md and the Release body (D-136) - #41
Merged
Merged
Conversation
…the Release body
`cliff.toml` sets conventional_commits = false + filter_unconventional = false
and ends its parser list with a catch-all `{ message = ".*", group = "Other" }`,
so any subject not explicitly skipped renders. Three `Merge remote-tracking
branch ...` lines sat in the Unreleased section and three more inside [0.1.0];
release.yaml renders the GitHub Release body with `git-cliff --latest` and no
`--strip header`, so the unreleased three would have shipped verbatim onto the
v0.2.0 Release page. The only defence was a working rule that depends on every
integrator remembering, and it had already failed twice.
Fix (D-136): one parser entry, first in the list, keyed on the commit SHAPE
rather than its text — `{ field = "merge_commit", pattern = "true", skip = true }`.
It holds whatever subject the integrator writes and whatever synthetic merge CI
mints for refs/pull/N/merge. The `.*` catch-all is untouched: it is load-bearing
for D-125's drift gate.
Proof in the already-wired `release-changelog-gate-test`:
- §6 asserts the release body, rendered with release.yaml's own extracted args,
carries no merge subject (this assertion was red before the parser entry);
- §7 asserts the same over real history and re-renders with the entry deleted
(mutation proven to have landed) to show the lines return and that the two
renders differ by nothing else;
- §7b builds a sandbox repo and shows git's default `Merge branch 'x'`, the
`Merge <sha> into <sha>` CI shape and a hand-written conventional merge
subject are all skipped while five ordinary commits render.
D-125's `pull_request` guard on the CI changelog step stays, but its stated
premise no longer holds — the synthetic merge subject is now skipped. The four
sites that stated it are corrected onto the surviving reason: the merge ref also
carries every commit landed on main since the branch forked, so the changelog
generated there is a union no committed CHANGELOG.md can match.
…rited GIT_DIR Section 7b builds a throwaway git repo to prove the D-136 merge-skip parser is structural. `-c` flags override CONFIG, but GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE / GIT_OBJECT_DIRECTORY come from the ENVIRONMENT and beat `-C`: reached from a git hook or a CI wrapper that exports them, the sandbox would have operated on the real repository, turning the proof into a repo-corrupting no-op. Every sandbox git invocation now runs under `env -u` for those variables, and the section refuses to continue unless the sandbox has its own .git and `rev-parse --show-toplevel` resolves back to it (physical paths — macOS $TMPDIR is /var, a symlink to /private/var). Verified green with GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE all pointed at a decoy repo, which is left untouched.
# Conflicts: # docs/decisions/decisions.md
…ng it (CL-02/CL-03)
Review finding CL-02: D-136's first version killed D-125's reason for
`if: github.event_name != 'pull_request'` and immediately asserted a successor
reason nobody had measured — the same defect one release later, inside the
commit that corrects it.
The successor reason ("the merge ref carries every commit landed on main since
the branch forked, so the render is a union the branch's file cannot match") was
measured three ways and is false:
1. PR #41's live refs/pull/41/merge (491bb2a, head 49eebb3 into base 7513d79),
rendered with the new cliff.toml -> `verify-changelog: ok`, 0 diff lines.
2. The direct counterexample: the same head merged into a main that HAD moved
(1d8aa60, containing PR #40) -> `verify-changelog: ok`, 0 diff lines.
3. Synthetic sandbox, both sides adding a commit to the same cliff group and
both regenerating -> `CONFLICT (content): Merge conflict in CHANGELOG.md`,
so the PR is unmergeable, no merge ref is minted and the gate never runs.
The mechanism overlooked: the merge ref's CHANGELOG.md is not "the branch's
committed file", it is the three-way merge RESULT, which already contains the
base's lines.
A fourth measurement fell out of this lane's own merge: merge DIRECTION decides
the render order. Merging origin/main INTO the lane (lane as first parent)
auto-merges CHANGELOG.md cleanly and then reds verify-changelog on ordering
alone, while the base-first direction GitHub actually mints matched. Recorded
too, because it is the "third outcome" the first draft of the OQ called untested.
D-136 now retires the premise without replacing it, and states plainly that the
guard is retained WITHOUT a demonstrated reason because changing CI gating needs
its own evidence. OQ-30 carries all four measurements and the ruling needed. The
four sites that asserted the dead premise say only that and point at OQ-30; the
gate test's section 3 assertion now pins the guard's PRESENCE as recorded state
rather than claiming a mechanism.
CL-03: cliff.toml's cost sentence now cites section 7b as the measurement behind
"the merged branch's own commits still render", which is the whole safety
argument for skipping on shape rather than text.
…e CL-02 corrections
…oji (REL-14, D-137) cliff.toml matched eight shortcodes and, inside the same rules, conventional types anchored at the START of the subject (^fix, ^ci, ^docs). The shortcode always comes first here, so those alternatives were dead regex: every subject whose shortcode was outside the eight fell through the `.*` catch-all into "Other". On the rendered v0.2.0 Release body that included - 🚑 fix(forge): skip malformed bot markers ... (AUD-S12, REL-06) a real fix an adopter would look for under Fixes, next to 18 ci(...), 26 test(...), 6 feat(...), 5 style(...), 4 specs(...), 3 docs(...), 2 refactor(...) and 2 chore(...). Eight parser entries now key on `^:[a-z0-9_]+: <type>[(:]` — the type the author declared — placed after the shortcode rules and before the catch-all so they only sort what the catch-all was already catching. Keyed on type and never on the emoji, because the emoji is the unreliable half: `💄 fix(provider): ...` is a fix, and `🎨` is used for both style(...) and refactor(...). Inventory derived from the full history (30 distinct shortcodes, 22 unmapped), where every unmapped-shortcode subject turned out to declare a type. One alias added: `:test:` -> Testing, a typo'd shortcode with 19 uses, all tests. Left in Other deliberately: revert(...) has no group and adding one is a structure change, not a categorisation fix; and one malformed `:test(release):` subject declares no parseable type. Those two lines are the whole remaining Other. Effect as a multiset, because the raw line diff misleads: the rendered bullet multiset is identical (509 unique bullets, 514 lines, nothing added or dropped); 69 bullets move, all out of Other -- Testing 28, Chores 20, Documentation 7, Refactoring 7, Features 5, Fixes 2; zero move between two non-Other groups. Proof in the already-wired release-changelog-gate-test: §8 asserts the hotfix renders under Fixes and, structurally so it survives new history, that nothing under Other declares a fileable type; §8a strips the `# REL-14` entries, shows the hotfix falls back to Other, and asserts the subject multiset is unchanged by re-grouping. §7a's "changes nothing else" is restated over that same multiset, because emptying a group also removes its ### heading and a raw diff reads that as unexplained churn. Also in this commit, from the review: - CL-05: OQ-30 gains M5, the re-minted live merge ref 7715bf7 (head ee5e527 into base 1d8aa60), re-fetched and re-run rather than transcribed -- verify-changelog ok, 0 diff lines -- plus the reviewer's own correction that four green measurements are one measurement short of a green light, since the merge- direction finding is a false-positive mechanism with no author fix. - CL-04: the duplicated "still untested" sentence in OQ-30 collapsed to one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
cliff.tomlsetsconventional_commits = false+filter_unconventional = falseand ends itsparser list with a catch-all
{ message = ".*", group = "Other" }, so any subject that is notone of the two explicitly skipped
chore(release):forms renders — merge commits included.Measured on
origin/main@7513d79: threeMerge remote-tracking branch 'origin/main' into lane/…lines in the Unreleased section and three more inside[0.1.0].release.yamlrenders the published Release body with
git-cliff --latestand deliberately no--strip header,so the unreleased three would have shipped verbatim onto the v0.2.0 Release page.
The only defence was the D-125 working rule — prefix every merge subject with a cliff-skipped form
— which depends on every integrator remembering, and had already failed twice.
Fix (D-136)
One
commit_parsersentry, first in the list, keyed on the commit shape rather than its text:merge_commitis true iff the commit has more than one parent, so it holds for git's defaultMerge branch 'x', for theMerge <sha> into <sha>thatactions/checkoutmints onrefs/pull/N/merge, and for a merge whose author wrote a perfectly conventional gitmoji subject.Chosen over a
^Mergemessage rule for exactly that last case. The.*catch-all is untouched— it is load-bearing for D-125's drift gate.
Accepted cost: a merge subject can no longer carry changelog content. The merged branch's own
commits still render, so nothing is lost.
Proof, both polarities
Added to the already-wired
release-changelog-gate-test(no new wiring to prove):merge subject. This assertion was red before the parser entry (that is the TDD red).
proven to have landed): the 6 merge lines return, and the clean vs mutant renders differ by
nothing else, so the rule is not quietly swallowing ordinary commits.
merge-ref shape and a hand-written
:sparkles: feat(...)merge subject are all skipped, whilefive ordinary commits render. Polarity control removes the parser and all three come back.
Regeneration diff (
task changelog-write), the complete change toCHANGELOG.md:The last three sit in the already-published
[0.1.0]section. Accepted:CHANGELOG.mdisregenerated in full from history on every run and has never been append-only, and the published
v0.1.0 GitHub Release body is a separate immutable artifact that is unaffected. Nothing pins
changelog line numbers or counts —
changelog_gate_test.shpins the## [0.1.0] - 2026-08-05heading, Unreleased ordering/non-emptiness and
pins.toolDigest;changelog_test.shpinsUnreleased+Pre-release development; D-101 is SHA-only. All survive.Premise correction (four sites)
D-125 justified
if: github.event_name != 'pull_request'on the CI changelog step by "the PR mergeref's synthetic merge subject always renders". That premise is now false. The guard stays, but
restated on its surviving reason:
refs/pull/N/mergealso carries every commit landed onmainsince the branch forked, so the changelog generated there is a union no
CHANGELOG.mdcommitted onthe branch can match — red by construction, unfixable by the author, since
maincan move againafter any rebase. Corrected in
Taskfile.yml,.github/workflows/verify.yaml,hack/release/README.mdandchangelog_gate_test.sh§3's fail message.D-125 status
Superseded in part, not replaced. Still in force: the
pull_requestguard, the Keep-a-Changelogheadings, the D-124
check:wiring closure, and — still load-bearing — that a regenerationcommit must be prefixed
:memo:/:wrench: chore(release):(a regeneration commit is an ordinarysingle-parent commit) and that a lane regenerates after its last content commit and after any
git merge origin/main. Only the habit of prefixing merge subjects is downgraded tobelt-and-braces.
Gates
task checkgreen — all 14 stages, isolatedGOCACHE/GOLANGCI_LINT_CACHE:fmt · vet · lint · test · coverage · build · dogfood-comparison · compare-exitgate-test · changelog-verify · release-changelog-gate-test · release-verify-tag-gate-test · docs-gates · lint-depguard-test · lint-workflow-pins-test→workflow_pins_test.sh: PASSIntegrator note
If
mainmoves before this merges, regenerate onmainafter the merge — the merge brings thenewly-landed commits into range and the branch's
CHANGELOG.mdwill not contain them. That is theusual D-125 one-commit lag, unchanged by this lane. The merge commit itself no longer contributes
anything, so a merge onto an unmoved
mainleaveschangelog-verifygreen with no follow-up.Follow-up commits on this branch
:white_check_mark: test(release)hardens §7b's sandbox:-coverrides config, butGIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE/GIT_OBJECT_DIRECTORYcome from the environment and beat-C, so under a git hook or a CI wrapper that exports them the sandbox would have operated on thereal repository. Every sandbox git call now runs under
env -u, and the section refuses tocontinue unless
rev-parse --show-toplevelresolves back to the sandbox. Verified green with allthree pointed at a decoy repo, which is left untouched.
task checkre-run green at the tip, same14 stages.
Review round 2 — CL-02 (P1) and CL-03 addressed
CL-02 accepted in full. D-136's first version killed D-125's reason for the
pull_requestguard and immediately asserted a successor reason nobody had measured — the same defect one
release later, inside the commit that corrects it. I reproduced all three measurements myself
before recording them; all three hold.
refs/pull/41/merge(491bb2a= head49eebb3into base7513d79) rendered with the newcliff.tomlverify-changelog: ok, 0 diff linesmain(1d8aa60, containing PR #40)verify-changelog: ok, 0 diff lines — the direct counterexampleCONFLICT (content): Merge conflict in CHANGELOG.md→ unmergeable → no merge ref → gate never runsMechanism the dead premise overlooked: the merge ref's
CHANGELOG.mdis not "the branch'scommitted file", it is the three-way merge result, which already contains the base's lines.
A fourth measurement fell out of this lane's own
git merge origin/main, and it is recordedtoo: merge direction decides the render order. Merging
maininto the lane (lane asfirst parent) auto-merges
CHANGELOG.mdcleanly and then redsverify-changelogon orderingalone — one line moved, PR #40's lines repositioned, zero content lost (verified by multiset
comparison: the only content delta was the new commit's own line). The base-first direction
GitHub actually mints matched. That is exactly the "third outcome" my first draft of the OQ
called untested, so it is now measured rather than speculative.
Remedy applied, boundary respected — the PR-scoped gate is NOT enabled. D-136 now retires
the premise without replacing it and says plainly that the guard is retained without a
demonstrated reason, because changing CI gating needs its own evidence and is not this lane's
call. The four sites say only that and point at the OQ.
changelog_gate_test.sh§3 now pins theguard's presence as recorded state rather than claiming a mechanism, with a fail message that
tells the next person to close the OQ rather than delete the line as dead-premise cleanup.
OQ-30 (next free:
origin/mainholds OQ-29 from the merged PR #40; open PRs #35/#38 claimOQ-27/28 only) carries all four measurements, the mechanism, the residuals that remain untested
(
pull_request_target, fork PRs, force-push re-minting), the counter-evidence that the only redreproduced on a merge ref was a true positive, and the three-way ruling — deliberately not
taken.
CL-03 taken.
cliff.toml's cost sentence now cites §7b as the measurement behind "the mergedbranch's own commits still render", since that is the entire safety argument for skipping on shape
rather than text.
Base moved.
origin/mainmerged into the lane (593b5ec),decisions.mdconflict resolvedkeeping D-134, D-135 and D-136. Against the true merge base the changelog change is still
exactly the 6 merge lines and nothing else (§7a asserts this at every run).
task checkre-run green at the merged tip — all 14 stages:fmt · vet · lint · test · coverage · build · dogfood-comparison · compare-exitgate-test · changelog-verify · release-changelog-gate-test · release-verify-tag-gate-test · docs-gates · lint-depguard-test · lint-workflow-pins-test→workflow_pins_test.sh: PASSReview round 3 — CL-04, CL-05 and REL-14
CL-05 — M5 added, re-run not transcribed
The ref had not been re-minted again since the reviewer measured it. Re-fetched live and put
through the real gate script:
OQ-30 now reads "measured four ways", with M5 flagged as the strongest — the exact artifact a
pull_request-scoped gate would evaluate, base moved past the fork point and after the lanemerged
mainin — and491bb2ademoted to its stale predecessor. The reviewer's own correction isfolded into the row: four greens are one measurement short of a green light, because the
merge-direction finding is a false-positive mechanism with no author fix, so on today's evidence
the gate would not be enabled.
CL-04 — dedupe
The duplicated "still untested" sentence is collapsed to one.
REL-14 — a real hotfix was published under "Other" (new, D-137)
Shortcode inventory, derived from
git log --format=%sover the full history (not from asample): 30 distinct shortcodes, 8 mapped, 22 unmapped. In
v0.1.0..HEADthe unmapped ones are:construction_worker:×8,:test:,:closed_lock_with_key:,:ambulance:.The root cause is sharper than "missing shortcodes": the eight rules also listed
^fix/^ci/^docsalternatives, and since the shortcode always comes first here those alternatives weredead regex from the day the file was written.
Mapping — keyed on the conventional type, never the emoji. Every unmapped-shortcode subject in
this repo declares a type, which makes type-keying total rather than lucky. The emoji is the
unreliable half:
:lipstick: fix(provider): …is a fix, not a UI change, and:art:is used forboth
style(…)andrefactor(…)— an emoji-dictionary mapping would have mis-filed both. Eightentries
^:[a-z0-9_]+: <type>[(:], placed after the shortcode rules so they only sort what thecatch-all was already catching, plus one alias
:test:→ Testing (typo'd shortcode, no suchgitmoji, all 19 uses are tests including one whose type field is the equally typo'd
render(…)).Catch-all untouched.
Judgment calls —
specs(…)→ Documentation (written artifacts, same class asdocs);style(…)→ Refactoring (internal hygiene, no behaviour change);revert(…)left in Other onpurpose (no group fits, and adding a Reverts group is a structure change, not a categorisation
fix); one malformed
:test(release):subject declares no parseable type and stays. Those two linesare the entire remaining Other.
Before/after as a multiset (raw line diff misleads): bullet multiset identical — 509 unique
bullets, 514 rendered lines, nothing added or dropped. 69 bullets move, all one-directional out of
Other: Testing 28 · Chores 20 · Documentation 7 · Refactoring 7 · Features 5 · Fixes 2. Zero
move between two non-Other groups. It re-files lines inside published
[0.1.0]/[0.0.0];acceptable on the ground already established and accepted — derived artifact, regenerated in full,
and the published v0.1.0 Release body is separate and immutable.
Both polarities, in the already-wired gate (no new wiring): §8 asserts the
:ambulance:hotfixrenders under Fixes, and — structurally, so it survives new history — that nothing under
Other declares a fileable type. §8a strips the
# REL-14-tagged entries (mutation proven landed byline count), shows the hotfix falls back to Other, and asserts the subject multiset is unchanged
by re-grouping. The TDD red was real:
FAIL: the ':ambulance: fix(forge): ...' hotfix renders under 'Other', not 'Fixes'.Precision note applied
§7a's "changes nothing else" is restated over the bullet multiset — emptying a group also
removes its
###heading, which a raw diff reads as unexplained churn. Againstorigin/maintheCHANGELOG.mdchange is +101 / −105 raw lines, 631 → 627, and as a multiset exactly:only-in-main = the 6 merge lines + one now-empty
### Otherheading and its blank line;only-in-HEAD = this lane's 4 commit subjects. Nothing else.
Decision rows verified by set difference and content, not count: nothing missing, extras exactly
D-136/D-137, no shared row's content differs, D-134/D-135 byte-identical to1d8aa60, noduplicate ids.
task checkgreen at the tip — all 14 stages:fmt · vet · lint · test · coverage · build · dogfood-comparison · compare-exitgate-test · changelog-verify · release-changelog-gate-test · release-verify-tag-gate-test · docs-gates · lint-depguard-test · lint-workflow-pins-test→workflow_pins_test.sh: PASS