Recently I stumbled upon an article by Filip W. in which he wrote about Solution-wide Nuget package version handling with MsBuild 15+. That inspired me to give it a try but with a bit more fine grained control, by using multiple Directory.Build.targets and Directory.Build.props files. Here is the result of that experiment; I omitted other files such as README.md because they are not really relevant to the build process.

/
├─ src/
│  ├─ Directory.Build.props
│  ├─ Directory.Build.targets
│  ├─ Project.Common/
│  │  └─ Project.Common.csproj
│  └─ Project/
│     └─ Project.csproj
│
├─ test/
│  ├─ Directory.Build.props
│  ├─ Directory.Build.targets
│  ├─ Project.Common.Tests/
│  │  └─ Project.Common.Tests.csproj
│  └─ Project.Tests/
│     └─ Project.Tests.csproj
│
├─ Directory.Build.props
├─ Directory.Build.targets
└─ Project.sln

Directory.Build.props and Directory.Build.targets files.

The root Build.props only sets properties required for source link. Whereas the root Build.targets specifies the package versions for the entire solution. It also adds source link to all projects as all projects need to reference it for the build process. Additionally target framework dependent package version can be made in the global location.

/Directory.Build.props:

<Project>
	<PropertyGroup>
		<PublishRepositoryUrl>true</PublishRepositoryUrl>
		<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
	</PropertyGroup>
</Project>

/Directory.Build.targets:

<Project>
	<ItemGroup>
		<PackageReference Include="Microsoft.SourceLink.GitLab" Version="1.0.0-*">
			<PrivateAssets>all</PrivateAssets>
			<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
		</PackageReference>
	</ItemGroup>
	<ItemGroup>
		<PackageReference Update="System.Buffers" Version="4.5.*" />
		<PackageReference Update="System.Memory" Version="4.5.*" />
		<PackageReference Update="System.ValueTuple" Version="4.5.*" />
	</ItemGroup>
	<ItemGroup Condition="('$(TargetFramework)' == 'netstandard1.6') Or ('$(TargetFramework)' == 'netstandard1.3')">
		<PackageReference Update="System.Threading.ThreadPool" Version="4.3.*" />
		<PackageReference Update="Microsoft.Extensions.Logging.Abstractions" Version="[1.1.*,2)" />
	</ItemGroup>
	<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
		<PackageReference Update="Microsoft.Extensions.Logging.Abstractions" Version="2.2.*" />
	</ItemGroup>
</Project>

/src/Directory.Build.props:

<Project>
	<Import Project="../Directory.Build.props" />
	<PropertyGroup>
		<VersionPrefix>1.0.0</VersionPrefix>
		<PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
		<PackageProjectUrl>https://example.com/Project</PackageProjectUrl>
		<RepositoryUrl>https://example.com/Project.git</RepositoryUrl>
		<RepositoryType>git</RepositoryType>
		<PackageReleaseNotes>https://example.com/Project/blob/master/CHANGELOG.md</PackageReleaseNotes>
		<GenerateDocumentationFile>true</GenerateDocumentationFile>
		<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
	</PropertyGroup>
</Project>

/src/Directory.Build.targets:

<Project>
	<Import Project="../Directory.Build.targets" />
	<ItemGroup>
		<None Include="../../LICENSE.txt" Visible="false" Pack="true" PackagePath="$(PackageLicenseFile)" />
		<None Include="../../THIRD-PARTY-NOTICES.txt" Visible="false" Pack="true" PackagePath="THIRD-PARTY-NOTICES.txt" />
		<None Include="../../CHANGELOG.md" Visible="false" Pack="true" PackagePath="CHANGELOG.md" />
	</ItemGroup>
	<ItemGroup>
		<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
			<_Parameter1>$(MSBuildProjectName).Tests</_Parameter1>
		</AssemblyAttribute>
	</ItemGroup>
</Project>

I haven’t really done anything with the Build.props in the test directory and only listed it for completeness. In the Build.targets however I specified the versions for the test-framework packages. As they are specific to the test projects, so that they do not clutter up the root Build.targets.

/test/Directory.Build.props:

<Project>
	<Import Project="../Directory.Build.props" />
</Project>

/test/Directory.Build.targets:

<Project>
	<Import Project="../Directory.Build.targets" />
	<ItemGroup>
		<PackageReference Update="Microsoft.NET.Test.Sdk" Version="16.0.*" />
		<PackageReference Update="NUnit" Version="3.11.*" />
		<PackageReference Update="NUnit3TestAdapter" Version="3.13.*" />
	</ItemGroup>
</Project>

Project files

Because post of the includes and package config are defined in the .builds and the .targets files the .csproj files are rather slim, only including the name of the NuGet package to include.

Project.csproj:

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<TargetFrameworks>netstandard1.3;netstandard1.6;netstandard2.0</TargetFrameworks>
		<LangVersion>latest</LangVersion>
	</PropertyGroup>
	<PropertyGroup>
		<Description></Description>
		<PackageId>Project</PackageId>
	</PropertyGroup>
	<ItemGroup>
		<PackageReference Include="System.Memory"/>
	</ItemGroup>
	<ItemGroup Condition="('$(TargetFramework)' == 'netstandard1.6') Or ('$(TargetFramework)' == 'netstandard1.3')">
		<PackageReference Include="System.ValueTuple"/>
	</ItemGroup>
</Project>

Project.Common.csproj:

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<TargetFrameworks>netstandard1.3;netstandard1.6;netstandard2.0</TargetFrameworks>
		<LangVersion>latest</LangVersion>
	</PropertyGroup>
	<PropertyGroup>
		<Description></Description>
		<PackageId>Project.Common</PackageId>
	</PropertyGroup>
	<ItemGroup>
		<PackageReference Include="System.Buffers"/>
		<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
	</ItemGroup>
	<ItemGroup Condition="('$(TargetFramework)' == 'netstandard1.6') Or ('$(TargetFramework)' == 'netstandard1.3')">
		<PackageReference Include="System.Threading.ThreadPool"/>
	</ItemGroup>
</Project>

Conclusion

Alternatively to specifically importing the targets/props in the parent directory I could have also used the approach described in the Microsoft documentation. But in this case I think <Import Project="../Directory.Build.props" /> reads a bit better than: <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />.

Of course this layout will not fit all cases of the get go. But all in all I personally really like the way that the version of a package is specified in a central place. Combined with the Directory.Build.props I can save on some repettetive work.