Nuget Packaging Series 1: Including other projects’ output assemblies

As a reminder, the plugin assembly has a small public API implemented through multiple projects.
One requirement is that output assemblies from referenced projects needed to be included in the same package.

If we just build our MyLibrary package with dotnet pack we get all project references as package dependencies:

This is apparently by design. But I didn’t want to have to publish packages for supporting projects that were not supposed to be standalone libraries.

Workarounds for including multiple assemblies in the same nuget package come up in many a blog post, nuget issue discussion or stackoverflow answer. But they all seem to slightly differ from another, be outdated or have other quirks. I copy-pasted based my solution on this workaround.

I am aware that nugetizer exists to solve this problem among others. But it did appear neither wide-spread nor well-supported enough for something I wanted to depend on.

The first step is to prevent the referenced projects from being added as package dependencies. One way to do that is to mark the project references as private assets:

<ProjectReference Include="..\MyLibrary.Supporting1\MyLibrary.Supporting1.csproj" PrivateAssets="All" />
<ProjectReference Include="..\MyLibrary.Supporting2\MyLibrary.Supporting2.csproj" PrivateAssets="All" />

The nuget package now looks like this:

The package dependencies are gone. This will work as long as there are no projects depending on MyLibrary. PrivateAssets disables the flow of dependencies from MyLibrary to projects referencing it and may thus break their builds. This can be seen when building project “BrokenBuild” in the demo repo which does reference MyLibrary. The assemblies for both the supporting libraries are missing from the “BrokenBuild” output path.

The next step is to have the referenced projects’ output assemblies included in the package. That is where TargetsForTfmSpecificBuildOutput comes into play. This property is provided by the MSBuild pack target as an extension point. It will take care of putting our assemblies in the correct TFM subfolder of our package lib folder according to the nuget folder conventions. We can add our own target(s) to it. Within those custom targets we can then add additional files that should be copied to the package “lib/<TFM>” folder by setting the BuildOutputInPackage property. The additional files we want to copy are the referenced project’s output assemblies.

To finally meet our first requirement, we add to the existing targets in TargetsForTfmSpecificBuildOutput a custom target arbitrarily named “CopyProjectReferencesToPackage”. This custom target sets the BuildOutputInPackage property to Include a subset of the files in the ReferenceCopyLocalPaths list. ReferenceCopyLocalPaths is an output of the default MSBuild target ResolveAssemblyReferences (see the MSBuild default targets overview). Because we need ReferenceCopyLocalPaths populated to successfully run our custom target, the custom target needs to depend on the ResolveAssemblyReferences target.

ReferenceCopyLocalPaths contains all files that will be copied to the currently built project’s build output directory. For our package lib folder, we only want the assemblies that ended up in ReferenceCopyLocalPaths because of a project reference. And from those we only need the ones that we do not want as package dependencies. We achieve that by filtering ReferenceCopyLocalPaths on metadata values that MSBuild provides. To filter on files from project references, we use the apparently undocumented metadata ReferenceSourceTarget to only include files whose metadata value for ReferenceSourceTarget is set to “ProjectReference”. To avoid including the output assemblies of projects we would like to keep as package dependencies only, we add another filter to only include files whose metadata value for PrivateAssets is “All”.

The result then looks like this:

<PropertyGroup>
  <TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage</TargetsForTfmSpecificBuildOutput>
</PropertyGroup>
<Target Name="CopyProjectReferencesToPackage" DependsOnTargets="ResolveReferences">
  <ItemGroup>
    <BuildOutputInPackage Include="@(ReferenceCopyLocalPaths->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')->WithMetadataValue('PrivateAssets', 'All'))" />
  </ItemGroup>
</Target>

Funnily enough, now as I write this blog post a couple of weeks later and revisit everything I have learned, it does not even seem so much like the magic workaround it appeared to be back when I read about it. It seems like the logical solution now.