Squashing Changelog Noise When Your OSS Project Gets Hundreds of Commits
Your project just crossed 400 commits in a single release cycle, and your changelog looks like a transcript of every typo fix, dependency bump, and half-finished refactor your contributors ever pushed. Users are opening issues asking what actually changed. New contributors are lost. Sound familiar?
The problem isn't the volume of commits β it's the absence of structure. Without a deliberate system, a busy project generates noise that drowns out signal. This article shows you how to fix that.
What You'll Learn
- Why raw git logs fail as changelogs once a project scales
- How Conventional Commits give you machine-readable structure
- How to automate changelog generation with
conventional-changelogand similar tools - How to enforce commit format in CI so the system doesn't break down
- How to structure releases so users and contributors each get what they need
Prerequisites
This guide assumes you're working with a Git repository hosted on GitHub, GitLab, or a similar platform. The tooling examples use Node.js for the automation layer, but the principles apply to any language ecosystem. You should be comfortable with basic Git workflows and have some familiarity with CI configuration files.
Why Raw Git Logs Fall Apart at Scale
A single-person project can get away with free-form commit messages. You wrote the code, you remember the intent, and your changelog might just be a hand-edited CHANGELOG.md you update before each tag. This works until it doesn't.
At scale, three things happen simultaneously. Contributors arrive with different habits β some write detailed messages, others write fix stuff. The release cadence speeds up. And users start relying on your changelog to make upgrade decisions. At that point, a hand-curated log becomes a bottleneck and an inconsistent one at that.
The fix is to move from a human-curated process to a structured-input, automated-output process. You define a commit message format, enforce it, and let tooling generate the changelog from that structured data.
The Conventional Commits Standard
Conventional Commits is a lightweight specification that gives your commit messages a predictable shape. The core format looks like this:
<type>(<scope>): <short description>
[optional body]
[optional footer(s)]The type field is what powers downstream automation. Common types include:
- feat β a new feature visible to users
- fix β a bug fix
- docs β documentation only
- chore β maintenance tasks (dependency bumps, build config)
- refactor β code restructuring with no behavior change
- test β adding or updating tests
- perf β performance improvements
Breaking changes are flagged either by appending ! after the type (e.g., feat!:) or by adding a BREAKING CHANGE: footer. This lets tooling automatically determine whether a release should bump the major, minor, or patch version.
A real commit might look like this:
feat(auth): add OAuth2 PKCE flow support
Previously only implicit flow was supported. PKCE is now the default
for new OAuth2 clients. Existing clients are unaffected.
Closes #412This is a commit a tool can parse. More importantly, it's a commit a human can read six months later and understand instantly.
Automating the Changelog
Once your commits follow a convention, you can generate a changelog from them rather than writing one. The conventional-changelog ecosystem is the most widely adopted toolchain for this.
Install the CLI:
npm install -g conventional-changelog-cliGenerate a changelog from your commit history:
conventional-changelog -p angular -i CHANGELOG.md -sThe -p angular flag tells the tool to use Angular's preset, which maps to standard Conventional Commits types. The -i and -s flags tell it to read from and write back to CHANGELOG.md in place.
On a first run, this inserts a new block at the top of your changelog covering commits since the last Git tag. Subsequent runs append the next block. The output groups commits by type β features first, then fixes, then anything else you've configured to show β and links each entry back to the commit hash or the pull request that introduced it.
If you prefer a more opinionated all-in-one solution, release-please (from Google) and semantic-release are both worth looking at. They handle version bumping, tag creation, GitHub Release drafting, and changelog generation in a single pipeline step.
Enforcing the Format in CI
Automation only works if the inputs are clean. You need a guardrail that rejects non-conforming commit messages before they hit your main branch.
commitlint is the standard tool for this. Install it alongside a config preset:
npm install --save-dev @commitlint/cli @commitlint/config-conventionalCreate a commitlint.config.js at your project root:
module.exports = {
extends: ['@commitlint/config-conventional'],
};For local enforcement, pair this with husky to run commitlint as a Git commit-msg hook:
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'Local hooks help, but they can be skipped with --no-verify. The real enforcement happens in CI. Here's a minimal GitHub Actions step that lints commit messages on every pull request:
name: Lint Commits
on:
pull_request:
branches: [main]
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 ci
- run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verboseNote the fetch-depth: 0 β without a full history fetch, commitlint can't walk the commit range and will silently pass everything.
Separating User-Facing from Internal Changes
Not every commit belongs in your public changelog. A dependency security patch matters to users. A reformatted test file does not. Conventional Commits makes this separation mechanical.
Configure your changelog generator to include only feat, fix, perf, and breaking changes in the user-facing output. Everything else goes into a separate section or is omitted entirely. Here's an example customization for conventional-changelog using a .versionrc.json file:
{
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "chore", "hidden": true },
{ "type": "docs", "hidden": true },
{ "type": "refactor", "hidden": true },
{ "type": "test", "hidden": true }
]
}With this config, your public release notes stay focused on what changed for the user, not what changed inside the codebase. Your maintainer team can always read the full git log if they need the internals.
Structuring Releases for Two Audiences
Your changelog has two distinct audiences: users who want to know if they should upgrade and what will break, and contributors who want context on what's been merged so they can avoid conflicts and understand the codebase's direction.
For users, the GitHub Releases page (or equivalent) is the primary surface. Keep it short: breaking changes at the top in bold, new features in a bullet list, notable fixes below that. Link to the migration guide if anything is breaking. Skip internal chore commits entirely.
For contributors, your CHANGELOG.md in the repository can be more complete. Some projects maintain a separate CONTRIBUTORS.md that lists everyone who shipped commits in a release β tools like all-contributors can automate that layer too.
The key is to generate both outputs from the same source of truth β your structured commit history β rather than maintaining them separately.
Common Pitfalls
Squash-merging destroys your commit history
If your GitHub repository is configured to squash all pull requests into a single commit, the per-commit type information disappears. You end up with one commit per PR, and whoever writes the squash message controls the changelog entry. This isn't necessarily bad β it simplifies history β but it means contributors need to write a Conventional Commit-formatted squash message, not just their branch commit messages. Set expectations explicitly in your CONTRIBUTING.md.
Scope creep in commit types
Teams often start adding custom types that overlap with existing ones: improvement, enhancement, update. These don't map cleanly to semver bumps and confuse the tooling. Keep your type list short and force contributors to choose the closest standard type.
Retroactive adoption is painful but worth it
If your project has years of free-form commits, don't try to rewrite history. Pick a tag as your starting point and apply the convention from there forward. Your changelog will only cover the structured era, which is fine β link to your old manual changelog for historical releases and move on.
Forgetting to tag before generating
Changelog generators use Git tags to determine release boundaries. If you run conventional-changelog without first creating a tag for the previous release, the tool has no anchor and may include commits from several releases back. Always tag first, then generate.
Integrating with Your Release Workflow
A mature setup connects all these pieces into a single automated flow triggered on merge to your main branch or on a manual dispatch. A rough sequence looks like this:
- PR is opened. CI runs commitlint against the PR's commit range.
- PR is merged. A release workflow fires.
- The workflow runs
conventional-changelog(orsemantic-release), determines the next version based on commit types, bumpspackage.jsonor the equivalent version file, commits the changelog update, tags the release, and publishes a GitHub Release draft. - A maintainer reviews the draft and hits publish.
The maintainer review step is optional but recommended β automated release notes occasionally need a human to add a migration note or fix a confusing entry before the world sees them.
Next Steps
Here are four concrete actions you can take starting today:
- Adopt Conventional Commits β update your
CONTRIBUTING.mdwith the format and add one or two examples so contributors know exactly what's expected. - Add commitlint to CI β even before you generate changelogs, enforcing the format in pull requests costs almost nothing to set up and pays dividends immediately.
- Run
conventional-changelogagainst your current history β see what your changelog would have looked like if you'd been using the convention. It's a good reality check and helps you pick a sensible starting tag. - Configure hidden types in
.versionrc.jsonβ filter outchore,docs, andtestcommits from user-facing output so your release notes stay focused. - Evaluate semantic-release or release-please β if you want to remove the manual tag-and-publish step entirely, either tool can close that loop in CI with minimal configuration.
A clean changelog isn't a luxury for large projects β it's basic respect for the people who depend on your code. Getting the structure right now means every future release takes minutes to publish instead of an afternoon to hand-edit.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!