DEV Community

Erik Lieben
Erik Lieben

Posted on • Originally published at eriklieben.com on

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

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:

  • 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 #

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? #

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.

{
  "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",
  }
}
Enter fullscreen mode Exit fullscreen mode

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 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 file/ document with version history
@semantic-release/git plugin to perform a commit with the 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.

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}'
        ]
      }
    ]
  ]
}
Enter fullscreen mode Exit fullscreen mode

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:


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"
Enter fullscreen mode Exit fullscreen mode

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


<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>
Enter fullscreen mode Exit fullscreen mode

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:

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

Enter fullscreen mode Exit fullscreen mode

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 #

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

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

How the docs/CHANGELOG.md will look after the new release:

Changelog after (minor) release

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 #

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

How the docs/CHANGELOG.md will look after the new release:

Changelog after (patch) release

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 pull request for non-essential documentation changes will not cause a release.

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

The docs/CHANGELOG.md after a the new release:

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

Top comments (0)