Skip to content

ci: reject a pull request that mixes release classes - #3102

Open
mfal wants to merge 5 commits into
claude/title-driven-release-gatefrom
claude/commit-mixture-guard
Open

ci: reject a pull request that mixes release classes#3102
mfal wants to merge 5 commits into
claude/title-driven-release-gatefrom
claude/commit-mixture-guard

Conversation

@mfal

@mfal mfal commented Sep 2, 2026

Copy link
Copy Markdown
Member

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.

Case What is lost
feat: and fix: commits in one PR one subject cannot tell both; whichever the title omits disappears
a feat: commit under a fix: title routing sends fix: to main, so the feature lands on the stable line
a breaking commit (! or a BREAKING CHANGE: footer) under a fix:/feat: title routing sends it to a standing line instead of the major line
a releasing commit under a non-releasing title the change still ships — relevance is decided by the changed paths — but the changelog announces it as docs/chore, and the commit's own description is discarded
releasing commits across different scopes fix(List): plus fix(Button): keeps one subject, so the other fix reaches no changelog
a feat: title over nothing but fix: commits the branch is mislabelled in the other direction — a minor for a patch
a non-releasing title over shipping paths the 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

Repeats within one scope are fine: fix(List): a plus fix(List): b is one logical change, and the surviving subject still names it.

What it deliberately allows

  • Non-releasing commits mix with anything — a feature with its docs, a fix with its test, chore/ci/refactor/style alongside either.
  • A commit whose subject is not a Conventional Commit is ignored. 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.
  • A docs-only branch may still be titled fix:. Whether that releases anything is a different question — the path-based release gate answers it.
  • Promotion and sync sources are exempt, the same heads routing and the version contract already exempt (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 scopefix(docs): add a migration entry says 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, 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 commit-message.prefix is per ecosystem, so no title it can produce would be true for all of it.

Over main since #2933 this half fires on 7 of 81 conventional merges — #2902, #3016 and #3062 (genuinely mislabelled), #3000 (a build config plus a dependency patch under test:), #2954 (honest docs that ships components/AGENTS.md, so it wants fix(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.mjsclassifyCommit and classifyMixture, no git, no IO
  • .github/scripts/commit-mixture-guard.mjs — reads git log -z --format=%s%n%b, writes ::error:: per offending commit
  • .github/scripts/commit-mixture-lib.test.mjs — 16 tests
  • a commit-mixture job in commit-guard.yml, gated to PRs against main/next

-z rather 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+fix mixed fails naming both commits, a feat under a fix title fails as smuggling, a BREAKING CHANGE: footer under a fix title fails, a fix under a docs title fails naming the changelog consequence, two fixes in different scopes fail naming the scopes, two fixes in the same scope pass, feat + docs + wip passes, and a docs-only branch titled fix(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, and Dockerfile/.dockerignore join the package-local denylist. Without those two, this check would fire on #3009 and #3008, which reach nobody. GitHub retargets this PR to main when #3098 merges.

Independent of #3103 (the declaration emit) — no shared files.

🤖 Generated with Claude Code

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>
@mfal mfal self-assigned this Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for ./packages/components/

Status Category Percentage Covered / Total
🔵 Lines 78.69% 746 / 948
🔵 Statements 78.57% 763 / 971
🔵 Functions 80.09% 165 / 206
🔵 Branches 70.33% 377 / 536
File CoverageNo changed files found.
Generated in workflow #6598 for commit fd023e6 by the Vitest Coverage Report Action

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>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-3102.docs.review.flow-components.de
storybook pr-3102.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-3102
  • storybook: ghcr.io/mittwald/flow/storybook:pr-3102

mfal and others added 2 commits September 2, 2026 16:08
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
mfal changed the base branch from main to claude/title-driven-release-gate September 2, 2026 14:11
@mfal
mfal marked this pull request as ready for review September 2, 2026 14:16
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