Squashing Changelog Noise When Your OSS Project Gets Hundreds of Commits

June 10, 2026 8 min read 3 views
A clean structured changelog document emerging from a tangle of unorganized lines, representing automated release note generation

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-changelog and 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 #412

This 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-cli

Generate a changelog from your commit history:

conventional-changelog -p angular -i CHANGELOG.md -s

The -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-conventional

Create 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 }} --verbose

Note 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:

  1. PR is opened. CI runs commitlint against the PR's commit range.
  2. PR is merged. A release workflow fires.
  3. The workflow runs conventional-changelog (or semantic-release), determines the next version based on commit types, bumps package.json or the equivalent version file, commits the changelog update, tags the release, and publishes a GitHub Release draft.
  4. 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.md with 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-changelog against 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 out chore, docs, and test commits 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 save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.