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:
- 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.
- 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
/aboutor/versionendpoint). - 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.xwhile5.xdevelops on trunk is not legacy thinking; it is what supporting4.xmeans. - 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:
- Every merge to
mainbuilds and tests a candidate artifact. - 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.
- The same immutable artifact is promoted through environments; only configuration differs.
- Version numbers follow SemVer where there are programmatic consumers, and are honest deployment identifiers where there are not.
- 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¶
- Semantic Versioning 2.0.0
- Hyrum's Law
- Conventional Commits
- Keep a Changelog
- Jez Humble and David Farley, Continuous Delivery (Addison-Wesley, 2010)
- Trunk Based Development
- Refactoring Databases: Evolutionary Database Design (expand–contract)