Search code examples
packagedependenciesnugetcsproj

NuGet does not resolve transitive dependencies


I am trying to build a NuGet package from a project (Sub.nupkg). This works quite well.

The problems start as soon as this project itself uses a NuGet (Base.nupkg) which contains contentFiles. These are needed "transitive", but are not copied to the target directory.

The problem can be reproduced with IronPython.StdLib. Once this package is included, the contentFiles are no longer copied when used in another project.

If we change the private assets in Sub.nupkg we're able to force the copy to output:

<PackageReference Include="pkg" Version="1.*">
  <PrivateAssets>none</PrivateAssets>
</PackageReference>

If this is configured in Sub.nupkg it will foce a copy of the contentFiles to the output folder.

The problem is that we're not able to create a NuGet package that configures that assets automatically.

We can specify developmentDependency in nuspec like:

<developmentDependency>true</developmentDependency>

But this leads to this usage:

<PackageReference Include="pkg" Version="1.*">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

And this does not copy all the contentFiles to the target directory.

I tried something using *.props but didn't work because I'm not able to reconfigure an existing PackageReference.

Our dependencies are similar to:

  • Using-Project
    • Sub.nupkg
      • Base.nupkg (containing contentFiles needed to be copied to the output folder)

How do we solve that problem?

I found Why are native dll's from dependent libraries not included in build output? but it does not contain any solution to that problem. And I don't understand how to use buildTransitive to achieve our needs.

Btw: We're only using package references.

Projects for reproducing that issue using IronPython.StdLib:

Consumer (that uses a packages that references IronPython):

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

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="IronPythonConsumer" Version="1.0.0" />
  </ItemGroup>
   
</Project>

Project that consumes IronPython:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net70</TargetFramework>
    <Authors />
    <Product>IronPythonConsumer</Product>
    <AssemblyVersion>1.0.0</AssemblyVersion>
    <FileVersion>1.0.0</FileVersion>
    <Version>1.0.0</Version>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <OutputPath>$(SolutionDir)bin\$(Configuration)</OutputPath>
  </PropertyGroup>

  <PropertyGroup>
    <GeneratePackageOnBuild>True</GeneratePackageOnBuild>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="IronPython.StdLib" Version="2.7.12" />
  </ItemGroup>
</Project>

You can make that work if you change the PackageReference of IronPython to:

<PackageReference Include="IronPython.StdLib" Version="2.7.12">
  <PrivateAssets>none</PrivateAssets>
</PackageReference>

The Consumer does not need to be changed. But after that all the contentFiles are copied to the output folder.

It is not an option to adjust the assets every time you use them. This leads too frequently to errors, because the compile runs without errors. The problems occur only at runtime.


Solution

  • I was able to solve the problem by analyzing the package Stub.System.Data.SQLite.Core.NetFramework. This package manages that the ContentFiles transitively end up in the target directory. This package manages that the contentFiles will transitively be placed in the target directory.

    This is achieved by specifying the necessary copy operations in a targets file. This file is included by MSBuild and extends the existing csproj.

    The full targets file might look like this:

    <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    
      <Import Condition="'$(MSBuildThisFileFullPath)' != '' And
                         Exists('$(MSBuildThisFileFullPath).user')"
              Project="$(MSBuildThisFileFullPath).user" />
    
      <ItemGroup>
        <CustomInteropFiles Condition="'$(MSBuildThisFileDirectory)' != '' And
                                       HasTrailingSlash('$(MSBuildThisFileDirectory)')"
                            Include="$(MSBuildThisFileDirectory)..\..\contentFiles\any\any\**" />
      </ItemGroup>
    
      <ItemGroup Condition="'$(ContentCustomInteropFiles)' != '' And
                            '$(ContentCustomInteropFiles)' != 'false' And
                            '@(CustomInteropFiles)' != ''">
        <Content Include="@(CustomInteropFiles)">
          <Link>%(RecursiveDir)%(FileName)%(Extension)</Link>
          <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        </Content>
      </ItemGroup>
    
      <Target Name="CopyCustomInteropFiles"
              Condition="'$(CopyCustomInteropFiles)' != 'false' And
                         '$(OutDir)' != '' And
                         HasTrailingSlash('$(OutDir)') And
                         Exists('$(OutDir)')"
              Inputs="@(CustomInteropFiles)"
              Outputs="@(CustomInteropFiles -> '$(OutDir)%(RecursiveDir)%(Filename)%(Extension)')">
        <Copy SourceFiles="@(CustomInteropFiles)"
              DestinationFiles="@(CustomInteropFiles -> '$(OutDir)%(RecursiveDir)%(Filename)%(Extension)')" />
      </Target>
    
      <Target Name="CollectCustomInteropFiles"
              Condition="'$(CollectCustomInteropFiles)' != 'false'">
        <ItemGroup>
          <FilesForPackagingFromProject Include="@(CustomInteropFiles)">
            <DestinationRelativePath>bin\%(RecursiveDir)%(Filename)%(Extension)</DestinationRelativePath>
          </FilesForPackagingFromProject>
        </ItemGroup>
      </Target>
    
      <PropertyGroup>
        <PostBuildEventDependsOn>
          $(PostBuildEventDependsOn);
          CopyCustomInteropFiles;
        </PostBuildEventDependsOn>
        <BuildDependsOn>
          $(BuildDependsOn);
          CopyCustomInteropFiles;
        </BuildDependsOn>
      </PropertyGroup>
    
      <PropertyGroup Condition="'$(VisualStudioVersion)' == '' Or
                                '$(VisualStudioVersion)' == '10.0' Or
                                '$(VisualStudioVersion)' == '11.0' Or
                                '$(VisualStudioVersion)' == '12.0' Or
                                '$(VisualStudioVersion)' == '14.0' Or
                                '$(VisualStudioVersion)' == '15.0' Or
                                '$(VisualStudioVersion)' == '16.0' Or
                                '$(VisualStudioVersion)' == '17.0'">
        <PipelineCollectFilesPhaseDependsOn>
          CollectCustomInteropFiles;
          $(PipelineCollectFilesPhaseDependsOn);
        </PipelineCollectFilesPhaseDependsOn>
      </PropertyGroup>
    </Project>
    

    Explanation

    <Import Condition="'$(MSBuildThisFileFullPath)' != '' And
                         Exists('$(MSBuildThisFileFullPath).user')"
            Project="$(MSBuildThisFileFullPath).user" />
    

    If the per-user settings file exists, import it now. The contained settings, if any, will override the default ones provided below.

      <ItemGroup>
        <CustomInteropFiles Condition="'$(MSBuildThisFileDirectory)' != '' And
                                       HasTrailingSlash('$(MSBuildThisFileDirectory)')"
                            Include="$(MSBuildThisFileDirectory)..\..\contentFiles\any\any\**" />
      </ItemGroup>
    

    This determines the files to be copied and stores them on the variable CustomInteropFiles. It is important to know that all NuGet packages are unpacked in the local cache. This directory structure can be accessed. The variable MSBuildThisFileDirectory contains the directory in the local NuGet cache where this targets file is located.

    In my example this targets file is located in the build folder of the NuGet package and further in the target platform folder. For this reason we need to go two directories up to access the contentFiles. In my case all files should be copied to the destination directory.

      <ItemGroup Condition="'$(ContentCustomInteropFiles)' != '' And
                            '$(ContentCustomInteropFiles)' != 'false' And
                            '@(CustomInteropFiles)' != ''">
        <Content Include="@(CustomInteropFiles)">
          <Link>%(RecursiveDir)%(FileName)%(Extension)</Link>
          <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        </Content>
      </ItemGroup>
    

    Here it is checked if per-user settings prevent copying and if there are files to copy. Then these files are linked and marked for copying.

      <Target Name="CopyCustomInteropFiles"
              Condition="'$(CopyCustomInteropFiles)' != 'false' And
                         '$(OutDir)' != '' And
                         HasTrailingSlash('$(OutDir)') And
                         Exists('$(OutDir)')"
              Inputs="@(CustomInteropFiles)"
              Outputs="@(CustomInteropFiles -> '$(OutDir)%(RecursiveDir)%(Filename)%(Extension)')">
        <Copy SourceFiles="@(CustomInteropFiles)"
              DestinationFiles="@(CustomInteropFiles -> '$(OutDir)%(RecursiveDir)%(Filename)%(Extension)')" />
      </Target>
    

    This is the Target in the csproj file, which is used to copy the files. Before that, self-explanatory tests are made whether copying is possible or not. This Target is executed in the post-build event.

      <Target Name="CollectCustomInteropFiles"
              Condition="'$(CollectCustomInteropFiles)' != 'false'">
        <ItemGroup>
          <FilesForPackagingFromProject Include="@(CustomInteropFiles)">
            <DestinationRelativePath>bin\%(RecursiveDir)%(Filename)%(Extension)</DestinationRelativePath>
          </FilesForPackagingFromProject>
        </ItemGroup>
      </Target>
    

    This part collects the files to be copied. This is triggered by Visual Studio.

      <PropertyGroup>
        <PostBuildEventDependsOn>
          $(PostBuildEventDependsOn);
          CopyCustomInteropFiles;
        </PostBuildEventDependsOn>
        <BuildDependsOn>
          $(BuildDependsOn);
          CopyCustomInteropFiles;
        </BuildDependsOn>
      </PropertyGroup>
    

    This entry registers the post-build event and triggers the process of copying the files.

      <PropertyGroup Condition="'$(VisualStudioVersion)' == '' Or
                                '$(VisualStudioVersion)' == '10.0' Or
                                '$(VisualStudioVersion)' == '11.0' Or
                                '$(VisualStudioVersion)' == '12.0' Or
                                '$(VisualStudioVersion)' == '14.0' Or
                                '$(VisualStudioVersion)' == '15.0' Or
                                '$(VisualStudioVersion)' == '16.0' Or
                                '$(VisualStudioVersion)' == '17.0'">
        <PipelineCollectFilesPhaseDependsOn>
          CollectCustomInteropFiles;
          $(PipelineCollectFilesPhaseDependsOn);
        </PipelineCollectFilesPhaseDependsOn>
      </PropertyGroup>
    

    When the project is opened, the files to be copied are determined.

    This targets file ensures that the files are copied to the target directory. But to trigger this, the base name of the file must match the NuGet id and it must be located in the build folder.

    In order to make the whole thing work transitively, this file has to be placed in the buildTransitive directory.

    In my case the appropriate nuspec file looks something like this:

    <?xml version="1.0" encoding="utf-8"?>
    <package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
      <metadata>
        <id>*****</id>
        <version>*****</version>
        <authors*****</authors>
        <requireLicenseAcceptance>false</requireLicenseAcceptance>
        <license type="file">LICENSE.txt</license>
        <projectUrl>*****</projectUrl>
        <description>*****</description>
        <copyright>*****</copyright>
        <dependencies>
          <group targetFramework=".NETFramework4.7.2" />
          <group targetFramework="net7.0" />
        </dependencies>
        <contentFiles>
          <files include="**" buildAction="None" copyToOutput="false" flatten="false" />
        </contentFiles>
      </metadata>
      <files>
        <file src=".doc\**" target=".doc" />
        <file src="contentFiles\**" target="contentFiles" />
        <file src="build\**" target="build\net472" />
        <file src="build\**" target="build\net7.0" />
        <file src="build\**" target="buildTransitive\net472" />
        <file src="build\**" target="buildTransitive\net7.0" />
        <file src="..\_._" target="lib\net472" />
        <file src="..\_._" target="lib\net7.0" />
        <file src="licenses\LICENSE.txt" target="" />
      </files>
    </package>
    

    With this nuspec and targets file the files from the contentFiles folder are copied transitively to the target directory.

    It might be possible that there is a simpler solution for this, but unfortunately I have not found it.

    In the package Stub.System.Data.SQLite.Core.NetFramework a cleanup is defined. This ensures that the files are deleted from the target directory during a clean.

    However, we use multi targeting (net472; net7.0), which are built in parallel by Visual Studio. This causes a sporadic error during a clean because the files are in use by another process. We have decided not to allow a clean and the files remain in the target directory.