Conventional Commits Guide: Rules, Tools and CI/CD Enforcement

News, Open Source, Tips & Tricks, Tutorials, and What Is

Conventional Commits Guide: Rules, Tools and CI/CD Enforcement

If you have ever dug through a Git log trying to understand what changed and why, you know the pain of inconsistent commit messages. fixed stuff, updates, WIP -- these tell you nothing. Conventional Commits solves this by giving your team a shared vocabulary for describing changes, and it unlocks powerful automation: changelogs that write themselves, versions that bump automatically, and CI/CD pipelines that know exactly what type of change just landed.

We adopted Conventional Commits at DeployHQ several years ago, and the difference was immediate. Code reviews got faster because the commit type told reviewers what to expect. Releases became predictable because tooling could determine version bumps from commit history alone. This guide covers everything you need to adopt the convention, enforce it, and wire it into your deployment pipeline.

What Are Conventional Commits?

Conventional Commits is a specification that adds a lightweight structure to commit messages. It was inspired by the Angular commit convention but has evolved into its own open standard. The core idea is simple: prefix every commit message with a type that describes the nature of the change, optionally scoped to a specific module, followed by a concise description.

The format ties directly into Semantic Versioning -- a feat commit triggers a minor version bump, a fix triggers a patch, and a breaking change triggers a major bump. This connection between commit messages and version numbers is what makes the whole ecosystem of changelog generators and release automation possible.

The Commit Message Format

Every conventional commit follows this structure:

<type>(<optional scope>): <description>

<optional body>

<optional footer(s)>

Types

Here are the standard types and when to use each:

Type Purpose Version impact
feat A new feature visible to users Minor bump
fix A bug fix Patch bump
docs Documentation only No release
style Formatting, semicolons, whitespace -- no logic change No release
refactor Code restructuring without changing behaviour No release
perf Performance improvement Patch bump
test Adding or updating tests No release
build Build system or external dependency changes No release
ci CI/CD configuration changes No release
chore Maintenance tasks (dependency updates, tooling) No release

Scope

The scope is optional but valuable when your codebase has clear modules. It goes in parentheses after the type:

feat(auth): add OAuth2 PKCE flow for mobile clients
fix(api): return 404 instead of 500 for missing resources

Description

Write the description in imperative mood (add feature not added feature), keep it under 72 characters, and make it specific. Fix bug is useless. Fix race condition in deployment queue processing tells you exactly what happened.

The body is where you explain why the change was made, not what changed (the diff shows that). The footer is reserved for metadata like issue references and breaking change notices.

Real-World Examples

Here are commit messages from real projects -- not the sanitised textbook examples:

feat(deploy): add rollback confirmation prompt before reverting production

fix(webhooks): prevent duplicate delivery when GitHub sends retry headers

perf(assets): switch to Brotli compression for static files, ~40% smaller

refactor(queue): replace polling with Redis pub/sub for job notifications

docs(api): add rate limiting section to REST API reference

ci(github): add Node 20 to test matrix, drop Node 16

chore(deps): bump express from 4.18.2 to 4.21.0

test(auth): add integration tests for SSO SAML flow edge cases

Notice how each one tells you the type, the area affected, and the specific change. You can scan a log of these and understand a release in seconds.

Breaking Changes

Breaking changes deserve special attention because they signal to consumers that they need to update their code. There are two ways to mark them:

The ! Notation

Add ! after the type (and scope, if present):

feat!: remove v1 API endpoints

feat(auth)!: require API key header on all endpoints

For more detailed breaking change descriptions, use the footer:

refactor(config): migrate from JSON to YAML configuration

BREAKING CHANGE: Configuration files must be converted from .json
to .yaml format. Run `npx migrate-config` to convert automatically.

Both approaches trigger a major version bump. Use the ! notation for simple breaks, and the footer when you need to explain migration steps. You can also combine them for maximum visibility.

Setting Up Enforcement with commitlint and Husky

Conventions only work if they are enforced. Relying on code review to catch bad commit messages is slow and inconsistent. Instead, use commitlint to validate messages automatically and Husky to run the check on every commit.

Install the dependencies

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky

Configure commitlint

Create commitlint.config.js at your project root:

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style', 'refactor',
      'perf', 'test', 'build', 'ci', 'chore', 'revert'
    ]],
    'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
    'header-max-length': [2, 'always', 100],
    'body-max-line-length': [2, 'always', 200],
  },
};

Set up Husky

npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

Now try a non-conforming commit:

git commit -m "fixed things"
# => Error: subject may not be empty
# => type may not be empty

And a valid one:

git commit -m "fix(deploy): resolve timeout on large file transfers"
# => Commit passes validation

This catches mistakes before they reach your remote repository. If you are working with a team that uses pull request best practices, commitlint ensures every commit in the PR already conforms to the convention.

Automating Changelogs with semantic-release

Once every commit follows the convention, you can generate changelogs and version bumps automatically. semantic-release analyses your commit history since the last release and determines the next version number:

  • fix commits produce a patch release (1.0.0 -> 1.0.1)
  • feat commits produce a minor release (1.0.0 -> 1.1.0)
  • BREAKING CHANGE commits produce a major release (1.0.0 -> 2.0.0)
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git

Add to your package.json:

{
  "release": {
    "branches": ["main"],
    "plugins": [
      "@semantic-release/commit-analyzer",
      "@semantic-release/release-notes-generator",
      "@semantic-release/changelog",
      "@semantic-release/npm",
      ["@semantic-release/git", {
        "assets": ["CHANGELOG.md", "package.json"],
        "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
      }]
    ]
  }
}

This removes the human from the versioning decision entirely. No more debates about whether a change is a patch or a minor -- the commit type decides.

CI/CD Enforcement

Local hooks can be bypassed with --no-verify. For team-wide enforcement, add a CI check. Here is a GitHub Actions workflow:

# .github/workflows/commitlint.yml
name: Lint Commits
on: [pull_request]

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install --save-dev @commitlint/cli @commitlint/config-conventional
      - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }}

Combine this with branch protection rules that require the check to pass before merging, and no non-conforming commit can reach your main branch.

Integrating with Deployment Pipelines

Conventional commits become even more powerful when they inform your deployment process. Instead of deploying on every push, you can deploy selectively based on what changed.

flowchart LR
    A[git commit] --> B[commitlint validates]
    B --> C[CI checks pass]
    C --> D[changelog generated]
    D --> E[version bumped]
    E --> F[deploy triggered]

For example, a docs commit does not need a production deployment. A feat or fix commit does. You can configure your build pipeline to parse the latest commit message and decide whether to trigger a full deployment or skip it.

If you use DeployHQ's automated Git deployments, you can set up build commands that inspect commit messages:

# build-check.sh -- skip deployment for non-release commits
COMMIT_MSG=$(git log -1 --pretty=%B)
if echo "$COMMIT_MSG" | grep -qE "^(docs|style|test|ci|chore)"; then
  echo "Non-release commit type detected, skipping deployment"
  exit 1  # Non-zero exit skips the deployment in DeployHQ
fi
echo "Release-relevant commit, proceeding with deployment"

This works especially well when you deploy from GitHub -- every push triggers a webhook, but your build script decides whether the deployment actually proceeds.

Monorepo and Scope Conventions

In monorepo setups, scopes become essential for routing changes to the right package. Establish a scope convention that maps directly to your directory structure:

feat(web): add dark mode toggle to settings page
fix(api): handle null response from payment gateway
perf(shared): memoize expensive config parsing in shared utils
ci(infra): add Terraform plan check to PR workflow

Document your scopes in a CONTRIBUTING.md so new team members know the vocabulary. Tools like commitizen provide an interactive prompt that lists available scopes, eliminating guesswork.

Common Mistakes and How to Avoid Them

Using the wrong type. A refactor that also fixes a bug is a fix, not a refactor. The type describes the outcome for users, not the technique you used.

Scope inconsistency. If half your team writes feat(authentication) and the other half writes feat(auth), your changelogs will split related changes into separate sections. Pick one and enforce it in your commitlint config.

Overly vague descriptions. Fix issue or Update code are worse than no convention at all because they create a false sense of structure. Be specific: Fix race condition in webhook retry logic.

Forgetting the BREAKING CHANGE footer. If your change requires consumers to update their code, you must flag it. Automated tooling cannot detect breaking changes from code alone -- it relies on you to declare them.

Giant commits with mixed types. A commit that adds a feature, fixes two bugs, and updates documentation should be split into separate commits. Each commit message should describe exactly one logical change.

Conventional Commits vs Other Conventions

Convention Structure Automation Adoption
Conventional Commits type(scope): description Full (changelogs, versioning, CI) Widely adopted, tooling-rich
Angular Convention type(scope): description Similar (Conventional Commits is derived from this) Angular ecosystem
Gitmoji :emoji: description Limited (emoji-based categorisation) Popular in open source
Freeform Whatever you want None Default when no convention is set

Conventional Commits is the right choice for most teams because of its tooling ecosystem. Gitmoji is fun but harder to parse programmatically. The Angular convention is effectively the same thing -- Conventional Commits standardised it into a broader specification. Freeform works for solo projects but falls apart the moment a second person touches the code.

Getting Your Team to Actually Follow It

Tooling enforcement is only half the battle. Here are practical approaches that helped us:

  1. Add commitizen for interactive commits. Running npx cz walks developers through the format with prompts. This is especially helpful for onboarding.

  2. Make it part of your Git branching strategy. If you use feature branches, enforce the convention on squash merge messages so the main branch always has clean history.

  3. Show the value. The first time your team sees an auto-generated changelog that perfectly summarises a sprint, the convention sells itself.

  4. When using AI coding tools like Claude Code with Git, configure them to follow Conventional Commits. Most AI assistants can be instructed to format their commit messages according to the spec.

Conclusion

Conventional Commits is one of those rare practices where the upfront cost is minimal and the compounding returns are significant. You get readable history, automated changelogs, predictable versioning, and smarter deployment pipelines -- all from a simple prefix on your commit messages.

Start with commitlint and Husky. Once your team sees how clean the commit log becomes, add semantic-release for automated versioning. Then wire it into your deployment pipeline so only release-worthy commits trigger builds.

Ready to automate your deployment workflow end-to-end? Start your free DeployHQ trial and connect your repository in minutes.


If you have questions or need help setting up Conventional Commits with DeployHQ, reach out to us at support@deployhq.com or on Twitter/X.