March 3, 20265 min read

From commit to changelog: automating versions with Conventional Commits and GitHub Actions

When I wrote about how to write commit messages that are clear, useful and machine-readable, I left a promise half kept. I explained the Conventional Commits spec and said it lets you “automate changelogs, versioning and releases”… and stopped there.

This article is the other half: the system that reads those commits. If no tool uses the convention, it stays a style agreement, and those get dropped as soon as there’s a deadline.

What exactly gets automated

The idea is that the commit type decides the version bump, without anyone deciding by hand:

Commit Bump Example
fix: patch 1.4.2 → 1.4.3
feat: minor 1.4.2 → 1.5.0
BREAKING CHANGE: in the body, or feat!: major 1.4.2 → 2.0.0
chore:, docs:, style:, refactor:, test: none 1.4.2

From there, the tool works out the next version, groups commits by type, writes the CHANGELOG.md, creates the git tag and publishes the release. You never touch a version number by hand again.

And the changelog stops being a pending task. Documentation tasks that rely on someone remembering usually end up abandoned.

Choosing a tool

There are two ways to do it, and it’s worth understanding the difference before copying a YAML file.

semantic-release publishes as soon as the commit lands. You merge to main and the workflow works out the version, creates the tag and publishes, with no intervention. It suits a library delivered continuously.

release-please opens a release pull request. It collects the commits since the last version into a PR containing the CHANGELOG.md and the version bump. That PR sits and waits: you publish when you merge it.

I prefer the second one for almost everything, for a practical reason: it lets you see the changelog before the version exists. If a commit was badly described, you fix it in the PR and not in a release that’s already out. Also, publishing stays a decision made by a person, which on client projects is often a requirement.

The rest of the article uses release-please, but the problems at the end apply to both.

The workflow

A minimal .github/workflows/release.yml:

name: release

on:
  push:
    branches: [main]

permissions:
  contents: write
  pull-requests: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: node

Three things to watch:

The permissions block is required. Without contents: write the action can’t create tags or commits, and without pull-requests: write it can’t open the PR. It fails with a 403 that doesn’t say what’s missing, and it’s the most common reason this doesn’t work the first time.

release-type defines where the version comes from. node reads it from and writes it to package.json. There are types for other languages, and simple if you only want a version.txt file with no ecosystem behind it.

When you copy this, check what the current major version of each action is: they change over time, and today’s @v4 may not be the current one when you read this.

Validating the messages

All of the above has a weak spot: a commit that doesn’t follow the convention doesn’t break anything, it just doesn’t show up in the changelog, and nobody notices.

That’s why you have to validate. The standard tool is commitlint:

npm i -D @commitlint/cli @commitlint/config-conventional husky
npx husky init
echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg

And the config, in commitlint.config.js:

export default { extends: ["@commitlint/config-conventional"] };

From then on, a git commit -m "misc fixes" gets rejected on your machine before it’s created.

That said, local hooks aren’t a guarantee: they can be skipped with --no-verify, and they don’t exist for anyone who clones the repository without installing dependencies. If more than one person works on the project, validate in CI as well:

name: commitlint
on: pull_request

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: wagoid/commitlint-github-action@v6

That fetch-depth: 0 is required: without the full history, the action doesn’t know which commits the PR brings and validates nothing, also without warning.

Three problems that don’t raise an error

Any of these can cost you an afternoon, because none of them gives a clear error.

1. Squash merge replaces your commits

If your repository uses squash and merge, the PR’s commits disappear and the message that reaches main is the pull request title. It doesn’t matter how well the ten commits on the branch were written: that title is the one that counts.

With squash enabled you have to validate the PR title, not the commits:

name: pr-title
on:
  pull_request:
    types: [opened, edited, synchronize]

permissions:
  pull-requests: read

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: amannn/action-semantic-pull-request@v5
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

It’s the most common one, and it’s confusing because the team is writing good commits; that work just gets lost in the merge.

2. GITHUB_TOKEN doesn’t trigger other workflows

This is the most puzzling one. GitHub doesn’t trigger workflows from events created with the default GITHUB_TOKEN. It’s a safeguard against infinite loops, and it makes sense.

The consequence is that if you have a workflow with on: release: [published] to deploy or publish to npm, it won’t run when release-please creates the release. The pipeline stops halfway without any error.

There are two solutions:

  • Put the deploy in the same workflow, conditioned on the action’s output. It’s the simplest and what I recommend:

        - uses: googleapis/release-please-action@v4
          id: release
          with:
            release-type: node
    
        - if: ${{ steps.release.outputs.release_created }}
          run: npm publish
  • Use your own token (a PAT or a GitHub App token) instead of GITHUB_TOKEN. It works, but it’s one more secret to rotate, with more permissions than you usually need.

3. The CI loop

If your automation commits to the repository and your CI runs on every push, you have a loop. Putting [skip ci] in the automated commit message breaks it; the well-known tools already do this, but if you automate something yourself, remember it.

What if your project has no versions?

Not all of this applies to every project. This blog, for example, has no version number: it deploys on every push and there’s nothing to publish as a release.

For projects like that, the useful part is the other half: commitlint and a generated changelog, without semantic versioning. You still get a readable history and a list of changes, which is most of the value, without inventing a 2.4.0 that means nothing.

The question that decides it is simple: does anyone outside depend on the version number? If not, semantic versioning doesn’t give you much.

Share