We are still cooking the magic in the way!
Changelogs & Conventional Commits
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 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 footerBREAKING 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.
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
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.
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, generatesCHANGELOG.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.
semantic-release in CI (GitHub Actions)
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:
goreleaserwith conventional commit support - Rust:
cargo-release+git-clifffor changelog generation - Generic / monorepo:
release-please(Google),changesets(npm)
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 withamannn/action-semantic-pull-requestin CI so the squash message is always valid. - Breaking change buried in body: Engineers write
feat: update SDKwithBREAKING CHANGE: ...in the body but the CI commitlint rule only validates the header. The analyzer still picks it up, but reviewers miss the signal. Requirefeat!syntax in the type field for visibility. - Bot commits triggering loops: When
semantic-releasecommits the updatedCHANGELOG.mdback tomain, that commit re-triggers thepushworkflow. Guard withif: "!contains(github.event.head_commit.message, 'chore(release)')"or use theskip_citoken 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.