Using Microsoft.Win32.Registry in a custom MSBuild task shipped via NuGet

Summary

A custom MSBuild task attempted to read from the Windows Registry while targeting .NET Standard 2.0, causing MSBuild to load the .NET Standard stub version of Microsoft.Win32.Registry. That assembly contains no platform-specific implementation, leading to a PlatformNotSupportedException at runtime.

Root Cause

The failure occurs because:

  • MSBuild running on .NET (Core/SDK-based) loads the .NET Standard version of Microsoft.Win32.Registry, which is only a facade.
  • Registry access is only implemented on Windows-specific frameworks, such as:
    • .NET Framework
    • .NET Core / .NET 5+ Windows-specific TFMs (e.g., net6.0-windows)
  • A task targeting .NET Standard 2.0 cannot guarantee that MSBuild will load the Windows-specific implementation assembly.
  • When MSBuild runs on non-Windows or on .NET Core without Windows-specific assemblies, registry APIs throw PlatformNotSupportedException.

Why This Happens in Real Systems

This is a classic example of API facades in multi-targeted environments:

  • .NET Standard exposes APIs that may not be implemented on all platforms.
  • MSBuild tasks are loaded by the host MSBuild process, not by your NuGet package.
  • MSBuild running on .NET Core uses different assembly resolution rules than MSBuild on .NET Framework.
  • Tasks that rely on OS-specific features must not target .NET Standard alone.

Real-World Impact

This issue commonly leads to:

  • Runtime crashes when tasks run on SDK-based MSBuild.
  • Silent failures in CI pipelines running Linux or macOS agents.
  • Inconsistent behavior between Visual Studio (MSBuild on .NET Framework) and dotnet build (MSBuild on .NET Core).
  • Hard-to-debug assembly loading issues because MSBuild chooses the “best available” TFM, not necessarily the one you expect.

Example or Code (if necessary and relevant)

Below is a minimal example of a multi-targeted MSBuild task that safely uses the registry only on Windows:

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.Win32;

public class ReadRegistryValue : Task
{
    public override bool Execute()
    {
#if NETFRAMEWORK || NET6_0_WINDOWS
        var value = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Test", "Key", null);
        Log.LogMessage($"Registry value: {value}");
#else
        Log.LogWarning("Registry access is not supported on this platform.");
#endif
        return true;
    }
}

How Senior Engineers Fix It

Experienced engineers solve this by multi-targeting:

  • Target both:
    • netstandard2.0 (for compatibility)
    • net6.0-windows or net472 (for registry access)
  • Place registry-dependent code only in Windows-specific TFMs.
  • Use conditional compilation to isolate platform-specific logic.
  • Ensure the NuGet package includes both assemblies so MSBuild loads the correct one.
  • Add a guard to prevent registry access when running on non-Windows MSBuild hosts.

Typical .csproj fix:

netstandard2.0;net6.0-windows

Why Juniors Miss It

Less experienced developers often overlook:

  • MSBuild tasks run inside MSBuild, not inside your project.
  • Assembly resolution differs between .NET Framework MSBuild and .NET Core MSBuild.
  • .NET Standard is not a runtime, so APIs may exist but not be implemented.
  • NuGet packaging does not override MSBuild’s assembly loading rules.
  • Platform-specific APIs require platform-specific TFMs, even if the code compiles.

They see the API available in IntelliSense and assume it will work everywhere, not realizing that API availability ≠ runtime support.

Leave a Comment