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 .

mariana trench 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 early
  • Directory.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.

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(&quot;$(BuildDir)&quot;,&quot;$(MSBuildProjectName)*_wpftmp_*&quot;))"/>
    </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.

2021.11.19