Code

Versioning and publishing NuGet packages automatically using Azure DevOps Pipelines

Say you’re working on some dotnet projects that you would like to share with the world. Or that you would like to share with other teams in your company. Or your project has multiple git repos and the output of one is the input of another. Creating a NuGet package is an excellent way to zip up and redistribute your build output, but there are a few details about versioning to sort out before that process is streamlined and effortless.

For goals – we’d like new NuGet packages as often as every single time a change is merged. It’d also be ideal if every single package had a unique version number that is related to the build which produced it.

To show an example of setting this up lets build up a solution that publishes a pair of assemblies named Example.Hello.dll and Example.World.dll

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\Example.Hello\Example.Hello.csproj" />
  </ItemGroup>
</Project>

Aside, isn’t it nice how clean the modern project files are? They’ve become very powerful too. In the past you would also need to create a .nuspec for each package. Today, just the two files above should be enough to create packages!

> dotnet pack src\Example.Hello\Example.Hello.csproj
Microsoft (R) Build Engine version 15.8.166+gd4e8d81a88 for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restoring packages for C:\...\Example.Hello.csproj...
  ...
  Example.Hello -> C:\...\Example.Hello.dll
  Successfully created package C:\...\Example.Hello.1.0.0.nupkg'.

> dotnet pack src\Example.World\Example.World.csproj
Microsoft (R) Build Engine version 15.8.166+gd4e8d81a88 for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restoring packages for C:\...\Example.World.csproj...
  ...
  Example.World -> C:\...\Example.World.dll
  Successfully created package 'C:\...\Example.World.1.0.0.nupkg'.

Assign the version from the build number

Next, let’s do something about those version numbers. There are a lot of version numbers involved but they are all based on a Version property. There are also VersionPrefix and VersionSuffix properties available that can be assigned instead, in which case the Version property defaults to VersionPrefix-VersionSuffix.

The dash means the VersionSuffix becomes the SemVer pre-release tag. A good use of the pre-release tag is to label a package as a “ci”, “beta”, or “release candidate”. This enables the consumers to distinguish between supported stable builds and latest edge builds. Packages with a pre-release tag will not be shown on NuGet clients by default until the consumer opts-in with a check-box or command line switch.

Because these two projects we are building are a “suite” that are compiled and shipped together we will want them to have the same version number. The Directory.Build.props and Directory.Build.targets files are a good place to assign those values repo-wide. We’re going to be using Azure DevOps pipelines, formerly VSTS, so lets assume there will be a BUILD_BUILDID environment variable that is going to have a unique number we can include.

<Project>
  <PropertyGroup>
    <MsBuildAllProjects>$(MsBuildAllProjects);$(MsBuildThisFileFullPath)</MsBuildAllProjects>
  </PropertyGroup>
  <PropertyGroup>
    <!-- edit this value to change the current major.minor version -->
    <VersionPrefix>0.1</VersionPrefix>

    <!-- append the build number if it is available -->
    <VersionPrefix Condition=" '$(BUILD_BUILDID)' != '' ">$(VersionPrefix).$(BUILD_BUILDID)</VersionPrefix>
  </PropertyGroup>
</Project>

With that we can create a sln file for convenience, set the environment variable to a test value, and rebuild the projects.

> dotnet new sln
The template "Solution File" was created successfully.

>dotnet sln add src\Example.Hello\Example.Hello.csproj
Project `src\Example.Hello\Example.Hello.csproj` added to the solution.

>dotnet sln add src\Example.World\Example.World.csproj
Project `src\Example.World\Example.World.csproj` added to the solution.

>set BUILD_BUILDID=1234 (or) export BUILD_BUILDID=1234

>dotnet pack
Microsoft (R) Build Engine version 15.8.166+gd4e8d81a88 for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 39.46 ms for C:\...\Example.World.csproj.
  Restore completed in 39.46 ms for C:\...\Example.Hello.csproj.
  Example.Hello -> C:\...\Example.Hello.dll
  Successfully created package 'C:\...\Example.Hello.0.1.1234.nupkg'.
  Example.World -> C:\...\Example.World.dll
  Successfully created package 'C:\...\Example.World.0.1.1234.nupkg'.
Example.World.dll Properties

That doesn’t just change the NuGet package version number. The assembly’s version numbers, and the various version numbers in the PE file header, are all affected.

Use prerelease tag for continuous integration builds

There is one more thing we should add. Let’s introduce a Prerelease environment variable which can be set. When it is set we should put the build number in the VerionSuffix instead of in the VersionPrefix. When we do that we should also left-pad the build number. That’s because in NuGet the pre-release tag is sorted as a string rather than as a number, and otherwise when you jump from build99 to build100 the sorting used to determine the latest build will be wrong.

<Project>
  <PropertyGroup>
    <MsBuildAllProjects>$(MsBuildAllProjects);$(MsBuildThisFileFullPath)</MsBuildAllProjects>
  </PropertyGroup>

  <PropertyGroup>
    <!-- edit this value to change the current MAJOR.MINOR version -->
    <VersionPrefix>0.1</VersionPrefix>
  </PropertyGroup>

  <Choose>
    <When Condition=" '$(Prerelease)' != '' ">
      <PropertyGroup>
        <!-- Prerelease version numbers are MAJOR.MINOR.0-pre-build###### -->
        <VersionSuffix>$(Prerelease)-build$(BUILD_BUILDID.PadLeft(6, '0'))</VersionSuffix>
      </PropertyGroup>
    </When>
    <Otherwise>
      <PropertyGroup>
        <!-- Release version numbers are MAJOR.MINOR.# -->
        <VersionPrefix>$(VersionPrefix).$(BUILD_BUILDID.PadLeft(1, '0'))</VersionPrefix>
      </PropertyGroup>
    </Otherwise>
  </Choose>
</Project>

There! Now on the console you can set Prerelease=ci or export Prerelease=ci and the packages created will be like Example.World.0.1.0-ci-build001234.nupkg. If you change the file extension to .zip you can take a look at the generated .nuspec file it contains. In that you’ll see how values like the package version is correct and the ProjectReference has become a dependency with the correct version as well.

<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
  <metadata>
    <id>Example.World</id>
    <version>0.1.0-ci-build001234</version>
    <!-- ... -->
    <dependencies>
      <group targetFramework=".NETStandard2.0">
        <dependency id="Example.Hello" version="0.1.0-ci-build001234" exclude="Build,Analyzers" />
      </group>
    </dependencies>
  </metadata>
</package>

Automating build and publish with Azure DevOps

With the automatic versioning of packages in place we can now take advantage of it when we automate our build pipelines. To prevent this article from becoming too long, I’ll list the steps I took to set this up, but I won’t go into them in detail here. If there’s interest I can follow up with additional articles detailing how these were configured.

To create the YAML CI build definition, a .vsts-ci.yaml is added to the repo. This file lists the steps that the build agent needs to take. It’s a pretty nice file format and fairly easy to read. Note: when the CI build definition is created it is also given two build variables of Configuration=Release and Prerelease=ci.

steps:
- task: DotNetCoreInstaller@0
  displayName: install dotnet 2.1.401
  inputs:
    packageType: 'sdk'
    version: '2.1.401' 

- task: DotNetCoreCLI@2
  displayName: dotnet build
  inputs:
    command: build

- task: DotNetCoreCLI@2
  displayName: dotnet test
  inputs:
    command: test
    projects: 'test/**/*.csproj'

- task: DotNetCoreCLI@2
  displayName: dotnet pack
  inputs:
    command: pack
    packDirectory: '$(Build.ArtifactStagingDirectory)/packages'

- task: PublishBuildArtifacts@1
  displayName: publish artifacts
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
  inputs:
    PathtoPublish: '$(Build.ArtifactStagingDirectory)'

Those steps are enough to build, test, and pack the projects in the repo. The PublishBuildArtifacts task will post the generated nupkg files which can be downloaded from the artifacts drop-down on any build that succeeded.

The very last thing to hook up is a task to push the packages to a public NuGet feed hosted on MyGet.org. This involves adding a build definition variable named myget-apikey and adding the following step to the CI YAML. Be sure to mark the build definition variable as secret by clicking the padlock when you add it! That ensures it will never be revealed.

- task: DotNetCoreCLI@2
  displayName: dotnet nuget push
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
  inputs:
    command: custom
    custom: nuget
    arguments: push $(Build.ArtifactStagingDirectory)/packages/*.nupkg --source https://www.myget.org/F/lodejard-greeting/api/v3/index.json --no-service-endpoint --api-key $(myget-apikey)

And there you have it! Every time code is moved to the master branch a new set of NuGet packages are created and published. To depend on the latest stable release a consumer can use Version="0.1.*" in their dependency, and to depend on the absolute latest CI build Version="0.1.0-ci-*" can be used.

This examples used public GitHub for code and public MyGet for packages, but those services have private options as well. Or if what you’re doing is more work-related Azure DevOps provides options for git repos and NuGet feeds which are private and secured using your company’s AAD login credentials.

3 thoughts on “Versioning and publishing NuGet packages automatically using Azure DevOps Pipelines

  1. Very good article!

    I’ve two questions/remarks:

    1.
    I tried to use the official task for pushing to MyGet, however I run into issues, see this link https://github.com/MicrosoftDocs/vsts-docs/issues/2051
    Did you have the same issues with that official task?

    2.
    When I use your code for pushing to NuGet, I get an error in the build-pipeline like:
    error: File does not exist (D:\a\1\a/packages/*.nupkg).

    Note that when I change the this fragment
    $(Build.ArtifactStagingDirectory)/packages/*.nupkg
    into
    $(Build.ArtifactStagingDirectory)\packages\*.nupkg

    It works.

    Note that I’m using vmImage: ‘vs2017-win2016’.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.