erik lieben

Erik Lieben software developer @Effectory

Automate your release flow of NuGet packages using Azure DevOps and Node's semantic-release

Published , 18 min read (4100 words)

In the NodeJS ecosystem, a great solution is available for automating the workflow of releasing packages, explicitly concerning the versioning of packages, named semantic-release.

All it takes for you to use this in your .NET project is a willingness to accept a little bit of JavaScript in your .NET deployment pipeline. A well worth exception you should be willing to take to improve your overall development experience.

In this blog post, I will take you through my setup for a project that builds & publishes a NuGet package using Azure DevOps to an Azure DevOps artifact feed.

Semantic-release you say? #

According to its tagline, semantic-release is a fully automated version management and package publishing system. You can configure it to manage your entire workflow around the versioning of your NuGet packages. All based upon the git commit message (pull request title in our case) you write to save your changes.

The versioning structure is based upon semantic versioning 2.0, which, as stated on their site, follows the following pattern:

Given a version number MAJOR.MINOR.PATCH, increment the:

MAJOR version when you make incompatible API changes,
MINOR version when you add functionality in a backward-compatible manner, and
PATCH version when you make backward-compatible bug fixes.

If you are using NuGet 4.3.0+ and Visual Studio 2017 version 15.3+, NuGet has support for the same versioning structure, as can be read in the Package versioning - Version basics section of the documentation site.

How do I use it? #

After the configuration described in this blog post, the pipeline will use your PR (pull request) title to determine if a new package needs to be released and the version number of that package.

Your PR titles will need to be written confirming a convention. There are many preset conventions to pick from; the default is the Angular Commit Message Conventions format. In this blog post, we will use the preset conventionalcommits, which is the barest one.

Our configuration for the convention will mean that your titles need to confirm to:

<type>(<scope>): <short summary>

Where both the <type> and <short summary> fields are mandatory, and the (<scope>) field is an optional field. The scope field can be the area or scope of the package you are working on, or in the case of a repository that shares the source multiple packages, this might be the package name.

Create a release, bump the version #

If you want to create a release, the <type> field of the PR title needs to be one of the following:

These are the ones we will create as defaults in this blog post, but you can adjust these to your liking.

Commit work, that should not cause a version bump #

Next to the above PR titles, which create a new release for you, you might also have work that won't require an immediate release—for example, code style or some non-crucial documentation changes.

We will add the following types for those use cases:

What if I forget the format/ convention? #

If you want to follow the convention by the letter, that's up to you because semantic-release will ignore anything other than the setup types.

So when you start to use the convention as a team, and from time to time you forget about it, it won't break anything. It won't release your packages or leave the added PRs out of the documentation, but the build won't break due to this.

What if my PR breaks the pipeline and I need to fix it? #

In the unfortunate case where the modifications you've made in your PR bring the state of the main branch to a non-releasable condition and the Azure DevOps pipeline that runs your PR failed to block this.

No additional steps are required to fix the above issue other than to fix the actual problems that brought your pipeline into a breaking condition with a new PR of which the title starts with fix:.

Semantic-release will, in this case, retry its actions and perform the bump that includes the previous change.

So far, we have covered the information needed for you to get started, so we will move on to how to configure this. If you want to see the information covered so far in action, see the section You're now all set.

How do I configure it? #

To configure semantic release in your .NET project, a package.json file is required in the root folder of your repository. By default, there is no requirement to perform npm install (which will retrieve these packages and install them in the node_modules folder on your machine). However, if you want to debug the workflow or run it locally, this is required.

json
{
"name": "nuget-package",
"private": true,
"scripts": {
"ci:release": "semantic-release"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/commit-analyzer": "^9.0.2",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/release-notes-generator": "^10.0.3",
"semantic-release": "^19.0.2",
}
}
json
{
"name": "nuget-package",
"private": true,
"scripts": {
"ci:release": "semantic-release"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/commit-analyzer": "^9.0.2",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/release-notes-generator": "^10.0.3",
"semantic-release": "^19.0.2",
}
}

This file contains a couple of properties; the name field can be anything you like that confirms to the standard naming format of an npm package. It's not used by the release workflow or will appear anywhere in your final release.

The private property set to truemakes sure that if you accidentally try to release this as an npm package, it will refuse to publish the package.

The package file contains one script that is defined, ci:release, which executes the semantic-release package/code. The Azure DevOps pipeline only calls this script; thus, there is no need to run this script locally (only needed if you want to debug your semantic release setup locally).

The devDependencies section contains the packages required to run our workflow.

Package nameDescription
semantic-releaseThis is the main semantic release executable
@semantic-release/commit-analyzerplugin to analyse the commit message format
@semantic-release/release-notes-generatorplugin to generate the release notes history
@semantic-release/changelogplugin to generate a CHANGELOG.md file/ document with version history
@semantic-release/gitplugin to perform a commit with the CHANGELOG.md & new version info
@semantic-release/execplugin to hook into stages of the version upgrade workflow and execute scripts

Next up, we need to set up semantic-release and add some adjustments to make it work together with Azure DevOps. Create a file named release.config.js in your root folder next to the package.json file created above.

js
module.exports = {
branches: [
'main'
],
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: [
{breaking: true, release: 'major'},
{type: 'docs', scope:'README', release: 'patch'},
{type: 'perf', release: 'patch'},
{type: 'fix', release: 'patch'},
{type: 'deps', release: 'patch'},
{type: 'feat', release: 'minor'},
],
parserOpts: {
mergePattern: '^Merged PR (\\d+): (\\w*)(?:\\(([\\w\\$\\.\\-\\* ]*)\\))?\\: (.*)$',
mergeCorrespondence: [
'id',
'type',
'scope',
'subject'
],
noteKeywords: [
'BREAKING CHANGE',
'BREAKING CHANGES'
]
}
}
],
['@semantic-release/release-notes-generator', {
preset: 'conventionalcommits',
presetConfig: {
types: [
{
type: 'docs',
section: 'Documentation',
hidden: false
},
{
type: 'fix',
section: 'Bug fixes',
hidden: false
},
{
type: 'feat',
section: 'New features',
hidden: false
},
{
type: 'perf',
section: 'Performance improvement',
hidden: false
},
{
type: 'style',
section: 'Code style adjustments',
hidden: false
},
{
type: 'test',
section: '(Unit)test cases adjusted',
hidden: false
},
{
type: 'refactor',
section: 'Refactor',
hidden: false
},
{
type: 'deps',
section: ':arrow_up: Dependency updates',
hidden: false
}
],
issueUrlFormat: '//' + process.env.SYSTEM_TEAMPROJECT + '/_workitems/edit/'
},
writerOpts: {
finalizeContext: function (context, options, filteredCommits, keyCommit, commits) {
const parts = /(.*)\/_git\/(.*)/gm.exec(context.repository);
const repoUrl = `${context.host}/${context.owner}/${parts[1]}`;
return {
...context,
repository: null,
repoUrl,
commit: `_git/${parts[2]}/commit`,
issue: '_workitems/edit',
linkCompare: false
};
}
},
parserOpts: {
mergePattern: '^Merged PR (\\d+): (\\w*)(?:\\(([\\w\\$\\.\\-\\* ]*)\\))?\\: (.*)$',
mergeCorrespondence: [
'id',
'type',
'scope',
'subject'
],
noteKeywords: [
'BREAKING CHANGE',
'BREAKING CHANGES'
]
}
}],
[
'@semantic-release/changelog',
{
changelogFile: 'docs/CHANGELOG.md'
}
],
['@semantic-release/exec', {
prepareCmd: "pwsh -NoLogo -NoProfile -NonInteractive -Command ./prepare.ps1 '${ nextRelease.version }' '${ nextRelease.gitHead }' '${ options.repositoryUrl }' '${process.env.CSPROJ}'",
}],
[
'@semantic-release/git',
{
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
assets: [
'docs/CHANGELOG.md',
'${process.env.CSPROJ}'
]
}
]
]
}
js
module.exports = {
branches: [
'main'
],
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: [
{breaking: true, release: 'major'},
{type: 'docs', scope:'README', release: 'patch'},
{type: 'perf', release: 'patch'},
{type: 'fix', release: 'patch'},
{type: 'deps', release: 'patch'},
{type: 'feat', release: 'minor'},
],
parserOpts: {
mergePattern: '^Merged PR (\\d+): (\\w*)(?:\\(([\\w\\$\\.\\-\\* ]*)\\))?\\: (.*)$',
mergeCorrespondence: [
'id',
'type',
'scope',
'subject'
],
noteKeywords: [
'BREAKING CHANGE',
'BREAKING CHANGES'
]
}
}
],
['@semantic-release/release-notes-generator', {
preset: 'conventionalcommits',
presetConfig: {
types: [
{
type: 'docs',
section: 'Documentation',
hidden: false
},
{
type: 'fix',
section: 'Bug fixes',
hidden: false
},
{
type: 'feat',
section: 'New features',
hidden: false
},
{
type: 'perf',
section: 'Performance improvement',
hidden: false
},
{
type: 'style',
section: 'Code style adjustments',
hidden: false
},
{
type: 'test',
section: '(Unit)test cases adjusted',
hidden: false
},
{
type: 'refactor',
section: 'Refactor',
hidden: false
},
{
type: 'deps',
section: ':arrow_up: Dependency updates',
hidden: false
}
],
issueUrlFormat: '//' + process.env.SYSTEM_TEAMPROJECT + '/_workitems/edit/'
},
writerOpts: {
finalizeContext: function (context, options, filteredCommits, keyCommit, commits) {
const parts = /(.*)\/_git\/(.*)/gm.exec(context.repository);
const repoUrl = `${context.host}/${context.owner}/${parts[1]}`;
return {
...context,
repository: null,
repoUrl,
commit: `_git/${parts[2]}/commit`,
issue: '_workitems/edit',
linkCompare: false
};
}
},
parserOpts: {
mergePattern: '^Merged PR (\\d+): (\\w*)(?:\\(([\\w\\$\\.\\-\\* ]*)\\))?\\: (.*)$',
mergeCorrespondence: [
'id',
'type',
'scope',
'subject'
],
noteKeywords: [
'BREAKING CHANGE',
'BREAKING CHANGES'
]
}
}],
[
'@semantic-release/changelog',
{
changelogFile: 'docs/CHANGELOG.md'
}
],
['@semantic-release/exec', {
prepareCmd: "pwsh -NoLogo -NoProfile -NonInteractive -Command ./prepare.ps1 '${ nextRelease.version }' '${ nextRelease.gitHead }' '${ options.repositoryUrl }' '${process.env.CSPROJ}'",
}],
[
'@semantic-release/git',
{
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
assets: [
'docs/CHANGELOG.md',
'${process.env.CSPROJ}'
]
}
]
]
}

To reduce the size of this post, here is a rough overview of the above file content:

As shown on lines 116 to 118 in the above file, we use a custom PowerShell script to perform the required updates to the .csproj file. So in the root, we also require a file named prepare.ps1 with the following content:

ps1
param($version, $gitHead, $repoUri, $filename)
$repoUriWithoutKey = $repoUri -replace 'https:\/\/(.*)@','https://'
$xml = [xml](Get-Content $fileName)
$versionPrefixNode = $xml.SelectSingleNode("//Project/PropertyGroup/VersionPrefix");
$versionPrefixNode.InnerText = $version
$repositoryCommit = $xml.SelectSingleNode("//Project/PropertyGroup/RepositoryCommit");
$repositoryCommit.InnerText = $gitHead
$repositoryUrl = $xml.SelectSingleNode("//Project/PropertyGroup/RepositoryUrl");
$repositoryUrl.InnerText = $repoUriWithoutKey
$packageProjectUrl = $xml.SelectSingleNode("//Project/PropertyGroup/PackageProjectUrl");
$packageProjectUrl.InnerText = $repoUriWithoutKey
[System.Xml.Linq.XDocument]::Parse($Xml.OuterXml).ToString() | Out-File $filename
Write-Host "##vso[task.setvariable variable=version;]$version"
ps1
param($version, $gitHead, $repoUri, $filename)
$repoUriWithoutKey = $repoUri -replace 'https:\/\/(.*)@','https://'
$xml = [xml](Get-Content $fileName)
$versionPrefixNode = $xml.SelectSingleNode("//Project/PropertyGroup/VersionPrefix");
$versionPrefixNode.InnerText = $version
$repositoryCommit = $xml.SelectSingleNode("//Project/PropertyGroup/RepositoryCommit");
$repositoryCommit.InnerText = $gitHead
$repositoryUrl = $xml.SelectSingleNode("//Project/PropertyGroup/RepositoryUrl");
$repositoryUrl.InnerText = $repoUriWithoutKey
$packageProjectUrl = $xml.SelectSingleNode("//Project/PropertyGroup/PackageProjectUrl");
$packageProjectUrl.InnerText = $repoUriWithoutKey
[System.Xml.Linq.XDocument]::Parse($Xml.OuterXml).ToString() | Out-File $filename
Write-Host "##vso[task.setvariable variable=version;]$version"

This file roughly performs the following actions:

Next up, we need to open up our .csproj file and make some adjustments:

MyProject.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>MyProjectPackageId</PackageId>
<PackageIcon>icon.png</PackageIcon>
<PackageTags>tag A;tag B</PackageTags>
<VersionPrefix>0.0.1</VersionPrefix>
<Authors>Your name</Authors>
<Company>Your company</Company>
<Copyright>Copyright © $(Company) $([System.DateTime]::Now.Year)</Copyright>
<Description>package description</Description>
<PackageReleaseNotes>docs/CHANGELOG.md</PackageReleaseNotes>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<RepositoryUrl></RepositoryUrl>
<PackageProjectUrl></PackageProjectUrl>
<RepositoryCommit></RepositoryCommit>
<RepositoryBranch>main</RepositoryBranch>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(TF_BUILD)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.AzureRepos.Git" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="..\icon.png" Visible="false">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<None Include="..\docs\CHANGELOG.md" Visible="false">
<Pack>True</Pack>
<PackagePath>\docs</PackagePath>
</None>
</ItemGroup>
</Project>
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>MyProjectPackageId</PackageId>
<PackageIcon>icon.png</PackageIcon>
<PackageTags>tag A;tag B</PackageTags>
<VersionPrefix>0.0.1</VersionPrefix>
<Authors>Your name</Authors>
<Company>Your company</Company>
<Copyright>Copyright © $(Company) $([System.DateTime]::Now.Year)</Copyright>
<Description>package description</Description>
<PackageReleaseNotes>docs/CHANGELOG.md</PackageReleaseNotes>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<RepositoryUrl></RepositoryUrl>
<PackageProjectUrl></PackageProjectUrl>
<RepositoryCommit></RepositoryCommit>
<RepositoryBranch>main</RepositoryBranch>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(TF_BUILD)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.AzureRepos.Git" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="..\icon.png" Visible="false">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<None Include="..\docs\CHANGELOG.md" Visible="false">
<Pack>True</Pack>
<PackagePath>\docs</PackagePath>
</None>
</ItemGroup>
</Project>

The modifications we make are largely in relation to information shared in the NuGet package:

That leaves us with one last step, the build pipeline that brings this all together. Create a azure-devops.yml file and add the following content:

yaml
trigger:
- main
pool:
vmImage: ubuntu-latest
variables:
- name: csproj
value: ./MyProject/MyProject.csproj
steps:
- task: NodeTool@0
displayName: Use node v16
inputs:
versionSpec: '16.x'
- task: UseDotNet@2
displayName: Use .NET v6.0
inputs:
packageType: 'sdk'
version: '6.0.x'
- task: NuGetAuthenticate@0
displayName: Authenticate to NuGet
- task: Npm@1
displayName: Install NPM packages
inputs:
command: 'install'
- task: Npm@1
displayName: Bump version & Create release doc (semantic-release)
env:
GIT_CREDENTIALS: $(System.AccessToken)
CSPROJ: $(csproj)
HUSKY: 0
inputs:
command: custom
verbose: false
customCommand: run ci:release
- task: DotNetCoreCLI@2
displayName: Build & pack package
condition: ne(variables['version'], '')
inputs:
command: pack
projects: $(csproj)
arguments: '-c $(buildConfiguration) /p:ContinuousIntegrationBuild=true'
- task: NuGetCommand@2
displayName: Publish package
condition: ne(variables['version'], '')
inputs:
command: push
publishVstsFeed: 'AzureDevopsProjectName/FeedName'
allowPackageConflicts: true
yaml
trigger:
- main
pool:
vmImage: ubuntu-latest
variables:
- name: csproj
value: ./MyProject/MyProject.csproj
steps:
- task: NodeTool@0
displayName: Use node v16
inputs:
versionSpec: '16.x'
- task: UseDotNet@2
displayName: Use .NET v6.0
inputs:
packageType: 'sdk'
version: '6.0.x'
- task: NuGetAuthenticate@0
displayName: Authenticate to NuGet
- task: Npm@1
displayName: Install NPM packages
inputs:
command: 'install'
- task: Npm@1
displayName: Bump version & Create release doc (semantic-release)
env:
GIT_CREDENTIALS: $(System.AccessToken)
CSPROJ: $(csproj)
HUSKY: 0
inputs:
command: custom
verbose: false
customCommand: run ci:release
- task: DotNetCoreCLI@2
displayName: Build & pack package
condition: ne(variables['version'], '')
inputs:
command: pack
projects: $(csproj)
arguments: '-c $(buildConfiguration) /p:ContinuousIntegrationBuild=true'
- task: NuGetCommand@2
displayName: Publish package
condition: ne(variables['version'], '')
inputs:
command: push
publishVstsFeed: 'AzureDevopsProjectName/FeedName'
allowPackageConflicts: true

This is a simplified version of a build pipeline to demonstrate the functionality discussed in the post. You most likely want to use a more advanced pipeline in your production deployment with additional steps.

Let's go over the lines to get an overview of what occurs in the pipeline:

As you can see on line 31,we use the $(System.AccessToken) access token or PAT (Personal Access Token) that is temporarily generated to perform this build by the build agent. Since the versioning workflow adds git tags, commits code, and more to the git repository, you also need to assign the build services client.

Assign rights to build services #

In the above process, during the 'Bump version & Create release doc (semantic-release)' step, the build server performs the following tasks to your git repository:

Your build services need to have rights to perform these actions, which can be set up per repository or for all repositories in your project.

Open your project, and on the left bottom, click Project Settings and go to Repositories in the Repos section. Select either a repository and select the tab Security or select the tab Security in the All repositories view. Select the user (AzureDevOpsProjectName Build Service (OrganizationName)) and assign the rights on the left side.

Configure rights for build agent
Configure rights for build agent

You're now all set #

With the above configuration, you're all set to release your NuGet packages with ease. Let's go over some of the scenarios and see what occurs.

Releasing a new feature #

The release of a new feature will cause a minor version (0.x.0) release.

Your pull request:
Create pull request for feature (minor) release
Create pull request for feature (minor) release

How the docs/CHANGELOG.md will look after the new release:
Changelog after (minor) release
Changelog after (minor) release

As you can see in the above screenshot, the release was created with:

Releasing a bug fix #

The release of a bug fix will cause a patch version (0.0.x) release.

Your pull request:
Create pull request for bug fix (patch) release
Create pull request for bug fix (patch) release

How the docs/CHANGELOG.md will look after the new release:
Changelog after (patch) release
Changelog after (patch) release

As you can see in the above screenshot, the release was created with:

A non essential (documentation) update #

A pull request for non-essential documentation changes will not cause a release.
Create pull request for documentation change
Create pull request for documentation change

Thus, it won't cause any changes to the docs\CHANGELOG.md file. However, in the next release, the changes will be included:

The next pull request:
Next/ new pull after documentation change
Next/ new pull after documentation change

The docs/CHANGELOG.md after a the new release:
Changelog after new release, including previous doc changes
Changelog after new release, including previous doc changes

Finally #

You're now all set to release NuGet packages with ease and keep a nice changelog of changes that occurred. The semantic versioning structure will help the consumers of your package upgrade their dependencies with ease, and the changelog gives them easy access to the changes in your package.

For example, when using Renovate to keep dependencies of solutions up to date. More often than not, the person upgrading the dependency wants easy and quick insights into the changes to decide if they can apply the upgrade or if more work is required to perform the upgrade.

With the above setup, you allow them to access that with ease, while at the same time, you can release and generate that information with ease.

Up next #

In a follow-up blog post, I will show you how to add a customized pull request status policy using Azure Functions to help your team members format PR titles according to the new convention required. The policy can either enforce the pull request title convention or be a helpful optional reminder to create titles according to the pattern.

Pull request title check, title not according to conventions
Pull request title check, title not according to conventions

Pull request title check: okay, we will release a minor version
Pull request title check: okay, we will release a minor version

And after that, I have another blog post for you in which we will look at how you could use the same workflow to streamline your release flow for Node/npm packages published to your Azure Artifacts npm feed.

Keep an eye out on the blog or follow me on Twitter to get an update once these additional blog posts are published.

Have fun releasing packages!