Automate your release flow of NuGet packages using Azure DevOps and Node's semantic-release
Published: 2022-04-19 10:54:34

---
title: Automate your release flow of NuGet packages using Azure DevOps and Node's semantic-release
published: true
date: 2022-04-17 19:03:18 UTC
tags: 
canonical_url: https://www.eriklieben.com/posts/semantic-release-nuget/
---

In the [NodeJS](https://nodejs.org/en/) ecosystem, a great solution is available for automating the workflow of releasing packages, explicitly concerning the versioning of packages, named [semantic-release](https://www.npmjs.com/package/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](https://azure.microsoft.com/en-us/services/devops/) to an [Azure DevOps artifact feed](https://azure.microsoft.com/en-us/services/devops/artifacts/).

<!-- Excerpt End -->
## Semantic-release you say? [#](#semantic-release-you-say%3F)

According to its tagline, [semantic-release](https://www.npmjs.com/package/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](https://git-scm.com/) commit message (pull request title in our case) you write to save your changes.

The versioning structure is based upon [semantic versioning 2.0](https://semver.org/), 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](https://docs.microsoft.com/en-us/nuget/concepts/package-versioning#version-basics) section of the documentation site.

## How do I use it? [#](#how-do-i-use-it%3F)

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](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-format) 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 [#](#create-a-release%2C-bump-the-version)

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

- `BREAKING CHANGE`: A breaking change (causes a release with a MAJOR version bump)
- `feat`: A new feature (causes a release with a MINOR version bump)
- `fix`: A bug fix (causes a release with a PATCH version bump)
- `docs(README)`: An update to the README documentation (causes a release with a PATCH version bump)
- `perf`: A performance fix (causes a release with a PATCH version bump)
- `deps`: A dependency update (causes a release with a PATCH version bump)

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 [#](#commit-work%2C-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:

- `refactor:`: A refactor of a section of your code
- `docs`: A non-crucial documentation update
- `style`: A adjustment to the style of your code
- `test`: A added or adjusted (unit)test case

### What if I forget the format/ convention? [#](#what-if-i-forget-the-format%2F-convention%3F)

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? [#](#what-if-my-pr-breaks-the-pipeline-and-i-need-to-fix-it%3F)

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](#you're-now-all-set).

## How do I configure it? [#](#how-do-i-configure-it%3F)

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",
  }
}
```

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](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#name). It's not used by the release workflow or will appear anywhere in your final release.

The `private` property set to `true`makes 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 name | Description |
| --- | --- |
| semantic-release | This is the main semantic release executable |
| @semantic-release/commit-analyzer | plugin to analyse the commit message format |
| @semantic-release/release-notes-generator | plugin to generate the release notes history |
| @semantic-release/changelog | plugin to generate a [CHANGELOG.md](http://changelog.md/) file/ document with version history |
| @semantic-release/git | plugin to perform a commit with the [CHANGELOG.md](http://changelog.md/) & new version info |
| @semantic-release/exec | plugin 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}'
        ]
      }
    ]
  ]
}
```

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

- **Line 3** : The name of the release branch to work against, in our case `main`.
- **Line 7 to 23** : custom configuration of the `commit-analyzer` plugin to understand commits made AzureDevOps and the release rules (when to perform, what release). The `mergePattern` is a custom regex that helps the commit analyzer understand the Azure DevOps format.
- **Line 33 to 109** : custom configuration of the `release-note-generator` plugin to generate release notes to a format of your choice. The `presetConfig.types` section allows you to define the sections each PR/ change falls under. The `writerOpts` and `parserOpts` are custom configurations for the release note generator to understand/ generate notes in an Azure DevOps format.  
**Line 110 to 115** : Set up the `changelog` package and specify where you want your changelog to be generated.
- **Line 116 to 118** : setup of the `exec` plugin to execute a custom PowerShell script to update version information in our `.csproj` file as preparation for the release of our NuGet package.
- **Line 119 to 128** : setup of the `git` plugin defines which files to commit and what message to use for the commit. The `[skip ci]` label in this message makes sure that there isn't a additional build triggered due to a commit in the Azure DevOps repository.

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"
```

This file roughly performs the following actions:

- **Line 1** : this allows us to call the script with four parameters, the new version, the git hash of the current head, the URI to the repository, and the filename of the `.csproj` to adjust.
- **Line 3** : by default, the system provides us with the URI to the git repository that includes the PAT token to access it. Of course, we don't want to publish this token, so we replace/ remove that value at this line.
- **Line 5 to 14** : we open up the `.csproj` file and add the data provided (version, hash, repo), and store it again on disk.
- **Line 16** : we write the variable version to the Azure DevOps pipeline for later usage.

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>
```

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

- **Line 7 to 14** : general information about your NuGet package
- **Line 15** : link to the generated version history changelog
- **Line 16 to 24** : links to your Azure DevOps git repository & embedding debug symbols.
- **Line 28 to 33** : include a package for source linking
- **Line 35 to 46** : include the icon in the package, but don't show it in the Visual Studio solution explorer.

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

```

_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:

- **Line 1 to 5** : define the basic setup of the branch that triggers a build and the type of build server to use.
- **Line 7 to 9** : defines the variables that can be used in this file, replace the value of the `csproj` varaible with the path to your `.csproj` file.
- **Line 12 to 26** : These steps prepare the build process. Ensure that node js is installed, use .NET v6 SDK (this is optional today), Authenticate NuGet with the PAT token from the build agent, and install the required npm packages.
- **Line 28 to 37** : This is the step that performs the complete versioning workflow. Any tests or things to validate if the package can be released need to be performed before this step.
- **Line 39 to 45** : build & pack the NuGet package using the new version generated in the previous step.
- **Line 47 to 53** : publish the NuGet package to the Azure DevOps Artifacts feed.

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 [#](#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:

- create a versioning tag
- adds additional metadata to git commits (notes)
- pushes a commit directly to your main branch

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](https://www.eriklieben.com/img/posts/semantic-release-nuget/security-settings-light-mode.jpg)

## You're now all set [#](#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 [#](#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](https://www.eriklieben.com/img/posts/semantic-release-nuget/create-pr-feature-light-mode.jpg)

How the docs/CHANGELOG.md will look after the new release:  
 ![Changelog after (minor) release](https://www.eriklieben.com/img/posts/semantic-release-nuget/after-pr-change-log-light-mode.jpg)

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

- A link to the git diff compare (`1.1.0`) between the new version tag and the previous version tag
- A link to the git commit (`652864d`) with the code adjustments that caused this update
- A link to the story (`#143`) that was attached to this PR

### Releasing a bug fix [#](#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](https://www.eriklieben.com/img/posts/semantic-release-nuget/create-pr-fix-light-mode.jpg)

How the `docs/CHANGELOG.md` will look after the new release:  
 ![Changelog after (patch) release](https://www.eriklieben.com/img/posts/semantic-release-nuget/after-pr-fix-change-log-light-mode.jpg)

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

- A link to the git diff compare (`1.1.1`) between the new version tag and the previous version tag (`1.1.0`)
- A link to the git commit (`38bffff`) with the code adjustments that caused this update
- A link to the story (`#145`) that is attached to this PR

### A non essential (documentation) update [#](#a-non-essential-(documentation)-update)

A pull request for non-essential documentation changes will not cause a release.  
 ![Create pull request for documentation change](https://www.eriklieben.com/img/posts/semantic-release-nuget/pr-title-docs-light-mode.jpg)

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](https://www.eriklieben.com/img/posts/semantic-release-nuget/create-pr-next-feature-light-mode.jpg)

The `docs/CHANGELOG.md` after a the new release:  
 ![Changelog after new release, including previous doc changes](https://www.eriklieben.com/img/posts/semantic-release-nuget/after-new-release-light-mode.jpg)

## Finally [#](#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](https://github.com/renovatebot/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 [#](#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](https://www.eriklieben.com/img/posts/semantic-release-nuget/pr-title-check-light.jpg)

 ![Pull request title check: okay, we will release a minor version](https://www.eriklieben.com/img/posts/semantic-release-nuget/pr-title-check-ok-light-mode.jpg)

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!