Guide

From Conventional Commit to Published Changelog: A Complete SaaS Pipeline

Conventional commits alone do not produce a changelog. Here is the full pipeline — commitlint, semantic-release, GitHub Actions, and the publish step most teams skip.

Photo of ReleaseGlow TeamReleaseGlow Team
April 27, 2026
11 min read

There is a quiet lie that lives in many engineering README files: "We use Conventional Commits." It is technically true. Every commit message starts with feat: or fix:. The history looks beautiful in git log. And yet, when a customer asks "what shipped last week?", someone is still copy-pasting bullet points into a Notion page on Friday afternoon.

Conventional Commits are a prerequisite for an automated changelog. They are not the changelog. The gap between "we follow the spec" and "every push to main produces a polished, customer-facing release note" is four distinct tooling stages — and most teams stop after the first.

This guide walks through the complete pipeline: how commits become validated, how versions get bumped automatically, how CHANGELOG.md gets generated, and — the step nobody talks about — how that markdown file becomes a hosted page your users actually read.

TL;DR

  • Conventional Commits is a naming convention, not a release tool.
  • A working pipeline has four stages: commit → validate → release → publish.
  • Validate with commitlint + Husky. Release with semantic-release, changesets, or standard-version. Publish with a hosted changelog.
  • Squash-merge defaults silently break the whole thing. Configure your merge strategy.
  • CHANGELOG.md in the repo is for engineers. A hosted changelog page is for users. You need both.

Conventional Commits in 60 seconds

The Conventional Commits spec defines a structured message format:

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

<optional body>

<optional footer(s)>

The types that matter for changelog automation:

  • feat: — a new feature (triggers a minor version bump)
  • fix: — a bug fix (triggers a patch bump)
  • perf: — a performance improvement (patch)
  • refactor: — internal change with no user impact (no bump by default)
  • docs: — documentation only (no bump)
  • chore: — tooling, dependencies, build (no bump)
  • test: — tests only (no bump)

Breaking changes are signaled either with a ! after the type — feat!: drop support for Node 18 — or with a BREAKING CHANGE: footer in the body. Either one triggers a major bump.

feat(api)!: rename /users endpoint to /accounts

BREAKING CHANGE: The /users endpoint has been removed.
Migrate to /accounts. See the migration guide.

That is the whole spec. The power is not in the format itself — it is in the fact that machines can now parse your history. (For more on what versions even mean for a hosted product, see our SaaS semver guide.)

Conventional Commits is a contract between your engineers and your tooling. If a single commit slips through with a non-conformant message, the next release either skips changes or labels them wrong. The validation stage exists precisely because humans will, eventually, type "fix the bug" instead of fix: prevent crash on empty cart.

The 4-stage pipeline

A real changelog automation pipeline has four stages. Skipping any one of them is why most "automated" setups still produce manual work somewhere.

Stage 1 — Commit

Engineers type Conventional Commit messages. That is the input. No tooling required, but you can make it easier with a guided prompt like commitizen (git cz instead of git commit).

Stage 2 — Validate

Before a commit is accepted, a hook checks the message against the spec. If feat add login button shows up (missing the colon), the commit is rejected at the developer's terminal — not three days later in CI.

This stage uses commitlint plus a Git hook manager (husky or lefthook).

Stage 3 — Release

On every push to main (or merge), a release tool reads the new commits, calculates the next version number, generates a changelog entry, tags the release, and optionally publishes to npm or a registry.

The three serious options here: semantic-release, changesets, and standard-version. We compare them below.

Stage 4 — Publish

The release tool produces a CHANGELOG.md file inside your repo. Your users do not read your repo. This stage is where most pipelines stop — and where the entire investment in automation fails to deliver value to the people it was supposed to serve.

A hosted changelog page, RSS feed, in-app widget, or email digest closes the loop. (See our deeper dive on end-to-end release notes automation.)

Stop at stage 3 and your users still won't know what shipped

ReleaseGlow takes the CHANGELOG.md your pipeline already produces and turns it into a hosted page, in-app widget, RSS feed, and email digest — automatically.

Tooling comparison: semantic-release vs changesets vs standard-version

The three tools solve overlapping problems with different philosophies.

For a third option, here is a markdown comparison covering all three:

| Feature | semantic-release | changesets | standard-version | |---|---|---|---| | Maintained by | community | Atlassian / Vercel | community (deprecated for new projects) | | Auto-publishes to npm | Yes | Yes | No (you publish manually) | | Generates CHANGELOG.md | Yes | Yes | Yes | | Requires PR-level metadata | No | Yes (.changeset/*.md) | No | | Monorepo independent versioning | No | Yes | No | | Tags + GitHub Releases | Yes | Yes | Yes | | Best fit | Single SaaS app or library | Monorepo / design system | Legacy projects, simple libs |

Our default recommendation for a B2B SaaS shipping a single product: semantic-release. For a design system or component library shipped as multiple packages: changesets. We would only reach for standard-version for a hobby project — it is essentially in maintenance mode.

Concrete configuration

Here is the minimum viable configuration for a Next.js or Node SaaS using semantic-release.

commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'perf', 'refactor', 'docs', 'chore', 'test', 'build', 'ci', 'revert'],
    ],
    'subject-case': [2, 'never', ['upper-case', 'pascal-case']],
    'header-max-length': [2, 'always', 100],
  },
};

Husky hook (.husky/commit-msg)

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install commitlint --edit "$1"

.releaserc.json

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

package.json scripts

{
  "scripts": {
    "prepare": "husky install",
    "commit": "cz",
    "release": "semantic-release"
  },
  "devDependencies": {
    "@commitlint/cli": "^19.0.0",
    "@commitlint/config-conventional": "^19.0.0",
    "commitizen": "^4.3.0",
    "cz-conventional-changelog": "^3.3.0",
    "husky": "^9.0.0",
    "semantic-release": "^24.0.0"
  },
  "config": {
    "commitizen": { "path": "cz-conventional-changelog" }
  }
}

GitHub Action (.github/workflows/release.yml)

name: Release

on:
  push:
    branches: [main]

permissions:
  contents: write
  issues: write
  pull-requests: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: false

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - run: npm run build

      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

That is the entire pipeline. Push a feat: commit to main, the action runs, the version bumps, CHANGELOG.md updates, a GitHub Release is created, and a tag is pushed.

From CHANGELOG.md to a hosted page

This is the step the open-source ecosystem mostly ignores, because for libraries, a CHANGELOG.md on GitHub is enough — developers read GitHub. For a B2B SaaS, your customers do not have GitHub accounts and would not look there even if they did.

You need at least one of:

  1. A hosted changelog page under yourapp.com/changelog with permalinks per release.
  2. An in-app widget that shows unread updates the next time the user logs in.
  3. An RSS feed so power users and integrations can subscribe.
  4. An email digest for users who want a weekly summary.

The naive approach: a junior PM copy-pastes from CHANGELOG.md into a CMS every Friday. Congratulations — you have automated stages 1 through 3 and re-introduced manual labor at stage 4.

The right approach: pipe the same artifact your release tool generates into a publishing layer. ReleaseGlow does this by ingesting your release notes (from the GitHub release, a webhook, or our API), letting AI rewrite the technical entries into customer-friendly prose, and then publishing to a hosted page, widget, RSS feed, and email — all from the same source.

The mental model: CHANGELOG.md is your internal source of truth for engineers. Your hosted changelog is the external surface for customers. The pipeline's job is to keep them in sync without human copy-paste.

Pitfalls (where the pipeline silently breaks)

Squash-merge eats commit messages

This is the single most common failure. GitHub's default squash-merge concatenates the PR title and the commit list into one new commit. If the PR title is Fix login bug (no fix: prefix), the squashed commit on main is Fix login bug — and semantic-release ignores it.

Fix: enforce Conventional Commits on PR titles with a separate GitHub Action like amannn/action-semantic-pull-request. Then your squash-merge produces a valid commit on main.

Rebase resets author dates

If you rebase a feature branch before merging, the commit dates jump to "now". This usually does not matter — but if you rely on commit dates for ordering in your changelog, you will see entries appearing out of expected order. Prefer merge commits or squash for the canonical history.

Monorepos break single-version assumptions

semantic-release assumes one version per repo. If you have apps/web and apps/api shipping independently, you need either semantic-release-monorepo (a wrapper) or — more cleanly — switch to changesets.

[skip ci] infinite loops

If your release commit pushes back to main (to update CHANGELOG.md and package.json), make sure the commit message contains [skip ci] or the CI will trigger itself recursively. The default .releaserc above handles this.

Version 0.x semantics

Conventional Commits assumes semver, but semver says 0.x versions can break anything at any time. If you are pre-1.0, every feat!: will bump to 0.y.0 — not 1.0.0. This is correct per spec but often surprises teams. Decide explicitly when to cut your 1.0.0.

One source of truth, four publish surfaces

Connect your repo to ReleaseGlow once. Every semantic-release tag becomes a hosted page, an in-app widget, an RSS feed, and an email — written in the voice your customers actually want to read.

FAQ

Wrapping up

A Conventional Commits adoption that stops at the commit message is a half-built bridge. The pipeline only pays off when validation rejects bad commits at the developer's terminal, when semantic-release cuts versions without a human in the loop, and when the resulting CHANGELOG.md flows automatically to a surface your customers actually read.

Build all four stages, or accept that you are still doing the work manually — just with prettier git history.