Resolve-MSBuild.ps1

function Invoke-MSBuildVsWhere {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    $PSNativeCommandUseErrorActionPreference = $false
    $output = & $Path -products '*' -requires Microsoft.Component.MSBuild -format json -utf8 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "Cannot discover Visual Studio MSBuild: vswhere.exe exited with code $LASTEXITCODE. $($output -join "`n")"
    }
    $output
}

function Get-MSBuildDefaultArchitecture {
    [CmdletBinding()]
    param()

    $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
    if ($architecture -notin @('x86', 'x64', 'arm64')) {
        throw "No supported Visual Studio MSBuild architecture for this operating system: $architecture."
    }
    $architecture
}

function Resolve-MSBuild {
    <#
    .SYNOPSIS
        Resolves an installed Visual Studio MSBuild.exe without running it.
 
    .DESCRIPTION
        Uses Visual Studio Installer's vswhere.exe to discover complete,
        launchable release installations containing Microsoft.Component.MSBuild,
        including standalone Build Tools. Preview installations are excluded.
        Selects the newest installationVersion with an existing MSBuild.exe for
        the requested architecture; equal versions are ordered by installation
        path ascending. Supports Visual Studio 2017 and later.
 
        Returns the real Visual Studio MSBuild.exe, not dotnet msbuild. This is
        useful for Visual Studio workloads and packaging targets that require
        full MSBuild. Discovery does not guarantee that any particular workload,
        SDK, or project target is installed. No Set-VS<year> provider is needed,
        no developer shell is started, and PATH is not modified.
 
        This command requires Windows; importing the module does not.
 
    .PARAMETER Version
        Optional Visual Studio year (2017, 2019, 2022, or 2026) or numeric
        installation version prefix, such as 17, 17.10, or 18.0. This is the
        Visual Studio version, not an MSBuild or .NET SDK version. Omit to
        select the newest compatible installation. Ranges are not accepted.
 
    .PARAMETER Architecture
        Architecture of the MSBuild executable (not the project's target).
        Accepts x86, x64, amd64 (an alias value for x64), or arm64. Defaults
        to the native operating system architecture, even in an emulated
        PowerShell process. Never falls back to a different architecture.
 
    .PARAMETER PathOnly
        Returns only the executable path as a string, suitable for the call
        operator, instead of an MSBuildInstallation object.
 
    .OUTPUTS
        MSBuildInstallation
        Path (string), VisualStudioVersion (System.Version), VisualStudioYear
        (integer, or null for an unrecognized future major), InstallationPath
        (string), and Architecture (x86, x64, or arm64).
 
        System.String
        With PathOnly, the executable path.
 
    .EXAMPLE
        Resolve-MSBuild
        Describes the newest installed release MSBuild for the native OS
        architecture without executing it.
 
    .EXAMPLE
        $msbuild = Resolve-MSBuild -Version 2022 -Architecture x64
        & $msbuild.Path .\App.sln -restore
        Explicitly invokes Visual Studio MSBuild rather than dotnet msbuild.
 
    .EXAMPLE
        & (Resolve-MSBuild -Version 18 -PathOnly) .\App.sln -t:Build
        Invokes MSBuild from the newest compatible Visual Studio 18.x instance.
 
    .LINK
        https://learn.microsoft.com/visualstudio/install/tools-for-managing-visual-studio-instances
 
    .LINK
        https://github.com/microsoft/setup-msbuild
    #>

    [CmdletBinding()]
    [OutputType('MSBuildInstallation', [string])]
    param(
        [Parameter(Position = 0)]
        [ValidatePattern('^(2017|2019|2022|2026|(?:1[5-9]|[2-9][0-9])(?:\.[0-9]+){0,3})$')]
        [ValidateScript({
            $parsed = $null
            $candidate = if ($_ -notmatch '\.') { "$_.0" } else { $_ }
            if (-not [version]::TryParse($candidate, [ref]$parsed)) {
                throw 'Version components must fit in a System.Version value.'
            }
            $true
        })]
        [string]$Version,

        [Alias('Arch')]
        [ValidateSet('x86', 'x64', 'amd64', 'arm64')]
        [string]$Architecture,

        [switch]$PathOnly
    )

    Assert-WindowsOnly -CommandName $MyInvocation.MyCommand.Name

    $selectedArchitecture = if ($Architecture) { $Architecture.ToLowerInvariant() } else { Get-MSBuildDefaultArchitecture }
    if ($selectedArchitecture -eq 'amd64') { $selectedArchitecture = 'x64' }

    $versionParts = if ($Version) { @($Version -split '\.' | ForEach-Object { [int]$_ }) }
    $instances = @(Invoke-VsWhere -MSBuild -ErrorAction Stop)
    $instances = $instances | Sort-Object -Property @(
        @{ Expression = { [version]$_.installationVersion }; Descending = $true },
        @{ Expression = { [string]$_.installationPath }; Ascending = $true }
    )

    foreach ($instance in $instances) {
        $installationVersion = [version]$instance.installationVersion
        if ($installationVersion.Major -lt 15) { continue }
        $year = ConvertTo-VsYear -InstallationVersion $instance.installationVersion
        if ($Version) {
            if ($versionParts[0] -ge 2000) {
                if ($year -ne $versionParts[0]) { continue }
            } else {
                $installedParts = @($installationVersion.Major, $installationVersion.Minor, $installationVersion.Build, $installationVersion.Revision)
                $matches = $true
                for ($index = 0; $index -lt $versionParts.Count; $index++) {
                    if ($versionParts[$index] -ne $installedParts[$index]) {
                        $matches = $false
                        break
                    }
                }
                if (-not $matches) { continue }
            }
        }

        $toolset = if ($installationVersion.Major -eq 15) { '15.0' } else { 'Current' }
        $binPath = Join-Path $instance.installationPath 'MSBuild' $toolset 'Bin'
        $binPath = switch ($selectedArchitecture) {
            'x64' { Join-Path $binPath 'amd64' }
            'arm64' { Join-Path $binPath 'arm64' }
            default { $binPath }
        }
        $path = Join-Path $binPath 'MSBuild.exe'
        if (-not (Test-Path -LiteralPath $path -PathType Leaf -ErrorAction Stop)) { continue }

        if ($PathOnly) { return $path }
        return [pscustomobject]@{
            PSTypeName          = 'MSBuildInstallation'
            Path                = $path
            VisualStudioVersion = $installationVersion
            VisualStudioYear    = $year
            InstallationPath    = [string]$instance.installationPath
            Architecture        = $selectedArchitecture
        }
    }

    $versionDescription = if ($Version) { " matching Visual Studio '$Version'" } else { '' }
    throw "No compatible Visual Studio MSBuild.exe installation found$versionDescription for architecture '$selectedArchitecture'. A complete release installation of Visual Studio 2017 or later (or Build Tools) with the MSBuild component and requested executable is required."
}