How to publish a NuGet package with CircleCI

23 minute read

Sometimes we want to extract some piece of code from our project and be able to reuse it in other projects. In scenarios like this, publishing a NuGet package can be a quick way to make the functionality available.

In this post we’ll look at how we can leverage CircleCI to create a continuous integration pipeline that builds, tests, packs and publishes a NuGet package.

Setting up the solution

Let’s start by setting up the repository. I’ll quickly create a .NET solution that contains a project representing our library and another one that uses NUnit to test it.

First, we create the directories that will contain our projects.

$ mkdir src/
$ mkdir tests/

Then, we use the .NET CLI to create an empty solution file and a default .gitignore file.

$ dotnet new sln
$ dotnet new gitignore

Since we are going to use few dotnet tools, we use the .NET CLI to generate an empty manifest file.

$ dotnet new tool-manifest

Then, we create a class library project in the src/ directory. This project will contain the library that we want to publish on NuGet

$ dotnet new classlib -n NuGetLibrary -o ./src/NuGetLibrary

I will not delve in the logic of the class library. Let’s just assume that we have some classes that are exposed and that can be used by installing the NuGet package.

Then, we create a test project using NUnit and we add a project reference pointing to the library project

$ dotnet new nunit -n Tests.NuGetLibrary -o ./tests/Tests.NuGetLibrary
$ dotnet add ./tests/Tests.NuGetLibrary/ reference ./src/NuGetLibrary/

I will not delve in the logic of the unit tests. Let’s just assume that we have some unit tests that make sure that our functionality is correctly implemented.

Finally, we add the two projects to the solution

$ dotnet sln add ./src/NuGetLibrary/
$ dotnet sln add ./tests/Tests.NuGetLibrary/

We can now build the solution and run the tests contained in our test project to ensure that everything is set up correctly

$ dotnet build

Build succeeded.
    0 Warning(s)
    0 Error(s)

$ dotnet test

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: 17 ms - Tests.NuGetLibrary.dll (net7.0)

Introducing the CI pipeline

Let’s take a minute to think about the CI pipeline that we want to create.

Here are the steps that we are going to implement in our pipeline:

  • A step to make sure that coding styles are properly followed
  • A step to build the library in debug mode and execute the tests
  • A step to build the library in release mode and create a NuGet package containing it
  • A step to push the produced package to a NuGet feed. This step will be executed only when the build is associated to a tag that matches a specific format.

We want all but the last step to be executed on every commit. The last step is only executed when the commit is associated with a GitHub release whose name matches a specific format.

Also, we want that every package produced by the pipeline is strongly associated to the commit it’s based on.

CircleCI configuration file

Like other CI tools, CircleCI requires a configuration file to be available directly in the repository. By default, the configuration file should be called config.yml and placed in a directory .circleci in the root of the repository.

While it’s quite convenient to store the configuration file in the same repository of the code to be built, it makes it harder to create multi-repository setups.

The CircleCI configuration file is a YAML file with a well-known structure. The most basic file contains the definition of jobs and workflows. A job is a sequence of steps that get executed in the same environment. A workflow is a sequence of jobs that get executed when a build is started.

CircleCI has a good documentation. You can get an introduction on the configuration file here. Additionally, you can check the complete reference for the configuration file here.

Let’s start creating a simple pipeline for our CI build.

version: 2.1

jobs:
  default:
    docker:
      - image: mcr.microsoft.com/dotnet/sdk:7.0
    steps:
      - checkout
      - run: dotnet test
      - run: dotnet pack
      - run: dotnet nuget push
workflows:
  version: 2
  default:
    jobs:
      - default

In the snippet above, we can see that we have a workflow composed by a single job. The job has multiple steps that take care of checking out the code, running the unit tests and creating the package.

The step dotnet nuget push requires some settings to correctly work.

In the coming paragraphs we will introduce some useful features like

  • verification of the code style rules
  • export of test results
  • export of build artifacts
  • automatic calculation of the version based on the git history
  • conditional pushing to NuGet of the packages produced by the pipeline

Additionally, we will move the dotnet nuget push step to its own job so that it can be executed only when we are publishing a new version of our library.

Code style rules

Enforcing code style rules helps keeping the code written by multiple developers consistent across the whole codebase.

In the recent years EditorConfig, a standard file format for defining coding styles, is adopted by IDEs like the Microsoft Visual Studio family and the JetBrain one.

A EditorConfig configuration file is a INI file composed by different sections, each identified by a wildcard pattern, that affects the files matching the pattern. By default, the file’s name is expected to be .editorconfig.

Here is a sample of a section for C# code files

[*.cs]
indent_size = 4
tab_width = 4
end_of_line = crlf
insert_final_newline = false

The latest versions of the .NET SDK has built-in support for generating EditorConfig configuration files. The command below can be used to generate a configuration file with the defaults for .NET development.

$ dotnet new editorconfig

The default configuration file contains many coding style rules that can be enforced by the editor of choice. It is a good starting point, but it can be customized to match and enforce the style adopted by your team.

By default, the code style rules are not applied during the build process, but that can be fixed.

First of all, let’s add the following rule to a section targeting C# files, for example the [{*.cs,*.vb}] one.

dotnet_analyzer_diagnostic.category-Style.severity = warning

The rule above instructs the analyzer to emit a warning for rules within the category Style.

Now, editors will show a warning when any of the Style rules are not obeyed to.

By default, code style analysis is disabled on build for all .NET projects but we can enable it by setting the EnforceCodeStyleInBuild property to true.

This can be done by adding the following property to the library project.

<PropertyGroup>
  <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>

Finally, we want the build to fail if the code style analysis returns any warning. This can be done by setting the TreatWarningsAsErrors property to true. Like for the previous property, we could modify the project file.

Alternatively, we can set the TreatWarningsAsErrors property only during the CI build process by passing an override to the dotnet build command.

$ dotnet build -p:TreatWarningsAsErrors=true

The command above executes the build of the solution but makes sure that any build warning causes the build to fail.

We could also pass EnforceCodeStyleInBuild as a parameter to the dotnet build command, but the combination of the two properties would expose us to the potential fail of the build if the test project is not aligned to the code style rules. Some teams like a bit more of flexibility when it comes to the test projects.

If you want to enforce the code style rules for the whole solution, regardless of the project type, you can run the following command

dotnet build -p:TreatWarningsAsErrors=True -p:EnforceCodeStyleInBuild=True

Last but not least, at the time of writing, the EditorConfig configuration file generated by the .NET SDK is not updated to support the latest C# features.

Since the default template for new C# files leverages the File Scoped Namespace feature introduced in C# 10, we would be subject to build failures.

To fix this, we can make sure that the following rule is included in the .editorconfig file.

csharp_style_namespace_declarations = file_scoped

Here is a reference to the many rules that can be added or customized via the .editorconfig file.

We can use the command above to define a step that will be responsible for making sure that the codebase follows the code styles defined in the .editorconfig file.

- run:
    name: Verify
    command: |
      dotnet build -p:TreatWarningsAsErrors=True -p:EnforceCodeStyleInBuild=True

Exporting the test results

Like similar tools, CircleCI offers a good overview of the tests results tracking their results and detecting possible flaky tests.

To do so, it needs to be fed the results of the test runs for each build.

Unfortunately, CircleCI doesn’t support the default format used by the .NET SDK to export test results. On their documentation, they suggest using a tool called trx2junit that converts .NET SDK test outputs to the JUnit format, one of the supported formats.

Personally, I prefer using a custom formatter like JUnit Test Logger to output the test results according to the JUnit format.

To begin with, we need to add the package to the test project.

$ dotnet add ./tests/Tests.NuGetLibrary/ package JunitXml.TestLogger

The package needs only to be installed. Then we can use it by specifying the logger parameter in the dotnet test command.

$ dotnet test --logger "junit"

The library offers the possibility to customize the name and the path of the file containing the results. We can use it to collect the test results in a well-known directory for easier retrieval later. To do so, we change the command like follows

$ dotnet test --logger "junit;LogFilePath=$(pwd)/outputs/tests/{assembly}/{framework}/TestResults.xml"

In the snippet above, we instruct the formatter to save the file in a directory structure that uses the assembly name and the framework shortname as tokens of the full path. If we run the command above, we will see that the test results are stored in a file named ./outputs/tests/Tests.NuGetLibrary/NETCoreApp70/TestResults.xml, relative to the root of the solution.

We can use the command above to define a step that will be responsible for running the tests and storing its results in a directory of our preference.

- run:
    name: Run Tests
    command: |
      dotnet test --logger "junit;LogFilePath=$(pwd)/outputs/tests/{assembly}/{framework}/TestResults.xml"

Finally, we can leverage the store_test_results step to push the test results to CircleCI, as suggested in the documentation.

To do so, we will need to add a step that looks like the following snippet

- store_test_results:
    path: outputs/tests

The built-in step will take care of uploading all the files available in the specified path and any nested directory and inform CircleCI to parse their content to extract information about the test run.

Creating the package

The .NET SDK has made it incredibly easy to distribute a library in a NuGet package but few steps need to be taken before we can call it a day.

Let’s start by showing a simple build. For the example, I’ll hardcode the version number to a test value.

$ dotnet pack --configuration Release --property:Version=0.0.1-preview --output ./outputs/
MSBuild version 17.4.0+18d5aef85 for .NET
  Determining projects to restore...
  Restored .\NuGetPackageCircleCI\src\NuGetLibrary\NuGetLibrary.csproj (in 118 ms).
  Restored .\NuGetPackageCircleCI\tests\Tests.NuGetLibrary\Tests.NuGetLibrary.csproj (in 122 ms).
  NuGetLibrary -> .\NuGetPackageCircleCI\src\NuGetLibrary\bin\Release\net7.0\NuGetLibrary.dll
  Successfully created package '.\NuGetPackageCircleCI\outputs\NuGetLibrary.0.0.1-preview.nupkg'.

This is now our baseline and we can use it to track our additions.

In the snippet above you can see that I pass to the pack command three parameters:

  • --configuration specifies the configuration used to build the project
  • --property allows me to pass MSBuild a value for a property to override using the syntax --property:PROPERTY=VALUE.
  • --output specifies the path where the outputs should be placed when the build is complete.

Versioning

A very important aspect of distributing a library is versioning. Many efforts have been done in the recent years to define strong and widely accepted guidelines regarding what version to assign to a given library build.

Nowadays, the most popular framework of rules for versioning is Semantic Versioning 2.0. These rules dictate that a version number should follow the format X.Y.Z and when each section should be incremented.

GitVersion is a tool that can be used to calculate the version of a library by the history of the git repository where it’s stored.

First of all, let’s add the GitVersion CLI to the list of .NET tools of this solution.

$ dotnet tool install GitVersion.Tool

This command will install the tool locally and, most importantly, add an entry in the .NET tools manifest file. Later, we’ll make sure that all our tools and dependencies are correctly restored while processing the pipeline. Registering the tool in the manifest file makes it trivial to download all the needed tools.

{
  "version": 1,
  "isRoot": true,
  "tools": {
    "gitversion.tool": {
      "version": "5.11.1",
      "commands": [
        "dotnet-gitversion"
      ]
    }
  }
}

Finally, we need to add a configuration file so that we can override some of the default settings of the tool. The defaults used by the tool are pretty sensible, so the configuration file will be quite minimal. You can check the reference for the GitVersion configuration file here.

Here is the GitVersion.yml I use for my projects

next-version: 0.1.0
mode: ContinuousDeployment
continuous-delivery-fallback-tag: preview

The snippet above forces GitVersion to use the ContinuousDeployment mode and append the tag preview to all commits that are explicitly not tagged.

Now, we can use the tool to calculate the current version of our repository.

For brevity, I will include only the interesting bits of the output.

$ dotnet gitversion
{
  "Major": 0,
  "Minor": 1,
  "Patch": 0,
  "MajorMinorPatch": "0.1.0",
  "SemVer": "0.1.0-preview.8",
  ...
}

In CircleCI, we will use the value of the field SemVer as the version to assign to the package.

We can use the tool jq to extract the variable we need.

$ dotnet gitversion | jq -r ".SemVer"
0.1.0-preview.8

Now we have all we need to assemble a CircleCI step to create a NuGet package for our library.

- run:
    name: Pack
    command: |
      VERSION=$(dotnet gitversion | jq -r ".SemVer")
      dotnet pack --configuration Release -p:Version=$VERSION -o ./outputs/packages/

Notice that in the snippet above, we put the output of GitVersion and jq in a variable that is then consumed by the dotnet pack command.

Unfortunately, jq is not part of the .NET SDK image we will use to run our builds, so we will need to install it manually. The same goes for other dependencies needed by GitVersion.

This can be done by adding another step.

- run:
    name: Patch image
    command: |
      apk add openssh git jq

We will be using an image based on the Alpine Linux distribution, therefore we can use the apk package management tool to install the packages we need. I started using Alpine images because there used to be an incompatibility between Ubuntu-based SDK images and GitVersion. Most likely this incompatibility has been solved now but I never had a reason to move away from the Alpine image.

Now that versioning is taken care of, we can add support for SourceLink to provide a secure debugging experience to the users of our package.

Nowadays, enabling support for SourceLink is as easy as adding a package to our project. The package will take care of collecting all the information needed and use it to decorate the package according to the SourceLink specification. Specifically, the package will be tagged with the URL of the repository where the source is stored and the SHA1 of the commit used as source for the build.

The package we need to add is DotNet.ReproducibleBuilds. We can use the .NET CLI to add the package but since we need to amend the project file to avoid listing this package as a dependency, we can go directly into our project file and add the following snippet.

<ItemGroup>
  <PackageReference Include="DotNet.ReproducibleBuilds" Version="1.1.1">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
  </PackageReference>    
</ItemGroup>

You can read more about producing packages with SourceLink support here.

We are now able to build a package with a well-defined version number and that is associated to the commit used to build it.

Symbols package

A symbols package is a package that contains all the symbols needed for stepping into the source code of the library while debugging the client code.

We can instruct the dotnet pack command to produce the symbols package by customizing the command.

Here are the parameters that we need to specify:

  • IncludeSymbols controls whether the symbols files should be produced in the process,
  • EmbedUntrackedSources controls whether items not included in the source control, such as build outputs, should be embedded,
  • SymbolPackageFormat allows us to override the format of the symbols package.
  • DebugType specifies the format used by the compiler to output the debug symbols. More information about the portable DebugType here.

Here is how our command will look like after we have added the needed parameters.

$ dotnet pack --configuration Release -p:Version=$VERSION -o ./outputs/packages/ -p:SymbolPackageFormat=snupkg -p:IncludeSymbols=True -p:EmbedUntrackedSources=True -p:DebugType=portable

Alternatively, we can define those parameters as properties in our library’s project file

<PropertyGroup>
  <EmbedUntrackedSources>true</EmbedUntrackedSources>
  <IncludeSymbols>true</IncludeSymbols>
  <SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>

Regardless of which way we choose, here is what the .NET CLI will output.

MSBuild version 17.4.0+18d5aef85 for .NET
  Determining projects to restore...
  Restored .\NuGetPackageCircleCI\src\NuGetLibrary\NuGetLibrary.csproj (in 121 ms).
  Restored .\NuGetPackageCircleCI\tests\Tests.NuGetLibrary\Tests.NuGetLibrary.csproj (in 124 ms).
  NuGetLibrary -> .\NuGetPackageCircleCI\src\NuGetLibrary\bin\Release\net7.0\NuGetLibrary.dll
  Successfully created package '.\NuGetPackageCircleCI\outputs\packages\NuGetLibrary.0.1.0-preview.8.nupkg'.
  Successfully created package '.\NuGetPackageCircleCI\outputs\packages\NuGetLibrary.0.1.0-preview.8.snupkg'.

You can see how we are now able to produce both our nupkg and snupkg file.

You can read more about producing symbol packages here.

Storing the artifacts

We want CircleCI to store our artifacts both for using them in a subsequent job of our pipeline but also so that they are available for download if we need to access them.

CircleCi gives us two built-in steps to accomplish this: store_artifacts and persist_to_workspace. Both steps make it so CircleCi uploads the content of the specified directory.

- store_artifacts:
    path: ./outputs/packages
    destination: packages
- persist_to_workspace:
    root: ./outputs/packages
    paths:
      - "*.nupkg"
      - "*.snupkg"

In the snippet above, you can see how I instruct CircleCI to fetch the content of the ./outputs/packages directory. In the case of the persist_to_workspace step, I also specify the pattern used to filter the files to be uploaded.

We are using the persist_to_workspace step because we are going to publish the packages in another job. Since different jobs are ran in different containers, we use the workspace functionality to pass files between jobs.

Caching the dependencies

Since the pipeline we are building will be executed on every commit and pull request on the repository, it’s important to make it as fast as we can.

We can leverage the caching infrastructure of CircleCI to make our builds faster. As explained by the relevant CircleCI documentation page, we are going to cache the dependencies restored during the build so that we can skip it in later runs.

To use the cache, we need two steps. One to save certain directories in the cache under a certain key, another one to retrieve the same directories from the cache using the same key.

CircleCi offers two steps:

  • restore_cache that accepts a list of keys that it will attempt to restore
  • save_cache that accepts a key and a a list of paths to save.

To avoid polluting our builds with caches that are invalid, we are going to use the checksum of the content of the files containing the list of our dependencies.

We are going to monitor the following files:

  • ./config/dotnet-tools.json that contains the list of tools used by our build
  • any C# project file in the subdirectories

Since we can use only a single key to save the cache, we are going to run a script that dynamically generates the SHA1 of all our sources. We will then use the checksum of the content of that file as part of the key used to persist the cache content.

$ touch hashes.txt
$ sha1sum ./.config/dotnet-tools.json >> hashes.txt
$ find . -name "*.csproj*" -type f -exec sha1sum {} \; >> hashes.txt

After all the steps are executed, we will have a file named hashes.txt whose content will be like

cdfbfe468f22263165a65df230383ba4bfe575f7  ./.config/dotnet-tools.json
b953f0cb459bfb81a60becbdb859ec4c20f4ce79  ./src/NuGetLibrary/NuGetLibrary.csproj
459defa0e1187eece34ef42c15c7c430e85328af  ./tests/Tests.NuGetLibrary/Tests.NuGetLibrary.csproj

We can put everything together with the following CircleCI steps:

- run:
    name: Create cache key
    command: |
      touch hashes.txt
      sha1sum ./.config/dotnet-tools.json >> hashes.txt
      find . -name "*.csproj*" -type f -exec sha1sum {} \; >> hashes.txt
- restore_cache:
    keys:
      - dotnet-{{ checksum "hashes.txt" }}
      - dotnet-
- run:
    name: Restore tools
    command: |
      dotnet tool restore
- run:
    name: Restore dependencies
    command: |
      dotnet restore
- save_cache:
    paths:
      - ~/.nuget/packages
    key: dotnet-{{ checksum "hashes.txt" }}

We added a second key to the restore_cache step to enable what CircleCI refers to as partial cache restore. You can read more about it here.

An added benefit of creating the cache key in its own step is that we didn’t hardcode any project file name in the script. This will prevent us from breaking the build in case of a change of name and, most importantly, it will work out-of-the-box if we added another project to the repository.

Assembling the Pack job

Now we have all the pieces for the first job of our pipeline. This job will be responsible for:

  • checking out the source code
  • restoring the dependencies
  • building and executing the tests
  • packaging the library and the debug symbols in NuGet packages

Here it is:

pack:
  docker:
    - image: mcr.microsoft.com/dotnet/sdk:7.0-alpine
  steps:
    - run:
        name: Patch image
        command: |
          apk add openssh git jq
    - checkout
    - run:
        name: Create cache key
        command: |
          touch hashes.txt
          sha1sum ./.config/dotnet-tools.json >> hashes.txt
          find . -name "*.csproj*" -type f -exec sha1sum {} \; >> hashes.txt
    - restore_cache:
        keys:
          - dotnet-{{ checksum "hashes.txt" }}
          - dotnet-
    - run:
        name: Restore tools
        command: |
          dotnet tool restore
    - run:
        name: Restore dependencies
        command: |
          dotnet restore
    - save_cache:
        paths:
          - ~/.nuget/packages
        key: dotnet-{{ checksum "hashes.txt" }}
    - run:
        name: Verify
        command: |
          dotnet build -p:TreatWarningsAsErrors=True -p:EnforceCodeStyleInBuild=True
    - run:
        name: Run Tests
        command: |
          dotnet test --logger "junit;LogFilePath=$(pwd)/outputs/tests/{assembly}/{framework}/TestResults.xml"
    - store_test_results:
        path: outputs/tests
    - run:
        name: Pack
        command: |
          VERSION=$(dotnet gitversion | jq -r ".SemVer")
          dotnet pack --configuration Release -p:Version=$VERSION -o ./outputs/packages/ -p:SymbolPackageFormat=snupkg -p:IncludeSymbols=True -p:DebugType=portable
    - store_artifacts:
        path: ./outputs/packages
        destination: packages
    - persist_to_workspace:
        root: ./outputs/packages
        paths:
          - "*.nupkg"
          - "*.snupkg"

This job will be executed on every commit and pull request on the repository and, assuming everything works, it will produce our packages and make them available for other jobs to consume.

Pushing the packages

Now that we have our packages built and stored in the workspace of the build execution, we need to fetch those packages and push them to a NuGet repository.

First, we use the attach_workspace built-in step so that we can access our packages.

- attach_workspace:
    at: /tmp/workspace

Then, we can use the dotnet nuget push command to push our packages.

$ dotnet nuget push /tmp/workspace/*.* --api-key ${NUGET_TOKEN} --skip-duplicate --source https://api.nuget.org/v3/index.json

We can use the source parameter to specify the NuGet repository where our package should be pushed. Besides the official NuGet server, we could push to GitHub Packages or to MyGet.

The skip-duplicate parameter is useful to recover from cases where some packages have been correctly pushed but some other were not. Without this parameter, the build would fail if we tried to push a package whose version is already available on the server. We are still not able to replace an existing version but we can stop our automated builds from failing.

Finally, the api-key parameter is used to specify the key that authenticates us to the server. For security reasons, we’re not going to hardcode the token in the build script but we are going to use CircleCI contexts to feed our builds with confidential values.

By default, CircleCI will inject the variables defined in a context as environment variables to the container hosting the job. In this way we can use the variables without ever being exposed to their value. This is usedul for working with database passwords, API keys, or other kind of security tokens.

We can use a run step to execute the command above.

- run:
    name: Push to NuGet
    command: |
      dotnet nuget push /tmp/workspace/*.* --api-key ${NUGET_TOKEN} --skip-duplicate --source https://api.nuget.org/v3/index.json

Assembling the Push job

The job responsible for pushing the packages to a NuGet server is much simpler than the previous one.

Here is its definition

push:
  docker:
    - image: mcr.microsoft.com/dotnet/sdk:7.0
  steps:
    - attach_workspace:
        at: /tmp/workspace
    - run:
        name: Push to NuGet
        command: |
          dotnet nuget push /tmp/workspace/*.* --api-key ${NUGET_TOKEN} --skip-duplicate --source https://api.nuget.org/v3/index.json

Defining the workflows

Now that our jobs are fully defined, we can start looking at the workflow that represents our CI pipeline.

As stated earlier, we want the Pack job to be executed on every commit or pull request and limit the execution of the Push job to those commits that are ready to be released. Specifically, we will execute the job steps to all and only the commits that have been tagged with a label that matches a valid SemVer version following a v.

Here are some valid values for the tag labels:

  • v1.0.0
  • v1.0.0-preview1
  • v1.0.0-preview1+1234567

Also, we need the Push job to have access to the configuration values that we have defined in a CircleCI context called NuGet.

Here is the workflow definition

workflows:
  version: 2
  default:
    jobs:
      - pack:
          filters:
            tags:
              only: /.*/
      - push:
          requires:
            - pack
          context:
            - NuGet
          filters:
              tags:
                only: /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
              branches:
                ignore: /.*/

In the snippet above, you can see how we make sure that the push job is executed only after the pack job is complete and when the commit is tagged with a tag matching a regular expression. You can use RegExPal to test the validity of the regular expression used in the snippet above.

Assembling the configuration file

Here is the final version of the whole CircleCI configuration file with the definition of all the jobs, steps and workflows we discussed in this post.

version: 2.1

jobs:
  pack:
    docker:
      - image: mcr.microsoft.com/dotnet/sdk:7.0-alpine
    steps:
      - run:
          name: Patch image
          command: |
            apk add openssh git jq
      - checkout
      - run:
          name: Create cache key
          command: |
            touch hashes.txt
            sha1sum ./.config/dotnet-tools.json >> hashes.txt
            find . -name "*.csproj*" -type f -exec sha1sum {} \; >> hashes.txt
      - restore_cache:
          keys:
            - dotnet-{{ checksum "hashes.txt" }}
            - dotnet-
      - run:
          name: Restore tools
          command: |
            dotnet tool restore
      - run:
          name: Restore dependencies
          command: |
            dotnet restore
      - save_cache:
          paths:
            - ~/.nuget/packages
          key: dotnet-{{ checksum "hashes.txt" }}
      - run:
          name: Verify
          command: |
            dotnet build -p:TreatWarningsAsErrors=True -p:EnforceCodeStyleInBuild=True
      - run:
          name: Run Tests
          command: |
            dotnet test --logger "junit;LogFilePath=$(pwd)/outputs/tests/{assembly}/{framework}/TestResults.xml"
      - store_test_results:
          path: outputs/tests
      - run:
          name: Pack
          command: |
            VERSION=$(dotnet gitversion | jq -r ".SemVer")
            dotnet pack --configuration Release -p:Version=$VERSION -o ./outputs/packages/ -p:SymbolPackageFormat=snupkg -p:IncludeSymbols=True -p:DebugType=portable
      - store_artifacts:
          path: ./outputs/packages
          destination: packages
      - persist_to_workspace:
          root: ./outputs/packages
          paths:
            - "*.nupkg"
            - "*.snupkg"
  push:
    docker:
      - image: mcr.microsoft.com/dotnet/sdk:7.0
    steps:
      - attach_workspace:
          at: /tmp/workspace
      - run:
          name: Push to NuGet
          command: |
            dotnet nuget push /tmp/workspace/*.* --api-key ${NUGET_TOKEN} --skip-duplicate --source https://api.nuget.org/v3/index.json

workflows:
  version: 2
  default:
    jobs:
      - pack:
          filters:
            tags:
              only: /.*/
      - push:
          requires:
            - pack
          context:
            - NuGet
          filters:
              tags:
                only: /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
              branches:
                ignore: /.*/

You can also check the GitHub repository that I used while preparing this post. As you can see, I avoided hardcoding any solution or project file name so that the file can be reused on any repository.

NuGetPackageCircleCI

Finally, feel free to give a peek at the CircleCI dashboard for the project backing this repository.

Recap

In this post, we’ve seen the basic structure of a CircleCI configuration file. Then, we’ve created a more robust pipeline that supports features that are needed when publishing a NuGet packages.