From a00475d3dc7b30742352722281e7e9e3f958a9bd Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:49:56 +0200 Subject: [PATCH 01/10] Add workaround for SDK root drive casing issue --- .../tools/Workarounds.targets | 200 +++++++++++++++++- 1 file changed, 199 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index b45e3018635..f296b9682c9 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -1,5 +1,5 @@ - + + + + + + + + + + = 2 && canonical[1] == ':') + { + char canonicalDrive = canonical[0]; + if (canonicalDrive != SdkRoot[0]) + { + Result = canonicalDrive + SdkRoot.Substring(1); + Log.LogMessage( + MessageImportance.Low, + "Normalized NetCoreSdkRoot drive casing '{0}' -> '{1}' to match Environment.ProcessPath (#14026 workaround).", + SdkRoot[0], + canonicalDrive); + } + } + } + finally + { + FreeLibrary(module); + } + } + catch (Exception ex) + { + // Swallow: a casing mismatch is the only thing we're trying to fix, and + // we must not regress builds where this best-effort probe cannot run. + Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing skipped: {0}", ex.Message); + } + + return true; + } +} +]]> + + + + + + + + + From c7e87d6bfd8902ddd908715db301c2bbd8c8e129 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:01:59 +0200 Subject: [PATCH 02/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index f296b9682c9..3ec0e30a9d3 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -258,7 +258,7 @@ + Condition="'$(OS)' == 'Windows_NT' and '$(MSBuildRuntimeType)' == 'Full'"> From d42487d44fa8d0428e9b3288b40753f6a5f711c4 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:09 +0200 Subject: [PATCH 03/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 3ec0e30a9d3..b0e0dac6690 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -376,8 +376,7 @@ public class NormalizeSdkRootDriveCasing : Task - + Condition="'$(OS)' == 'Windows_NT' and '$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> From 18703249772ed8704aa81c2c08686ecaee056ae3 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:23 +0200 Subject: [PATCH 04/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../tools/Workarounds.targets | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index b0e0dac6690..9f1c4869c01 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -243,15 +243,13 @@ Wiring ------ - Import this file from a Directory.Build.props (or Directory.Build.targets) - so it is imported into every project: + This workaround is implemented in Workarounds.targets and is imported automatically + by Microsoft.DotNet.Arcade.Sdk (see sdk/Sdk.targets). No additional wiring is + required for Arcade SDK consumers. - - - InitialTargets on this aggregates into the importing project's - InitialTargets, so the target runs once per project BEFORE any other target - (and therefore before the first .NET task host is launched). NetCoreSdkRoot - is defined during evaluation, so it is available by the time the target runs. + InitialTargets on this aggregates into the importing project's InitialTargets, + so the target runs early in the build (before the first .NET task host is launched). + NetCoreSdkRoot is defined during evaluation, so it is available by the time the target runs. ========================================================================== --> From 7fd82d0ace98087de8de9a8ea354efecefbfeacb Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:13 +0200 Subject: [PATCH 05/10] Update conditions for NormalizeSdkRootDriveCasing task --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 9f1c4869c01..85a10d606b6 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -256,7 +256,7 @@ + Condition="'$(MSBuildRuntimeType)' == 'Full' and and '$(NetCoreSdkRoot)' != ''"> @@ -374,7 +374,7 @@ public class NormalizeSdkRootDriveCasing : Task + Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> From ae6747d75d0befbbd8395981f12e3a86018b665b Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:32 +0200 Subject: [PATCH 06/10] Fix condition syntax in Workarounds.targets --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 85a10d606b6..e99150ea26f 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -256,7 +256,7 @@ + Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> From 57ae827dc245b35140d936ee5822ece78e01fe53 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:04:36 +0200 Subject: [PATCH 07/10] Update Workarounds.targets --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index e99150ea26f..da0d833eedc 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -375,6 +375,7 @@ public class NormalizeSdkRootDriveCasing : Task + From dd1aa62f99790f2e3ff7024397b7309058e47aec Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:41:56 +0200 Subject: [PATCH 08/10] Use CreateFile+GetFinalPathNameByHandle for SDK root drive casing The previous LoadLibraryEx(LOAD_LIBRARY_AS_DATAFILE)+GetModuleFileName(hModule) technique was a silent no-op: GetModuleFileName returns ERROR_MOD_NOT_FOUND for an image loaded only as a data file (the SDK's MSBuild.exe/MSBuild.dll are not otherwise loaded in the VS MSBuild process), so the casing was never fixed and MSB4216 persisted (verified in build 2998220). Open the SDK root directory with CreateFileW(FILE_FLAG_BACKUP_SEMANTICS) and ask the OS for its canonical path via GetFinalPathNameByHandleW. This is load- and bitness-independent. Splice only the canonical drive letter, and only when the rest of the path is otherwise case-insensitively identical, so symlink/junction resolution (which GetFinalPathNameByHandle performs but the child's Environment.ProcessPath does not) cannot desync the two salts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/Workarounds.targets | 131 +++++++++++------- 1 file changed, 83 insertions(+), 48 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index da0d833eedc..a3530bee288 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -220,23 +220,32 @@ HOW WE MATCH Environment.ProcessPath EXACTLY -------------------------------------------- - Environment.ProcessPath is implemented as GetModuleFileNameW(NULL) for the - current process. We obtain the identical canonicalization in-process, WITHOUT - launching the SDK, by: - - LoadLibraryEx(, LOAD_LIBRARY_AS_DATAFILE) - GetModuleFileNameW(hModule) - - Empirically (verified on Windows), GetModuleFileNameW IGNORES the drive - casing passed to LoadLibraryEx and returns the volume's canonical casing - - the exact same value Environment.ProcessPath yields for a process launched - from that volume. The canonical drive-letter casing is a property of the - volume mount, not of the individual file, so loading MSBuild.exe (or, if the - apphost is absent, MSBuild.dll) from $(NetCoreSdkRoot) yields the same drive - letter the child task host will see. - - This is Windows-only, idempotent, and a safe no-op when no loadable image is - found under $(NetCoreSdkRoot). + Environment.ProcessPath is GetModuleFileNameW(NULL), which reports the + volume's canonical on-disk drive-letter casing. We obtain the identical + drive-letter casing in-process, WITHOUT launching the SDK, by opening + $(NetCoreSdkRoot) and asking the OS for its final (canonical) path: + + CreateFileW(, FILE_FLAG_BACKUP_SEMANTICS) // open the dir + GetFinalPathNameByHandleW(handle, VOLUME_NAME_DOS) // canonical path + + The canonical drive-letter casing is a property of the volume mount, not of + the individual file, so the drive letter returned for the SDK root directory + is the exact same drive letter the child task host will see via + Environment.ProcessPath. We splice ONLY that drive letter onto the original + path, and only when the rest of the canonical path is otherwise identical + (case-insensitively) to $(NetCoreSdkRoot). That guard rejects any change + introduced by symlink/junction resolution (which GetFinalPathNameByHandle + performs but GetModuleFileNameW does not), keeping the two sides in agreement. + + NOTE: an earlier revision used LoadLibraryEx(LOAD_LIBRARY_AS_DATAFILE) + + GetModuleFileNameW(hModule). That is unreliable: GetModuleFileNameW returns + ERROR_MOD_NOT_FOUND (0) for an image loaded only as a data file (e.g. the + SDK's MSBuild.exe/MSBuild.dll, which are not otherwise loaded in the VS + MSBuild process), making the workaround a silent no-op. CreateFile + + GetFinalPathNameByHandle is load- and bitness-independent. + + This is Windows-only, idempotent, and a safe no-op when $(NetCoreSdkRoot) + cannot be opened. Remove this workaround once BOTH the VS MSBuild host and the .NET SDK task host carry the bilateral salt-casing normalization from #14026. @@ -264,7 +273,6 @@ sb.Capacity) + { + sb = new StringBuilder((int)len); + len = GetFinalPathNameByHandleW(handle, sb, (uint)sb.Capacity, VOLUME_NAME_DOS); + if (len == 0) + { + return true; + } + } - // GetModuleFileNameW returns the same canonical path Environment.ProcessPath - // would report. Splice ONLY its drive letter onto the original SDK root. + // Strip the extended-length prefix (\\?\ or \\?\UNC\) that + // GetFinalPathNameByHandle prepends. string canonical = sb.ToString(); - if (canonical.Length >= 2 && canonical[1] == ':') + if (canonical.StartsWith(@"\\?\UNC\", StringComparison.Ordinal)) + { + // UNC path: no drive letter to splice, leave unchanged. + return true; + } + if (canonical.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + canonical = canonical.Substring(4); + } + + // Only adopt the canonical drive letter when the rest of the path is + // otherwise identical (case-insensitively). This rejects any structural + // change from symlink/junction resolution, which GetFinalPathNameByHandle + // performs but the child's Environment.ProcessPath (GetModuleFileNameW) + // does not - keeping both sides' salts in agreement. + if (canonical.Length >= 2 && canonical[1] == ':' && + string.Equals(canonical, SdkRoot, StringComparison.OrdinalIgnoreCase)) { char canonicalDrive = canonical[0]; if (canonicalDrive != SdkRoot[0]) @@ -356,7 +391,7 @@ public class NormalizeSdkRootDriveCasing : Task } finally { - FreeLibrary(module); + CloseHandle(handle); } } catch (Exception ex) From 427dadda576a0860c5def6013a0bf569ebb5daaf Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:21:08 +0200 Subject: [PATCH 09/10] Log NetCoreSdkRoot casing normalization at High importance Make the temporary #14026 workaround leave visible evidence in CI console logs (it only fires when the drive casing actually differs, so this is not noisy on healthy machines). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index a3530bee288..042df6bc027 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -381,8 +381,9 @@ public class NormalizeSdkRootDriveCasing : Task if (canonicalDrive != SdkRoot[0]) { Result = canonicalDrive + SdkRoot.Substring(1); + // High importance so the (temporary) workaround leaves clear evidence in CI logs. Log.LogMessage( - MessageImportance.Low, + MessageImportance.High, "Normalized NetCoreSdkRoot drive casing '{0}' -> '{1}' to match Environment.ProcessPath (#14026 workaround).", SdkRoot[0], canonicalDrive); From 21f1eaf402a59208fda751ddcec1991d17ea704f Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:49:06 +0200 Subject: [PATCH 10/10] Fix NetCoreSdkRoot casing guard to ignore trailing separators NetCoreSdkRoot carries a trailing directory separator, but GetFinalPathNameByHandle returns the canonical path without one. The strict full-path equality guard therefore always failed, making the drive-casing normalization a silent no-op and leaving MSB4216 (#14026) unfixed. Compare with trailing separators trimmed from both sides. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets index 042df6bc027..5a6f851dcbd 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Workarounds.targets @@ -374,8 +374,13 @@ public class NormalizeSdkRootDriveCasing : Task // change from symlink/junction resolution, which GetFinalPathNameByHandle // performs but the child's Environment.ProcessPath (GetModuleFileNameW) // does not - keeping both sides' salts in agreement. + // NetCoreSdkRoot carries a trailing directory separator, but + // GetFinalPathNameByHandle returns the path without one, so compare with + // trailing separators trimmed off both sides. + string canonicalTrimmed = canonical.TrimEnd('\\', '/'); + string sdkRootTrimmed = SdkRoot.TrimEnd('\\', '/'); if (canonical.Length >= 2 && canonical[1] == ':' && - string.Equals(canonical, SdkRoot, StringComparison.OrdinalIgnoreCase)) + string.Equals(canonicalTrimmed, sdkRootTrimmed, StringComparison.OrdinalIgnoreCase)) { char canonicalDrive = canonical[0]; if (canonicalDrive != SdkRoot[0])