A practical, no-fluff cheatsheet for writing clean commit messages with Conventional Commits and versioning releases with Semantic Versioning. Examples use the Node.js / JavaScript ecosystem (npm, commitlint, semantic-release).
<type>(<scope>): <short summary>
<BLANK LINE>
<body: why the change, what was wrong>
<BLANK LINE>
<footer: issue refs, BREAKING CHANGE, Co-authored-by>
- Only the first line is required. Body and footer are optional.
- Write the summary in the imperative mood:
add,fix,remove— notadded/adds/fixing.- Mnemonic: "If applied, this commit will → add search endpoint."
- The spec mandates only two types:
featandfix. Everything else is convention. feat→ minor bump,fix→ patch bump,!orBREAKING CHANGE:→ major bump.
- Awesome Commit Conventions
- TL;DR
- Table of Contents
- Anatomy of a commit
- The first line (the only required part)
- Commit types
- Scopes
- Body & footer: when to add them
- Breaking changes
- A realistic history (20 commits)
- Examples by area
- Semantic Versioning (SemVer)
- How Conventional Commits drive SemVer
- Node.js tooling
- The first commit
- Myth-busting: what the spec actually requires
- Reality check: what big projects really do
- Other conventions
- What should you use?
- One-page cheat card
- Sources
- License
feat(search): add full-text search endpoint
│ │ │
│ │ └─⫸ Summary: imperative, ≤ ~50 chars, no trailing period
│ └─────────⫸ Scope (optional): the module/area touched
└──────────────⫸ Type: feat, fix, docs, refactor, perf, test, build, ci, chore, style, revert
Full form with body and footer:
fix(auth): reject tokens issued before password reset
Previously a leaked token stayed valid after the user reset their
password. Now we compare the token's `iat` against the last
password-change timestamp.
Closes #482
The header answers what. The body answers why. The footer carries metadata (issues, breaking changes, co-authors).
| Rule | Do | Don't |
|---|---|---|
| Mood | add search endpoint |
added / adds / adding |
| Length | ≤ ~50 characters | a paragraph in the header |
| Casing | feat: add ... (lowercase after :) |
Feat: Add ... (see lowercase reality) |
| Punctuation | no period at the end | feat: add search. |
| Scope | optional, in parentheses | inconsistent names for the same module |
The 50/72 rule (popularized by the Linux kernel team long before Angular): keep the summary ≤ 50 chars and wrap body lines at 72 chars. It's the most universal discipline in git — even outside Conventional Commits.
Only feat and fix are defined by the spec. The rest come from the Angular convention and are recommended by the commitlint preset:
| Type | When to use | SemVer impact |
|---|---|---|
feat |
A new feature | minor (x.Y.z) |
fix |
A bug fix | patch (x.y.Z) |
docs |
Documentation only | none |
refactor |
Code change that neither fixes a bug nor adds a feature | none |
perf |
Performance improvement | none (or patch) |
test |
Adding or fixing tests | none |
build |
Build system or dependencies (npm, bundler) |
none |
ci |
CI configuration and pipelines | none |
chore |
Routine maintenance, not user-facing | none |
style |
Formatting, whitespace — no logic change | none |
revert |
Reverts a previous commit | depends |
Add ! after the type/scope for a breaking change — it bumps the major version regardless of the type. See Breaking changes.
chore is the catch-all bin for "this doesn't affect users": dependency bumps, config tweaks (.gitignore, linter settings), version bumps, release publishing. These commits usually don't appear in the changelog and don't move the version — the release-notes generator ignores them.
The common mistake: chore ≠ "any small change".
- Rewrote code without changing behavior? →
refactor - Made it faster? →
perf - Fixed formatting? →
style
chore is strictly for git-adjacent housekeeping that doesn't touch feature source code.
The scope is your responsibility area in the project, not a fixed dictionary. You invent auth, payments, ui, etc. to match your repo structure.
Rules of thumb:
- Be consistent. If you start with
auth, never call the same areaauthenticationorloginlater — it breaks changelog grouping andgit log --grepfiltering. - Type and scope are independent. Any type pairs with any scope:
feat(auth),fix(auth),perf(auth),test(auth). - Some types rarely need a scope.
build,ci,chore(release), and project-widedocsare global — parentheses are often noise. - Match your folders. If game modes live in
modes/, name the scopemodes.
Casing — use kebab-case:
fix(game-modes): correct scoring in survival mode
feat(game-modes): add ranked matchmaking
The spec technically tolerates spaces (
fix(game modes): ...), but the default@commitlint/config-conventionalpreset enforcesscope-case: lower-case, so a spaced scope will be rejected by CI.kebab-caseis what humans and tools expect.camelCaseandsnake_caseexist but are rarer.
Add a body when the header alone doesn't explain why. If the fix is a one-liner and obvious, skip it. If you changed behavior or made a non-obvious decision, write it down — imagine someone doing git blame on your line two years from now.
The footer matters in three cases:
1. Linking an issue — GitHub auto-closes the issue on merge to the default branch:
Closes #482 # also: Fixes #482, Resolves #482
Refs #482 # mention without closing
2. Breaking change — see the next section.
3. Co-authorship — when pairing or applying someone else's patch:
Co-authored-by: Jane Doe <jane@example.com>
Two equivalent ways to flag a breaking change. Either triggers a major version bump.
A) ! in the header (compact):
feat(api)!: change default sort to relevance
B) BREAKING CHANGE: in the footer (explicit, lands in the changelog with a callout):
feat(api)!: remove deprecated v1 endpoints
BREAKING CHANGE: /api/v1/* removed. Migrate to /api/v2/*.
BREAKING CHANGEis the one token the spec requires in uppercase. Everything else is case-insensitive to tools.
A feature (adding search) with bug fixes, review churn, and a release:
feat(search): add basic full-text search endpoint
feat(search): add pagination support to results
test(search): add unit tests for query parser
fix(search): handle empty query string gracefully
docs(search): document search API in README
refactor(search): extract query builder into separate module
perf(search): add index on title column
fix(search): escape special characters in user input
style: format search module with prettier
test(search): add integration tests for pagination
chore(deps): bump elasticsearch client to 8.2.0
ci: run search tests on pull requests
fix(search): correct off-by-one error in page offset
docs: add search examples to contributing guide
refactor(search): simplify result mapping logic
feat(search): add filter by category
fix(search): return 400 on invalid filter values
revert: "perf(search): add index on title column"
feat(search)!: change default sort to relevance
chore(release): bump version to 1.4.0
Two details:
- A
revert:commit repeats the subject of the commit it undoes. feat(search)!— the!before the colon flags a breaking change right in the header.
Same type set, applied across different parts of a project.
Authentication (auth)
feat(auth): add login with email and password
feat(auth): add password reset via email link
fix(auth): reject tokens issued before password change
perf(auth): cache JWT public keys to avoid refetch
test(auth): add tests for refresh token rotation
feat(auth)!: drop support for legacy session cookies
Payments (payments)
feat(payments): integrate Stripe checkout
fix(payments): handle webhook retries idempotently
refactor(payments): extract invoice builder into service
docs(payments): document refund flow in wiki
fix(payments): round currency amounts to avoid drift
API (api)
feat(api): add v2 endpoints for bulk operations
fix(api): return 429 with Retry-After on rate limit
docs(api): add OpenAPI spec for orders resource
refactor(api): unify error response shape
feat(api)!: remove deprecated v1 endpoints
Database / migrations (db)
feat(db): add index on orders.created_at
fix(db): correct foreign key on order_items
perf(db): batch insert for import jobs
refactor(db): rename ambiguous status column
UI (ui)
feat(ui): add dark mode toggle
fix(ui): keep dropdown open on keyboard navigation
style(ui): switch to design-token spacing scale
perf(ui): memoize heavy list components
a11y(ui): add ARIA labels to icon buttons
a11yis a non-standard but common type for accessibility. If your linter doesn't know it, add it to the config or usefix(ui):instead.
Localization (i18n)
feat(i18n): add Ukrainian and Polish locales
fix(i18n): correct plural rules for Russian
chore(i18n): sync translation keys with source
Infrastructure & maintenance (no business logic)
build: migrate from webpack to vite
ci: cache node_modules between pipeline runs
chore(deps): bump axios to 1.7.0
chore(deps-dev): update eslint to v9
docs: rewrite installation section in README
revert: "feat(api): add v2 endpoints for bulk operations"
chore(release): v2.0.0
A version is MAJOR.MINOR.PATCH (e.g. 2.4.1). Given a change, increment:
| Part | Bump when | Example |
|---|---|---|
| MAJOR | You make incompatible API changes | 1.4.2 → 2.0.0 |
| MINOR | You add functionality in a backward-compatible way | 1.4.2 → 1.5.0 |
| PATCH | You make backward-compatible bug fixes | 1.4.2 → 1.4.3 |
Rules that trip people up:
- When you bump a higher part, lower parts reset to 0:
1.4.2→2.0.0, not2.4.2. 0.y.zis the wild west. Anything may change at any time; the public API is not stable. Many npm packages sit at0.xdeliberately.1.0.0defines your public API. After it, you're on the hook for compatibility.- Once released, a version is immutable — never re-publish
1.4.2with different content; ship1.4.3.
Pre-release and build metadata:
1.0.0-alpha 1.0.0-alpha.1 1.0.0-beta.2 1.0.0-rc.1
1.0.0+20130313144700 1.0.0-beta+exp.sha.5114f85
- Pre-release (
-alpha,-beta.2,-rc.1) has lower precedence than the normal version:1.0.0-rc.1<1.0.0. - Build metadata (
+...) is ignored when comparing precedence. - Precedence order:
1.0.0-alpha<1.0.0-alpha.1<1.0.0-beta<1.0.0-rc.1<1.0.0.
npm range operators (what your package.json dependencies actually mean):
| Range | Matches | Notes |
|---|---|---|
^1.4.2 |
>=1.4.2 <2.0.0 |
Caret — default on npm install; allows minor + patch |
~1.4.2 |
>=1.4.2 <1.5.0 |
Tilde — allows patch only |
1.4.2 |
exactly 1.4.2 |
Pinned |
* / latest |
anything | Avoid in production |
Caret (
^) is why0.xis special in npm too:^0.4.2resolves to>=0.4.2 <0.5.0(patch only), because pre-1.0.0every minor is treated as potentially breaking.
This mapping is the whole point of Conventional Commits — it lets tools compute the next version and generate the changelog automatically.
| Commit | Triggers | Example bump |
|---|---|---|
fix: ... |
PATCH | 1.4.2 → 1.4.3 |
feat: ... |
MINOR | 1.4.2 → 1.5.0 |
any type + ! or BREAKING CHANGE: |
MAJOR | 1.4.2 → 2.0.0 |
docs, chore, test, ci, ... |
usually no release | — |
So a release since the last tag containing three fix, one feat, and one BREAKING CHANGE produces a major bump — the highest-ranked change wins.
The JS ecosystem has the richest automation around this convention. A typical setup:
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msgcommitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
// Optional: teach it your custom types/scopes
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'refactor', 'perf', 'test',
'build', 'ci', 'chore', 'style', 'revert', 'a11y'],
],
},
};Now a malformed message is rejected before it's even committed:
$ git commit -m "fixed the search bug"
⧗ input: fixed the search bug
✖ type must be one of [feat, fix, ...] [type-enum]
✖ found 1 problem, 0 warnings
npm install --save-dev commitizen cz-conventional-changelog
npx commitizen init cz-conventional-changelog --save-dev --save-exact
# then commit with:
npx czsemantic-release — fully automated: analyzes commits, bumps version, writes the changelog, tags, and publishes to npm + GitHub, all in CI. Zero manual version editing.
npm install --save-dev semantic-release# .github/workflows/release.yml (excerpt)
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}changesets — popular for monorepos; contributors add an intent file per change and versions are batched at release time. Less "magic", more control.
commit-and-tag-version — the maintained fork of the now-retired standard-version. Local, no CI: one command does the whole cut — derive the next version from the commits since the last v* tag, then write CHANGELOG.md, the release commit, and the tag. The best middle ground when you release by hand but still want the version and changelog derived from history.
npm install --save-dev commit-and-tag-versionnpm run release # feat -> minor, fix -> patch, ! -> major
npx commit-and-tag-version --dry-run # preview the bump + notes, write nothing
npx commit-and-tag-version --first-release # first cut: tag current version, don't bump
git push --follow-tags # publish the release commit and its tagShape the changelog with .versionrc.json — map each type to a heading, or hide it so housekeeping never clutters the notes:
{
"header": "# Changelog\n\nFormat: Conventional Commits · Versioning: SemVer\n",
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "revert", "section": "Reverts" },
{ "type": "docs", "hidden": true },
{ "type": "chore", "hidden": true },
{ "type": "ci", "hidden": true }
]
}The generated CHANGELOG.md groups commits under those headings, links each to its hash, and takes the version from the highest-ranked change in the range:
## [1.5.0](https://github.com/you/repo/compare/v1.4.2...v1.5.0) (2026-07-04)
### Features
* **search:** add filter by category (a1b2c3d)
### Bug Fixes
* **search:** return 400 on invalid filter values (d4e5f6a)This is the auto-changelog payoff of Conventional Commits: you write the commits, the tool writes the release notes. Hidden types (
docs,chore,ci, …) are still committed — they just don't appear in the notes or move the version.
npm version bumps package.json, commits, and tags in one step:
npm version patch # 1.4.2 -> 1.4.3
npm version minor # 1.4.2 -> 1.5.0
npm version major # 1.4.2 -> 2.0.0
npm version prerelease --preid=beta # 1.5.0 -> 1.5.1-beta.0The spec says nothing about the first commit — it's not special. In practice:
Initial commitis the de-facto default — it's literally what GitHub auto-generates when you create a repo "with a README". (Amusingly, it violates the lowercase convention itself.)- In strict Conventional Commits repos, it's usually shaped to fit:
chore: init
chore(repo): initialize project
feat: initial release # only if the first commit ships working functionality
chore is the most logical type — scaffolding a repo doesn't concern users. Some teams configure commitlint to ignore the very first commit so it can be named anything.
The gap between "the specification" and "the habit" trips people up constantly.
- Only
featandfixare defined by the spec. All the other types (build,chore,ci,docs,style,refactor,perf,test) come from the Angular convention and thecommitlintpreset — they're conventional, not mandated, and by themselves don't drive SemVer. - lowercase is NOT a spec requirement. The spec treats commit parts as case-insensitive, except
BREAKING CHANGE, which must be uppercase. The all-lowercase habit (feat:, no capital, no trailing period) is Angular style, adopted wholesale — but not part of the required core. React proves the point:[Fizz] Fix non-script modulepreload trackinguses a capitalFix. - Where it comes from: the
feat/fix/docs/... vocabulary originated in AngularJS's contributing guide. The Conventional Commits spec was created by Benjamin E. Coe (of the Yargs/npm ecosystem) in 2017, to automate version bumping and changelog management.
Pulled from real repositories — the landscape splits into two camps.
Use Conventional Commits (strictly):
Angular (the originator):
feat(migrations): add migration from injectable to service
fix(docs-infra): remove white flash on example viewer tab labels
build: update pnpm to v11.8.0
Vue (with automated releases):
fix(compiler-core): avoid crash on CDATA at the document root (#14916)
chore(deps): update all non-major dependencies (#14938)
release: v3.5.38
Do NOT — each has its own system:
React (Meta) — bracket-tag area instead of type::
[Fizz] Fix non-script modulepreload tracking (#36564)
[compiler] Count loop reassignments as the enclosing scope reassigning the variable
Kubernetes — mostly free-form imperative:
Return a runtime-specific error when NUMA distances are unavailable
Remove unused scheduling queue methods
Linux — subsystem: description + mandatory Signed-off-by: (DCO):
erofs: handle 48-bit blocks_hi for compressed inodes
Signed-off-by: Jane Doe <jane@example.com>
VS Code (Microsoft) — free-form, sometimes with a colon-area:
sessions: show newly started local sessions in the list immediately (#322471)
Respect workspace trust for the remote agent host (#322459)
Takeaway: Conventional Commits is the most popular named convention — dominant in the npm/JS ecosystem and among libraries that want automatic changelogs and SemVer releases. But it is not an industry-wide mandate. Half of the world's largest projects use their own rules. What's truly universal isn't the prefixes — it's the discipline: a clear imperative header, a narrow scope, and the 50/72 rule.
Roughly from freest to strictest:
- Plain imperative ("50/72" / GitHub style) — just a clear command header. Zero overhead, nothing to learn; but no changelog/version automation.
Add search endpoint Fix off-by-one error in pagination - Conventional Commits —
type(scope): description. Automatic changelog + SemVer; needs a linter to hold together. Best for libraries and published packages. - Angular convention — CC's stricter parent (fixed scope list, mandated body). Rarely adopted whole; most people take the softer CC.
- Linux kernel style —
subsystem: description+ mandatorySigned-off-by:(DCO — legal proof of authorship). Required to contribute to the kernel. - Bracket-tag style — area in brackets instead of
type:(React's[Fizz] Fix ...). A house convention, not standardized. - gitmoji — emoji as (or with) the type:
✨ feat: add dark mode. Visual, niche, renders poorly in some terminals. - Ticket-prefixed (enterprise) —
[JIRA-1234] Add login throttling. Dominant in closed corporate repos; rare in open source (external contributors have no tracker access).
The format should match whether you need automation.
- Public library / published package? → Conventional Commits. You get a free changelog and automatic SemVer, and it's the one named convention contributors likely already know. Add
commitlint+huskyso the format doesn't drift. - App / personal project / internal tool / script? → plain imperative + 50/72 discipline. No changelog or SemVer needed, so don't over-engineer. It's honest, readable, and free.
- Contributing to someone else's project? → the choice is already made for you. Open
CONTRIBUTING.mdand match it. That overrides every personal preference — writefix(compiler-core):for Vue,[Fizz] Fix ...for React.
FORMAT
<type>(<scope>)<!>: <summary> # header (required)
# blank line
<body> # why (optional, wrap at 72)
# blank line
<footer> # Closes #, BREAKING CHANGE:, Co-authored-by:
TYPES feat fix docs refactor perf test build ci chore style revert
(spec mandates only feat + fix)
SEMVER fix -> PATCH 1.4.2 -> 1.4.3
feat -> MINOR 1.4.2 -> 1.5.0
! / BREAKING CHANGE -> MAJOR 1.4.2 -> 2.0.0
SUMMARY imperative • ≤50 chars • lowercase • no trailing period
SCOPE kebab-case • consistent • matches folder structure
FOOTER Closes #482 • BREAKING CHANGE: ... • Co-authored-by: Name <email>
GOLDEN RULE Header = WHAT. Body = WHY. When in doubt, read CONTRIBUTING.md.
- Conventional Commits 1.0.0 — https://www.conventionalcommits.org/en/v1.0.0/
- Semantic Versioning 2.0.0 — https://semver.org/
- Angular commit message guidelines (the origin of the type vocabulary) — https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md
- commitlint — https://commitlint.js.org/
- semantic-release — https://semantic-release.gitbook.io/
- commit-and-tag-version — https://github.com/absolute-version/commit-and-tag-version
- Changesets — https://github.com/changesets/changesets
Documentation is released under Creative Commons Attribution 4.0 International (CC BY 4.0). Code snippets are released under the MIT License.