Bending .NET - Common Flat Build Output
or how to gather all rebel humans in Zion so the machine Sentinels can destroy them all.
UPDATE 2022-01-15: The approach defined here has a serious flaw that causes go
to definition (F12) to fail across projects in the solution. That is, given
CommonFlatBuild.Test
in a test method UnitTest1.TestMethod
refers to class
Class1
in CommonFlatBuild
, then hitting Go To Definition (F12) on
Class1
will jump to the metadata definition or source link derived source and
not the actual source code part of the project and hence cannot be edited. This
I initially thought was an issue in Visual Studio as reported in F12 not
working across C# projects in solution perhaps due to customized output paths in
VS22,
however the issue appears to be the reassignment of key output properties (e.g.
OutDir
) in Directory.Build.targets/OutputBuildTargets.props
. While this
works for building it does not work for the project system used in Visual Studio
since it appears these reassignments do not take effect as part of the project
system evaluation of properties. In Bending .NET - Corrected Common Flat Build
Output a
new corrected approach is described.
In this post, part of the Bending .NET
series, I will cover a general top-level way to move and flatten - so it is not
Mariana Trench deep -
all build output from .NET to a common build
directory .
Source: wikimedia
I have the recently released awesome .NET 6
installed and copy a global.json
file to a directory to ensure I am running fully specified.
1
2
3
4
5
6
7
{
"sdk": {
"version": "6.0.100",
"rollForward": "latestMajor",
"allowPrerelease": false
}
}
Which means dotnet --info
returns something like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.NET SDK (reflecting any global.json):
Version: 6.0.100
Commit: 9e8b04bbff
Runtime Environment:
OS Name: Windows
OS Version: 10.0.19043
OS Platform: Windows
RID: win10-x64
Base Path: C:\Program Files\dotnet\sdk\6.0.100\
Host (useful for support):
Version: 6.0.0
Commit: 4822e3c3aa
...
I then create a set of projects and a solution using a
quick PowerShell script
create-new.ps1
.
PowerShell has been cross-platform since January 2018 and works almost anywhere.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/local/bin/powershell
mkdir src
pushd src
mkdir CommonFlatBuild
pushd CommonFlatBuild
dotnet new classlib
popd
mkdir CommonFlatBuild.AppConsole
pushd CommonFlatBuild.AppConsole
dotnet new console
popd
mkdir CommonFlatBuild.AppWpf
pushd CommonFlatBuild.AppWpf
dotnet new wpf
popd
mkdir CommonFlatBuild.AppWinForms
pushd CommonFlatBuild.AppWinForms
dotnet new winforms
popd
mkdir CommonFlatBuild.Test
pushd CommonFlatBuild.Test
dotnet new mstest
popd
popd
dotnet new sln -n CommonFlatBuild
$projects = gci -Recurse *.csproj
$projects | % { Invoke-Expression -Command "dotnet sln add --in-root ""$_""" }
The files created can be seen with:
1
gci -Recurse *.* | Resolve-Path -Relative
ooops, I forget I already built the solution and now there are a ton of files and a deep hierarchy mixed together with the code files.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.\src\CommonFlatBuild\bin\Debug\net6.0
.\src\CommonFlatBuild\bin\Debug\net6.0\ref\CommonFlatBuild.dll
.\src\CommonFlatBuild\bin\Debug\net6.0\CommonFlatBuild.deps.json
.\src\CommonFlatBuild\bin\Debug\net6.0\CommonFlatBuild.dll
.\src\CommonFlatBuild\bin\Debug\net6.0\CommonFlatBuild.pdb
.\src\CommonFlatBuild\bin\Release\net6.0
.\src\CommonFlatBuild\obj\Debug\net6.0
.\src\CommonFlatBuild\obj\Debug\net6.0\ref\CommonFlatBuild.dll
.\src\CommonFlatBuild\obj\Debug\net6.0\.NETCoreApp,Version=v6.0.AssemblyAttributes.cs
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.AssemblyInfo.cs
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.AssemblyInfoInputs.cache
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.assets.cache
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.csproj.AssemblyReference.cache
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.csproj.CoreCompileInputs.cache
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.csproj.FileListAbsolute.txt
...
(continues with lots of files)
...
.\CommonFlatBuild.sln
.\create-new.ps1
.\global.json
I find this really annoying and don’t want to waste my time having to navigate
so deep - 5 directories \src\CommonFlatBuild\bin\Debug\net6.0\
- either
in console or file explorer to find the built output. Additionally, even if you run:
1
dotnet clean
the output is still full of files from the build.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.\src\CommonFlatBuild\bin\Debug\net6.0
.\src\CommonFlatBuild\bin\Release\net6.0
.\src\CommonFlatBuild\obj\Debug\net6.0
.\src\CommonFlatBuild\obj\Debug\net6.0\.NETCoreApp,Version=v6.0.AssemblyAttributes.cs
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.assets.cache
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.csproj.FileListAbsolute.txt
.\src\CommonFlatBuild\obj\Debug\net6.0\CommonFlatBuild.GlobalUsings.g.cs
.\src\CommonFlatBuild\obj\Release\net6.0
.\src\CommonFlatBuild\obj\Release\net6.0\.NETCoreApp,Version=v6.0.AssemblyAttributes.cs
.\src\CommonFlatBuild\obj\Release\net6.0\CommonFlatBuild.AssemblyInfo.cs
.\src\CommonFlatBuild\obj\Release\net6.0\CommonFlatBuild.AssemblyInfoInputs.cache
.\src\CommonFlatBuild\obj\Release\net6.0\CommonFlatBuild.assets.cache
.\src\CommonFlatBuild\obj\Release\net6.0\CommonFlatBuild.csproj.AssemblyReference.cache
.\src\CommonFlatBuild\obj\Release\net6.0\CommonFlatBuild.GeneratedMSBuildEditorConfig.editorconfig
.\src\CommonFlatBuild\obj\Release\net6.0\CommonFlatBuild.GlobalUsings.g.cs
.\src\CommonFlatBuild\obj\CommonFlatBuild.csproj.nuget.dgspec.json
...
(continues with lots of files)
Therefore, I also prefer being able to delete all this output by running
a simple rmdir
command. While we now have git clean
you still have to remember options
(git clean -d -X -f)
to run this and you risk deleting untracked code files, so I don’t like this option either.
I’ll use it now to show only the files generated with the above script, though.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.\src\CommonFlatBuild\Class1.cs
.\src\CommonFlatBuild\CommonFlatBuild.csproj
.\src\CommonFlatBuild.AppConsole
.\src\CommonFlatBuild.AppConsole\CommonFlatBuild.AppConsole.csproj
.\src\CommonFlatBuild.AppConsole\Program.cs
.\src\CommonFlatBuild.AppWinForms
.\src\CommonFlatBuild.AppWinForms\CommonFlatBuild.AppWinForms.csproj
.\src\CommonFlatBuild.AppWinForms\CommonFlatBuild.AppWinForms.csproj.user
.\src\CommonFlatBuild.AppWinForms\Form1.cs
.\src\CommonFlatBuild.AppWinForms\Form1.Designer.cs
.\src\CommonFlatBuild.AppWinForms\Program.cs
.\src\CommonFlatBuild.AppWpf
.\src\CommonFlatBuild.AppWpf\App.xaml
.\src\CommonFlatBuild.AppWpf\App.xaml.cs
.\src\CommonFlatBuild.AppWpf\AssemblyInfo.cs
.\src\CommonFlatBuild.AppWpf\CommonFlatBuild.AppWpf.csproj
.\src\CommonFlatBuild.AppWpf\MainWindow.xaml
.\src\CommonFlatBuild.AppWpf\MainWindow.xaml.cs
.\src\CommonFlatBuild.Test
.\src\CommonFlatBuild.Test\CommonFlatBuild.Test.csproj
.\src\CommonFlatBuild.Test\UnitTest1.cs
.\CommonFlatBuild.sln
Luckily, there is a somewhat easy solution to the build output issue
by using Directory.Build.props
and Directory.Build.targets
,
which we will place in the src
directory,
so all sub-projects will pick these up. How to use these is covered in
Customize your build.
An important take-away from this is:
Directory.Build.props is imported very early in Microsoft.Common.props, and properties defined later are unavailable to it. So, avoid referring to properties that are not yet defined (and will evaluate to empty).
Properties that are set in Directory.Build.props can be overridden elsewhere in the project file or in imported files, so you should think of the settings in Directory.Build.props as specifying the defaults for your projects.
Directory.Build.targets is imported from Microsoft.Common.targets after importing .targets files from NuGet packages. So, it can override properties and targets defined in most of the build logic, or set properties for all your projects regardless of what the individual projects set.
so:
Directory.Build.props
is imported earlyDirectory.Build.targets
is imported later - after package.targets
files
Here, I have added two more files to be able to more easily reuse and update the properties needed to flatten and build to common directory giving us:
1
2
3
4
.\src\Directory.Build.props
.\src\Directory.Build.targets
.\src\OutputBuildProps.props
.\src\OutputBuildTargets.props
That is, OutputBuildProps.props
is imported by Directory.Build.props
and OutputBuildTargets.props
is imported by Directory.Build.targets
as detailed below.
Before diving in, the following resources were very helpful in figuring out how to get the built output I wanted.
- Common MSBuild project properties covers the most frequently used properties.
- MSBuild targets covers default build targets.
- The excellent MSBuild Binary and Structured Log Viewer tool was extremely helpful in debugging build errors and finding targets and what properties to override. Thanks to Kirill Osenkov for this and a tip to use it. 👍 Kirill has his own take on how to define common build output.
Directory.Build.props
is shown below and is pretty straight-forward.
1
2
3
4
5
6
7
8
9
10
11
<Project>
<PropertyGroup>
<!-- Other common properties like -->
<Deterministic>true</Deterministic>
<LangVersion>10.0</LangVersion>
</PropertyGroup>
<Import Project="$(MSBuildThisFileDirectory)\OutputBuildProps.props" />
</Project>
OutputBuildProps.props
is shown below and both defines custom properties
for easy reuse and overrides the most important top-level properties like
BaseIntermediateOutputPath
, IntermediateOutputPath
and
OutputPath
. Note that the latter forces a trailing slash \
, which is
why we need the OutputPathWithoutEndSlash
property.
1
2
3
4
5
6
7
8
9
10
11
<Project>
<PropertyGroup Label="OutputBuildProps">
<Configuration Condition="$(Configuration) == ''">Debug</Configuration>
<BuildDir>$(MSBuildThisFileDirectory)..\build\</BuildDir>
<BaseIntermediateOutputPath>$(BuildDir)obj\$(MSBuildProjectName)_$(Configuration)\</BaseIntermediateOutputPath>
<IntermediateOutputPath>$(BaseIntermediateOutputPath)</IntermediateOutputPath>
<ProjectBuildDirectoryName>$(MSBuildProjectName)_$(Platform)_$(Configuration)</ProjectBuildDirectoryName>
<OutputPathWithoutEndSlash>$(BuildDir)$(ProjectBuildDirectoryName)</OutputPathWithoutEndSlash>
<OutputPath>$(OutputPathWithoutEndSlash)</OutputPath>
</PropertyGroup>
</Project>
Directory.Build.targets
basically just forwards to OutputBuildTargets.props
,
the reason for this is to allow a specific git repository to still override
or define other properties as needed, but still be able to easily update the
common build output properties by pasting a file.
1
2
3
<Project>
<Import Project="$(MSBuildThisFileDirectory)\OutputBuildTargets.props" />
</Project>
OutputBuildTargets.props
is shown below, and it took a little while to figure
out that I needed to override the different Target
* properties to get everything working
incl. packing nuget packages, since the evaluation of properties differs from target to target.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<Project>
<PropertyGroup Label="OutputBuildTargets">
<BaseOutDir>$(OutputPathWithoutEndSlash)</BaseOutDir>
<OutDir>$(BaseOutDir)_$(TargetFramework)\</OutDir>
<TargetDir>$(OutDir)</TargetDir>
<TargetPath>$(TargetDir)$(TargetFileName)</TargetPath>
<TargetRefPath>$(TargetDir)ref\$(TargetFileName)</TargetRefPath>
<PublishDir>$(BaseOutDir)_$(TargetFramework)_$(RuntimeIdentifier)</PublishDir>
</PropertyGroup>
<!--
WPF projects output temporary assemblies in directories that are not deleted after use.
See https://github.com/dotnet/wpf/issues/2930
-->
<Target Name="RemoveWpfTemp" AfterTargets="Build">
<ItemGroup>
<WpfTempDirectories Include="$([System.IO.Directory]::GetDirectories("$(BuildDir)","$(MSBuildProjectName)*_wpftmp_*"))"/>
</ItemGroup>
<RemoveDir Directories="@(WpfTempDirectories)" />
</Target>
</Project>
This also includes a “hack” needed to cleanup WPF temporary output, as is discussed in the linked issue. This is unfortunate, and if anyone knows how this could be solved differently please let me know.
Let’s build, publish and pack to be sure output is as expected.
1
2
3
4
dotnet build -c Debug
dotnet build -c Release
dotnet publish -c Release -r win-x64
dotnet pack -c Release
The end result in tree form (with details omitted) then is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
├───build
│ ├───CommonFlatBuild.AppConsole_AnyCPU_Debug_net6.0
│ ├───CommonFlatBuild.AppConsole_AnyCPU_Release
│ ├───CommonFlatBuild.AppConsole_AnyCPU_Release_net6.0
│ ├───CommonFlatBuild.AppConsole_AnyCPU_Release_net6.0_win-x64
│ ├───CommonFlatBuild.AppWinForms_AnyCPU_Debug_net6.0-windows
│ ├───CommonFlatBuild.AppWinForms_AnyCPU_Release
│ ├───CommonFlatBuild.AppWinForms_AnyCPU_Release_net6.0-windows
│ ├───CommonFlatBuild.AppWinForms_AnyCPU_Release_net6.0-windows_win-x64
│ ├───CommonFlatBuild.AppWpf_AnyCPU_Debug_net6.0-windows
│ ├───CommonFlatBuild.AppWpf_AnyCPU_Release
│ ├───CommonFlatBuild.AppWpf_AnyCPU_Release_net6.0-windows
│ ├───CommonFlatBuild.AppWpf_AnyCPU_Release_net6.0-windows_win-x64
│ ├───CommonFlatBuild.Test_AnyCPU_Debug_net6.0
│ ├───CommonFlatBuild.Test_AnyCPU_Release_net6.0
│ ├───CommonFlatBuild.Test_AnyCPU_Release_net6.0_win-x64
│ ├───CommonFlatBuild_AnyCPU_Debug_net6.0
│ ├───CommonFlatBuild_AnyCPU_Release
│ ├───CommonFlatBuild_AnyCPU_Release_net6.0
│ ├───CommonFlatBuild_AnyCPU_Release_net6.0_win-x64
│ └───obj
└───src
├───CommonFlatBuild
├───CommonFlatBuild.AppConsole
├───CommonFlatBuild.AppWinForms
├───CommonFlatBuild.AppWpf
└───CommonFlatBuild.Test
Nice and flat. Now granted this can get a bit busy in big solutions with lots of projects, so you may want to customize for that e.g. separate published output from normal build output, but for small and focused libraries this is exactly what I want.
Compare this to how the default
output looks like below (again with details omitted).
Note how published output goes 7 levels deep with
src\CommonFlatBuild.AppWpf\bin\Release\net6.0-windows\win-x64\publish
😅
Length is actually a bit longer for the flattened output due to adding the platform AnyCPU
to the path, this is because I need this in some projects.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
└───src
├───CommonFlatBuild
│ ├───bin
│ │ ├───Debug
│ │ │ └───net6.0
│ │ └───Release
│ │ └───net6.0
│ │ └───win-x64
│ │ ├───publish
│ └───obj
│ ├───Debug
│ │ └───net6.0
│ └───Release
│ └───net6.0
│ └───win-x64
├───CommonFlatBuild.AppConsole
│ ├───bin
│ │ ├───Debug
│ │ │ └───net6.0
│ │ └───Release
│ │ └───net6.0
│ │ └───win-x64
│ │ ├───publish
│ └───obj
│ ├───Debug
│ │ └───net6.0
│ └───Release
│ └───net6.0
│ └───win-x64
├───CommonFlatBuild.AppWinForms
│ ├───bin
│ │ ├───Debug
│ │ │ └───net6.0-windows
│ │ └───Release
│ │ └───net6.0-windows
│ │ └───win-x64
│ │ ├───publish
│ └───obj
│ ├───Debug
│ │ └───net6.0-windows
│ └───Release
│ └───net6.0-windows
│ └───win-x64
├───CommonFlatBuild.AppWpf
│ ├───bin
│ │ ├───Debug
│ │ │ └───net6.0-windows
│ │ └───Release
│ │ └───net6.0-windows
│ │ └───win-x64
│ │ ├───publish
│ └───obj
│ ├───Debug
│ │ └───net6.0-windows
│ └───Release
│ └───net6.0-windows
│ └───win-x64
└───CommonFlatBuild.Test
├───bin
│ ├───Debug
│ │ └───net6.0
│ └───Release
│ └───net6.0
│ ├───win-x64
│ │ ├───publish
└───obj
├───Debug
│ └───net6.0
└───Release
└───net6.0
└───win-x64
Run the below and there will be no build output lingering causing build issues or similar.
1
rmdir build
No more rebel humans (build artefacts) 😉
PS: Example source code can be found in the GitHub repo for this blog nietras.github.io and as a zip-file CommonFlatBuild.zip.