I use semantic-release
for both personal and work projects to automate version management and publishing of packages. It's nice because it takes care of the entire release flow, like figuring out the next version (using Angular Commit Message Conventions), auto-generating GitHub release notes, and finally publishing the package to the npm registry.
I like semantic-release
for the handful of personal projects I work on because package publishing is a part of DivOps that I don't want to deal with. Developing the library is enough. 😃 At work semantic-release
provides a centralized point in CI that handles releasing a package so that individual developers aren't trying to figure it out on their machines.
The default semantic-release
configuration does all that I described, so initially I could release without an explicit config. Then I use npx to run it in CI. For example, a release.yml
Github workflow:
name: Release
on: push
jobs:
main:
name: NPM Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use Node v16
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
env:
CI: true
- name: Run integration tests
run: npm run integrate
env:
CI: true
- name: Release new version to NPM
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# highlight-next-line
run: npx semantic-release
I don't even install
semantic-release
as a dependency in the project. I always use the latest version.
However, sadly the default configuration still uses master
as the default branch. Since all of my repos use main
as the default branch, I now need a release.config.js
to configure the supported branches.
module.exports = {
branches: [
'main',
'next',
'next-major',
// version number branches will release that version
'+([0-9])?(.{+([0-9]),x}).x',
{ name: 'beta', prerelease: true },
{ name: 'alpha', prerelease: true },
],
}
And because all of my libs are written in TypeScript, I also have a build step before releasing the pacakge. The build step allows the project to transpile TypeScript into vanilla JavaScript and auto-generate TypeScript declaration files (*.d.ts
). As a result, I need to specifically configure the @semantic-release/npm
plugin to specify the build directory (typically lib/
for me). But to configure the plugin, I also must include all of the plugins used by default.
module.exports = {
branches: [
'main',
'next',
'next-major',
'+([0-9])?(.{+([0-9]),x}).x',
{ name: 'beta', prerelease: true },
{ name: 'alpha', prerelease: true },
],
plugins: [
// analyzes commits w/ conventional-changelog
'@semantic-release/commit-analyzer',
// generates a changelog w/ conventional-changelog
'@semantic-release/release-notes-generator',
// publishes the npm package from the specified folder
['@semantic-release/npm', {
pkgRoot: './lib'
}]
// Publishes changelog as a GitHub release and
// comments on released Pull Requests & Issues
'@semantic-release/github',
],
}
FYI: at work I also use
semantic-release-slack-bot
to get release notifications in Slack from a Slack bot, but I'm leaving it out to simplify the discussion.
With the addition of the build step, the release.yml
Github workflow now looks like:
name: Release
on: push
jobs:
main:
name: NPM Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use Node v16
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
env:
CI: true
- name: Run integration tests
run: npm run integrate
env:
CI: true
# highlight-start
- name: Build package
run: npm run build
# highlight-end
- name: Release new version to NPM
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release
Because semantic-release
relies on a strict commit format (by default Angular Commit Message Conventions) in order to auto-determine the next version, I also set up all of my GitHub projects to only support squash merge commits in Pull Requests. I've found that it's the least painful way to ensure developers use the proper commit format because I also add another GitHub workflow to validate the Pull Request title using the amannn/action-semantic-pull-request
GitHub action. An example validate-pr.yml
workflow file looks like:
name: Pull Request
on:
pull_request_target:
types:
- opened
- edited
- synchronize
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
## highlight-next-line
- uses: amannn/action-semantic-pull-request@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Supporting one-off releases
When working on a library project, at times I need to test how it'll work in a host application before merging a Pull Request. There are some features that automated tests just cannot cover. They are best verified by using the library in a host app. We need to add a "dev" version of the package as a dependency in the app, just like we would for a real released version. But how exactly do we do that?
Back in the day I would use npm link
to symlink the dependency in the app's node_modules
to the library project folder. But that stopped working once I started transpiling code and the project folder looked different than the package folder. This difference is also why using a GitHub project reference also doesn't work.
So on a library that I develop solely by myself, I would run npm pack
locally to create a tarball (.tgz
) of the package. An app's package.json
can install a tarball from a local path instead of a version in the registry. So I would npm pack
the package, put it somewhere on the filesystem, and reference it in my test app's package.json
. But that's not an easily repeatable pattern for multiple devs on a team, especially when some are infrequently contributing to the lib.
So when on a team, I initially ab-used the alpha
pre-release branch. When someone on the team needed to test a release, they merged their (properly-titled) commits into the project's alpha
branch. And when they pushed it to origin
, the release.yml
workflow would run. And since alpha
is listed in the branches
of the release.config.js
as a pre-release, the new code would release at a new alpha version.
This approach worked okay. The alpha
branch ended up having a lot of trial and error code. But as long as we merged main
into it regularly, it cleaned up pretty well. However, when we had multiple developers who wanted to test their dev branches with alpha
releases simultaneously, we ran into trouble. We had to get the "all clear" to release alphas in serial. And since we could have multiple dev branches merging into alpha sequentially, the branch itself could get into a pretty gnarly state.
We need to isolate the dev branches into their releases. We need something akin to running npm pack
locally, but with the consistency that running semantic-release
in CI provides. So what we can do is add another pre-release branch, but use a pattern for multiple branch support.
module.exports = {
branches: [
'main',
'next',
'next-major',
'+([0-9])?(.{+([0-9]),x}).x',
{ name: 'beta', prerelease: true },
{ name: 'alpha', prerelease: true },
// Any branch starting with `test-` will be auto-released
// as a pre-release (e.g. `1.3.0-test-add-cool-new-feature.1`)
{ name: 'test-*' prerelease: true},
],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
['@semantic-release/npm', {
pkgRoot: './lib'
}]
'@semantic-release/github',
],
}
Let me break down how I can release a new "dev" version for my PR branch.
- I do my dev work on a branch named
add-cool-new-feature
- I merge the (properly-titled) commits into a
test-add-cool-new-feature
branch (creating it if this is the first time) - I push
test-add-cool-new-feature
toorigin
- CI runs and releases the one-off version (e.g.
1.3.0-test-add-cool-new-feature.1
)
Instead of including "alpha" or "beta" in the version, it includes the entire name of the branch. If I continue to push new commits to the branch the .1
part will be .2
, .3
, etc. And now I can create my test release branches while other developers can do the same in parallel. And once we're done, we delete our branches from origin
. The alpha
and beta
pre-release branches remain as they should be.
Well, hopefully, this example helps you out! The configuration for semantic-release
itself is surprisingly uncomplicated. It's just an additional entry in the branches
config. But I think what's most interesting is the concept itself. Even though semantic-release
has a pretty strict commit syntax which enables it to publish a real versioned release on every commit, we can still configure it to act like our old strategies from before.
I would love it if you could let me know if you found this post helpful. Feel free to reach out to me on Twitter with comments or questions at @benmvp.
Keep learning my friends. 🤓