Package Versioning Best Practices in Azure DevOps
A comprehensive guide to package versioning in Azure DevOps covering automatic version generation, GitVersion, pre-release workflows, and promotion through feed views.
Package Versioning Best Practices in Azure DevOps
Package versioning sounds straightforward until you are three months into a project and realize that twelve teams are consuming your internal library, nobody knows which version is stable, and someone just published a breaking change as a patch bump. I have seen this exact scenario play out more times than I care to admit.
Getting versioning right is not just about slapping numbers on packages. It is about communication. A version number tells consumers whether they can safely upgrade, whether new features are available, and whether something fundamentally changed. Azure DevOps gives you powerful tools to automate this entire workflow, but you need to understand the strategies before you wire up the pipelines.
Semantic Versioning Fundamentals
If you are publishing packages in Azure Artifacts, semantic versioning (SemVer) should be your baseline. The format is MAJOR.MINOR.PATCH:
- MAJOR — breaking changes that require consumer code modifications
- MINOR — new features that are backward-compatible
- PATCH — bug fixes with no API surface changes
Pre-release versions extend this with a hyphen suffix: 1.2.0-beta.1, 2.0.0-rc.3. This matters enormously in Azure Artifacts because feed views use pre-release labels to control visibility.
The single biggest mistake I see teams make is treating version numbers as build counters. Version 1.0.347 does not communicate anything useful. It tells consumers nothing about compatibility. Use build metadata (the + suffix in SemVer) for build-specific information: 1.2.0+build.347.
Automatic Version Generation in Pipelines
Using Build Numbers
The simplest approach is deriving versions from pipeline build numbers. This works for teams that do not need strict SemVer compliance:
trigger:
branches:
include:
- main
- feature/*
variables:
majorVersion: 1
minorVersion: 3
name: $(majorVersion).$(minorVersion).$(Rev:r)
stages:
- stage: Build
jobs:
- job: PackageAndPublish
pool:
vmImage: ubuntu-latest
steps:
- script: |
echo "Package version: $(Build.BuildNumber)"
displayName: Display version
- task: Npm@1
inputs:
command: custom
customCommand: version $(Build.BuildNumber) --no-git-tag-version --allow-same-version
displayName: Set npm version
- task: Npm@1
inputs:
command: publish
publishRegistry: useFeed
publishFeed: MyProject/my-npm-feed
displayName: Publish to feed
The $(Rev:r) token auto-increments within each major.minor combination, resetting when you bump either number. It is simple but limited — you have to manually update the major and minor variables.
Using Git Tags
Git tags give you version control that lives alongside your code history:
steps:
- checkout: self
fetchDepth: 0
fetchTags: true
- script: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
echo "Latest tag: $LATEST_TAG"
echo "##vso[task.setvariable variable=packageVersion]$LATEST_TAG"
displayName: Get version from Git tag
- script: |
echo "Building version $(packageVersion)"
displayName: Build with version
This approach requires discipline — someone needs to create the tags — but it keeps version information in Git where it belongs.
GitVersion — The Right Way
GitVersion is the tool I recommend for most teams. It analyzes your Git history, branch names, and merge patterns to automatically calculate the correct semantic version. No manual variables, no tag management, no guesswork.
Install it as a pipeline tool:
steps:
- checkout: self
fetchDepth: 0
- task: gitversion/setup@3
inputs:
versionSpec: '6.x'
displayName: Install GitVersion
- task: gitversion/execute@3
inputs:
useConfigFile: true
configFilePath: GitVersion.yml
displayName: Calculate version
- script: |
echo "SemVer: $(GitVersion.SemVer)"
echo "NuGet version: $(GitVersion.NuGetVersion)"
echo "Major: $(GitVersion.Major)"
echo "Minor: $(GitVersion.Minor)"
echo "Patch: $(GitVersion.Patch)"
echo "Pre-release tag: $(GitVersion.PreReleaseTag)"
displayName: Display calculated version
Create a GitVersion.yml configuration file in your repository root:
mode: ContinuousDeployment
branches:
main:
regex: ^main$
tag: ''
increment: Patch
prevent-increment-of-merged-branch-version: true
develop:
regex: ^develop$
tag: alpha
increment: Minor
feature:
regex: ^feature[/-]
tag: beta.{BranchName}
increment: Inherit
release:
regex: ^release[/-]
tag: rc
increment: None
hotfix:
regex: ^hotfix[/-]
tag: hotfix
increment: Patch
assembly-versioning-scheme: MajorMinorPatch
tag-prefix: v
With this configuration, a commit on develop produces 1.3.0-alpha.4, a feature branch produces 1.3.0-beta.my-feature.1, a release branch produces 1.3.0-rc.1, and a merge to main produces 1.3.0. No human intervention required.
Pre-Release Versions and Feed Views
Azure Artifacts feed views are the mechanism for version promotion. Every feed comes with three default views: Local, Prerelease, and Release. Think of them as quality gates.
Here is the promotion workflow I use on every project:
- Local — every published package lands here automatically
- Prerelease — packages that passed integration tests
- Release — packages approved for production consumption
Configure consumers to pull from specific views by appending the view name to the feed URL:
# NuGet — Release view only
https://pkgs.dev.azure.com/myorg/myproject/_packaging/my-feed@Release/nuget/v3/index.json
# NuGet — Prerelease view (includes Release packages too)
https://pkgs.dev.azure.com/myorg/myproject/_packaging/my-feed@Prerelease/nuget/v3/index.json
# npm — Release view
https://pkgs.dev.azure.com/myorg/myproject/_packaging/my-feed@Release/npm/registry/
Promote packages through views using the Azure CLI in your pipeline:
steps:
- task: AzureCLI@2
inputs:
azureSubscription: my-service-connection
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az artifacts universal publish \
--organization https://dev.azure.com/myorg \
--project myproject \
--scope project \
--feed my-feed \
--name my-package \
--version $(GitVersion.SemVer) \
--description "Release $(GitVersion.SemVer)"
displayName: Promote to Release view
You can also promote via the REST API, which gives you more granular control:
curl -X PATCH \
"https://pkgs.dev.azure.com/myorg/myproject/_apis/packaging/feeds/my-feed/npm/@myorg/my-package/versions/$(packageVersion)?api-version=7.1" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $(System.AccessToken)" \
-d '{"views": {"op": "add", "path": "/views/-", "value": "Release"}}'
Package-Specific Versioning
NuGet Versioning
NuGet has its own versioning quirks. The PackageVersion property in your .csproj file drives the package version:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PackageId>MyOrg.Utilities</PackageId>
<PackageVersion>$(GitVersion_NuGetVersion)</PackageVersion>
<AssemblyVersion>$(GitVersion_AssemblySemVer)</AssemblyVersion>
<FileVersion>$(GitVersion_AssemblySemFileVer)</FileVersion>
<InformationalVersion>$(GitVersion_InformationalVersion)</InformationalVersion>
</PropertyGroup>
</Project>
Pipeline step to pack and push:
- task: DotNetCoreCLI@2
inputs:
command: pack
packagesToPack: '**/*.csproj'
versioningScheme: byEnvVar
versionEnvVar: GitVersion.NuGetVersion
displayName: Pack NuGet packages
- task: NuGetCommand@2
inputs:
command: push
publishVstsFeed: MyProject/my-nuget-feed
allowPackageConflicts: true
displayName: Push to feed
npm Versioning in Pipelines
For npm packages, set the version before publishing:
- script: |
npm version $(GitVersion.SemVer) --no-git-tag-version --allow-same-version
displayName: Set npm package version
- task: Npm@1
inputs:
command: publish
publishRegistry: useFeed
publishFeed: MyProject/my-npm-feed
displayName: Publish npm package
For scoped packages, make sure your .npmrc is configured correctly:
@myorg:registry=https://pkgs.dev.azure.com/myorg/myproject/_packaging/my-npm-feed/npm/registry/
always-auth=true
Maven and Gradle Version Management
Maven versions go in pom.xml. Use the versions-maven-plugin to set them from the pipeline:
- script: |
mvn versions:set -DnewVersion=$(GitVersion.SemVer) -DgenerateBackupPoms=false
displayName: Set Maven version
- task: Maven@4
inputs:
mavenPomFile: pom.xml
goals: deploy
publishJUnitResults: false
mavenFeedAuthenticate: true
mavenAuthenticateFeed: my-maven-feed
displayName: Deploy to Maven feed
For Gradle, pass the version as a property:
- script: |
gradle publish -Pversion=$(GitVersion.SemVer)
displayName: Publish Gradle package
Complete Working Example
Here is a full pipeline that versions an npm package using GitVersion, handles pre-release and release workflows, promotes through feed views, and generates a changelog.
trigger:
branches:
include:
- main
- develop
- feature/*
- release/*
pr:
branches:
include:
- main
- develop
variables:
feedName: 'MyProject/shared-packages'
isMain: ${{ eq(variables['Build.SourceBranch'], 'refs/heads/main') }}
isRelease: ${{ startsWith(variables['Build.SourceBranch'], 'refs/heads/release/') }}
stages:
- stage: Version
displayName: Calculate Version
jobs:
- job: GitVersion
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0
- task: gitversion/setup@3
inputs:
versionSpec: '6.x'
displayName: Install GitVersion
- task: gitversion/execute@3
inputs:
useConfigFile: true
configFilePath: GitVersion.yml
displayName: Calculate SemVer
- script: |
echo "##vso[build.updatebuildnumber]$(GitVersion.SemVer)"
displayName: Update build number
- script: |
echo "Calculated version: $(GitVersion.SemVer)"
echo "Pre-release label: $(GitVersion.PreReleaseLabel)"
echo "NuGet version: $(GitVersion.NuGetVersion)"
displayName: Log version details
- stage: Build
displayName: Build and Test
dependsOn: Version
jobs:
- job: BuildPackage
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0
- task: gitversion/setup@3
inputs:
versionSpec: '6.x'
- task: gitversion/execute@3
inputs:
useConfigFile: true
configFilePath: GitVersion.yml
- task: NodeTool@0
inputs:
versionSpec: '20.x'
displayName: Use Node.js 20
- script: npm ci
displayName: Install dependencies
- script: npm test
displayName: Run tests
- script: |
npm version $(GitVersion.SemVer) --no-git-tag-version --allow-same-version
displayName: Stamp version
- script: |
echo "Generating changelog..."
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREVIOUS_TAG" ]; then
echo "## $(GitVersion.SemVer)" > CHANGELOG_ENTRY.md
echo "" >> CHANGELOG_ENTRY.md
git log $PREVIOUS_TAG..HEAD --pretty=format:"- %s (%h)" --no-merges >> CHANGELOG_ENTRY.md
else
echo "## $(GitVersion.SemVer)" > CHANGELOG_ENTRY.md
echo "" >> CHANGELOG_ENTRY.md
echo "- Initial release" >> CHANGELOG_ENTRY.md
fi
echo ""
echo "--- Changelog Entry ---"
cat CHANGELOG_ENTRY.md
displayName: Generate changelog from commits
- script: npm pack
displayName: Create package tarball
- task: PublishPipelineArtifact@1
inputs:
targetPath: $(System.DefaultWorkingDirectory)
artifact: package-output
displayName: Publish build artifacts
- stage: PublishDev
displayName: Publish to Feed
dependsOn: Build
jobs:
- job: Publish
pool:
vmImage: ubuntu-latest
steps:
- download: current
artifact: package-output
- task: Npm@1
inputs:
command: publish
workingDir: $(Pipeline.Workspace)/package-output
publishRegistry: useFeed
publishFeed: $(feedName)
displayName: Publish package
- stage: PromotePrerelease
displayName: Promote to Prerelease
dependsOn: PublishDev
condition: |
and(
succeeded(),
or(
eq(variables.isMain, true),
eq(variables.isRelease, true)
)
)
jobs:
- job: Promote
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0
- task: gitversion/setup@3
inputs:
versionSpec: '6.x'
- task: gitversion/execute@3
inputs:
useConfigFile: true
configFilePath: GitVersion.yml
- script: |
az devops configure --defaults organization=$(System.CollectionUri) project=$(System.TeamProject)
PACKAGE_NAME=$(node -p "require('./package.json').name")
echo "Promoting $PACKAGE_NAME@$(GitVersion.SemVer) to Prerelease view"
az artifacts universal promote \
--feed $(feedName) \
--name "$PACKAGE_NAME" \
--version $(GitVersion.SemVer) \
--to-view Prerelease
displayName: Promote to Prerelease view
env:
AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)
- stage: PromoteRelease
displayName: Promote to Release
dependsOn: PromotePrerelease
condition: and(succeeded(), eq(variables.isMain, true))
jobs:
- deployment: PromoteToRelease
pool:
vmImage: ubuntu-latest
environment: production-packages
strategy:
runOnce:
deploy:
steps:
- checkout: self
fetchDepth: 0
- task: gitversion/setup@3
inputs:
versionSpec: '6.x'
- task: gitversion/execute@3
inputs:
useConfigFile: true
configFilePath: GitVersion.yml
- script: |
az devops configure --defaults organization=$(System.CollectionUri) project=$(System.TeamProject)
PACKAGE_NAME=$(node -p "require('./package.json').name")
echo "Promoting $PACKAGE_NAME@$(GitVersion.SemVer) to Release view"
az artifacts universal promote \
--feed $(feedName) \
--name "$PACKAGE_NAME" \
--version $(GitVersion.SemVer) \
--to-view Release
displayName: Promote to Release view
env:
AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)
- script: |
git tag -a "v$(GitVersion.SemVer)" -m "Release $(GitVersion.SemVer)"
git push origin "v$(GitVersion.SemVer)"
displayName: Tag release in Git
This pipeline gives you a complete promotion workflow. Feature branches produce beta pre-release packages. Release branches produce release candidates. Merges to main produce clean release versions that get promoted all the way to the Release view, with a manual approval gate via the production-packages environment.
Version Consumption Strategies
When consuming versioned packages, how you specify version ranges matters:
{
"dependencies": {
"@myorg/api-client": "^2.1.0",
"@myorg/ui-components": "~3.4.0",
"@myorg/config-schema": "1.0.0"
}
}
- Caret
^2.1.0— accepts2.x.xwhere x >= stated values. Good for libraries you trust. - Tilde
~3.4.0— accepts3.4.xpatches only. Safer for UI components where minor changes can be visually breaking. - Exact
1.0.0— no flexibility. Use for configuration schemas or anything where drift causes subtle bugs.
For reproducible builds, always commit your lock files (package-lock.json, packages.lock.json, yarn.lock). Use npm ci instead of npm install in pipelines — it installs exactly what the lock file specifies.
Multi-Package Repository Versioning
Monorepos with multiple packages need a versioning strategy. You have two options:
Synchronized versions — all packages share the same version number. Simple but wasteful (bumping a patch in one package bumps everything).
Independent versions — each package has its own version. More accurate but harder to manage.
For independent versioning in a monorepo, configure GitVersion to calculate per-project:
# GitVersion.yml for monorepo
mode: ContinuousDeployment
branches:
main:
tag: ''
increment: Patch
merge-message-formats:
Default: '^Merge pull request .* from .*$'
Then in your pipeline, filter by path changes:
trigger:
branches:
include:
- main
paths:
include:
- packages/api-client/**
variables:
packagePath: packages/api-client
Deprecating Old Versions
When you need to deprecate a package version, do not just delete it. Consumers may have pinned to that exact version. Instead, deprecate it with a message:
# npm deprecation
npm deprecate @myorg/my-package@"< 2.0.0" "Versions before 2.0.0 have a critical security issue. Upgrade to >= 2.0.0"
# NuGet — use the Azure DevOps REST API to delist
curl -X PATCH \
"https://pkgs.dev.azure.com/myorg/_apis/packaging/feeds/my-feed/nuget/packages/MyOrg.Utilities/versions/1.5.0?api-version=7.1" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $PAT" \
-d '{"listed": false}'
Delisted NuGet packages are still restorable by exact version but will not appear in search results. This prevents new adoption without breaking existing consumers.
Common Issues and Troubleshooting
1. GitVersion produces unexpected versions after a merge.
This usually happens when fetchDepth is not set to 0 in your checkout step. GitVersion needs the full Git history to calculate versions correctly. Shallow clones cause it to fall back to 0.1.0. Always use fetchDepth: 0.
2. Package version conflict — "A package with the same version already exists."
Azure Artifacts feeds are immutable by default. You cannot overwrite a published version. For NuGet, set allowPackageConflicts: true on the push task to ignore this error when it is expected (like re-running a failed pipeline). For npm, you need to either unpublish the specific version first or bump the version. The better long-term fix is to make sure your versioning is truly deterministic based on Git state.
3. Pre-release packages not visible to consumers.
Check two things: first, make sure the consumer's feed URL includes the correct view (or the Local view, which shows everything). Second, verify that NuGet consumers have "Include prerelease" checked in Visual Studio, or use the --prerelease flag with dotnet add package. For npm, pre-release versions require an explicit version string — npm install @myorg/[email protected].
4. Version numbers not incrementing between builds.
In ContinuousDeployment mode, GitVersion uses the commit count since the last tag as the pre-release number. If you are squash-merging PRs, a single merge commit might not increment the count as expected. Switch to ContinuousDelivery mode if you want versions that only increment when you explicitly tag, or make sure your branch strategy produces new commits consistently.
5. Feed view promotion fails with 403.
The pipeline's build service identity needs Feed and Upstream Reader (Collaborator) or Feed Publisher (Contributor) permissions on the feed. Go to your feed settings, click Permissions, and add [ProjectName] Build Service (orgName) with the appropriate role.
Best Practices
Use GitVersion or a similar tool for automatic versioning. Manual version management does not scale. A single misconfigured variable in a YAML file should not be able to ship a breaking change as a patch.
Enforce SemVer with API compatibility checks. For NuGet packages, use tools like
Microsoft.DotNet.ApiCompatto detect breaking changes at build time. If the public API surface changed incompatibly, fail the build unless the major version was bumped.Commit your lock files and use deterministic install commands.
npm ci,dotnet restore --locked-mode, and equivalent commands ensure that what you tested is what you deploy.Use feed views as quality gates, not just organizational labels. Wire your Release view promotion to an environment with approval gates. A human should sign off before a package version is marked as production-ready for the whole organization.
Tag releases in Git as part of your pipeline. This creates an audit trail and gives GitVersion the anchoring points it needs for future version calculations. Always use annotated tags (
git tag -a) with meaningful messages.Communicate breaking changes before they ship. Use deprecation notices, changelog entries, and team notifications. A version bump without context forces every consumer to investigate on their own.
Separate package publishing from application deployment. Package versions and application versions serve different audiences. Your API client library at
2.3.1might be consumed by applications at version4.0.0,1.12.0, and3.5.2. Keep these version streams independent.