Back to all posts

Automating Releases with Semantic-Release & Commitlint

Nov 10, 2025
3 min read
blog post cover

Level Up Your Git Workflow: Automated Releases with Semantic-Release and Commitlint

I recently shipped ContactLink, a simple link-in-bio project, and used it as a testing ground to implement a robust and professional Git workflow. This setup automates versioning and changelog generation using semantic-release driven by disciplined commit messages enforced by commitlint. It’s a game-changer for maintaining clean release history and looks great in any professional portfolio!

Consistency and Automation

Using Conventional Commits (enforced by commitlint) ensures that every commit message follows a standard format (e.g., feat: add new button, fix: correct typo). This structure is not just for neatness; it's the foundation for semantic-release, which automatically determines the next version number (major, minor, or patch) and generates the CHANGELOG.md based on your commits.

Setting the Stage

First, you need the right tools. Here are the core development dependencies you’ll need (as shown in my package.json):

npm install --save-dev @commitlint/cli @commitlint/config-conventional \
  semantic-release @semantic-release/changelog @semantic-release/git husky

After installation, initialize Husky (a Git hooks manager) and set up the scripts:

npx husky init

This command creates the .husky/ directory. Now, you can add your Git hooks.

Enforcing Commit Discipline with Commitlint

I use a commit-msg hook to run commitlint every time I try to commit, ensuring the message adheres to the Conventional Commits specification.

.husky/commit-msg

npx --no -- commitlint --edit $1

commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',
        'fix',
        'docs',
        'style',
        'refactor',
        'perf',
        'test',
        'build',
        'ci',
        'chore',
        'revert'
      ]
    ]
  }
};

This configuration makes sure commits start with an approved type (like feat for a new feature or fix for a bug). I also added a pre-commit hook to run linting before a commit can be created, keeping the codebase clean:

.husky/pre-commit

npm run lint

The Automation Engine: Semantic-Release in GitHub Actions

The magic happens in the CI/CD pipeline. My workflow runs semantic-release on every push to the main branch. This process does a few key things:

  1. Analyzes the commit history.
  2. Determines the next version (e.g., v1.0.0 -> v1.1.0).
  3. Updates the CHANGELOG.md file.
  4. Updates the package.json version.
  5. Creates a new Git tag and a GitHub release.

Here’s the GitHub Actions workflow:

.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:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrents.GITHUB_TOKEN }}
        run: npm run semantic-release
Crucial Setup Note: For semantic-release to be able to commit the updated CHANGELOG.md and version files back to the repository, you must update your GitHub repository settings. Go to Settings -> Actions -> General -> Workflow permissions and ensure Read and write permissions is selected. Also, ensure you have an empty CHANGELOG.md file in your repository to start.

Semantic-Release Configuration

The .releaserc.json file orchestrates which steps and plugins semantic-release should execute.

.releaserc.json

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

The key parts here are the @semantic-release/changelog and @semantic-release/git plugins, which handle updating the local files and committing them back, respectively. The commit message includes [skip ci] to prevent an infinite loop of releases triggering the workflow again!

Keep building, keep automating!