Artifact Management & Release Engineering

Semantic Versioning & Release Schemes

18 min Lesson 2 of 28

Semantic Versioning & Release Schemes

Version numbers are not cosmetic. At big-tech scale they are a contract between producers and consumers of software: a promise about what changed, how safe it is to upgrade, and whether automation can promote a build without human review. Picking the wrong scheme — or applying it inconsistently — causes real production incidents: a MINOR bump that silently ships a breaking API change, or a CalVer date that causes a stale dependency to be pinned forever because no one knows whether the jump is safe. This lesson covers every versioning scheme used in production, the pre-release ladder, and the tooling that automates bumps so humans never touch a version number by hand.

SemVer: The Three-Part Contract

Semantic Versioning (semver), defined at semver.org, uses a three-integer tuple: MAJOR.MINOR.PATCH. The rules are precise and machine-enforceable.

  • PATCH — backward-compatible bug fix. No public API changes. Consumers can auto-upgrade safely.
  • MINOR — backward-compatible new functionality added. Existing calls continue to work. Consumers can auto-upgrade safely.
  • MAJOR — incompatible API change. Consumers must read release notes and opt in explicitly. Bumping MAJOR signals a migration path is required.

These rules make dependency managers reliable: a consumer declaring ^2.3.0 (caret range in npm) will automatically install any 2.x.y where x >= 3 without human intervention, trusting that MINOR and PATCH bumps are safe. The contract breaks — and production breaks — the moment you violate it. If a MINOR release removes a method, every downstream service using that method breaks at their next npm install or pip install --upgrade, silently, days after the release ships.

The single most common semver violation in industry: treating MINOR as "significant enough to deserve attention" instead of "strictly backward-compatible." Renaming, removing, or changing the signature of any public API symbol — a function, a REST endpoint, a gRPC field — is a MAJOR bump, period. The discipline of bumping MAJOR hurts at first. It is supposed to: it is a forcing function that makes engineers design stable interfaces instead of mutating them carelessly. Libraries that bump MAJOR frequently (like React 16 → 17 → 18) typically provide codemods and migration guides to ease the transition.

Pre-release Identifiers and the Release Ladder

SemVer supports two optional suffixes with strict semantics. Understanding them is essential because a CI/CD promotion pipeline maps directly onto the pre-release ladder.

  • Pre-release identifier — appended with a hyphen: 2.0.0-alpha.1, 2.0.0-beta.3, 2.0.0-rc.1. Pre-releases sort lower than the release itself: 2.0.0-rc.1 < 2.0.0. A package manager running in default mode will never auto-upgrade a stable consumer to a pre-release — the consumer must explicitly opt in (e.g., npm install mylib@next or pip install --pre mylib).
  • Build metadata — appended with a plus: 2.0.0+20260611.sha.a3f9d12. Build metadata is completely ignored when comparing versions; two artifacts with different metadata are semver-equal. Use build metadata to embed the git SHA, CI run ID, or build timestamp for human traceability — without affecting version ordering or dependency resolution.

In a mature release pipeline, the pre-release ladder maps to promotion gates. The artifact is built once and re-tagged at each gate — the binary never changes:

SemVer pre-release promotion ladder SemVer Pre-release Promotion Ladder alpha.1 internal only unit + smoke tests gate beta.2 opt-in users integration tests gate rc.1 feature-frozen canary + load tests gate 2.0.0 GA release 100% traffic Same binary SHA throughout — only the version tag changes at each gate
The pre-release ladder maps directly to promotion gates. alpha → beta → rc → GA, same artifact binary each time.

CalVer: Versioning by Date

Calendar Versioning (CalVer) encodes the release date into the version string. It is the right choice when "when was this released" is more actionable to consumers than "what semantically changed." Common formats in production systems:

  • YYYY.MM — Ubuntu: 24.04 (April 2024 LTS), 24.10. The date directly signals the support window end date.
  • YYYY.MM.MICRO — Django used this historically: 2024.6.0, 2024.6.1. The micro segment is a patch counter within the month.
  • YYYY.MINOR — pip: 24.1, 24.2. Minor increments within a year, no month granularity.
  • YYYY.0M.DD — some internal Google toolchains use ISO-style full dates for daily release trains.

CalVer is a poor fit for libraries with many downstream consumers and frequent breaking changes: there is no built-in signal that 2024.072024.08 is safe to auto-upgrade or requires a migration. For libraries with stable APIs but occasional big shifts, a hybrid works well: 24.6.0 where 24.6 is CalVer (year.month) and the last segment is semver-style patch count. Kubernetes uses pure semver (1.30.2) but publishes minor releases on a roughly quarterly cadence — effectively acting like a release train with semver labels.

Rule of thumb for scheme selection: Use semver for any software that is imported as a library by other code. Use CalVer for end-user products, OS images, or LTS toolchain distributions where support windows matter more than API stability signals. When in doubt, semver is the safer default — its contract is universally understood and tooling support is broader.

Automating Version Bumps: semantic-release

semantic-release is the standard tool for zero-human-intervention semver automation. It reads your git commit history — specifically commits formatted as Conventional Commits (feat:, fix:, feat!:) — determines the correct next version, generates a changelog, publishes the artifact, creates a GitHub release, and tags the commit. No engineer ever edits a version number by hand.

# Install semantic-release core + required plugins (Node.js ecosystem) npm install --save-dev \ semantic-release \ @semantic-release/commit-analyzer \ @semantic-release/release-notes-generator \ @semantic-release/changelog \ @semantic-release/npm \ @semantic-release/git \ @semantic-release/github # .releaserc.json — minimal production config { "branches": [ "main", {"name": "next", "prerelease": true}, {"name": "beta", "prerelease": "beta"}, {"name": "release/+([0-9]).x", "range": "${major}.x", "channel": "${major}.x"} ], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/changelog", ["@semantic-release/npm", {"npmPublish": true}], ["@semantic-release/git", {"assets": ["package.json", "CHANGELOG.md"], "message": "chore(release): ${nextRelease.version} [skip ci]"}], "@semantic-release/github" ] } # Commit message examples and what they trigger: # fix: correct null dereference in token parser -> PATCH bump (1.2.3 -> 1.2.4) # feat: add gzip response compression -> MINOR bump (1.2.3 -> 1.3.0) # feat!: remove deprecated /v1 API endpoints -> MAJOR bump (1.2.3 -> 2.0.0) # (footer) BREAKING CHANGE: /v1 removed -> MAJOR bump (same effect) # docs: update README -> no release (docs-only) # chore: bump dev dependency -> no release

The branch configuration above implements a full multi-channel strategy: main produces stable GA releases, the next branch produces x.y.z-next.N pre-releases for cutting-edge consumers, beta produces x.y.z-beta.N builds, and maintenance branches like release/2.x receive backport patches to the 2.x line without disturbing main. This is exactly the model used by large open-source projects like Babel, Jest, and semantic-release itself.

Python and Container Ecosystem Specifics

Not every project uses the Node.js toolchain. The same semver-from-commits discipline applies across ecosystems, but the tooling differs:

  • Python: python-semantic-release reads Conventional Commits and bumps pyproject.toml. It publishes to PyPI and creates GitHub releases. PEP 440 pre-release syntax is slightly different: 2.0.0a1 (alpha), 2.0.0b2 (beta), 2.0.0rc1 — no hyphen. Tools like pip understand both PEP 440 and semver formats.
  • Go modules: semver is enforced by the Go toolchain. A v2+ module must have a /v2 import path suffix (e.g., github.com/myorg/mylib/v2). The Go proxy caches immutable module versions — you cannot re-tag v2.0.0 after it has been cached.
  • Container images: there is no standardized version-from-commits tool for OCI images, but the convention is to tag with the semver string alongside the git SHA. A typical CI job produces both myimage:2.3.1 and myimage:sha-a7f3d91 pointing to the same digest. The semver tag is for human consumers; the SHA tag is used in deployment manifests for immutability.
# GitHub Actions: compute semver tag from git, build and push container image # This pattern is used when semantic-release handles version computation # and a separate step tags the container image. - name: Get version from package.json (set by semantic-release) id: version run: echo "VERSION=$(node -p \"require('./package.json').version\")" >> "$GITHUB_OUTPUT" - name: Build and push image with semver + SHA tags uses: docker/build-push-action@v6 with: push: true tags: | ghcr.io/${{ github.repository }}:${{ steps.version.outputs.VERSION }} ghcr.io/${{ github.repository }}:${{ github.sha }} ghcr.io/${{ github.repository }}:latest # For non-Node projects, use python-semantic-release (Python) or # goreleaser (Go) to compute the version and drive the image build. # goreleaser example (.goreleaser.yaml): # dockers: # - image_templates: # - "ghcr.io/myorg/myapi:{{ .Tag }}" # - "ghcr.io/myorg/myapi:{{ .ShortCommit }}" # dockerfile: Dockerfile # build_flag_templates: # - "--label=org.opencontainers.image.version={{ .Version }}" # - "--label=org.opencontainers.image.revision={{ .FullCommit }}"

Version Scheme at Scale: What Actually Breaks

The theory is clean; production is messier. These are the failure modes that actually occur at scale when versioning discipline breaks down:

  • Zero-based major versions: semver specifies that 0.y.z is for initial development — the API is unstable and anything may change at any time. Many teams keep libraries at 0.x for years, giving consumers a false sense that minor bumps are safe. If your library is used in production, ship 1.0.0.
  • Re-tagging published versions: on npm, PyPI, and Go proxy, versions are generally immutable once published. Re-tagging (deleting and re-publishing the same version) breaks deterministic builds for every consumer who resolved that version. On GitHub Container Registry, re-pushing a mutable tag breaks this guarantee for image consumers. Never re-tag a published release.
  • Version drift in monorepos: in a monorepo with 50 packages, it is tempting to version all packages in lockstep (all at 2.3.0 regardless of what changed). This violates the semver contract: packages that had no changes should not bump. Tools like Lerna, Nx, and Changesets support independent versioning — each package versions separately based on its own commit history.
  • Missing pre-release channel: teams that skip the pre-release ladder and ship directly from alpha to GA give consumers no opportunity to validate the upgrade in non-production environments. The pre-release channel is as important as the stable channel — it is the safety valve that catches breaking changes before they reach the full install base.
Production discipline: Set up branch protection on your version tags. On GitHub, add a tag protection rule for v* so that only the CI bot (the service account running semantic-release) can create or delete version tags. This prevents an engineer from manually pushing v2.0.0 over the CI-managed tag, which would corrupt the release history and potentially cause consumers on cached versions to get the wrong artifact.

The next lesson covers artifact repositories — where versioned artifacts live between build and deployment, how registries enforce immutability, and how to configure retention policies that balance storage cost against the compliance need to reproduce any release from the last seven years.