Skip to content

Semantic Versioning for APIs and Libraries: Why CI/CD Should Produce Versioned Releases

Ask most teams what their CI/CD pipeline does and you will hear some variation of "it runs the tests and deploys to production." That answer describes deployment automation, and deployment automation is genuinely valuable — but it is only half of what a pipeline should do. The other half is producing releases: repeatable, traceable, versioned units of software that you can point to, reason about, promote, and roll back. For APIs, libraries, and anything else with downstream consumers, the difference between "we deploy" and "we release" is the difference between a pipeline that moves code and a pipeline that communicates intent.

Deployment automation is not release management

A deployment answers the question "is the new code running?" A release answers harder questions:

  • Which exact code is running, and which commit produced it?
  • Is this change safe for consumers to adopt, or will it break them?
  • Can we run the same thing in staging that we will run in production?
  • If this goes wrong, what precisely do we roll back to?

A pipeline that rebuilds from a branch tip on every deploy can answer the first question, on a good day. A pipeline that produces versioned, immutable artifacts can answer all of them, every day. That property — being able to trace any running system back to an exact artifact, and that artifact back to an exact commit — is what makes releases auditable, and it is worth designing for even in teams that deploy many times a day.

Not every version number means the same thing

Much confusion about versioning comes from using one word for five different concepts. It helps to separate them explicitly:

Concept What it identifies Typical form Who cares
Application version A release of a deployable app 2.3.1, a date, or a build number Operators, support, release notes
API contract version The shape of an interface consumers code against /v1, /v2, header-based External and internal API consumers
Package / library version A published unit others depend on Strict SemVer: 4.1.0 Every downstream build
Deployment identifier One specific deployment event Commit SHA, image digest, run ID Pipelines, incident response
Database schema version The state of a schema's migration history Sequential ID or timestamp Migration tooling, DBAs, on-call

These move at different speeds and serve different audiences. A web application might deploy fifty times between meaningful "versions." An API contract might stay at v1 across thousands of deployments. A library must version every single publish. Treating them as one number produces either version churn nobody reads or breaking changes nobody was warned about.

Where semantic versioning earns its keep

Semantic versioning assigns meaning to the three parts of MAJOR.MINOR.PATCH:

  • MAJOR — incompatible changes; consumers must do work to upgrade
  • MINOR — new functionality, backwards compatible
  • PATCH — fixes, backwards compatible

The value is not the numbering scheme itself; it is that the version number becomes a compatibility promise. When a library moves from 4.1.7 to 4.2.0, a consumer can adopt it with reasonable confidence. When it moves to 5.0.0, they know to read the changelog before upgrading. Dependency managers — npm, NuGet, pip, Terraform module constraints — are built around this promise: a range like ^4.1.0 or ~> 4.1 only works if maintainers keep it honestly.

That is why SemVer matters most for things with programmatic consumers: libraries, packages, shared Terraform modules, internal SDKs, versioned HTTP APIs. The consumer cannot ask you in a meeting whether the upgrade is safe; the version number is the conversation.

Two honest caveats:

  1. SemVer is a claim, not a guarantee. It communicates intent. It does not detect breaking changes for you — that still requires contract tests, API-diff tooling, and review discipline. Hyrum's Law applies: with enough consumers, someone depends on behaviour you never promised, and a technically-compatible release will still break them.
  2. Not everything needs it. A continuously deployed web application with no programmatic consumers gains little from hand-managed version numbers. For that class of system, the commit SHA or an automatic build identifier is often the more honest "version," and forcing SemVer onto it is ceremony. Use SemVer where compatibility communication matters; use deployment identifiers where it does not.

Build once, promote the artifact

The second property a good pipeline provides is artifact immutability: a release is built exactly once, and that same artifact — the same container image digest, the same .nupkg, the same zip — moves through every environment.

flowchart LR
    C[Commit + tag v4.2.0] --> B[CI build and test]
    B --> A[(Artifact v4.2.0
    immutable, checksummed)]
    A --> T[Test]
    T --> S[Staging]
    S --> P[Production]

The anti-pattern is rebuilding per environment: one build for staging, a fresh build from the same branch for production. Even with identical source, the two builds can differ — a floating base image, an unpinned transitive dependency, a different toolchain patch level. When they do, your staging sign-off validated an artifact that never reached production. Promotion of an immutable artifact eliminates that class of failure and makes the audit trail trivial: what is in production is byte-for-byte what was tested.

Configuration, by contrast, should vary per environment — injected at deploy time, not baked into the artifact. "Build once" only works when the artifact is environment-agnostic.

Connect source to artifact with tags and release metadata

A version number is only useful if it resolves unambiguously to source. The mechanics are simple and worth automating:

  • Tag the commit that produced the release (v4.2.0), so the version is resolvable in Git history.
  • Stamp the artifact with the version and commit SHA (assembly metadata, image labels, an /about or /version endpoint).
  • Publish release metadata — changelog, checksums, provenance — alongside the artifact, e.g. as a GitHub Release.

A minimal GitHub Actions release job triggered by a tag:

name: Release

on:
  push:
    tags:
      - "v*.*.*"

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Extract version from tag
        id: version
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

      - name: Build versioned artifact
        run: |
          dotnet pack --configuration Release \
            -p:Version=${{ steps.version.outputs.version }} \
            -p:RepositoryCommit=${{ github.sha }}

      - name: Create GitHub release with artifact
        run: |
          gh release create "$GITHUB_REF_NAME" \
            --generate-notes ./artifacts/*.nupkg
        env:
          GH_TOKEN: ${{ github.token }}

Whether the tag is created by a human decision or derived automatically from Conventional Commits is a team choice; what matters is that tag, version, commit, and artifact are mechanically linked, with no step where a person retypes a number.

Branching strategies are tools, not ideologies

Trunk-based development and Git Flow both have vocal advocates, and both advocacies overreach. They are tools with different sweet spots:

  • Trunk-based development fits products with a single production version, frequent deploys, and good feature-flag discipline. It minimizes merge debt and keeps integration continuous.
  • Release branches remain genuinely useful when you support multiple production versions simultaneously (an installed product, a versioned SDK with an LTS line) or when a regulated release needs a stabilization window while trunk keeps moving. Backporting a fix to release/4.x while 5.x develops on trunk is not legacy thinking; it is what supporting 4.x means.
  • Full Git Flow — permanent develop, release branches, hotfix branches — is more machinery than most teams need, but the underlying pattern is not wrong, just frequently over-applied.

The correct strategy falls out of four questions: What kind of product is it (SaaS, installed, library)? What is the deployment model (continuous, batched, customer-controlled)? What compatibility commitments have you made (one live version, or several supported in parallel)? And what do operations require (stabilization freezes, regulatory audit, hotfix SLAs)? A SaaS team answering those questions will usually land on trunk-based development with tags. A library maintainer supporting two major versions will land on trunk plus release branches. Neither should feel guilty about it.

APIs and databases deserve special care

Two areas where "just bump the version" is not enough:

API contracts. The version in the URL or header is a contract version, and a major bump means running two contracts in parallel — you cannot force consumers to migrate on your schedule. That is expensive, so the strong default is to evolve v1 compatibly for as long as possible: add fields, never repurpose them; add endpoints, never change the semantics of existing ones; treat unknown-field tolerance as a consumer requirement. Reserve v2 for genuine redesigns, and plan its deprecation timeline before you ship it.

Database schemas. Schema versions are sequential and directional, not semantic — migration 042 follows 041, and what matters is compatibility with the application versions running during the deployment. Rolling deployments mean old and new code briefly share one database, so migrations need the expand–contract pattern: add the new column (compatible with old code), deploy code that writes both, backfill, then remove the old column releases later. A schema change that requires simultaneous code-and-schema cutover is the migration equivalent of a breaking API change, and should be treated with the same suspicion.

What a good pipeline gives you

Pulled together, the release-producing pipeline looks like this:

  1. Every merge to main builds and tests a candidate artifact.
  2. A release is an explicit, cheap act: a tag (human or automated) that versions the artifact, stamps it with provenance, and publishes it with generated release notes.
  3. The same immutable artifact is promoted through environments; only configuration differs.
  4. Version numbers follow SemVer where there are programmatic consumers, and are honest deployment identifiers where there are not.
  5. Any running system can be traced to an artifact, a version, a tag, and a commit in one step each.

None of this slows delivery down. Done well, it makes releasing safer and more boring: rollbacks become "promote the previous artifact," audits become queries instead of archaeology, and consumers of your APIs and libraries get told — by the version number, before they upgrade — whether a change will hurt.

That is the standard worth holding a pipeline to. Not "it deploys," but "it produces releases we can trust."

References