Artifact Management & Release Engineering

Changelogs & Conventional Commits

18 min Lesson 6 of 28

Changelogs & Conventional Commits

A changelog is a contract with your users. It tells them what changed, what broke, and what they need to do before upgrading. But at scale — dozens of engineers, hundreds of commits a week — no human can keep a changelog accurate manually. The industry answer is Conventional Commits: a lightweight specification that gives your commit messages a machine-parseable structure, enabling tools to generate changelogs, bump versions, and trigger releases with zero manual intervention. This is how Google, Microsoft, and most modern open-source projects manage their release communication.

The Conventional Commits Specification

The spec (conventionalcommits.org) defines a simple message format:

<type>[optional scope]: <description> [optional body] [optional footer(s)]

Type is the key field. The spec mandates two types and reserves others by convention:

  • fix — a bug fix. Maps to a PATCH version bump in SemVer.
  • feat — a new feature. Maps to a MINOR bump.
  • feat! or a footer BREAKING CHANGE: ... — breaking API change. Maps to a MAJOR bump.
  • Community types (from the Angular convention, widely adopted): build, chore, ci, docs, perf, refactor, revert, style, test. These do not trigger a version bump on their own.

The optional scope narrows the context: feat(auth): add OIDC provider support. Scopes appear in the generated changelog grouped under their type, and can be used to scope release rules per-component in monorepos.

Why the format matters: The machine does not understand "fixed the login thing." It needs fix(auth): resolve token expiry race condition. The type field is the signal; the description is human prose. Both matter — one for automation, one for the engineer reading the changelog at 2 AM during an incident.

Real Commit Examples

# PATCH bump — bug fix fix(payments): handle Stripe webhook signature validation on replay Previously, replayed webhooks could fail signature checks if the server clock drifted more than 5 seconds. Now using Stripe's recommended tolerance window. # MINOR bump — new feature feat(api): add pagination cursor support to /v2/events endpoint Closes #4821. Backward-compatible; existing offset-based callers are unaffected. # MAJOR bump — breaking change (two equivalent forms) feat(sdk)!: drop support for Node.js 16 BREAKING CHANGE: minimum required Node.js version is now 18. Node 16 reached EOL in September 2023. # CI-only change — no version bump ci: migrate GitHub Actions runners to ubuntu-24.04 # Dependency update — no version bump chore(deps): bump axios from 1.6.0 to 1.7.2

Tooling: commitlint + Husky

The spec is worthless if engineers ignore it. commitlint enforces the format at commit time via a Git hook, giving immediate feedback before the message reaches CI. husky wires the hook automatically after npm install.

# Install tooling npm install --save-dev \ @commitlint/cli \ @commitlint/config-conventional \ husky # commitlint.config.js module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [ 'build','chore','ci','docs','feat','fix', 'perf','refactor','revert','style','test' ]], 'subject-case': [2, 'always', 'lower-case'], 'header-max-length': [2, 'always', 100], } }; # Wire the commit-msg hook (husky v9) npx husky init echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg chmod +x .husky/commit-msg # Test it — this will be REJECTED git commit -m "fixed stuff" # ✖ subject may not be empty [subject-empty] # ✖ type may not be empty [type-empty] # This passes git commit -m "fix(auth): resolve session cookie SameSite mismatch"
Enforce in CI too. Husky only runs on the committer's machine — a git commit --no-verify or a direct push bypasses it. Add commitlint as a CI job that runs on pull requests: npx commitlint --from origin/main --to HEAD. The hook is developer UX; CI is the actual gate.

Automated Changelog Generation: standard-version & semantic-release

With structured commits in place, two tools dominate the changelog-generation space:

  • standard-version — local CLI that bumps package.json, generates CHANGELOG.md, and creates a git tag. Good for teams that want a human to trigger releases. Deprecated upstream but still widely used.
  • semantic-release — fully automated, CI-driven. No human triggers it; the CI pipeline determines the next version, publishes the artifact, creates a GitHub release, and commits the changelog. This is the big-tech default for libraries and services with frequent releases.
Conventional Commits to Release Pipeline feat(api): ... MINOR bump fix(db): ... PATCH bump feat!: ... MAJOR bump chore: ... no bump Commit Analyzer semantic-release Next Version 1.4.0 → 2.0.0 determined CHANGELOG Git Tag npm publish GH Release
Commit types feed the analyzer; the determined version drives all release artifacts automatically.

semantic-release in CI (GitHub Actions)

# .github/workflows/release.yml name: Release on: push: branches: [main] jobs: release: runs-on: ubuntu-24.04 permissions: contents: write # tag + commit changelog issues: write # comment on issues when fixed pull-requests: write # comment on merged PRs id-token: write # OIDC for npm provenance steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # full history required — semantic-release reads ALL commits - uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://registry.npmjs.org - run: npm ci - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release
fetch-depth: 0 is mandatory. A shallow clone (fetch-depth: 1, the GitHub Actions default) gives semantic-release only the latest commit. It cannot determine the previous tag, so it either crashes or misidentifies the bump type. This is the single most common reason semantic-release produces wrong versions in CI — always fetch the full history.

Changelog Format and Keep a Changelog

The generated CHANGELOG.md follows the Keep a Changelog convention (keepachangelog.com): sections per release, each subdivided by ### Added, ### Fixed, ### Changed, ### Removed, ### Breaking Changes. Human readers scan the changelog top-down; automated tools parse it bottom-up. Both audiences are equally important.

For non-Node ecosystems, equivalent tools exist:

  • Python: python-semantic-release (reads pyproject.toml, publishes to PyPI)
  • Go: goreleaser with conventional commit support
  • Rust: cargo-release + git-cliff for changelog generation
  • Generic / monorepo: release-please (Google), changesets (npm)
Monorepo strategy: In a monorepo with independent package versioning, use changesets (for JS) or release-please (language-agnostic). Both open a "release PR" that accumulates changelog entries; merging the PR triggers publish. This gives humans a review point before artifacts ship — a middle ground between fully manual and fully automated.

Production Failure Modes

The top failure patterns seen at scale:

  • Squash-merging bypass: GitHub's "Squash and merge" button generates a default commit message like feat: my PR title (#123), which is often correct — but only if the PR title was written in conventional format. Enforce PR title linting with amannn/action-semantic-pull-request in CI so the squash message is always valid.
  • Breaking change buried in body: Engineers write feat: update SDK with BREAKING CHANGE: ... in the body but the CI commitlint rule only validates the header. The analyzer still picks it up, but reviewers miss the signal. Require feat! syntax in the type field for visibility.
  • Bot commits triggering loops: When semantic-release commits the updated CHANGELOG.md back to main, that commit re-triggers the push workflow. Guard with if: "!contains(github.event.head_commit.message, 'chore(release)')" or use the skip_ci token in the commit message.
  • Missing NPM_TOKEN: The release job succeeds, tag is created, GitHub release is published — but the npm publish step silently fails if the secret is not set. Always check the full job output, not just the green tick.