I have a Node.js project deployed to production using PM2, with GitHub Actions automatically deploying every merge to main. PM2 shows a version number beside each process, and for the longest time I ignored it. It always showed 1.0.0.
Recently I started thinking it would be nice if that version actually reflected what's running in production. I deploy multiple times a day, and I wanted the number to tell me not just that something changed, but what kind of change it was: a feature, a fix, a breaking change. At the same time, I didn't want to hand-manage SemVer or think about versioning at all. I just wanted something automatic and accurate.
The options
Once I laid it out, the choices were basically:
- Leave it as
1.0.0 - Manual SemVer
- Auto-bump on every deploy
- Git SHA versioning
- Automated SemVer derived from commit messages
Most of them either add friction or turn versioning into extra process overhead. The last one doesn't; it reads meaning out of work I'm already doing.
The approach I picked
semantic-release. It looks at my Conventional Commits since the last release, decides whether that's a major, minor, or patch bump, and produces a proper SemVer: feat: becomes minor, fix: becomes patch, a breaking change becomes major. No manual decisions, and the number genuinely describes the code.
Configuring it
I only want it to compute versions and publish GitHub Releases, with no npm publishing and no committing files back to the repo (which avoids CI loops and branch-protection headaches). This goes in .releaserc.json:
{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits",
"releaseRules": [{ "type": "enhance", "release": "minor" }]
}],
["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }],
"@semantic-release/github"
]
}
The one custom line maps my existing enhance: commit type to a minor bump. By default semantic-release ignores anything that isn't feat/fix/perf/breaking.
One thing the docs underplay: semantic-release has no "starting version" setting. With no existing tags its first release is always 1.0.0, regardless of how much history precedes it. Since my project was already well past that, I seeded a baseline by tagging once from my terminal (a one-time step, not part of CI) and let it pick up from there:
git tag v1.37.0
git push origin v1.37.0
GitHub Actions
Two steps in the workflow (.github/workflows/deploy.yml). Let semantic-release cut the release (it tags the repo and publishes the GitHub Release), then point package.json at the resulting version:
npx semantic-release
LATEST_TAG=$(git describe --tags --abbrev=0)
npm pkg set version="${LATEST_TAG#v}"
Checkout needs fetch-depth: 0 so semantic-release can see the full history and tags, and the job needs permissions: contents: write to push tags and create releases.
The part that's easy to get wrong
That npm pkg set line is the one that actually moves the number in PM2, and it took me a while to understand why. PM2's version column comes from the version field in package.json, read from the working directory when the process starts.
Two details matter here:
- Use
npm pkg set version=..., notnpm version. The latter creates its own git commit and tag (which collides with semantic-release), and silently mangles anything that isn't strict SemVer. - PM2 caches process metadata, so
pm2 restart <name>reuses the version it captured at first start. To force a re-read, rebuild the process:pm2 delete <name>thenpm2 start ecosystem.config.js.
What this changes
Instead of treating versioning as something I manage, I let my commits describe reality. The question is no longer "what version should this be?" but simply "what changed, and what's running right now?" The answer shows up in PM2 on its own.