Most teams have a version number that lives in a file: <Version> in a .csproj, "version" in a package.json, a line in a props file. And most of those teams have a convention: bump it in your pull request.
That convention produces merge conflicts on a schedule. This article explains why, gives you the merge order that limits the damage, and then makes the case for the real fix: stop writing version numbers by hand, and let the pipeline derive them.
Why a hand-edited version conflicts
Picture two branches cut from the same commit, where the version is 2.4.0.
Your feature branch bumps it:
<Version>2.5.0</Version>
Meanwhile a teammate merges a fix to main and bumps the same line:
<Version>2.4.1</Version>
Two branches changed the same line of the same file to different values. Git has no way to know which one you meant, so when the branches meet, you get a conflict. Not sometimes. Every time two branches bump the version independently, which on an active repository is most of the time.
Worse, resolving it by hand invites a second mistake: picking a number that was never released, or a number lower than the one already on main.
The tactical fix: merge first, then bump
If you are stuck with a version file for now, the order of operations matters.
- Bring your branch up to date with the target branch first: merge it in, or rebase onto it.
- Read the version from the merged file. That is the current number.
- Bump from there, and commit.
git fetch origin
git merge origin/main # or: git rebase origin/main
# open the version file, read the merged value, bump it
git commit -am "chore: bump version to 2.5.0"
Your bump now lands on top of the number main already has. One writer, one line, no conflict. If you want certainty before touching anything, do a trial merge locally (git merge --no-commit --no-ff origin/main, inspect, then git merge --abort) instead of guessing.
This works. It also has a hard limit: it only holds until two pull requests race each other. Both authors merge main, both bump to 2.5.0, the first one merges, and the second is back to a conflict, or worse, a duplicate version number. The ordering trick reduces the frequency of the problem. It does not remove the cause.
The real fix: derive the version, don’t write it
The cause is that a release fact, “what version is this build?”, is being recorded as a manual edit in every pull request. A version number is release metadata. It answers a question about the release, not about the change. The fix is to compute it from information that already exists: git history, tags, and commit messages.
Before choosing a tool, it helps to be precise about what a version should communicate. Semantic Versioning defines the common contract:
- MAJOR version “when you make incompatible API changes”
- MINOR version “when you add functionality in a backward compatible manner”
- PATCH version “when you make backward compatible bug fixes”
And one rule that people forget: “Major version zero (0.y.z) is for initial development. Anything MAY change at any time.” A 0.x version promises nothing to its consumers.
Every tool below produces versions in this shape. They differ in where the decision lives.
Option 1: the tag is the release (MinVer)
MinVer is the smallest step away from a version file for .NET projects. You add one package, delete the <Version> line, and the version comes from git tags.
dotnet add package MinVer
The MinVer documentation recommends the package reference include PrivateAssets="All", so it doesn’t flow to consumers of your package.
From then on:
- A commit with a version tag gets exactly that version. To release, you tag:
git tag 2.5.0and push the tag. - A commit after a tag gets a prerelease version that includes the height, the number of commits since the latest tag. Every build is unique and sorts after the last release.
- A repository with no tag yet starts from “the default version
0.0.0-alpha.0… with height added.”
If your tags carry a prefix, set it with the MinVerTagPrefix property:
<PropertyGroup>
<MinVerTagPrefix>v</MinVerTagPrefix>
</PropertyGroup>
One CI detail matters. MinVer reads history, and the docs say to configure CI systems “to fetch a sufficient number of commits.” In GitHub Actions the default checkout is shallow, so set fetch-depth: 0 on the checkout step, or every build will look like an untagged repository.
Good fit: libraries and services where “we released when we tagged” is already how the team thinks.
Option 2: versions that depend on the branch (GitVersion)
GitVersion describes itself as “a tool that generates a Semantic Version number based on your Git history.” Where MinVer has essentially one rule, GitVersion has a configurable set of them.
According to its how-it-works documentation, if the current commit is tagged, the tag is used. Otherwise it evaluates several strategies, such as the latest tagged commit, a version in the branch name, merge messages and configured next versions, and picks the highest base version before incrementing it. Version 3 is “driven by configuration, meaning most of the behaviors in GitVersion can be tweaked to work the way you want,” including branch-specific behavior for workflows like GitFlow and GitHubFlow.
It runs from the command line, as an MSBuild task during the build, inside CI pipelines, or as a library.
Good fit: repositories with long-lived release or develop branches, where a build on develop and a build on release/2.5 should get different version shapes. Poor fit: a small trunk-based repository, where the configuration is more machinery than the problem needs.
Option 3: intent in a file, numbers from history (Nerdbank.GitVersioning)
Nerdbank.GitVersioning keeps a file, but a very different one. Versions are “generated from a single, simple version.json file,” based on “an author-defined major.minor version and an optional unstable tag,” combined with an incrementing git “height.”
That is the key difference from the file you have now. version.json holds the intent, such as “we are working on 2.5”, and changes only when that intent changes, which is rarely. The number that changes on every commit is computed. Pull requests stop editing it, so they stop conflicting on it. And no tags are required at all.
Good fit: teams that want the next version to be a reviewed, explicit decision in the repository, without tagging discipline.
Option 4: let commit messages decide (release-please, semantic-release)
The last family moves the decision into commit messages, using Conventional Commits. The mapping to versions is part of that specification:
- a
fixcommit “correlates with PATCH in Semantic Versioning” - a
featcommit “correlates with MINOR” - a
BREAKING CHANGEfooter, or a!after the type, introduces “a breaking API change (correlating with MAJOR)”
feat(api): add bulk export endpoint
fix: handle empty payloads in the importer
feat!: drop support for the v1 token format
release-please reads that history and opens a release pull request that updates the changelog and the version files. When you merge it, it tags the commit and creates a GitHub Release. The version file still exists, but only one actor edits it: the release pull request. The easiest way to run it is its GitHub Action.
One honest caveat for .NET teams: release-please’s list of release types covers ecosystems like Node, Python, Go, Java, Rust and PHP, plus a simple type for “a repository with a version.txt and a CHANGELOG.md”. It does not list a .NET type. Treat it as the natural choice for Node and mixed repositories, not as a drop-in for .csproj versioning.
semantic-release goes one step further. It “automates the whole package release workflow” and runs in CI after each successful build on a release branch: it reads the commits, works out the next version, generates release notes and publishes. There is no release pull request to merge; a merge to the release branch is the release.
Good fit: teams already disciplined about commit messages, who want the changelog for free. Poor fit: teams where commit messages are an afterthought. These tools are exactly as accurate as the commits they read.
How to choose
| If you want | Use |
|---|---|
| .NET, minimal setup, a tag is a release | MinVer |
| Different version rules per branch | GitVersion |
| The next version as an explicit, reviewed decision in the repo | Nerdbank.GitVersioning |
| Version and changelog from commit messages, via a release pull request | release-please |
| Fully automated release on every merge to a release branch | semantic-release |
If you are unsure, start with the simplest option that matches how your team already releases. Moving from MinVer to GitVersion later is cheap. Untangling a pipeline full of features you never needed is not.
What changes in your pull requests
With any of these in place, a pull request carries code, and nothing else. Nobody bumps anything. The release intent moves to where it belongs: a tag, a branch, a version.json that changes once per release cycle, or the commit messages themselves.
The merge conflicts disappear because the line they fought over no longer exists in your branches.
Summary
- A version number edited by hand in every pull request conflicts whenever two branches bump it, which on a busy repository is most of the time.
- If you must keep a version file for now: merge the target branch first, read the merged value, then bump. It reduces conflicts, but it can’t prevent two pull requests racing.
- The real fix is to derive the version: from tags with MinVer, from branch-aware rules with GitVersion, from a
version.jsonplus git height with Nerdbank.GitVersioning, or from Conventional Commits with release-please or semantic-release. - Whatever you choose, make sure CI checks out enough git history for the tool to see your tags.
A version number answers a question about a release. Let the release pipeline answer it.