Set NuGet metadata via MSBuild

For .NET, the standard mechanism for sharing packages is NuGet. A .nupkg file is an archive that contains your compiled code (DLLs), other files related to your code, and a manifest containing metadata (source). This blog post will show you how data in this manifest can be controlled by using MSBuild.

For simplification purposes, my sample project will consist of only a single class library project. I like you to keep in mind that this would scale to many projects as Microsoft did with the “Microsoft.Extensions packages”. The sky is the limit.

There are bits of this demo that work cross-platform and bits that require you to run on Windows. For example, I like the control the .NET CLI gives me when creating a new project. If you prefer to use Visual Studio, the result will remain the same.

$ dotnet new sln

The template "Solution File" was created successfully.

$ dotnet new classlib --framework netstandard2.0 --output src/Kaylumah.Logging.Extensions.Abstractions

The template "Class library" was created successfully.

Processing post-creation actions...
Running 'dotnet restore' on src/Kaylumah.Logging.Extensions.Abstractions\Kaylumah.Logging.Extensions.Abstractions.csproj...
Determining projects to restore...
Restored C:\Projects\NugetMetadata\src\Kaylumah.Logging.Extensions.Abstractions\Kaylumah.Logging.Extensions.Abstractions.csproj (in 84 ms).
Restore succeeded.

$ dotnet sln add src/Kaylumah.Logging.Extensions.Abstractions/Kaylumah.Logging.Extensions.Abstractions.csproj

Project `src\Kaylumah.Logging.Extensions.Abstractions\Kaylumah.Logging.Extensions.Abstractions.csproj` added to the solution.

I chose Kaylumah.Logging.Extensions.Abstractions to keep inline and in style with the extension packages Microsoft provides. By default, the namespace of the assembly sets the unique package identifier. Of course, this only matters when publishing the package to a NuGet source like https://nuget.org. That is not this article's scope, as publishing the default template with only the empty Class1.cs file would not benefit anyone by sharing it.

Before showing you how I set metadata, I like to show you what happens without specifying any metadata. You can run the command dotnet pack for a single project or an entire solution. If you do it for the solution, only projects that are <IsPackable>true</IsPackable> generate a package. The class library we created uses the Microsoft.NET.Sdk and is packable by default.

$ dotnet pack

Microsoft (R) Build Engine version 16.8.3+39993bd9d for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

Determining projects to restore...
All projects are up-to-date for restore.
Kaylumah.Logging.Extensions.Abstractions -> C:\Projects\NugetMetadata\src\Kaylumah.Logging.Extensions.Abstractions\bin\Debug\netstandard2.0\Kaylumah.Logging.Extensions.Abstractions.dll
Successfully created package 'C:\Projects\NugetMetadata\src\Kaylumah.Logging.Extensions.Abstractions\bin\Debug\Kaylumah.Logging.Extensions.Abstractions.1.0.0.nupkg'.

This command generated the package in my bin folder. Since I did not specify a configuration, it chose the default configuration, which is Debug. So how do we inspect Kaylumah.Logging.Extensions.Abstractions.1.0.0.nupkg? My prefered way is the NuGet Package Explorer, which is unfortunately only available on Windows.

There seems to be no metadata set by default. Let’s, for a quick moment, compare it to what Microsoft adds to its packages. We can do this by downloading the package from nuget.org and view it like we just did for Kaylumah.Logging.Extensions.Abstractions.1.0.0.nupkg. Alternatively, the NuGet Package Explorer also supports viewing metadata from remote sources such as nuget.org.

Now that is what I call metadata. Remember that .nupkg files are archives; this means we can easily verify what the explorer was telling us about our package. You can do this by changing the extension from .nupkg to .zip and then extracting it. It contains Kaylumah.Logging.Extensions.Abstractions.nuspec, which is the manifest I was talking about in the introduction. At the moment, it looks like this:

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
<metadata>
<id>Kaylumah.Logging.Extensions.Abstractions</id>
<version>1.0.0</version>
<authors>Kaylumah.Logging.Extensions.Abstractions</authors>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>Package Description</description>
<dependencies>
<group targetFramework=".NETStandard2.0" />
</dependencies>
</metadata>
</package>

So as expected, it matches what NuGet Package Explorer shows us. The default for both id and authors is the assembly namespace, whereas description defaults to “Package Description”, which tells our users nothing about what the package does.

Now that we have covered our basis, we can finally explain how we can set metadata via MSBuild.

Since we are working on a single project, the logical place to set metadata is by editing our .csproj file. I will not cover every property today, so I refer you to pack target docs link. I will, however, cover properties I often use in my projects.

So behind the scenes, what happens is that specific MSBuild properties map to properties in the .nuspec file. We have to either edit the existing PropertyGroup in our file or add one to set properties. In my opinion, every package should contain branding (like authors, company and copyright information), a helpful description and categorized by a series of tags. So in the example below, I have set these values.

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Authors>Max Hamulyák</Authors>
<!-- Note: Company does not get added to the .nuspec but it is part of the Assembly...Attribute so I often set them all -->
<Company>Kaylumah</Company>
<Description>Logging abstractions for Kaylumah.</Description>
<PackageTags>logging;abstractions</PackageTags>
<Copyright>Copyright (c) 2021 Kaylumah</Copyright>
</PropertyGroup>
</Project>

If we run dotnet pack now, we can immediately see that our package no longer has empty metadata.

You can also verify this in Visual Studio by checking your projects properties and clicking on the Package tab.

In the introduction, I talked about what exactly is a NuGet package. We are now at the part regarding other files. Since we already took care of branding, let us also add an icon. Our code is under license; how do we include it in the package?

Add files named Logo.png and LICENSE to the folder containing our project. We can then use the tags PackageIcon and PackageLicenseFile respectfully. We also need to tell MSBuild that these files should be part of the package. The updated project file looks like this:

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Authors>Max Hamulyák</Authors>
<Company>Kaylumah</Company>
<Description>Logging abstractions for Kaylumah.</Description>
<PackageTags>logging;abstractions</PackageTags>
<Copyright>Copyright (c) 2021 Kaylumah</Copyright>
<PackageIcon>Logo.png</PackageIcon>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>

<ItemGroup>
<None Include="Logo.png" Pack="true" PackagePath="" />
<None Include="LICENSE" Pack="true" PackagePath=""/>
</ItemGroup>

</Project>

Regarding these files, I like to say a couple of things before moving on to more advanced use cases. There is more than one way to set both the Icon and the License files for starters, which the Microsoft Docs describe. Both used to have a Url variant that would link to the Icon or License in question. Both of those options are now deprecated, and in the case of PackageLicenseFile, the alternative is PackageLicenseExpression, which uses SDPX license identifiers.

note: For backwards compatibility, PackageLicenseUrl gets populated with https://docs.microsoft.com/en-us/nuget/consume-packages/finding-and-choosing-packages#license-url-deprecation if you choose to use PackageLicenseFile and with https://licenses.nuget.org/MIT for example, if your SPDX would be MIT.

The second point I like to raise is regarding the file names. In my example, the value for PackageIcon and the name of my icon file match precisely; this is not necessary. What does matter is the name we specify in the package path. Failing to do so would, for example, trigger "NU5046: The icon file 'NotAnIcon.png' does not exist in the package. See a couple of samples below:

<!-- Visible 'False' hides the file in the Visual Studio explorer but still packages it under Logo.png -->
<None Include="Logo.png" Pack="true" PackagePath="" Visible="false" />

<!-- Link changes the name Visual Studio displays in the explorer but still packages it under Logo.png -->
<None Include="Logo.png" Pack="true" PackagePath="" Link="NotAnIcon.png" />

<!-- PackagePath rewrites the filename to Icon.png so PackageIcon remains unchanged -->
<None Include="KaylumahLogo.png" Pack="true" PackagePath="Icon.png" />

<!-- PackagePath rewrites the filename to KaylumahLogo.png so set PackageIcon to "KaylumahLogo" -->
<None Include="Icon.png" Pack="true" PackagePath="KaylumahLogo.png" />

Rewriting via package path only works for files with an extension. For historical purposes, both NuGet and MSBuild treat these files as directories. If we had used LICENSE.txt over LICENSE, we would have been able to modify the name in the package. However, our LICENSE file can apply both the Visible and the Link example. For more information regarding Package Icons, see package-icon. For packing licenses without an extension see package-license-1, and licenses with an extension see package-license-2

Keep in mind that by adding both Icon and License files to the package, the overall package size slightly increases; this can cause slower restore times on initial package downloads. This performance penalty is a trade-off you have to decide for your self. Given today’s network speeds, I think the impact isn’t noticeable.

So lets for a moment, assume our project is a huge success. We are creating more and more extension libraries. Think about the vast number of packages in dotnet/runtime. Even if we would only include an implementation for .Abstractions package, it would be very time consuming to do this for every project. It would also violate the DRY principle.

To get started, create a file called Directory.Build.props at the root of your solution. The way Microsoft handles this file, and in precisely that casing, is starting from your project folder; it goes up till it finds a match or it reaches the root of your drive. This Directory.Build.props file follows the same syntax we use in our .csproj files. To demonstrate, remove only the Copyright tag from the project and recreate it in the Directory.Build.props file. Now is the perfect moment to also demonstrate something I have not yet told you. We are using MSBuild to populate our metadata, and thus we can use the full force of MSBuild. For example, we can reference other variables and even use built-in functions. So the thing about our current Copyright implementation is that if after 31/12/2021 I want to release the next version, I have to remember to update my copyright notice. We can achieve this by setting the copyright tag like below.

<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Copyright>Copyright © $(Company) $([System.DateTime]::Now.Year)</Copyright>
</PropertyGroup>
</Project>

What happened? Something is wrong; why do I see the copyright year 2021, but not my company name? Before explaining it, let me prove it by adding a company tag to the Directory.Build.props with a different value. For example:

<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Company>NotKaylumah</Company>
<Copyright>Copyright © $(Company) $([System.DateTime]::Now.Year)</Copyright>
</PropertyGroup>
</Project>

Unlike the Copyright tag do not remove the Company tag from the .csproj file. The result, this time, is a little different.

It appears that I have two different values for Company; this happens because Directory.Build.props gets imported before your project, and Directory.Build.targets gets imported after. The latest registration wins. That is why if we would read the System.Reflection.AssemblyCopyrightAttribute the value for Company is "Kaylumah", but when we set Copyright, it is still "NotKaylumah". You can verify this behaviour by running the preprocess command (dotnet build -pp:fullproject.xml). See msbuild comand line reference for an explanation.

Word of caution, you should not set every property this way. You should only set the values that are shared cross-project. For example, Company and Copyright are likely to be the same for every project. The Authors and PackageTags could be project-specific; heck, even Description could be reused if so desired. One thing for sure is that Id can not be recycled since every package requires a unique Id.

<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Authors>Max Hamulyák</Authors>
<Company>Kaylumah</Company>
<Description>Logging abstractions for Kaylumah.</Description>
<Copyright>Copyright © $(Company) $([System.DateTime]::Now.Year)</Copyright>
<PackageTags>logging;abstractions</PackageTags>
<PackageIcon>Logo.png</PackageIcon>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>

<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)Logo.png" Pack="true" PackagePath="" />
<None Include="$(MSBuildThisFileDirectory)LICENSE" Pack="true" PackagePath="" />
</ItemGroup>

</Project>

In case you are wondering where did $(MSBuildThisFileDirectory) come from, it is one of the predefined MSBuild variables you can use. It allows us to set the path without thinking about relative file paths; for other variables, see the Microsoft Docs on the topic.

I have referred to the list of properties before. There are a couple of handy ones we have not yet discussed. I am talking about the repository fields, making sure that an artefact can always trace back to a specific revision of your source cod

Before I explain this, I am getting a bit tired of running dotnet pack every time. Lucky for me, there is a way to generate a package on build. Update the .csproj file to look like this:

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>

</Project>

So back to repository info. MSBuild itself is not aware of things like source control. Fortunately, we can pass parameters from the outside to use inside MSBuild. For this, we have the -p or -property switch. The following script retrieves the URL, branch name and SHA1 hash from the current commit.

#!/bin/sh -x

REPO_URL=$(git config --get remote.origin.url)
REPO_BRANCH=$(git branch --show-current)
REPO_COMMIT=$(git rev-parse HEAD)
dotnet build -p:RepositoryUrl="$REPO_URL" -p:RepositoryBranch="$REPO_BRANCH" -p:RepositoryCommit="$REPO_COMMIT" -p:RepositoryType="git"

Remember, we now generate a package on build. Let us verify we see repo info by opening the created package in NuGet Package Explorer.

Even though it is OK to add repo metadata this way, there is a better alternative. This alternative does more than add metadata; it also enables source code debugging from NuGet packages. How cool is that? This technology is called Source Link.

Like before with the properties, I have no wish to add source link to every package separately. For this, create Directory.Build.targets, which looks like this:

<?xml version="1.0" encoding="utf-8"?>
<Project>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="all" IsImplicitlyDefined="true" />
</ItemGroup>
</Project>

To configure source link, we need to update Directory.Build.props as well.

<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Authors>Max Hamulyák</Authors>
<Company>Kaylumah</Company>
<Description>Logging abstractions for Kaylumah.</Description>
<Copyright>Copyright © $(Company) $([System.DateTime]::Now.Year)</Copyright>
<PackageTags>logging;abstractions</PackageTags>
<PackageIcon>Logo.png</PackageIcon>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>

<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)Logo.png" Pack="true" PackagePath="" />
<None Include="$(MSBuildThisFileDirectory)LICENSE" Pack="true" PackagePath="" />
</ItemGroup>

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

</Project>

To prove that it is still working, here is the entire .nuspec file after adding Source Link

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
<metadata>
<id>Kaylumah.Logging.Extensions.Abstractions</id>
<version>1.0.0</version>
<authors>Max Hamulyák</authors>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<license type="file">LICENSE</license>
<licenseUrl>https://aka.ms/deprecateLicenseUrl</licenseUrl>
<icon>Logo.png</icon>
<description>Logging abstractions for Kaylumah.</description>
<copyright>Copyright © Kaylumah 2021</copyright>
<tags>logging abstractions</tags>
<repository type="git" url="https://github.com/Kaylumah/NugetMetadataDemo.git" commit="3378cf33e0061b234c1f58e060489efd81e08586" />
<dependencies>
<group targetFramework=".NETStandard2.0" />
</dependencies>
</metadata>
</package>

We looked at setting metadata via MSBuild and sharing metadata between projects. You can take this even further by using MSBuild tasks to verify that packages must have a description like shown here. It is also possible to create an entire SDK as Microsoft did with Arcade. Of course, Arcade goes much further than just specifying some metadata. You can read about how / why Microsoft did that on the devblogs. I experimented with a custom SDK heavily inspired by Arcade, but that is a blog post for another day.

For now, I hope I was able to teach you something about the power of MSBuild and how we can use it to manipulate our NuGet packages. If you have any questions, feel free to reach out.

The corresponding source code for this article is on GitHub, there you can see all the changes I addressed in sequence.

See you next time, stay healthy and happy coding to all 🧸!

This blog was written based on personal experience when creating packages. If not already explicitly linked in the text, here are some of the primary sources used in the article.

Building a solid solution is more important than the technology used | Kaylumah