Version every build: semantic-release on main, zerv everywhere else

Every build should carry a version.

A version is the shortest complete answer to “what is this?”. One string that says which tagged state the artifact descends from, how much work sits on top of it, and what branch it came from. A human can say it out loud during an incident, and a machine can compare two of them and know which is newer.

Release automation agrees with that definition. It is why every merge to main comes out the other end with a version, a tag, and a changelog entry. But the same pipeline builds far more artifacts on feature branches, release branches, and hotfixes than it ever builds on main, and those get a run ID, a branch name, a sha. The information a version needs sits in the repository the whole time. Reading it costs one command, and it lets every build describe itself: what it descended from, how much work it carries, whether it is newer than the one running in staging.

That is an arbitrary place to draw the line, and it is the gap this article is about.

What semantic-release promises, and what it does promise

semantic-release is the famous tool here, and it is excellent at its actual job. It reads conventional commits, decides the next version, writes the changelog, tags the release, and publishes. Fully automatic, fully correct.

Notice what that job is. semantic-release answers the question “what version is this release?” The question a feature-branch Docker image asks is different: “what version is this exact Git state?” semantic-release never hears that question, because CI builds artifacts on every push and releases happen on main, and between those two facts sits a large population of builds that will never be releases.

One fairness note: semantic-release does support prereleases.

Note

Name a branch beta, configure it as a prerelease branch, and conventional commits on that branch publish 1.0.0-beta.1, beta.2, and so on to a beta distribution channel. That is real, and it works, as long as everything comes from that one enumerated branch.

The moment it does not, the scheme runs out. Suppose the next candidate starts over from main on a new branch, a technically different branch from beta. There is no channel for it. To get a beta.3 at all, the work has to be merged back into the beta branch first. And even then, beta.3 counts releases on that branch, not commits, so it cannot say which commit it came from or how it relates to beta.2. Sometimes a beta.3 really is built on top of beta.2; sometimes it started over from main and only passed through the branch. The string looks identical either way:

gitGraph
    commit id: "1.0.0" tag: "v1.0.0"
    branch beta order: 2
    checkout beta
    commit id: "1.0.0-beta.1"
    commit id: "1.0.0-beta.2"
    commit id: "1.0.0-beta.3"
Case A: beta.3 built on top of beta.2, one continuous lineage
gitGraph
    commit id: "1.0.0" tag: "v1.0.0"
    branch beta order: 2
    checkout beta
    commit id: "1.0.0-beta.1"
    commit id: "1.0.0-beta.2"
    checkout main
    branch fresh order: 3
    checkout fresh
    commit id: "candidate work"
    checkout beta
    merge fresh id: "1.0.0-beta.3"
Case B: beta.3 started over from main and only passed through the beta branch: same version string, different history

A string like 1.0.0-beta.2 identifies a channel, not a Git state, and a Git state is exactly what a build needs to be.

To its credit, this is not an oversight. It is the stated model, in so many words: the project’s own supported-branching page recommends release-from-trunk and files git-flow-style branch orchestration under unsupported. semantic-release is built for trunk-based continuous delivery: release from trunk, verify artifacts in isolation, roll forward instead of maintaining branches. The maintainers are consistent about it. When teams asked for versioning on feature or release branches, the answer was that varying configuration between branches is not officially supported and that they do not intend to invest the effort, and a separate thread about release/vX.Y.Z branches was told, plainly, that you will fight semantic-release if you use it that way.

They are right by their own model. The problem is that the model is narrower than the teams who adopt the tool. Plenty of repositories run release branches because they ship to customers on schedules. Plenty run long-lived develop integration branches. Plenty just want a version on the PR build so the artifact can be traced. Those teams install semantic-release because it is the default answer to “automate my versions,” and it automates exactly one branch.

The question that fills the gap

The question “what version is this exact Git state?” can be answered mechanically, without reading a single commit message. A Git state is characterized by four facts: 1. the last tag, 2. the distance from it, 3. the branch you are on, and 4. whether the working tree is dirty. From those four facts alone you can produce a version string that is meaningful and traceable.

That is the entire idea behind zerv, and stating it plainly is also the honest description of its limits: zerv computes versions, it does not decide releases. It never writes tags, never parses commit messages, never publishes. It reads the tag that semantic-release (or you) left behind and answers the question the release tool does not hear.

zerv flow is the automated mode. On a repository tagged v1.0.0, from the project’s quick start, the current branch determines the output:

zerv flow
# → 1.0.0 (on main branch with tag v1.0.0)
# → 1.0.1-rc.1.post.1 (on release branch with pre-release tag)
# → 1.0.1-beta.1.post.3+develop.3.gf297dd0 (on develop branch)
# → 1.0.1-alpha.59394.post.1+feature.new.auth.1.g4e9af24 (on feature branch)
# → 1.0.1-alpha.17015.post.1.dev.1764382150+feature.dirty.work.1.g54c499a (on dirty feature branch)

Every piece of that string is information, not decoration. On a clean tree every version has the shape 1.0.1-<prerelease-label>.<prerelease-num>.post.N:

  • The base (1.0.1) comes from the last tag.
  • The prerelease label identifies what kind of branch it is, following the familiar maturity ladder: feature work is alpha, integration is beta, release preparation is rc. Everything defaults to alpha, and configurable patterns map develop to beta and release/* to rc. It also does quiet SemVer work: a prerelease sorts below its own release, so 1.0.1-alpha.x always reads as “before 1.0.1”.
  • The prerelease number says which branch it is, because the label alone cannot: every feature branch is alpha, so something has to disambiguate them. For release/1 it is the number in the name, so the branch yields rc.1. For every other branch zerv hashes the branch name into a stable integer, so one feature branch always produces alpha.59394 and a different one never does: two branches building side by side cannot share a version, and the same branch keeps its identity everywhere it goes.
  • post.N is the distance from the reference point.
  • The dev.<timestamp> segment appears only when the working tree is dirty.

Everything before the + already pins the Git state completely: base, branch identity, and distance. The build context after + is optional, and what it adds is readability: the branch name and commit hash spelled out, so a human tracing an artifact never has to decode them from numbers.

Notice the number is an identity, not a count: semantic-release’s beta.2 counts releases on a channel, zerv’s alpha.59394 addresses the branch itself.

One property matters more than the format. zerv is deterministic: the same Git state always yields the same version, no commit-message convention required. A repository with messy history drops zerv in without retraining anyone.

The whole picture in one history, straight from the zerv docs: features carry alpha, the develop integration carries beta, release preparation carries rc, emergency fixes branch from main and release clean, and main itself only ever holds clean versions. Every commit on every branch has a version at every moment, including the uncommitted ones.

gitGraph
    commit id: "1.0.0"
    branch develop order: 3
    checkout develop
    commit id: "1.0.1-beta.1.post.1"
    branch feature/auth order: 4
    checkout feature/auth
    commit id: "1.0.1-alpha.92409.post.2"
    commit id: "1.0.1-alpha.92409.post.3"
    checkout develop
    merge feature/auth id: "1.0.1-beta.1.post.3" tag: "feature merged"
    checkout main
    branch hotfix/critical order: 1
    checkout hotfix/critical
    commit id: "1.0.1-alpha.11477.post.1"
    checkout main
    merge hotfix/critical id: "1.0.1" tag: "hotfix released"
    checkout develop
    merge main id: "1.0.2-beta.1.post.4" tag: "sync main"
    commit id: "1.0.2-beta.1.post.5"
    branch release/1 order: 2
    checkout release/1
    commit id: "1.0.2-rc.1.post.1"
    commit id: "1.0.2-rc.1.post.2"
    commit id: "1.0.2-rc.1.post.3"
    checkout main
    merge release/1 id: "1.1.0" tag: "release 1.1.0"
    checkout develop
    merge main id: "1.1.1-beta.1.post.1" tag: "sync release"
One GitFlow history under zerv flow: every branch carries a version, from alpha features to rc release candidates to clean releases on main

One state, every format

Different pipeline stages want different version formats. npm wants SemVer, Python insists on PEP 440, and Docker accepts something SemVer-shaped but not SemVer itself. Deployment manifests often want something CalVer-shaped. The usual workaround is three tools or a hand-rolled script.

Note

A Docker tag cannot carry the + that marks build metadata, so the build context gets glued on with a - instead.

zerv computes the Git state once into a semantic payload, then renders any format from it:

ZERV_RON=$(zerv flow --output-format zerv)

echo $ZERV_RON | zerv version --source stdin --output-format semver
# → 1.0.1-alpha.17015.post.1.dev.1764382150+feature.dirty.work.1.g54c499a

echo $ZERV_RON | zerv version --source stdin --output-format pep440
# → 1.0.1a17015.post1.dev1764382150+feature.dirty.work.1.g54c499a

echo $ZERV_RON | zerv version --source stdin --output-template "{{ semver_obj.docker }}"
# → 1.0.1-alpha.17015.post.1.dev.1764382150-feature.dirty.work.1.g54c499a

echo $ZERV_RON | zerv version --source stdin --output-template "app:{{ major }}.{{ minor }}.{{ patch }}"
# → app:1.0.1

Same state, semver and PEP 440, standards-compliant and guaranteed consistent with each other. The Docker rendering is the same string with the + swapped for a -, exactly the substitution the tag grammar demands. And when a consumer wants none of the standards, a template renders anything from the same payload. CalVer, v-prefixed major tags, deploy manifest lines: all pure re-renders of the one resolved state. The version is computed once and fanned out, rather than computed separately per tool with room to disagree.

The wiring: both tools, one pipeline

In my own repositories the two tools run in separate workflows, each owning one branch population. On pull requests, ci.yml runs zerv alone:

# ci.yml: pull requests
zerv-versioning:
    uses: wislertt/zerv/.github/workflows/shared-zerv-versioning.yml@9d4743e # v0.8.29
    with:
        schema: standard-base-prerelease-post-dev
        # standard-base-prerelease-post             → 1.0.3-rc.1.post.2
        # standard-base-prerelease-post-dev         → 1.0.3-alpha.59394.post.1.dev.1764382150
        # standard-base-prerelease-post-dev-context → 1.0.3-alpha.59394.post.1.dev.1764382150+feature.auth.1.g4e9af24
        # no schema → standard, which auto-detects which parts to include

show-versions:
    needs: zerv-versioning
    steps:
        - run: |
              echo "semver: ${{ fromJson(needs.zerv-versioning.outputs.versions).semver }}"
              # semver: 1.0.3-alpha.59394.post.1.dev.1764382150
              echo "pep440: ${{ fromJson(needs.zerv-versioning.outputs.versions).pep440 }}"
              # pep440: 1.0.3a59394.post1.dev1764382150

semantic-release never hears about a pull request. zerv versions the PR head alone, and the last job just echoes the versions output: one Git state, twice, once per dialect.

On main, cd.yml runs both, in order:

# cd.yml: pushes to main
semantic-release:
    uses: wislertt/zerv/.github/workflows/shared-semantic-release.yml@9d4743e # v0.8.29

zerv-versioning:
    needs: semantic-release
    if: needs.semantic-release.outputs.is_valid_semantic_release == 'true'
    uses: wislertt/zerv/.github/workflows/shared-zerv-versioning.yml@9d4743e # v0.8.29

show-versions:
    needs: zerv-versioning
    steps:
        - run: |
              echo "semver:        ${{ fromJson(needs.zerv-versioning.outputs.versions).semver }}"
              # semver:        1.0.2
              echo "pep440:        ${{ fromJson(needs.zerv-versioning.outputs.versions).pep440 }}"
              # pep440:        1.0.2
              echo "v_major:       ${{ fromJson(needs.zerv-versioning.outputs.versions).v_major }}"
              # v_major:       v1
              echo "v_major_minor: ${{ fromJson(needs.zerv-versioning.outputs.versions).v_major_minor }}"
              # v_major_minor: v1.0

On main, semantic-release runs first and zerv runs after it, gated by that if: on the release validity, so the release build gets a version derived from the fresh tag. There semver and PEP 440 would echo identically: a clean release carries no prerelease, so the interesting formats are the moving Git tags. The full file also hands the PEP 440 rendering to the PyPI publish job.

The shared workflows are pinned by commit SHA with the version in a comment, which is the same distribution pattern as any versioned dependency: the improvement lands in one repository, consumers take it deliberately.

The version line that never changes

The last piece is the repository’s own metadata. A pyproject.toml conventionally carries version = "1.2.3", and that line is a lie waiting to happen: bump scripts, merge conflicts, a release commit that forgets the bump.

zerv’s own repository ends that with the most boring line in this article:

[project]
name = "zerv-version"
version = "0.0.0" # Use git tag

The version in the file never changes. The tag is the single source of truth, the tooling computes everything else, and the static field exists only so tooling that requires a literal version has something valid to read. The package itself does not carry the placeholder: the publish job passes the PEP 440 rendering from the last section to the build, and the wheel on PyPI wears the real version. One source of truth, everything else derived, is the same principle as the payload section above, applied one level down.

Against the alternatives

The comparison deserves honesty, because the space has real tools with real claims.

git describe is the primitive everyone reaches for first. It gives you v1.0.0-14-gf297dd0: tag, distance, commit. That is raw material, not a version. It is not SemVer, not PEP 440, carries no branch information, and every consumer has to parse it. zerv is, roughly, git describe with the semantics filled in.

setuptools-scm is the right tool for a narrower job: it versions the Python package at build time, computed from Git, written into the package metadata. If the package is the only artifact your pipeline produces, stop there, it is excellent. The gap opens when the version is needed outside package metadata: Docker images, deploy manifests, crates, npm packages. That either means duplicating the computation per ecosystem or choosing a tool that is not inside the Python build.

dunamai is the closest in spirit, a library and CLI producing standards-compliant versions from VCS state. It wins on breadth: Mercurial, Subversion, Fossil, more. Where it differs is branch semantics and runtime. Its prerelease label comes from the last tag, with the branch available only as a substitution variable in custom formats, while zerv’s flow mode derives label and number from branch patterns directly, which is what makes develop/feature/release branches come out with different, meaningful labels. And zerv is one static Rust binary; dunamai needs a Python environment in whatever job needs a version.

poetry-dynamic-versioning is dunamai packaged as a Poetry plugin: one language, one build tool, package metadata only. It is also where zerv drew its inspiration from. zerv keeps the idea, a version computed from the Git state, and drops the boundary: any repository, any artifact, no Poetry or Python in the loop.

None of these, including zerv, writes changelogs or decides version numbers from commit history. That remains semantic-release’s job, on main, exactly as before.

Should you add it?

The skip-if list first, because this tool is easy to not need.

Trunk-based CD where nothing but main ever ships: no release branches, no long-lived develop, and no pre-merge builds deployed or published anywhere. semantic-release covers main, main is all there is, and adding a second tool is ceremony. Package-only Python project: setuptools-scm is simpler and sufficient. No CI artifacts worth tracing: nothing here to fix.

The use-it-if list: CI builds artifacts on branches you cannot name by version today, a feature build deployed to dev or nonprod before merge counts, so does a prerelease package published so consumers can try the work early. You run release branches or a develop branch that semantic-release will fight you over. You maintain mixed-language pipelines that need one version in three formats. The change is one workflow job and one static version line, and every build you produce from now on answers the question the run ID could not.

That was the whole gap. Releases always had versions. Now builds do too.