Skip to content

Web Development · Developer Experience

Conventional Commits and Automated Releases: The Setup That Pays for Itself

Standardized commit messages unlock automatic changelogs, version bumps, and release notes. Here's the full setup — from writing commits to shipping releases without manual steps.

Anurag Verma

Anurag Verma

5 min read

Conventional Commits and Automated Releases: The Setup That Pays for Itself

Sponsored

Share

Every engineering team has a commit history that looks like this: “fix”, “wip”, “update”, “fix again”, “final”, “final2”, “ok this time”. The team ships a release, manually writes the changelog (“we fixed some bugs and added some features”), and bumps the version number by hand.

This is fixable. Not by persuading developers to write better commit messages through culture change or documentation, but by giving them a structured format that tools can parse.

Conventional Commits is that format. It’s a specification that maps commit types to semantic version bumps and changelog sections. Combined with a release tool, it removes the manual steps from the release process entirely.

The Specification

The format is:

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

[optional body]

[optional footer(s)]

The types that matter for releases:

TypeChangelog sectionVersion bump
featFeaturesMinor (1.x.0)
fixBug FixesPatch (1.0.x)
BREAKING CHANGE (footer)Breaking ChangesMajor (x.0.0)
docs, chore, test, refactorNot includedNo bump
perfPerformance ImprovementsPatch

Real examples:

feat(auth): add Google OAuth login
fix(api): return 404 instead of 500 for missing resources

Previously the endpoint was crashing on missing IDs. Added explicit
null check before database query.
feat(api)!: require authentication on all endpoints

BREAKING CHANGE: unauthenticated requests will now receive 401.
Applications that called public endpoints without a token need to
update their requests.

The ! after the scope is shorthand for breaking change. Either the ! syntax or the BREAKING CHANGE footer will trigger a major version bump.

Setting Up Commitlint

Commitlint enforces the format at commit time using a git hook. Nobody ships a non-conforming commit accidentally.

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init
// commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Optionally enforce allowed scopes
    'scope-enum': [2, 'always', ['api', 'auth', 'ui', 'docs', 'deps']],
    // Keep subject lines short
    'subject-max-length': [2, 'always', 72],
  }
};
# .husky/commit-msg
npx --no -- commitlint --edit $1

Now every commit attempt runs the linter. An invalid commit message fails immediately with a clear error:

⧗   input: update stuff
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings

If your team uses VS Code, the Conventional Commits extension (author: vivaxy) adds a commit message builder to the source control panel. This makes it much easier for developers who aren’t used to the format.

Automated Releases With semantic-release

semantic-release reads your git history, determines the next version based on commit types, generates a changelog, creates a git tag, publishes to npm (if applicable), and creates a GitHub release. All automatically, in CI.

npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git
// .releaserc
{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    ["@semantic-release/changelog", {
      "changelogFile": "CHANGELOG.md"
    }],
    ["@semantic-release/npm", {
      "npmPublish": false
    }],
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
    }],
    "@semantic-release/github"
  ]
}

The GitHub Actions workflow:

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      issues: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # semantic-release needs the full history
      
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      
      - run: npm ci
      
      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}  # if publishing to npm
        run: npx semantic-release

On every push to main, semantic-release analyzes commits since the last release. If it finds releasable commits (feat, fix, perf, breaking changes), it creates a release. If all commits since last release are chore, docs, or test, nothing happens. No release noise.

The generated changelog looks like this:

## [2.4.0](https://github.com/org/repo/compare/v2.3.1...v2.4.0) (2026-05-31)

### Features

* **auth:** add Google OAuth login ([abc1234](https://github.com/org/repo/commit/abc1234))
* **dashboard:** add export to CSV button ([def5678](https://github.com/org/repo/commit/def5678))

### Bug Fixes

* **api:** return 404 instead of 500 for missing resources ([ghi9012](https://github.com/org/repo/commit/ghi9012))

Every line is a link to the commit. The GitHub release notes are the same content, automatically posted.

For Teams That Don’t Publish to npm

If your project is an internal app rather than a library, you still get value from this setup. The changelog becomes your deployment log. GitHub releases become the record of what went out and when. You can tag specific versions for rollback purposes and see the exact commit range in any release.

Drop the @semantic-release/npm plugin and just keep the changelog and GitHub release plugins:

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

Handling a Gradual Migration

If your repo has an existing history of non-conforming commits, the migration is easy: semantic-release only looks at commits since the last git tag. Tag your current state as v1.0.0 (or whatever makes sense), merge the commitlint hook, and everything going forward is enforced.

git tag v1.0.0
git push origin v1.0.0

For teams that resist message format requirements, the minimum viable version is just feat and fix. You can skip scope entirely. The two types give you automatic minor/patch bumps and a readable changelog.

The Payoff

The friction this removes is hard to appreciate until you’ve experienced it. In teams running this setup, “how do we write the release notes?” is no longer a question. The version bump discussion disappears. The changelog is always current. Developers get immediate feedback on commit message format when they’re at their keyboard, not when a reviewer notices it three days later.

The setup takes about two hours: install the packages, configure commitlint and husky, set up the GitHub Actions workflow, create the initial tag. After that, it runs automatically on every merge.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored