Skip to content

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

Closed
mfal wants to merge 1 commit into
mainfrom
ci/commit-mixture-guard
Closed

mfal wants to merge 1 commit into
mainfrom
ci/commit-mixture-guard

Conversation

@mfal

@mfal mfal commented Sep 8, 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 scope — fix(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.mjs — classifyCommit 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 16 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.

Merge after #3098

This was reviewed as #3102 and merged into the #3098 branch rather than into main; #3098 has now been pulled apart, and this is its own line, rebased onto current main.

It still depends on #3098, which sharpens the path classifier this guard runs:

Independent of #3137 (the changelog preset) — no shared files.

🤖 Generated with Claude Code

Routing only ever sees the PR title. The repo squash-merges, so that title
becomes the release commit — a branch whose commits carry a higher release
class than its title walks straight past the routing guard.

The guard fails a PR that mixes `feat:` and `fix:`, hides a `feat:` or a
breaking commit under a `fix:` title, carries a releasing commit under a
non-releasing title, or spreads releasing commits over several scopes. Repeats
within one scope pass. Non-releasing commits mix with anything, an unparsable
subject (`wip`, `fixup!`) is ignored, and promotion/sync heads are exempt —
they carry every class by design (ADR 0004 §8).

The last case needs more than the subjects, so the job runs the release gate's
own path classifier and fails when the paths reach a consumer while the
title's type does not. Checked in that direction only: a `fix(docs):` title
over paths that reach nobody costs nothing.

Shape follows the existing guards — pure lib, IO shell, self-test the job runs
first. Over `main` since #2933 the path half fires on 7 of 81 conventional
merges.

Reviewed as #3102, which was merged into the #3098 branch rather than into
main; this pulls it back out onto its own line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal requested a review from a team September 8, 2026 14:07
@mfal mfal self-assigned this Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for ./packages/components/

Status Category Percentage Covered / Total
🔵 Lines 78.68% 779 / 990
🔵 Statements 78.59% 797 / 1014
🔵 Functions 79.82% 178 / 223
🔵 Branches 70.62% 404 / 572
File CoverageNo changed files found.
Generated in workflow #6714 for commit 46741dd by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

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

Images:

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

@mfal mfal closed this Sep 15, 2026
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