Case Studies
Topic 68

Releasing a Library with Automated Versioning

Case Study

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 vs Changesets

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.

Common Mistakes
  • 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.
Best Practices
  • 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 main after CI passes, never on a PR trigger.
  • Ship breaking changes through a beta or rc channel 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.
Comparable toolsrelease-please Google's PR-based release automationGoReleaser tag-driven Go binary and package releasesMaven / Gradle release plugins JVM artifact publishing

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:minor off 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.json is the only reliable path

You got correct