Ken Muse

Distributing .NET Dependencies and Settings With Packages


This is a post in the series Governance in dotnet with NuGet Packages. The posts in this series include:

Happy holidays!

Since it’s the gift-giving season, it seems like the perfect time to wrap up the posts about how to distribute packages. More specifically, how to distribute dependencies and settings with NuGet packages.

In the earlier posts, you’ve seen how to create templates. These allow you to add files to a project and customize the contents. This is great for configuring user-configurable files, but sometimes you want to distribute configuration settings or development dependencies that should not be able to be modified. For example, you might use this to include Code Analysis settings or to incorporate a standardized .editorconfig file to control the formatting of the code.

In a previous post, you saw that custom tasks can be distributed using a .csproj file, a .targets file, and a .props file. Handling settings and static content is not much different. It even uses many of the same files. For these examples, I want to implement a few requirements for standardizing .NET package development:

  • Distribute a Muse.Standards package that contains my standardized configurations. When teams install this package, it should automatically configure the project to use my settings and any required packages.
  • Include a global analyzer configuration that sets a baseline for code analysis.
  • Include Microsoft.CodeAnalysis.PublicApiAnalyzers to monitor public API changes in packages.
  • Configure some default projects settings, but all them to be overridable by the consuming project.

The project

Since I talked about the project structure in the earlier post, I won’t repeat the details here. I’ll just highlight some of the differences. As a starting point, here’s the .csproj file for the Muse.Standards package:

 1<Project Sdk="Microsoft.NET.Sdk">
 2
 3  <!-- Configuration settings -->
 4  <PropertyGroup>
 5    <TargetFramework>netstandard2.0</TargetFramework>
 6    <NoBuild>true</NoBuild>
 7    <IncludeBuildOutput>false</IncludeBuildOutput>
 8    <IncludeContentInPack>true</IncludeContentInPack>
 9    <NoDefaultExcludes>false</NoDefaultExcludes>
10    <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
11    <SuppressDependenciesWhenPacking>false</SuppressDependenciesWhenPacking>
12  </PropertyGroup>
13
14  <!-- Package metadata -->
15  <PropertyGroup>
16    <PackageId>Muse.Standards</PackageId>
17    <!-- Use a condition to allow this value to be passed in on the command line -->
18    <Version Condition="'$(Version)' == ''">1.0.0</Version>
19    <Authors>Ken Muse</Authors>
20    <Description>Standardizes library development</Description>
21
22    <!-- Optional metadata -->
23    <Title>Muse Standards for Libraries</Title>
24    <ProjectUrl>https://github.com/kenmuse/Standards</ProjectUrl>
25    <Copyright>Copyright ⓒ 2024 Ken Muse</Copyright>
26    <DevelopmentDependency>true</DevelopmentDependency>
27    <RequireLicenseAcceptance>false</RequireLicenseAcceptance>
28    <RepositoryType>git</RepositoryType>
29    <RepositoryUrl>https://github.com/kenmuse/Standards</RepositoryUrl>
30    <PackageLicenseExpression>MIT</PackageLicenseExpression>
31  </PropertyGroup>
32
33  <!-- Include the analyzer -->
34  <ItemGroup>
35    <PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4">
36      <PrivateAssets>None</PrivateAssets>
37    </PackageReference>
38  </ItemGroup>
39
40  <!-- Define the package contents -->
41  <ItemGroup>
42    <Content Include="buildTransitive/**" PackagePath="build/"/>
43    <Content Include="contentFiles/**" Pack="True" PackagePath="contentFiles/" BuildAction="None" />
44  </ItemGroup>
45</Project>

One important change is that we are now including a package reference. To be able to distribute this, we need to allow the dependencies to be included in the package. This requires a few settings to work correctly:

  • Set SuppressDependenciesWhenPacking to false. This adds the metadata referencing the dependencies into the package.
  • The PackageReference must have PrivateAssets set to None. This lets it become a transitive dependency. Without this setting, the dependency’s features will not be available to the consuming project.
  • Because dependencies are being analyzed, the metadata will only be generated for the specific TargetFramework (TFM) or TargetFrameworks. Unlike the previous project, we need to target a framework for distribution; the package will not be installable for any framework that is not included. In this case, I’m using netstandard2.0 to ensure it’s compatible with all modern .NET projects. I know the PackageReference has the same compatibility, so it will work. Alternatively, I could use <TargetFrameworks>net6.0;net7.0;net8.0;net9.0</TargetFrameworks> to limit it to .NET 6-9. If the TFM for a referenced package is not compatible with one or more environments, we can use the Condition attribute to selectively exclude it.

Because of the combination of not suppressing dependencies and providing a TFM, the package metadata (.nuspec) will include the PackageReference, and since we have set PrivateAssets to None, we will have an include="All" attribute rather than exclude="Runtime,Compile,Build,Native,Analyzers":

 1<package>
 2  <metadata>
 3 4    <dependencies>
 5       <group targetFramework=".NETStandard2.0">
 6        <dependency id="Microsoft.CodeAnalysis.PublicApiAnalyzers" version="3.3.4" include="All" />
 7      </group>
 8    </dependencies>
 910  </metadata>
11</package>

Understanding the ItemGroup

The ItemGroup defines what is included in the package. While the last package used the build directory for the .props and .targets, this one uses buildTransitive directory. The build directory is used for files that are only used by the project that pulls in the package. It is used for influencing the build of parent project. It is not used if the package is pulled in by another meta-package. For that, we need buildTransitive. For example:

  • If ProjectA imports PackageX, then the build directory in PackageX is used.
  • ProjectB imports ProjectA, the build directory in PackageX is not used because the package was not directly imported into ProjectB. IT’s a “transitive” dependency. Any targets in the buildTransitive directory will be executed instead.

The missing TFMs

If I use the project as-is, I will get an error when I try to run dotnet pack:

1error NU5128: 
2 Warning As Error: Some target frameworks declared in the dependencies group
3 of the nuspec and the lib/ref folder do not have exact matches in the other location.

To prevent certain classes of mistakes, NuGet now detects when we have configured TFMs but not included any of our own assemblies for that TFM. There are three ways to fix this. An easy way is to disable the warning. Add the following to the .csproj to disable the warning and retain support for disabling other warnings from the command line:

1<PropertyGroup>
2  <NoWarn>$(NoWarn);NU5128</NoWarn>
3</PropertyGroup>

We can also fix it by disabling the package analysis task. This prevents pack from analyzing the package for these kinds of errors. The dotnet pack command runner. This has a side effect of not running any of the rules that would normally execute to validate the assembly, but is a common approach (even with Core packages).

1<PropertyGroup>
2  <NoPackageAnalysis>true</NoPackageAnalysis>
3</PropertyGroup>

The final option is to explicitly include library directories in the package so that the analyzer knows that this was a deliberate decision. This allows the packaging rules to still execute whil avoiding this particular error explicitly. The documentation for the warning recommends creating an empty file called _._ in the lib/<tfm> directory:

Lib folder structure

The file is then included by adding a few lines to the .csproj:

1<ItemGroup>
2  <Content Include="lib/" Pack="True" PackagePath="lib/" BuildAction="None" />
3</ItemGroup>

Dynamically including files

It turns out there’s another approach to avoiding NU5128 without creating the files. Whenever possible, I like to take advantage of native features of the build system to avoid extra work. MSBuild makes it possible to dynamically include the files in the NuGet package. The Pack task relies on some properties to identify which (if any) Targets to run before the package is created. The solution to dynamically include the _._ file depends on that.

First, we need to create a PropertyGroup and use it to configure the property TargetsForTfmSpecificContentInPackage. This property is used to define a Target that can dynamically configure file references for any folder in the package. For the purpose of this example, we will assume the Target that will do the actual work is called CreateFiles:

1<PropertyGroup>
2  <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);CreateFiles</TargetsForTfmSpecificContentInPackage>
3</PropertyGroup>

Next, we need to create a Target that will dynamically create the empty _._ file. It will create an Item, TfmSpecificPackageFile, that maps the generated file to the correct location in the package. The Pack process will execute this Target using the TargetFramework (or once for each of the TargetFrameworks, passing in the appropriate TargetFramework value):

 1<Target Name="CreateFiles" >
 2  <WriteLinesToFile
 3    File="$(IntermediateOutputPath)lib/_._"
 4    Overwrite="true"/>
 5  <ItemGroup>
 6    <TfmSpecificPackageFile Include="$(IntermediateOutputPath)lib/_._">
 7      <PackagePath>lib/$(TargetFramework)</PackagePath>
 8    </TfmSpecificPackageFile>
 9  </ItemGroup>
10</Target>

When this Target executes, it will use WriteLinesToFile to create a file in the output path for the current TFM. Since the Lines attribute is not specified, it will default to an empty array. After that, it will create a TfmSpecificPackageFile to map that file to the correct location in the package.

No local lib files needed!

Distributed settings

SinceTo distribute settings, the value need to be included in the buildTransitive/Muse.Standards.props. This file will be included in any consuming project’s build process, even if this project is just a transitive dependency. Putting the values in the .props file ensures that they are applied before the consuming project’s settings, allowing these values to be overridden by the project if necessary. That lets them act as default settings.

For this example, I want to configure a few property values:

PropertyDescription
AnalysisLevelConfigures the specific set of code analyzers to run based on the .NET release. Setting this to latest uses the latest set of code analyzers.
AnalysisModeConfigures the specific set of rules to enable as build warnings. Recommended uses a broad set of rules that are generally recommended.
CodeAnalysisTreatWarningsAsErrorsDetermines whether or not to treat code analysis warnings (CAxxxx rules) as errors.
ContinuousIntegrationBuildIndicates when a build is running on a CI server. When this value and Deterministic are both true, a fully deterministic build is created. The project will set this value if the build is run on GitHub or Azure DevOps.
DeterministicEnsures the compiler produces an assembly that is byte-for-byte identical for a set if inputs.
EmbedUntrackedSourcesEmbed project source code that is not tracked by source control into the generated PDB. This is used as part of SourceLink
EnableNETAnalyzersUses the .NET code quality analyzers. This is enabled by default starting with .NET 5.
EnforceCodeStyleInBuildEnables code style analysis rules (IDExxxx)
TreatWarningsAsErrorsIndicates that the compiler should not produce any output if it generates a warning, and that the warning should be treated as an error.

The file looks like this:

 1<Project>
 2   <PropertyGroup>
 3      <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
 4      <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
 5      <AnalysisMode>Recommended</AnalysisMode>
 6      <AnalysisLevel>latest-major</AnalysisLevel>
 7      <Deterministic>true</Deterministic>
 8      <EnableNETAnalyzers>true</EnableNETAnalyzers>
 9      <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
10      <EmbedUntrackedSources>true</EmbedUntrackedSources>
11    </PropertyGroup>
12    <PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' OR '$(TF_BUILD)' == 'true'">
13      <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
14   </PropertyGroup>
15
16  <ItemGroup>
17    <GlobalAnalyzerConfigFiles Include="$(MSBuildThisFileDirectory)../contentFiles/any/any/Muse.Standards.globalconfig"  />
18  </ItemGroup>
19</Project>

The ItemGroup at the bottom is used to include the global analyzer configuration file. The special property, $(MSBuildThisFileDirectory), is used to find the path to the directory containing the .props file once the package is installed. The file is placed in contentFiles. This includes it whenever this package is added to a project that is using a PackageReference.

Content files folder layout

Global analyzer

Around 2019, Microsoft deprecated the concept of Ruleset files for configuring code analysis. Instead, projects can now use .editorconfig files to configure code analysis. Because the .editorconfig must exist on the file system, it cannot be included in a NuGet package. To make it possible to distribute the code analysis settings, Microsoft introduced the concept of global analyzer configuration. This file is configured using a GlobalAnalyzerConfigFiles.

The file looks something like this:

1is_global = true
2
3dotnet_diagnostic.CA1812.severity = error
4dotnet_public_api_analyzer.require_api_files = false

In this example, it adds the rule CA1812 to the project, configuring it to report an error if the rule is violated. That rule is not included in the Recommended rules, so this appends the rule into the project. It also configures the PublicApiAnalyzers to generate an error if the project has public APIs that are not explicitly included in either PublicAPI.Shipped.txt or PublicAPI.Unshipped.txt.

If you want to include an `.editorconfig` in your package, you'll need to use a hack to ensure that it works across IDEs. You'd need to add a `Target` in the `.csproj` to copy the file to the root of the project prior to the build. This file would be overwritten with each new build, ensuring that the correct file is used during the process.

This is required because the `.editorconfig` specification requires supporting IDEs to search [specific file locations](https://spec.editorconfig.org/index.html#id10) to find the file. In addition, the search stops if it finds a file with the `root=true` key. There is currently no way to reference a file from any other location, and the maintainers continue to debate support for using files from a different path to [extend a local configuration](https://github.com/editorconfig/editorconfig/issues/236).

By convention, the configuration file is named <package-name>.globalconfig. This is not required, but it is a good practice to follow.

What about the .targets?

That brings us to the last component, the .targets file. In this case, it isn’t actually needed. As a result, we can leave it out of this project. It’s main purpose is to include Targets and settings that are added after the project’s settings and which can override or extend the project itself.

As a result, it’s not part of the solution. Instead, we just have a simple tree:

Final folder structure

Ship it!

So that’s how you distribute your standards as a NuGet package. You can easily expand on this solution to include other settings, configurations, and packages. Because these dependencies are versioned, it’s also possible for standards to predictably evolve and be enhanced over time. Combined with some of the other solutions, it can create a robust framework for ensuring consistency across your projects.