Repository: dotnet/msbuild
Pull request #11535: Load `before/after.{solutionName}.sln.targets` for .`slnx`
Author: login: surayya-MS
Created: 2025-03-04T13:52:45Z | Merged: 2025-03-07T20:56:03Z

Fixes #11288

### Context
see the issue description #11288

### Changes Made
1. Load `before/after.{solutionName}.sln.targets` for .`slnx`
2. Added test checking that targets from these files are included in the project for `.sln` and `.slnx`

### Testing
Added tests, tested manually

### Notes


Linked issue #11288: Load `after.{SolutionName}.sln.targets` for slnx solutions
### Context
This recently came up internally when a customer tried to move from `.sln` to .`slnx`. Not all targets were included. The fix was to rename the existing `after.{SolutionName}.sln.targets` to `after.{SolutionName}.slnx.targets`.

### Suggestion
For the smoother transition experience from `.sln` to the new solution format `.slnx` we should load `after.{SolutionName}.sln.targets` for `.slnx` too. We decided not to load `after.{SolutionName}.slnx.targets` for `.slnx`.

Linked issue comments: user: login: NielsOkker
created_at: 2025-07-11T07:48:34Z
body: Dear @surayya-MS, 

I stumbled on this issue while working on a migration from .sln to .slnx. For me it seems to make sense that the after.{SolutionName}.slnx.targets should also work as it is an exact match to my solution name (including the extension). The documentation also says that both .sln and .slnx should work https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-solution-build?view=vs-2022. I wonder why it was decided to not support the after.{SolutionName}.slnx.targets. Could you elaborate on that? Would it be something I could create a pull-request for to support both? 

Thank you in advance!
author_association: NONE

Diff:
diff --git a/src/Build.UnitTests/Construction/SolutionFile_NewParser_Tests.cs b/src/Build.UnitTests/Construction/SolutionFile_NewParser_Tests.cs
index afd2fe258b7..014b8521524 100644
--- a/src/Build.UnitTests/Construction/SolutionFile_NewParser_Tests.cs
+++ b/src/Build.UnitTests/Construction/SolutionFile_NewParser_Tests.cs
@@ -144,7 +144,7 @@ private static SolutionFile ParseSolutionHelper(string solutionFileContents, boo
             }
         }
 
-        private static string ConvertToSlnx(string slnPath)
+        internal static string ConvertToSlnx(string slnPath)
         {
             string slnxPath = slnPath + "x";
             ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(slnPath).ShouldNotBeNull();
diff --git a/src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs b/src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs
index b952d0b703c..11179b170c6 100644
--- a/src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs
+++ b/src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs
@@ -83,6 +83,36 @@ public void SolutionProjectIgnoresDuplicateDefaultTargets(string name)
             }
         }
 
+        /// <summary>
+        /// Test that targets in before.{sln}.targets and after.{sln}.targets files are included in the project.
+        /// </summary>
+        [Theory]
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        public void SolutionProjectIncludesBeforeAndAfterTargets(string name, bool convertToSlnx)
+        {
+            using (TestEnvironment testEnvironment = TestEnvironment.Create())
+            {
+                TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
+                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", solutionFileContents);
+                string solutionPath = convertToSlnx ? SolutionFile_NewParser_Tests.ConvertToSlnx(sln.Path) : sln.Path;
+                testEnvironment.CreateFile(folder, name,
+                      """
+                      <Project>
+                          <Target Name="TestTarget" />
+                      </Project>
+                      """);
+                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(solutionPath), null, null, _buildEventContext, CreateMockLoggingService());
+                instances.ShouldHaveSingleItem();
+                instances[0].Targets.ShouldContainKey("TestTarget");
+                MockLogger logger = new MockLogger(output);
+                instances[0].Build(targets: null, new List<ILogger> { logger }).ShouldBeTrue();
+            }
+        }
+
         [Fact]
         public void BuildProjectAsTarget()
         {
diff --git a/src/Build/Construction/Solution/SolutionProjectGenerator.cs b/src/Build/Construction/Solution/SolutionProjectGenerator.cs
index 760fcb390f3..d535d1c73c0 100644
--- a/src/Build/Construction/Solution/SolutionProjectGenerator.cs
+++ b/src/Build/Construction/Solution/SolutionProjectGenerator.cs
@@ -948,15 +948,7 @@ private ProjectInstance CreateTraversalInstance(string wrapperProjectToolsVersio
             // Add our local extensibility points to the project representing the solution
             // Imported at the top: before.mysolution.sln.targets
             // Imported at the bottom: after.mysolution.sln.targets
-            string escapedSolutionFile = EscapingUtilities.Escape(Path.GetFileName(_solutionFile.FullPath));
-            string escapedSolutionDirectory = EscapingUtilities.Escape(_solutionFile.SolutionFileDirectory);
-            string localFile = Path.Combine(escapedSolutionDirectory, "before." + escapedSolutionFile + ".targets");
-            ProjectImportElement importBeforeLocal = traversalProject.CreateImportElement(localFile);
-            importBeforeLocal.Condition = @"exists('" + localFile + "')";
-
-            localFile = Path.Combine(escapedSolutionDirectory, "after." + escapedSolutionFile + ".targets");
-            ProjectImportElement importAfterLocal = traversalProject.CreateImportElement(localFile);
-            importAfterLocal.Condition = @"exists('" + localFile + "')";
+            (ProjectImportElement importBeforeLocal, ProjectImportElement importAfterLocal) = CreateBeforeAndAfterSolutionImports(traversalProject);
 
             // Put locals second so they can override globals if they want
             traversalProject.PrependChild(importBeforeLocal);
@@ -1025,6 +1017,27 @@ private ProjectInstance CreateTraversalInstance(string wrapperProjectToolsVersio
             return traversalInstance;
         }
 
+        private (ProjectImportElement ImportBeforeSln, ProjectImportElement ImportAfterSln) CreateBeforeAndAfterSolutionImports(ProjectRootElement traversalProject)
+        {
+            string escapedSolutionFileName = EscapingUtilities.Escape(Path.GetFileName(_solutionFile.FullPath));
+            if (escapedSolutionFileName.EndsWith(".slnx"))
+            {
+                // We want to load only after.{solutionFileName}.sln.targets for solution files with .slnx extension
+                escapedSolutionFileName = escapedSolutionFileName.Substring(0, escapedSolutionFileName.Length - 1);
+            }
+
+            string escapedSolutionDirectory = EscapingUtilities.Escape(_solutionFile.SolutionFileDirectory);
+            string localFile = Path.Combine(escapedSolutionDirectory, $"before.{escapedSolutionFileName}.targets");
+            ProjectImportElement importBeforeLocal = traversalProject.CreateImportElement(localFile);
+            importBeforeLocal.Condition = $"exists('{localFile}')";
+
+            localFile = Path.Combine(escapedSolutionDirectory, $"after.{escapedSolutionFileName}.targets");
+            ProjectImportElement importAfterLocal = traversalProject.CreateImportElement(localFile);
+            importAfterLocal.Condition = $"exists('{localFile}')";
+
+            return (importBeforeLocal, importAfterLocal);
+        }
+
         private void EmitMetaproject(ProjectRootElement metaproject, string path)
         {
             if (Traits.Instance.EmitSolutionMetaproj)


Reviews:
user: login: baronfel
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-04T16:21:41Z
user: login: maridematte
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:50:10Z
user: login: edvilme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T17:53:34Z
user: login: surayya-MS
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T07:27:38Z
user: login: surayya-MS
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T10:04:03Z
user: login: maridematte
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T12:34:06Z

Review comments:
diff_hunk: @@ -57,28 +57,64 @@ public void Dispose()
         /// Test that if a before.{sln}>.targets or after.{sln}.targets file has one of the default targets (Build, Clean, etc.) that it includes only the user-defined target.
         /// </summary>
         [Theory]
-        [InlineData("before.MySln.sln.targets")]
-        [InlineData("after.MySln.sln.targets")]
-        [InlineData("name.that.does.Not.Affect.The.Build.targets")]
-        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name)
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", false)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", true)]
+        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name, bool convertToSlnx)
         {
             using (TestEnvironment testEnvironment = TestEnvironment.Create())
             {
                 TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
-                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", @"Microsoft Visual Studio Solution File, Format Version 16.00");
-                TransientTestFile targetsFile = testEnvironment.CreateFile(folder, name,
-                    @"<Project>
-                        <Target Name=""Build"" AfterTargets=""NonsenseTarget"">
-                        </Target>
-                      </Project>");
-                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(sln.Path), null, null, _buildEventContext, CreateMockLoggingService());
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
+                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", solutionFileContents);
+                string solutionPath = convertToSlnx ? SolutionFile_NewParser_Tests.ConvertToSlnx(sln.Path) : sln.Path;
+                testEnvironment.CreateFile(folder, name,
+                      """
+                      <Project>
+                          <Target Name="TestTarget" />
+                      </Project>
+                      """);
+                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(solutionPath), null, null, _buildEventContext, CreateMockLoggingService());
                 instances.ShouldHaveSingleItem();
                 instances[0].Targets["Build"].AfterTargets.ShouldBe(string.Empty);
                 MockLogger logger = new MockLogger(output);
                 instances[0].Build(targets: null, new List<ILogger> { logger }).ShouldBeTrue();
             }
         }
 
+        /// <summary>
+        /// Test that targets in before.{sln}.targets and after.{sln}.targets files are included in the project.
+        /// </summary>
+        [Theory]
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        public void SolutionProjectIncludesBeforeAndAfterTargets(string name, bool convertToSlnx)
+        {
+            using (TestEnvironment testEnvironment = TestEnvironment.Create())
+            {
+                TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
path: src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs
user: login: maridematte
body: Does the file version here have a big impact? I do not know a lot about the difference between versions.
created_at: 2025-03-05T14:50:10Z
author_association: MEMBER
diff_hunk: @@ -57,28 +57,64 @@ public void Dispose()
         /// Test that if a before.{sln}>.targets or after.{sln}.targets file has one of the default targets (Build, Clean, etc.) that it includes only the user-defined target.
         /// </summary>
         [Theory]
-        [InlineData("before.MySln.sln.targets")]
-        [InlineData("after.MySln.sln.targets")]
-        [InlineData("name.that.does.Not.Affect.The.Build.targets")]
-        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name)
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", false)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", true)]
+        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name, bool convertToSlnx)
         {
             using (TestEnvironment testEnvironment = TestEnvironment.Create())
             {
                 TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
-                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", @"Microsoft Visual Studio Solution File, Format Version 16.00");
-                TransientTestFile targetsFile = testEnvironment.CreateFile(folder, name,
-                    @"<Project>
-                        <Target Name=""Build"" AfterTargets=""NonsenseTarget"">
-                        </Target>
-                      </Project>");
-                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(sln.Path), null, null, _buildEventContext, CreateMockLoggingService());
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
+                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", solutionFileContents);
+                string solutionPath = convertToSlnx ? SolutionFile_NewParser_Tests.ConvertToSlnx(sln.Path) : sln.Path;
+                testEnvironment.CreateFile(folder, name,
+                      """
+                      <Project>
+                          <Target Name="TestTarget" />
+                      </Project>
+                      """);
+                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(solutionPath), null, null, _buildEventContext, CreateMockLoggingService());
                 instances.ShouldHaveSingleItem();
                 instances[0].Targets["Build"].AfterTargets.ShouldBe(string.Empty);
                 MockLogger logger = new MockLogger(output);
                 instances[0].Build(targets: null, new List<ILogger> { logger }).ShouldBeTrue();
             }
         }
 
+        /// <summary>
+        /// Test that targets in before.{sln}.targets and after.{sln}.targets files are included in the project.
+        /// </summary>
+        [Theory]
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        public void SolutionProjectIncludesBeforeAndAfterTargets(string name, bool convertToSlnx)
+        {
+            using (TestEnvironment testEnvironment = TestEnvironment.Create())
+            {
+                TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
path: src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs
user: login: edvilme
body: I think this is required for empty solution files, and 12.00 is the latest .sln version
created_at: 2025-03-05T17:53:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,28 +57,64 @@ public void Dispose()
         /// Test that if a before.{sln}>.targets or after.{sln}.targets file has one of the default targets (Build, Clean, etc.) that it includes only the user-defined target.
         /// </summary>
         [Theory]
-        [InlineData("before.MySln.sln.targets")]
-        [InlineData("after.MySln.sln.targets")]
-        [InlineData("name.that.does.Not.Affect.The.Build.targets")]
-        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name)
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", false)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", true)]
+        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name, bool convertToSlnx)
         {
             using (TestEnvironment testEnvironment = TestEnvironment.Create())
             {
                 TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
-                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", @"Microsoft Visual Studio Solution File, Format Version 16.00");
-                TransientTestFile targetsFile = testEnvironment.CreateFile(folder, name,
-                    @"<Project>
-                        <Target Name=""Build"" AfterTargets=""NonsenseTarget"">
-                        </Target>
-                      </Project>");
-                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(sln.Path), null, null, _buildEventContext, CreateMockLoggingService());
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
+                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", solutionFileContents);
+                string solutionPath = convertToSlnx ? SolutionFile_NewParser_Tests.ConvertToSlnx(sln.Path) : sln.Path;
+                testEnvironment.CreateFile(folder, name,
+                      """
+                      <Project>
+                          <Target Name="TestTarget" />
+                      </Project>
+                      """);
+                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(solutionPath), null, null, _buildEventContext, CreateMockLoggingService());
                 instances.ShouldHaveSingleItem();
                 instances[0].Targets["Build"].AfterTargets.ShouldBe(string.Empty);
                 MockLogger logger = new MockLogger(output);
                 instances[0].Build(targets: null, new List<ILogger> { logger }).ShouldBeTrue();
             }
         }
 
+        /// <summary>
+        /// Test that targets in before.{sln}.targets and after.{sln}.targets files are included in the project.
+        /// </summary>
+        [Theory]
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        public void SolutionProjectIncludesBeforeAndAfterTargets(string name, bool convertToSlnx)
+        {
+            using (TestEnvironment testEnvironment = TestEnvironment.Create())
+            {
+                TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
path: src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs
user: login: surayya-MS
body: yes, exactly as @edvilme says
created_at: 2025-03-07T07:27:38Z
author_association: MEMBER
diff_hunk: @@ -57,28 +57,64 @@ public void Dispose()
         /// Test that if a before.{sln}>.targets or after.{sln}.targets file has one of the default targets (Build, Clean, etc.) that it includes only the user-defined target.
         /// </summary>
         [Theory]
-        [InlineData("before.MySln.sln.targets")]
-        [InlineData("after.MySln.sln.targets")]
-        [InlineData("name.that.does.Not.Affect.The.Build.targets")]
-        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name)
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", false)]
+        [InlineData("name.that.does.Not.Affect.The.Build.targets", true)]
+        public void SolutionProjectIgnoresDuplicateDefaultTargets(string name, bool convertToSlnx)
         {
             using (TestEnvironment testEnvironment = TestEnvironment.Create())
             {
                 TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
-                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", @"Microsoft Visual Studio Solution File, Format Version 16.00");
-                TransientTestFile targetsFile = testEnvironment.CreateFile(folder, name,
-                    @"<Project>
-                        <Target Name=""Build"" AfterTargets=""NonsenseTarget"">
-                        </Target>
-                      </Project>");
-                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(sln.Path), null, null, _buildEventContext, CreateMockLoggingService());
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
+                TransientTestFile sln = testEnvironment.CreateFile(folder, "MySln.sln", solutionFileContents);
+                string solutionPath = convertToSlnx ? SolutionFile_NewParser_Tests.ConvertToSlnx(sln.Path) : sln.Path;
+                testEnvironment.CreateFile(folder, name,
+                      """
+                      <Project>
+                          <Target Name="TestTarget" />
+                      </Project>
+                      """);
+                ProjectInstance[] instances = SolutionProjectGenerator.Generate(SolutionFile.Parse(solutionPath), null, null, _buildEventContext, CreateMockLoggingService());
                 instances.ShouldHaveSingleItem();
                 instances[0].Targets["Build"].AfterTargets.ShouldBe(string.Empty);
                 MockLogger logger = new MockLogger(output);
                 instances[0].Build(targets: null, new List<ILogger> { logger }).ShouldBeTrue();
             }
         }
 
+        /// <summary>
+        /// Test that targets in before.{sln}.targets and after.{sln}.targets files are included in the project.
+        /// </summary>
+        [Theory]
+        [InlineData("before.MySln.sln.targets", false)]
+        [InlineData("before.MySln.sln.targets", true)]
+        [InlineData("after.MySln.sln.targets", false)]
+        [InlineData("after.MySln.sln.targets", true)]
+        public void SolutionProjectIncludesBeforeAndAfterTargets(string name, bool convertToSlnx)
+        {
+            using (TestEnvironment testEnvironment = TestEnvironment.Create())
+            {
+                TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);
+                string solutionFileContents = "Microsoft Visual Studio Solution File, Format Version 12.00";
path: src/Build.UnitTests/Construction/SolutionProjectGenerator_Tests.cs
user: login: surayya-MS
body: For more context, changing the version in this PR doesn't matter. But we are bringing back parsing .sln with `SolutionPersistence` in [other PR](https://github.com/dotnet/msbuild/pull/11538). For the new parser the latest version is 12.00. If it is higher they throw an exception. In our old MSBuild parser we don't care about the version.
created_at: 2025-03-07T10:04:03Z
author_association: MEMBER

PR comments:
user: login: maridematte
created_at: 2025-03-11T12:54:16Z
body: /backport to vs17.14
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-03-11T12:54:29Z
body: Started backporting to _vs17.14_: https://github.com/dotnet/msbuild/actions/runs/13788521939
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-11T12:54:51Z
body: @maridematte an error occurred while backporting to "vs17.14", please check the run log for details!

Error: @maridematte is not a repo collaborator, backporting is not allowed. If you're a collaborator please make sure your dotnet team membership visibility is set to Public on https://github.com/orgs/dotnet/people?query=maridematte
author_association: CONTRIBUTOR
user: login: maridematte
created_at: 2025-03-11T13:09:48Z
body: /backport to vs17.14
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-03-11T13:09:59Z
body: Started backporting to _vs17.14_: https://github.com/dotnet/msbuild/actions/runs/13788840485
author_association: CONTRIBUTOR