Skip to content

🔧 fix(release): stop merge commits rendering into CHANGELOG.md and the Release body (D-136) - #41

Merged
konih merged 9 commits into
mainfrom
lane/cliff-skip-merge-commits
Aug 9, 2026
Merged

🔧 fix(release): stop merge commits rendering into CHANGELOG.md and the Release body (D-136)#41
konih merged 9 commits into
mainfrom
lane/cliff-skip-merge-commits

Conversation

@konih

@konih konih commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

cliff.toml sets conventional_commits = false + filter_unconventional = false and ends its
parser list with a catch-all { message = ".*", group = "Other" }, so any subject that is not
one of the two explicitly skipped chore(release): forms renders — merge commits included.

Measured on origin/main @ 7513d79: three Merge remote-tracking branch 'origin/main' into lane/… lines in the Unreleased section and three more inside [0.1.0]. release.yaml
renders the published Release body with git-cliff --latest and 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_parsers entry, first in the list, keyed on the commit shape rather than its text:

{ field = "merge_commit", pattern = "true", skip = true },

merge_commit is true iff the commit has more than one parent, so it holds for git's default
Merge branch 'x', for the Merge <sha> into <sha> that actions/checkout mints on
refs/pull/N/merge, and for a merge whose author wrote a perfectly conventional gitmoji subject.
Chosen over a ^Merge message 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):

  • §6 — the Release body, rendered with release.yaml's own extracted arguments, carries no
    merge subject. This assertion was red before the parser entry (that is the TDD red).
  • §7 / §7a — same over real history, then re-rendered with the parser entry deleted (mutation
    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.
  • §7b — a sandbox repo proves the rule is structural: git's default merge subject, the CI
    merge-ref shape and a hand-written :sparkles: feat(...) merge subject are all skipped, while
    five ordinary commits render. Polarity control removes the parser and all three come back.

Regeneration diff (task changelog-write), the complete change to CHANGELOG.md:

+- :wrench: fix(release): skip merge commits in cliff.toml so they never reach the Release body
-- Merge remote-tracking branch 'origin/main' into lane/aud-s10-s12-forge-hardening
-- Merge remote-tracking branch 'origin/main' into lane/fix-provider-symlink-containment
-- Merge remote-tracking branch 'origin/main' into lane/fix-provider-symlink-containment
-- Merge remote-tracking branch 'origin/main' into lane-e9-s11
-- Merge remote-tracking branch 'origin/main' into lane-e9-s10
-- Merge remote-tracking branch 'origin/main' into lane-e9-s11

The last three sit in the already-published [0.1.0] section. Accepted: CHANGELOG.md is
regenerated 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.sh pins the ## [0.1.0] - 2026-08-05
heading, Unreleased ordering/non-emptiness and pins.toolDigest; changelog_test.sh pins
Unreleased + 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 merge
ref's synthetic merge subject always renders". That premise is now false. The guard stays, but
restated on its surviving reason: refs/pull/N/merge also carries every commit landed on main
since the branch forked, so the changelog generated there is a union no CHANGELOG.md committed on
the branch can match — red by construction, unfixable by the author, since main can move again
after any rebase. Corrected in Taskfile.yml, .github/workflows/verify.yaml,
hack/release/README.md and changelog_gate_test.sh §3's fail message.

D-125 status

Superseded in part, not replaced. Still in force: the pull_request guard, the Keep-a-Changelog
headings, the D-124 check: wiring closure, and — still load-bearing — that a regeneration
commit must be prefixed :memo:/:wrench: chore(release): (a regeneration commit is an ordinary
single-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 to
belt-and-braces.

Gates

task check green — all 14 stages, isolated GOCACHE/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-testworkflow_pins_test.sh: PASS

Integrator note

If main moves before this merges, regenerate on main after the merge — the merge brings the
newly-landed commits into range and the branch's CHANGELOG.md will not contain them. That is the
usual D-125 one-commit lag, unchanged by this lane. The merge commit itself no longer contributes
anything, so a merge onto an unmoved main leaves changelog-verify green with no follow-up.

Follow-up commits on this branch

:white_check_mark: test(release) hardens §7b's sandbox: -c overrides config, but GIT_DIR /
GIT_WORK_TREE / GIT_INDEX_FILE / GIT_OBJECT_DIRECTORY come from the environment and beat
-C, so under a git hook or a CI wrapper that exports them the sandbox would have operated on the
real repository. Every sandbox git call now runs under env -u, and the section refuses to
continue unless rev-parse --show-toplevel resolves back to the sandbox. Verified green with all
three pointed at a decoy repo, which is left untouched. task check re-run green at the tip, same
14 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_request
guard 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.

# measurement result
1 live refs/pull/41/merge (491bb2a = head 49eebb3 into base 7513d79) rendered with the new cliff.toml verify-changelog: ok, 0 diff lines
2 same head merged into the moved main (1d8aa60, containing PR #40) verify-changelog: ok, 0 diff lines — the direct counterexample
3 sandbox: base and lane each add a commit to the same cliff group, both regenerate CONFLICT (content): Merge conflict in CHANGELOG.md → unmergeable → no merge ref → gate never runs

Mechanism the dead premise 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 git merge origin/main, and it is recorded
too:
merge direction decides the render order. Merging main into the lane (lane as
first parent) auto-merges CHANGELOG.md cleanly and then reds verify-changelog on ordering
alone — 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 the
guard'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/main holds OQ-29 from the merged PR #40; open PRs #35/#38 claim
OQ-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 red
reproduced 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 merged
branch's own commits still render", since that is the entire safety argument for skipping on shape
rather than text.

Base moved. origin/main merged into the lane (593b5ec), decisions.md conflict resolved
keeping 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 check re-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-testworkflow_pins_test.sh: PASS


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

merge ref 7715bf7  (head ee5e527 into base 1d8aa60)
verify-changelog: ok
M5 diff lines vs committed CHANGELOG.md: 0
merge subjects rendered: 0

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 lane
merged main in — and 491bb2a demoted to its stale predecessor. The reviewer's own correction is
folded 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=%s over the full history (not from a
sample): 30 distinct shortcodes, 8 mapped, 22 unmapped. In v0.1.0..HEAD the 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 /
^docs alternatives, and since the shortcode always comes first here those alternatives were
dead 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 for
both style(…) and refactor(…) — an emoji-dictionary mapping would have mis-filed both. Eight
entries ^:[a-z0-9_]+: <type>[(:], placed after the shortcode rules so they only sort what the
catch-all was already catching, plus one alias :test: → Testing (typo'd shortcode, no such
gitmoji, all 19 uses are tests including one whose type field is the equally typo'd render(…)).
Catch-all untouched.

Judgment callsspecs(…) → Documentation (written artifacts, same class as docs);
style(…) → Refactoring (internal hygiene, no behaviour change); revert(…) left in Other on
purpose
(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 lines
are 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: hotfix
renders 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 by
line 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. Against origin/main the
CHANGELOG.md change is +101 / −105 raw lines, 631 → 627, and as a multiset exactly:
only-in-main = the 6 merge lines + one now-empty ### Other heading 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 to 1d8aa60, no
duplicate ids.

task check green 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-testworkflow_pins_test.sh: PASS

konih added 9 commits August 9, 2026 03:50
…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.
…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.
…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.
@konih
konih merged commit 5d01992 into main Aug 9, 2026
8 checks passed
@konih
konih deleted the lane/cliff-skip-merge-commits branch August 9, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant