ci: reject a pull request that mixes release classes - #3102
Open
mfal wants to merge 5 commits into
Open
Conversation
Routing only ever sees the PR title. Because the repo squash-merges, that title becomes the release commit, so a branch whose commits carry a higher class than its title walks straight past routing: a `feat:` commit under a `fix:` title lands a feature on `main`, and a breaking commit under either lands it on a standing line. A PR that mixes `feat:` and `fix:` has the same problem from the other side — one squash subject cannot tell both, so one of them silently disappears from the changelog. The new `commit-mixture` job fails on both: - more than one releasing class among the commits (patch / feature / breaking); - a title whose class differs from the class its commits carry, in either direction — smuggling, or a title claiming more than the branch does. Non-releasing commits mix with anything: a feature with its docs, a fix with its test. A commit whose subject is not a Conventional Commit is ignored — `wip`, `review feedback` and `fixup!` are normal on a branch, and failing on them would make the guard a nuisance instead of a gate. An unknown type counts as unparsable rather than as non-releasing. Promotion and sync sources are exempt, the same heads routing and the version contract already exempt: they carry every class by design (ADR 0004 §8). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Coverage Report for ./packages/components/
File CoverageNo changed files found. |
A `fix:` commit under a `docs:` title is not routed to the wrong line — the squash merge keeps only the title, so that fix ships in no release and appears in no changelog. The guard reported the routing wording for it, which sends the reader looking in the wrong place. Three classes of mismatch now get three messages: no release at all (the title releases nothing), the wrong line (the title releases less than the commits do), and a title claiming more than the branch carries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections, both about what a squash merge actually loses. The message for a releasing commit under a non-releasing title was wrong. It claimed the change would ship in no release — but relevance is decided by the changed PATHS, so it does ship. What is lost is the announcement: the changelog files a bug fix under `docs`, and the fix's own description is discarded with the commit. The guard now says that. And one class deeper, the same information loss went unnoticed: several releasing commits across DIFFERENT scopes. `fix(List):` plus `fix(Button):` in one PR keeps one subject, so the other fix reaches no changelog at all. That now fails, naming the scopes. Repeats within one scope stay fine — `fix(List):` twice is one logical change, and the surviving subject still names it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
🚀 Preview DeploymentPreview environments are ready:
Images:
|
The guard checked the commits against each other and against the title. It did not check either against the changed paths — so the shape that started this went through: a fix folded INTO a docs commit, where no commit subject says `fix` at all. #2902 changed two `.module.scss` files under `docs: set max text width`, #3016 and #3062 shipped the migration catalogue under `docs:`. The job now runs the release gate's own classifier and fails when the paths reach a consumer while the title's type does not. The remedy is a releasing type with the same scope — `fix(docs): add a migration entry` says both things at once: it ships, and it is documentation. That is why this needs no tag. Checked in ONE direction only. A `fix(docs):` title over paths that reach nobody costs nothing, because the paths decide whether anything publishes — and `fix(docs):` for a docs-app fix is honest (#3020, #2993). Dependabot's generated `*(deps)` titles are exempt: a group is not homogeneous in effect and its prefix is per ecosystem, so no title it can produce would be true for all of it. Over `main` since #2933 this fires on 7 of 81 conventional merges — after the classifier sharpening in the base branch, which removed #3009 and #3008 from that list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mfal
marked this pull request as ready for review
September 2, 2026 14:16
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.
Routing only ever sees the PR title. The repo squash-merges, so that title becomes the release commit — which means a branch whose commits carry a higher release class than its title walks straight past the routing guard.
What it catches
Everything below is information the squash merge destroys, because only the title survives.
feat:andfix:commits in one PRfeat:commit under afix:titlefix:tomain, so the feature lands on the stable line!or aBREAKING CHANGE:footer) under afix:/feat:titledocs/chore, and the commit's own description is discardedfix(List):plusfix(Button):keeps one subject, so the other fix reaches no changelogfeat:title over nothing butfix:commitsdocs:commit, where no commit subject saysfixat all — #2902 changed two.module.scssfiles underdocs: set max text widthRepeats within one scope are fine:
fix(List): aplusfix(List): bis one logical change, and the surviving subject still names it.What it deliberately allows
chore/ci/refactor/stylealongside either.wip,review feedback,fixup! …and merge commits are normal on a branch; failing on them would make this a nuisance rather than a gate. An unknown type (wip(List): …) counts as unparsable, not as non-releasing.fix:. Whether that releases anything is a different question — the path-based release gate answers it.next,x.x,sync/*,release/*): they carry every class by design (ADR 0004 §8).The paths, in one direction
The last row needs more than the commit subjects, so the job runs the release gate's own classifier and fails when the changed paths reach a consumer while the title's type does not. The remedy is a releasing type with the same scope —
fix(docs): add a migration entrysays both things at once: it ships, and it is documentation. No tag, no second vocabulary.Checked in one direction only. A
fix(docs):title over paths that reach nobody costs nothing — the paths decide whether anything publishes, andfix(docs):for a docs-app fix is honest (#3020, #2993). Dependabot's generated*(deps)titles are exempt: a group is not homogeneous in effect andcommit-message.prefixis per ecosystem, so no title it can produce would be true for all of it.Over
mainsince #2933 this half fires on 7 of 81 conventional merges — #2902, #3016 and #3062 (genuinely mislabelled), #3000 (a build config plus a dependency patch undertest:), #2954 (honest docs that shipscomponents/AGENTS.md, so it wantsfix(docs):), and #2929 and #3013 where the classifier stays deliberately fail-safe.Shape
Follows the existing guard pattern in this repo — a pure lib, an IO shell, and a self-test the job runs before it:
.github/scripts/commit-mixture-lib.mjs—classifyCommitandclassifyMixture, no git, no IO.github/scripts/commit-mixture-guard.mjs— readsgit log -z --format=%s%n%b, writes::error::per offending commit.github/scripts/commit-mixture-lib.test.mjs— 16 testscommit-mixturejob incommit-guard.yml, gated to PRs againstmain/next-zrather than a textual record delimiter, because a commit body can contain any text but not a NUL.Verified
All 12 lib tests plus the real shapes through the guard end to end:
feat+fixmixed fails naming both commits, afeatunder afixtitle fails as smuggling, aBREAKING CHANGE:footer under afixtitle fails, afixunder adocstitle fails naming the changelog consequence, two fixes in different scopes fail naming the scopes, two fixes in the same scope pass,feat+docs+wippasses, and a docs-only branch titledfix(docs):passes.Note
Stacked on #3098, which sharpens the path classifier this guard depends on: a package's root Markdown is judged against its
files, andDockerfile/.dockerignorejoin the package-local denylist. Without those two, this check would fire on #3009 and #3008, which reach nobody. GitHub retargets this PR tomainwhen #3098 merges.Independent of #3103 (the declaration emit) — no shared files.
🤖 Generated with Claude Code