Artifacts

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:

  1. Local — every published package lands here automatically
  2. Prerelease — packages that passed integration tests
  3. 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 — accepts 2.x.x where x >= stated values. Good for libraries you trust.
  • Tilde ~3.4.0 — accepts 3.4.x patches 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

  1. 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.

  2. Enforce SemVer with API compatibility checks. For NuGet packages, use tools like Microsoft.DotNet.ApiCompat to detect breaking changes at build time. If the public API surface changed incompatibly, fail the build unless the major version was bumped.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. Separate package publishing from application deployment. Package versions and application versions serve different audiences. Your API client library at 2.3.1 might be consumed by applications at version 4.0.0, 1.12.0, and 3.5.2. Keep these version streams independent.

References

Powered by Contentful