DEV Community

Cover image for Automated versioning and package publishing using GitHub Actions and semantic-release
Giannis Koutsaftakis
Giannis Koutsaftakis

Posted on • Edited on

Automated versioning and package publishing using GitHub Actions and semantic-release

When we are developing JavaScript packages, there is a series of repetitive tasks that we have to complete manually every time we have to publish a new release to npm:

  • Change the version field in package.json
  • Create a new Git tag and a GitHub release
  • Execute any build steps to create the release artifacts
  • Update the changelog
  • Publish to npm

Wouldn't be great if we could automate all of these tasks?
GitHub Actions and semantic-release have us covered!

GitHub Actions is a GitHub feature that lets us build, test, and deploy our GitHub hosted projects. You can think of it as the CI/CD pipeline for GitHub. It uses YAML files, called workflows, that trigger based on specific events (e.g. when a commit is pushed).

semantic-release is a tool that uses the Conventional Commits message format to determine the type of changes in our code base. It automatically sets the next semantic version number, generates the changelog and publishes the release.

Let's start by preparing our repository.

Check existing version tags

If we are going to use semantic-release in an existing repository we'll first have to make sure that the most recent commit included in the last published npm release is in the release branches history and is tagged with the version released.

You can skip this step if you are setting up semantic-release in a new repository.

Assuming our release branch is main, last commit SHA is 1234567 and current published version of our project is v1.1.0

# Make sure the commit 1234567 is in the release branch history
$ git branch --contains 1234567

# If the commit is not in the branch history 
# we need to configure our repository to have the last release 
# commit in the history of the release branch

# List the tags for the commit 1234567
$ git tag --contains 1234567

# If v1.1.0 is not in the list we have to add it with
$ git tag v1.1.0 1234567
$ git push origin v1.1.0
Enter fullscreen mode Exit fullscreen mode

npm Authentication for CI/CD

For our GitHub Action to publish packages to npm, we need to set up authentication. npm offers two options for CI/CD workflows:

  1. Granular Access Tokens - Create tokens with specific permissions and expiration dates (max 90 days for write tokens). These require manual rotation and management.

  2. Trusted Publishers (OIDC) - Uses OpenID Connect to authenticate directly from GitHub Actions without any tokens. Short-lived credentials are generated on-demand during your workflow.

We'll use Trusted Publishers as it's the most secure approach—no tokens to manage, rotate, or risk exposing. npm automatically generates short-lived, cryptographically-signed tokens specific to your workflow.

First-time publish

Trusted Publishers can only be configured on existing packages. For new packages that haven't been published to npm yet, you'll need to do the first publish manually. See npm's guide on creating and publishing unscoped public packages for more details.

If your package is already published to npm, skip this section and go directly to Configure Trusted Publishers.

Make sure your package.json has the required fields:

{
  "name": "your-package-name",
  "version": "1.0.0",
  "main": "dist/index.js",
  "files": ["dist"],
  "repository": {
    "type": "git",
    "url": "https://github.com/username/repo-name"
  },
  "license": "MIT"
}
Enter fullscreen mode Exit fullscreen mode

For scoped packages (e.g., @username/package-name), add publishConfig to make it public:

{
  "name": "@username/package-name",
  "publishConfig": {
    "access": "public"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then run:

npm login
npm publish
Enter fullscreen mode Exit fullscreen mode

After publishing, create a git tag matching the version so semantic-release knows where to start:

git tag v1.0.0
git push origin v1.0.0
Enter fullscreen mode Exit fullscreen mode

Now you can configure Trusted Publishers for all subsequent releases.

Configure Trusted Publishers

Navigate to your package settings on npmjs.com and find the Trusted Publisher section. Click on GitHub Actions and configure the following fields:

  • Organization or user: Your GitHub username or organization name
  • Repository: Your repository name
  • Workflow filename: release.yml (must match your workflow file exactly)
  • Environment name: (optional) If using GitHub environments for deployment protection

Update version in package.json

Now that semantic-release will handle versioning, update your package.json to set "version": "0.0.0-semantic-release". This placeholder indicates that semantic-release manages the version.

Configure GitHub repository permissions

Navigate to your GitHub repository page, click Settings, then Actions -> General, scroll down, and in Workflow permissions select Read and write permissions.

That's it! When publishing, npm will automatically authenticate using OIDC tokens generated by GitHub Actions.

Create the GitHub release action

Let's create the GitHub release action that will run every time we push a commit to our main and beta branches. The beta branch will be used for our pre-releases in case we need any.

Create a .github/workflows/release.yml file in the project's root with the following contents.

.github/workflows/release.yml

name: Release

on:
  push:
    branches: [main, beta]

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: write

    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: 22.x
        registry-url: 'https://registry.npmjs.org'
    - name: Install dependencies
      run: npm ci
    - name: Install semantic-release extra plugins
      run: npm install --save-dev @semantic-release/changelog @semantic-release/git      
    - name: Lint
      run: npm run lint-fix
    - name: Test
      run: npm run test:unit
    - name: Build
      run: npm run build      
    - name: Release
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: npx semantic-release
Enter fullscreen mode Exit fullscreen mode

Here we're using Node.js version 22, as semantic-release requires Node 22.14.0 or higher. Make sure to align this with your project's node version if needed.
We also have steps for linting, testing, and building our code. Go ahead and remove or change these as you see fit.

The important parts are the permissions, Install semantic-release extra plugins and the Release steps.

Notice that we don't have to install semantic-release as it comes pre-installed in GitHub actions by default. We just need the @semantic-release/changelog and @semantic-release/git plugins.

The permissions block includes:

  • id-token: write - Required for OIDC authentication with npm. This allows GitHub Actions to generate short-lived tokens for secure publishing.
  • contents: write - Required for semantic-release to create Git tags and releases.

Inside the Release action you'll notice the GITHUB_TOKEN environment variable. This is the token used to authenticate to GitHub. It's an automatically created secret needed by semantic-release to create Git tags.

semantic-release configuration

semantic-release configuration can be set by using a .releaserc file, a release key inside package.json or a release.config.js file in the project's root. We'll use the latter.

release.config.js

module.exports = {
  branches: [
    'main',
    {
      name: 'beta',
      prerelease: true
    }
  ],
  plugins: [
    '@semantic-release/commit-analyzer',
    '@semantic-release/release-notes-generator',
    [
      '@semantic-release/changelog',
      {
        changelogFile: 'CHANGELOG.md'
      }
    ],
    '@semantic-release/npm',
    '@semantic-release/github',
    [
      '@semantic-release/git',
      {
        assets: ['CHANGELOG.md', 'dist/**'],
        message: 'chore(release): set `package.json` to ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}'
      }
    ]
  ]
}
Enter fullscreen mode Exit fullscreen mode

The branches attribute includes the branches on which releases should take place. Apart from main we also include a beta branch with prerelease: true, this way we can have beta versions published using a beta branch.

In the plugins section we define the list of semantic-release plugins to use. The plugins we have defined are already part of semantic-release so we don't have to install them separately.

  • @semantic-release/commit-analyzer
    It determines the type of our release (e.g. major, minor, patch) by analyzing commits with conventional-changelog. semantic-release uses Angular Commit Message Conventions by default.

  • @semantic-release/release-notes-generator
    It generates the release notes for the changelog.

  • @semantic-release/changelog
    It creates and updates the changelog file, with the content created by the release-notes-generator in the previous step.

  • @semantic-release/npm
    It publishes the npm package

  • @semantic-release/github
    It publishes the GitHub release and comment.

  • @semantic-release/git
    It commits the release artifacts to the project's Git repository. In this example we're committing the changelog file and all files inside the dist folder. We're also defining the message for the release commit.

    Note: [skip ci] in the commit message is used in order to not trigger a new build.

Enforce conventional commits with commitlint and husky

Since semantic-release uses the conventional commits format to automate the versioning, we need to make sure all commits in our repository follow the appropriate format.

For this purpose we are going to use commitlint and husky.
We'll leverage husky to add a Git hook that uses commitlint to check whether our commit message meets the conventional commit format, every time we commit.

Install commitlint

npm install -D @commitlint/cli @commitlint/config-conventional
Enter fullscreen mode Exit fullscreen mode

add the commitlint config file into the project's root
commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional']
}
Enter fullscreen mode Exit fullscreen mode

Install husky

npm install -D husky
Enter fullscreen mode Exit fullscreen mode

Enable husky

npx husky init
Enter fullscreen mode Exit fullscreen mode

The npx husky init command sets up husky in the project by creating a pre-commit script in .husky/ and inserting/updating the prepare script in package.json.

Add a hook to lint commits using commitlint before they are created, using husky's commit-msg hook:

echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg
Enter fullscreen mode Exit fullscreen mode

Ready to publish

We've finished the setup and configuration of semantic-release in our GitHub repository. From now on we have to use the Conventional Commits specification for our commit messages.

For example, if our package is now at version 1.0.0, a commit message with this format:

fix(homepage): fixed image gallery will bump the version to 1.0.1

feat(logging): added logs for failed signups will bump the version to 1.1.0

That's all there's to it!

semantic-release and GitHub Actions will take care of the rest, determining the next version number, generating the release notes and publishing the package to npm.

Top comments (9)

Collapse
 
zenithy profile image
Zenith Wogwugwu

Hi @kouts

Thanks a lot for your article, it was immensely helpful in my research.

I have brought together ideas from your article and others into a GitHub NPM starter project.

Kindly check it out and let me know what you think 🙏

Collapse
 
gpicazo profile image
Genaro Picazo

Hi. I followed the steps as outlined above, but am experiencing an issue and searching online has not helped me find an answer. After completing the above, I committed my changes to the beta branch (next, in my case), and when I check my git log locally, I see:

commit <hash> (HEAD -> next, origin/next)
Author: Me <my-email>
Date:   Fri Sep 16 20:12:34 2022 -0500

    feat(ci/cd): add semantic release versioning

commit ...
Enter fullscreen mode Exit fullscreen mode

However, when I push the branch to origin, my new Release action runs and fails with the following error:

[1:15:39 AM] [semantic-release] › ✖  An error occurred while running semantic-release: Error: Command failed with exit code 1: git commit -m chore(release): set `package.json` to 1.0.0-next.1 [skip ci]

# 1.0.0-next.1 (2022-09-17)


### Features

* **ci/cd:** add semantic release versioning ([hash](<hash URI>))

Enter fullscreen mode Exit fullscreen mode

Note that a long hash link is added to the end of my commit message, which causes the line to exceed the 100 character limit. Why is this being added and how can I avoid it?

Collapse
 
gpicazo profile image
Genaro Picazo

I was able to fix the issue by overriding the the max length rule on the body and footer as follows:

// commitlint.config.js

module.exports = {
    extends: ['@commitlint/config-conventional'],
    rules: {
      'body-max-line-length': [0, 'always'],
      'footer-max-line-length': [0, 'always']
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
lerenart profile image
Daniele Tabanella

This is gold! One more thing is that you have to ensure that the build directory (dist for instance) is not git ignored, if not you npm will ignore it too.

Collapse
 
kouts profile image
Giannis Koutsaftakis

You don't actually need to put the dist folder inside .gitignore, @semantic-release/git options take care of adding the dist folder to the release.

Collapse
 
yannick_rest profile image
Yannick Rehberger

Great article - thank you!

Collapse
 
cainenielsen profile image
Caine J Nielsen

For anyone who's interpreter who did not support the husky command above, you can see different versions here:
github.com/conventional-changelog/...

Collapse
 
dlw profile image
Darren White

This is immensely useful, thank you very much.

Am I right in thinking the version in package.json should be updated?

Collapse
 
kouts profile image
Giannis Koutsaftakis

Thanks @dlw, the version in package.json gets automatically populated by Semantic Release.