Releasing a Library with Automated Versioning
A library on npm or PyPI has reached the point where hand-cut releases hurt. The ritual — bump the version in package.json, write changelog entries from memory, tag, push, publish — is tedious enough that releases bunch up, and tedious enough that someone eventually forgets a step and ships a version whose tag and published artifact disagree. The fix is to make the release a consequence of merging, not a separate chore: the commit messages drive the version bump, a tool writes the changelog and tag, and an Action publishes to the registry.
The decisions this forces are about contracts. The commit message becomes machine-readable input, so its format is now load-bearing. The choice of release tool depends on whether you have one package or many. And the publish credential — historically a long-lived registry token that anyone who steals it can publish under your name — becomes the highest-value secret in the repo, which is exactly why it should stop being a stored secret at all.
Conventional Commits as the Contract
The version bump is computed from commit prefixes: fix: yields a patch, feat: yields a minor, and a BREAKING CHANGE: footer yields a major. The commit message is no longer just a note to future readers — it is the input that decides what SemVer number ships, so it has to be correct. A commitlint check on PRs enforces the format before anything merges, rejecting a message the release tool would misread.
feat: add streaming parser API BREAKING CHANGE: parse() now returns a stream, not a string
semantic-release vs Changesets
Two tools dominate, and they embody different philosophies. semantic-release is fully automated: merge to main, it reads the commits since the last tag, computes the version, and publishes — no human in the loop. Changesets takes the opposite stance: a contributor adds a changeset file in the PR describing the bump, that file is reviewed alongside the code, and the release is assembled from accumulated changesets. For a single package, semantic-release's automation is the win. For a monorepo where packages version independently, Changesets is the better fit because it tracks per-package bumps that one commit-stream cannot express.
The Release Pipeline
On merge to main, the pipeline computes the next version, writes the changelog, creates the git tag and a GitHub Release, then triggers the publish. The crucial structural rule is that publishing runs only on main after CI passes — never on a PR trigger, where untrusted contributor code could run alongside the publish credential.
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history for version computation
- run: npx semantic-release
Publishing With OIDC Trusted Publishing
Both npm and PyPI now accept short-lived OIDC tokens minted by Actions, which removes the standing NPM_TOKEN or PyPI API token from the repo's secrets entirely. The job requests an ID token, the registry verifies it came from this repo's workflow, and the publish proceeds with a credential that never existed before the run and is useless after it. As a bonus, the registry records build provenance, so consumers can verify the package was built where it claims.
permissions:
id-token: write # OIDC trusted publishing, no stored token
contents: write # create the tag and GitHub Release
steps:
- uses: actions/setup-node@v4
with: { registry-url: 'https://registry.npmjs.org' }
- run: npm publish
Pre-releases and Channels
A breaking change does not have to land on everyone at once. Publishing to a next or beta dist-tag, or versioning as 1.0.0-rc.1, lets willing consumers opt into unreleased code while the default install stays on the stable line. This is how you get real-world testing of a major version before you make it the default and break every caret-ranged dependent.
Failure and Idempotency
The ugly failure is a half-published release: the tag and GitHub Release got created, then the registry push failed. Recovering means not double-publishing the same version, since registries reject a re-publish of an existing version and a forced one would break anyone who already pulled it. The pipeline has to be safe to re-run — detect that the version is already published and skip, or fail loudly rather than minting a second tag for the same release.
semantic-release — fully automated tag-on-merge driven by Conventional Commits. The version, changelog, tag, and publish all happen with no human in the loop, which suits a single package where one commit stream maps cleanly to one version.
Changesets — explicit changeset files added in the PR and reviewed alongside the code, then assembled into a release. It tracks per-package bumps, which is why a monorepo with independently versioned packages favors it over a single commit-derived version.
- Bumping the version by hand in
package.json, so it drifts from the git tag and the published artifact and no single number is authoritative. - Squash-merging with a non-conventional title, so the release tool computes the wrong bump or skips the release entirely.
- Omitting the
BREAKING CHANGE:footer, so a breaking change ships as a minor and downstream caret ranges auto-upgrade into a broken build. - Keeping a long-lived registry token in secrets that, if leaked, lets anyone publish malware under your package name.
- Running the publish from a job that also executes untrusted PR code, exposing the publish credential to a contributor.
- Enforce Conventional Commits with a commitlint check and validate squash-merge titles against it.
- Use OIDC trusted publishing for npm and PyPI and remove static registry tokens from repo secrets.
- Generate the changelog, tag, and GitHub Release from the same tool that computes the version.
- Run the publish only on
mainafter CI passes, never on a PR trigger. - Ship breaking changes through a
betaorrcchannel before promoting them to the stable release. - Make the release job idempotent so a re-run after a partial failure does not double-publish a version.
Knowledge Check
Under Conventional Commits, which SemVer bump does a feat: commit produce?
- A minor bump for the new backward-compatible feature
- A patch bump, the same as a bug-fix commit
- A major bump, since any feature breaks the API
- No bump at all; only a
fix:commit ships a release
Why does a squash-merge title matter to an automated release tool?
- The squashed title becomes the commit the tool reads, so a non-conventional title yields the wrong bump or no release
- It has no effect, since the tool derives the bump from the PR's labels instead, reading a tag like
release:minoroff the merged pull request rather than parsing the commit text - A long title only makes the generated changelog harder to read, nothing more, since the bump is computed entirely from the individual commits inside the PR
- Its wording determines which registry the published package is sent to
What is the security win of OIDC trusted publishing over a stored registry token?
- No long-lived token exists to leak; the credential is minted per run and useless afterward
- It publishes faster because the run skips the authentication step entirely, uploading the package straight to the registry without any handshake
- It makes publishing from a fork's PR trigger safe to run, since the minted token is scoped tightly enough to hand to untrusted contributor code
- It removes the need to generate a changelog for the release
For a monorepo with independently versioned packages, which release approach fits better?
- Changesets, because it tracks per-package bumps that one commit stream cannot express
- semantic-release, because its fully automated single stream always scales better and derives one shared version that every package follows in lockstep
- Neither, because a monorepo cannot drive automated versioning at all
- Manual version bumps, since release automation breaks down in monorepos and a human editing each
package.jsonis the only reliable path
You got correct