'MSBuild on NET 5 Core projects produces different bin/x64 and bin/Debug folder structures

I am having an awful time understanding how MSBuild works with NET Core project files (csproj) on Windows 11. I have 71 NET Core C# project files (executables, libraries, test projects). They all compile and run properly under Visual Studio or with msbuild batch jobs that build and PUBLISH to a publish folder on the command line.

BUILDING LOCALLY PRODUCES DIFFERENT BIN/* FOLDERS

But when I run MSBuild (with the same command-line arguments) on the 71 projects to build locally (not publish locally), some of the projects produce different output folders.

Some of them create bin/Debug/* folders, some create bin/x64/Debug/ folders, and some projects produce both types of folders. The “*” part is framework/runtime, which is net5.0-windows/win-x64 on my system.

Here is what my typical NET 5.0 project file looks like:

<PropertyGroup>
      <Platforms>AnyCPU</Platforms>
      <PlatformTarget>AnyCPU</PlatformTarget>
      <TargetFramework>net5.0-windows7.0</TargetFramework>
      
      <SelfContained>false</SelfContained>
      <IsPackable>false</IsPackable>
      <IsPublishable>false</IsPublishable>
      // The IsPublishable setting makes no difference.
      // The build operation is not a publishable operation.
</PropertyGroup>

Here is what my MSBuild command looks like for all 71 projects

MSBuild /t:Restore;Build /p:Configuration=Debug /p:Platform=x64 /p:RuntimeIdentifier=win-x64 csproj-file-pathname

I have done many experiments, including completely deleting the obj and bin trees before running the MSBuild command lines, but nothing works. I cannot see any discernible relationships among project files, types of projects (lib or exe or app), and MSBuild command line arguments that would explain the different local build output folders.

Does anyone know what would make some projects create different output folder structures (and contents) if the project files are the same and the MSBuild arguments are the same?

Does anyone know for sure exactly what bin/* output folders should be produced by the msbuild arguments shown above? I have spent hours fighting the issue without success. Thank you.



Solution 1:[1]

MSBuild Structured Log Viewer is going to be your friend here. Whenever I need to know exactly what MSBuild is doing, I break this tool out.

I made a couple of library projects to show you what's going on

Projects

Here's the solution configuration for x64

Solution Configuration

I build the solution with the same properties you are using.

Building

I'm pretty familiar with MSBuild and I know what property to look for, for the final copy. So I search for $property OutDir. This means I'm looking for an MSBuild property with the name that contains OutDir.

OutDir

As you can see, we're pretty much getting the same situation you described. Some have bin\x64\Debug while some are just bin\Debug. They all have their target framework appended but they also have their runtime identifier appended.

If we want to see the project exactly as MSBuild sees it. We can expand the evaluation folder and click the "Preprocess" from the context menu. This will be a monster xml. This is actually your csproj in its full expanded glory after all the .props and .targets imported from the .NET SDK and from your repo.

You can get this file from the cli as well with MSBuild -preprocess[:file] option. Preprocess tab

There's a lot of stuff going on but really we're only looking for stuff that directs the output. I know OutputPath is used in OutDir so I search for OutputPath and jump around. I find that the special .targets file that controls default outputs is called Microsoft.NET.DefaultOutputPaths.targets. Here's the relevant portion:

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

    <BaseOutputPath Condition="'$(BaseOutputPath)' == ''">bin\</BaseOutputPath>
    <BaseOutputPath Condition="!HasTrailingSlash('$(BaseOutputPath)')">$(BaseOutputPath)\</BaseOutputPath>
    <OutputPath Condition="'$(OutputPath)' == '' and '$(PlatformName)' == 'AnyCPU'">$(BaseOutputPath)$(Configuration)\</OutputPath>
    <OutputPath Condition="'$(OutputPath)' == '' and '$(PlatformName)' != 'AnyCPU'">$(BaseOutputPath)$(PlatformName)\$(Configuration)\</OutputPath>
    <OutputPath Condition="!HasTrailingSlash('$(OutputPath)')">$(OutputPath)\</OutputPath>

The first part of your question can finally be answered. For projects that build under the AnyCPU solution (even when you specify x64), you don't get the $(Platform) in the output. You can see that in my example: only net6lib has x64 in the OutDir.

So what about the runtime identifier, I don't see it in this .targets file. It's actually expanded on a little later in a different file: Microsoft.NET.RuntimeIdentifierInference.targets.

Relevant portion with comment

  <!--
    Append $(RuntimeIdentifier) directory to output and intermediate paths to prevent bin clashes between
    targets.

    But do not append the implicit default runtime identifier for .NET Framework apps as that would
    append a RID the user never mentioned in the path and do so even in the AnyCPU case.
   -->
  <PropertyGroup Condition="'$(AppendRuntimeIdentifierToOutputPath)' == 'true' and '$(RuntimeIdentifier)' != '' and '$(_UsingDefaultRuntimeIdentifier)' != 'true'">
    <IntermediateOutputPath>$(IntermediateOutputPath)$(RuntimeIdentifier)\</IntermediateOutputPath>
    <OutputPath>$(OutputPath)$(RuntimeIdentifier)\</OutputPath>
  </PropertyGroup>

And there's the last part of your output portion. $(TargetFramework) gets inserted in there as well between the previous target I mentioned and this target but you already knew that.

There is no madness here at all. MSBuild is following exactly what it's been told to do.

Hopefully this helps some!

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Hank McCord