Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Broken Build]: Property defined in .user project file (csproj.user) not available in build process #9131

Closed
LittleGitPhoenix opened this issue Aug 16, 2023 · 10 comments · Fixed by #9444
Assignees
Labels
backlog iteration:2023November Priority:2 Work that is important, but not critical for the release triaged

Comments

@LittleGitPhoenix
Copy link

Issue Description

I am defining custom properties via a user project file as descripted here. The purpose is exactly the one outlined in the above documentation: I need to make temporary changes (that influence the build process down the line). Those changes mustn't be checked into source control. I have done this a couple of times already and it was always working as I would expect it to.

Today I started to work on a multi-target-framework library (e.g. .NET 6 and .NET Standard 2.0), where properties defined in my user file where not properly applied. If custom targets (e.g. AfterTargets="Build" and AfterTargets="Pack") that are defined outside of the user project file try to access those properties, they are only seeing empty strings.

After testing and digging around for some time, I narrowed the strange behavior down to a project defining target frameworks via TargetFrameworks (with s) and not the singular one TargetFramework.

Sample project

I attached a sample project ProjectPropertyTest that reproduces the behavior. It does not contain code, it only centers around its own and a user project file.

Project file

The ProjectPropertyTest.csproj contains all three possible combinations on how to define target frameworks. They can be (un)commented to check the different behaviors. Additionally it has a custom target WriteValueAfterBuild executed AfterTargets="Build" that outputs a variable named MyProperty. The output is prefixed with DIRECT.

User-Project file

The ProjectPropertyTest.csproj.user simply initializes the property MyProperty with MyValue and also outputs it via another custom target WriteValueAfterBuildFromUser again executed AfterTargets="Build". The output is prefixed with USER.

Tests

I checked this with three different ways to define target frameworks:

Single target framework defined via TargetFramework

<TargetFramework>net6.0</TargetFramework>
  • Both after-build-targets are executed as expected.
  • The property MyProperty that is only defined in ProjectPropertyTest.csproj.user is properly written to the output for both targets.
Rebuild started...
[...]
1>---→ DIRECT: After build of net6.0 and cross-targeting : Value is MyValue.
1>---→ USER: After build of net6.0 and cross-targeting : Value is MyValue.
========== Rebuild All: 1 succeeded, 0 failed, 0 skipped ==========

Multiple target frameworks defined via TargetFrameworks

<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
  • The after-build-target specified in ProjectPropertyTest.csproj.user is executed for every target framework and outputs MyProperty properly.
  • The after-build-target specified in ProjectPropertyTest.csproj is executed for every target framework and an additional time seemingly after all those targets where finished. The output of MyProperty is only correct for the execution of the after-build-target for each target framework, but it is missing for the additional execution.
Rebuild started...
[...]
1>---→ DIRECT: After build of net6.0 and cross-targeting : Value is MyValue.
1>---→ USER: After build of net6.0 and cross-targeting : Value is MyValue.
[...]
1>---→ DIRECT: After build of netstandard2.0 and cross-targeting : Value is MyValue.
1>---→ USER: After build of netstandard2.0 and cross-targeting : Value is MyValue.
1>---→ DIRECT: After build of  and cross-targeting true: Value is .
========== Rebuild All: 1 succeeded, 0 failed, 0 skipped ==========

Single target framework defined via TargetFrameworks

<TargetFrameworks>net6.0</TargetFrameworks>
  • Basically same as above.

The problem is, that when using multiple target frameworks, I can not specify (or override) variables via a user project file.

Steps to Reproduce

Sample project: ProjectPropertyTest.zip

Manual steps:

  • Create a new project.
  • Use the TargetFrameworks (with s) property to define the target frameworks for your project (even if it is just one).
  • Create a user project file.
  • Define a property in that user file.
  • Create a custom target for AfterTargets="Build" in the project to output the property.

Expected Behavior

The value of the property is written to the output.

Actual Behavior

The value of the property is not written to the output. It is just an empty string.

Ask us questions

Is this expected behavior? And if it is, how is the proper way to define properties via a user project file in multi-target libraries?

@LittleGitPhoenix LittleGitPhoenix added the needs-triage Have yet to determine what bucket this goes in. label Aug 16, 2023
@KalleOlaviNiemitalo
Copy link

With a single target framework:

  1. Sdk.targets imports Microsoft.CSharp.Targets, via $(LanguageTargets).
  2. Microsoft.CSharp.Targets imports Microsoft.CSharp.CurrentVersion.targets, via $(CSharpTargetsPath).
  3. Microsoft.CSharp.CurrentVersion.targets imports Microsoft.Common.targets.
  4. Microsoft.Common.targets imports Microsoft.Common.CurrentVersion.targets, via $(CommonTargetsPath).
  5. Microsoft.Common.CurrentVersion.targets imports $(MSBuildProjectFullPath).user.

However, if '$(IsCrossTargetingBuild)' == 'true', then Microsoft.CSharp.targets imports Microsoft.CSharp.CrossTargeting.targets instead, and that does not import $(MSBuildProjectFullPath).user.

It has behaved this way since Visual Studio 2017 at least. I expect changing it now would break people's projects.

Instead, you could perhaps add logic to the project file or to Directory.Build.props or Directory.Build.targets, to import a per-user file if it exists.

@LittleGitPhoenix
Copy link
Author

Thanks for the insight. Just out of curiosity, where did you get that import order from?

So, this is the default behavior for some time now, but is it really the expected one? Am I the only one to stumble upon this? And why does Microsoft.CSharp.CrossTargeting.targets not import $(MSBuildProjectFullPath).user? Is this a bug that became a feature?

The documentation for user profiles does not say anything special about multi targeting projects, even though they are common nowadays. It basically only says:

Microsoft.Common.CurrentVersion.targets imports $(MSBuildProjectFullPath).user if it exists, so you can create a file next to your project with that additional extension.

Being silly my, I tried it this way and it obviously failed.

@LittleGitPhoenix
Copy link
Author

One thing just came to my mind. It cannot be that the user project file is not imported, as its custom target is actually getting called. It is just, that the whole file (and therefore its properties) seem to be inside its own scope. Could it be, that the import order (the one listed above) is used for each defined target framework, but the the overall build process then ignores the user file? I mean, build is seemingly executed once for every target framework using the normal import order where Microsoft.CSharp.CurrentVersion.targets imports the user file thus executing the custom targets specified in it (hence the USER output). Those seem to run in their own scope. The overall build process on the other hand uses Microsoft.CSharp.CrossTargeting.targets that does not import the user file and therefore custom variables are empty.

Did I get this right?

@KalleOlaviNiemitalo
Copy link

Just out of curiosity, where did you get that import order from?

By searching for file names in *.targets and *.props files of MSBuild, and by searching for "Importing project" in -verbosity:diagnostic output.

@KalleOlaviNiemitalo
Copy link

build is seemingly executed once for every target framework

The DispatchToInnerBuilds target in Microsoft.Common.CrossTargeting.targets does that.

<Target Name="DispatchToInnerBuilds"
DependsOnTargets="_ComputeTargetFrameworkItems"
Returns="@(InnerOutput)">
<!-- If this logic is changed, also update Clean -->
<MSBuild Projects="@(_InnerBuildProjects)"
Condition="'@(_InnerBuildProjects)' != '' "
Targets="$(InnerTargets)"
BuildInParallel="$(BuildInParallel)">
<Output ItemName="InnerOutput" TaskParameter="TargetOutputs" />
</MSBuild>
</Target>

Surprisingly, the search at https://source.dot.net/ does not find DispatchToInnerBuilds, although it finds other MSBuild targets.

@KalleOlaviNiemitalo
Copy link

Microsoft.Common.CrossTargeting.targets also reads the CustomBeforeMicrosoftCommonCrossTargetingTargets and CustomAfterMicrosoftCommonCrossTargetingTargets properties to which you could hook your own files if you wanted. However, I feel those properties might be better used for solution-wide or server-wide customization than per-project.

@danmoseley
Copy link
Member

Note that the /pp switch can show what is imported. If Visual Studio is setting global properties that influence what is imported you would have to pass those too. (I can't help with the discussion just making sure you're aware of that switch)

@LittleGitPhoenix
Copy link
Author

Okay, thanks to the both of you for the answers and insights. I still think this is awfully complicated and the documentation is...well...lacking.

As KalleOlaviNiemitalo already suggested above, I decided to go the route with a custom Directory.Build.targets file in the root of my repository that will load user project files for projects targeting multiple frameworks if they exist. In my opinion this should be the default behavior, whether a single or multiple frameworks are defined for a project.

Here the code of the Directory.Build.targets file (the relevant part is line 12):

<Project>

	<!-- Import other Directory.Build.targets: https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-directory?view=vs-2022#use-case-multi-level-merging -->
	<PropertyGroup>
		<ParentDirectoryBuildTargetsPath>$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..\'))</ParentDirectoryBuildTargetsPath>
	</PropertyGroup>
	<ImportGroup>
		<Import Condition="$(ParentDirectoryBuildTargetsPath) != ''" Project="$(ParentDirectoryBuildTargetsPath)" />
	</ImportGroup>
	
	<!-- Since the user project file is not imported for projects targeting multiple frameworks (https://github.com/dotnet/msbuild/issues/9131), manually import the file. -->
	<Import Project="$(MSBuildProjectFullPath).user" Condition="'$(IsCrossTargetingBuild)' == 'true' and Exists('$(MSBuildProjectFullPath).user')" />
	
</Project>

@KalleOlaviNiemitalo
Copy link

I'm curious, what settings do your users store in those files that need to be loaded in a crosstargeting build?

@LittleGitPhoenix
Copy link
Author

Actually the properties in those user files have nothing to do with the build process and are not required by either single- or multi-framework builds - at least in our case. But they are of interest for later processes like Pack or Publish. Sadly this does not matter as the user file itself is being ignored for multi-framework builds.

An example would be a SignPackage property. It is evaluated after the regular Pack target and based on its state our assemblies will be digitally signed. For non-CI builds this is by default false, as signing of intermediate releases is not necessary and would only increase build time. Regular CI builds have this set to true, so every software release via our CI/CD pipeline is properly signed. And then there are edge cases where it is required to have local builds (non-CI) signed. And therefore the user should be able to set the SignPackage property in his user configuration to true.

@AR-May AR-May added backlog Priority:2 Work that is important, but not critical for the release Iteration:2023September and removed needs-triage Have yet to determine what bucket this goes in. labels Aug 22, 2023
maridematte added a commit that referenced this issue Dec 15, 2023
Fixes #9131

Context
As described on the issue, muti-targeted builds did not import the .user file on the outer build. This change makes the outer build import the .user file.

Changes Made
Added import reference to .user file in  Microsoft.Common.CrossTargeting.targets .

Testing
Test is in SDK repo (dotnet/sdk#37192)
maridematte added a commit to maridematte/msbuild that referenced this issue Dec 18, 2023
Fixes dotnet#9131

Context
As described on the issue, muti-targeted builds did not import the .user file on the outer build. This change makes the outer build import the .user file.

Changes Made
Added import reference to .user file in  Microsoft.Common.CrossTargeting.targets .

Testing
Test is in SDK repo (dotnet/sdk#37192)
maridematte added a commit that referenced this issue Dec 18, 2023
Fixes #9131

Context
As described on the issue, muti-targeted builds did not import the .user file on the outer build. This change makes the outer build import the .user file.

Changes Made
Added import reference to .user file in  Microsoft.Common.CrossTargeting.targets .

Testing
Test is in SDK repo (dotnet/sdk#37192)
@AR-May AR-May added the triaged label Feb 21, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backlog iteration:2023November Priority:2 Work that is important, but not critical for the release triaged
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants