Public/Update-DotNetRuntime.ps1

function Update-DotNetRuntime {
    <#
    .SYNOPSIS
        Updates installed .NET shared frameworks to the latest patch in each band.
 
    .DESCRIPTION
        Discovers the installed Microsoft.NETCore.App, Microsoft.AspNetCore.App,
        and Microsoft.WindowsDesktop.App shared frameworks. For every installed
        major.minor band, retrieves Microsoft's release metadata, downloads the
        matching Windows runtime installer, verifies its SHA-512 hash, and installs
        a newer patch when one is available.
 
        End-of-life channels are skipped unless -IncludeEol is specified. Use
        -CleanupStale to remove superseded patches after a band is current.
 
    .PARAMETER Band
        Limits processing to the specified major.minor bands, such as '8.0'.
        By default, every installed band is processed.
 
    .PARAMETER IncludeEol
        Includes end-of-life .NET channels. These channels no longer receive
        security updates.
 
    .PARAMETER CleanupStale
        Removes superseded patch versions from successfully processed bands with
        Remove-StaleDotNetRuntime, which uses the .NET Uninstall Tool when it can.
 
    .PARAMETER KeepVersions
        The number of newest patch versions to retain when -CleanupStale is used.
        A value greater than 1 cannot be expressed by the .NET Uninstall Tool, so
        the superseded directories are removed instead.
 
    .PARAMETER DownloadPath
        Directory used for temporary runtime installers.
 
    .EXAMPLE
        Update-DotNetRuntime -WhatIf
 
        Shows the runtime updates available for every installed supported band.
 
    .EXAMPLE
        Update-DotNetRuntime -Band '8.0', '10.0' -CleanupStale -KeepVersions 2 -Confirm:$false
 
        Updates the specified bands and retains their two newest patch versions.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [ValidatePattern('^\d+\.\d+$')]
        [string[]]$Band,

        [switch]$IncludeEol,

        [switch]$CleanupStale,

        [ValidateRange(1, 1000)]
        [int]$KeepVersions = 1,

        [ValidateNotNullOrEmpty()]
        [string]$DownloadPath = $env:TEMP
    )

    function Format-UpdateResult {
        param(
            [string]$Framework,
            [string]$Architecture,
            [string]$Band,
            [string]$CurrentVersion,
            [string]$TargetVersion,
            [string]$Status,
            [Nullable[int]]$ExitCode,
            [bool]$RebootRequired,
            [string]$ErrorMessage
        )

        [PSCustomObject][ordered]@{
            Framework      = $Framework
            Architecture   = $Architecture
            Band           = $Band
            CurrentVersion = $CurrentVersion
            TargetVersion  = $TargetVersion
            Status         = $Status
            ExitCode       = $ExitCode
            RebootRequired = $RebootRequired
            Error          = $ErrorMessage
        }
    }

    function Get-VersionInfo {
        param([string]$Value)

        $match = [regex]::Match($Value, '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?<suffix>-.+)?$')
        if (-not $match.Success) {
            return $null
        }

        [PSCustomObject]@{
            Display   = $Value
            SortValue = [version]('{0}.{1}.{2}' -f $match.Groups['major'].Value, $match.Groups['minor'].Value, $match.Groups['patch'].Value)
            IsStable  = -not $match.Groups['suffix'].Success
            Band      = '{0}.{1}' -f $match.Groups['major'].Value, $match.Groups['minor'].Value
        }
    }

    function Get-NativeArchitecture {
        $architecture = @($env:PROCESSOR_ARCHITEW6432, $env:PROCESSOR_ARCHITECTURE) |
            Where-Object { $_ } |
            Select-Object -First 1

        if ($architecture -match 'ARM64') {
            return 'arm64'
        }

        if ($architecture -match '64') {
            return 'x64'
        }

        return 'x86'
    }

    function Get-InstalledFramework {
        $nativeArchitecture = Get-NativeArchitecture
        $programFiles = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
        $roots = @(
            [PSCustomObject]@{ Path = $programFiles; Architecture = $nativeArchitecture },
            [PSCustomObject]@{ Path = ${env:ProgramFiles(x86)}; Architecture = 'x86' }
        )
        $frameworks = @{
            'Microsoft.NETCore.App'       = 'runtime'
            'Microsoft.AspNetCore.App'     = 'aspnetcore-runtime'
            'Microsoft.WindowsDesktop.App' = 'windowsdesktop'
        }
        $seenRoots = @{}

        foreach ($root in $roots) {
            if (-not $root.Path) {
                continue
            }

            $sharedPath = Join-Path $root.Path 'dotnet\shared'
            if ($seenRoots.ContainsKey($sharedPath) -or -not (Test-Path -LiteralPath $sharedPath -PathType Container)) {
                continue
            }
            $seenRoots[$sharedPath] = $true

            foreach ($framework in $frameworks.GetEnumerator()) {
                $frameworkPath = Join-Path $sharedPath $framework.Key
                if (-not (Test-Path -LiteralPath $frameworkPath -PathType Container)) {
                    continue
                }

                foreach ($directory in Get-ChildItem -LiteralPath $frameworkPath -Directory) {
                    $version = Get-VersionInfo $directory.Name
                    if ($null -eq $version) {
                        continue
                    }

                    [PSCustomObject]@{
                        Framework     = $framework.Key
                        Component     = $framework.Value
                        Architecture  = $root.Architecture
                        FrameworkPath = $frameworkPath
                        Version       = $version
                    }
                }
            }
        }
    }

    $installed = @(Get-InstalledFramework)
    if ($installed.Count -eq 0) {
        Write-Warning 'No installed .NET shared frameworks were found.'
        return
    }

    try {
        $releaseIndex = Invoke-RestMethod -Uri 'https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json' -ErrorAction Stop
    }
    catch {
        throw "Failed to retrieve .NET release metadata: $($_.Exception.Message)"
    }

    $releaseCache = @{}
    $cleanupTargets = [System.Collections.Generic.List[object]]::new()
    $groups = $installed | Group-Object Framework, Component, Architecture, { $_.Version.Band }

    foreach ($group in $groups) {
        $sample = $group.Group[0]
        $installedBand = $sample.Version.Band
        if ($Band -and $installedBand -notin $Band) {
            continue
        }

        $current = $group.Group |
            Sort-Object -Property @{ Expression = { $_.Version.SortValue }; Descending = $true }, @{ Expression = { $_.Version.IsStable }; Descending = $true } |
            Select-Object -First 1
        $currentVersion = $current.Version.Display
        $channel = @($releaseIndex.'releases-index' | Where-Object { $_.'channel-version' -eq $installedBand }) | Select-Object -First 1

        if ($null -eq $channel) {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $null -Status 'MetadataUnavailable' -ExitCode $null -RebootRequired $false -ErrorMessage "No release metadata was found for .NET $installedBand."
            continue
        }

        if (-not $IncludeEol -and $channel.'support-phase' -eq 'eol') {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $channel.'latest-release' -Status 'SkippedEol' -ExitCode $null -RebootRequired $false -ErrorMessage $null
            continue
        }

        if (-not $releaseCache.ContainsKey($installedBand)) {
            try {
                $releaseMetadata = Invoke-RestMethod -Uri $channel.'releases.json' -ErrorAction Stop
                $releaseCache[$installedBand] = @($releaseMetadata.releases | Where-Object { $_.'release-version' -eq $channel.'latest-release' }) | Select-Object -First 1
            }
            catch {
                $releaseCache[$installedBand] = $null
                Write-Verbose "Could not retrieve release details for .NET ${installedBand}: $($_.Exception.Message)"
            }
        }

        $release = $releaseCache[$installedBand]
        if ($null -eq $release -or $null -eq $release.$($sample.Component)) {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $channel.'latest-release' -Status 'MetadataUnavailable' -ExitCode $null -RebootRequired $false -ErrorMessage "No installer metadata was found for $($sample.Framework) $installedBand."
            continue
        }

        $component = $release.$($sample.Component)
        $target = Get-VersionInfo $component.version
        if ($null -eq $target) {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $component.version -Status 'MetadataUnavailable' -ExitCode $null -RebootRequired $false -ErrorMessage "The target version '$($component.version)' is not a supported runtime version."
            continue
        }

        $requiresUpdate = $target.SortValue -gt $current.Version.SortValue -or
            ($target.SortValue -eq $current.Version.SortValue -and $target.IsStable -and -not $current.Version.IsStable)
        if (-not $requiresUpdate) {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $target.Display -Status 'Current' -ExitCode $null -RebootRequired $false -ErrorMessage $null
            if ($CleanupStale) {
                $cleanupTargets.Add($sample)
            }
            continue
        }

        $rid = 'win-{0}' -f $sample.Architecture
        $installer = @($component.files | Where-Object { $_.rid -eq $rid -and $_.name -match '\.exe$' }) | Select-Object -First 1
        if ($null -eq $installer) {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $target.Display -Status 'InstallerUnavailable' -ExitCode $null -RebootRequired $false -ErrorMessage "No Windows installer was found for $rid."
            continue
        }

        $action = "Download and install $($sample.Framework) $($target.Display) ($($sample.Architecture))"
        if (-not $PSCmdlet.ShouldProcess($sample.FrameworkPath, $action)) {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $target.Display -Status 'WhatIf' -ExitCode $null -RebootRequired $false -ErrorMessage $null
            continue
        }

        $installerPath = Join-Path $DownloadPath $installer.name
        try {
            if (-not (Test-Path -LiteralPath $DownloadPath -PathType Container)) {
                New-Item -ItemType Directory -Path $DownloadPath -Force -ErrorAction Stop | Out-Null
            }

            Invoke-WebRequest -Uri $installer.url -OutFile $installerPath -UseBasicParsing -ErrorAction Stop
            $actualHash = (Get-FileHash -LiteralPath $installerPath -Algorithm SHA512 -ErrorAction Stop).Hash
            if ($actualHash -ne $installer.hash) {
                throw 'The SHA-512 hash did not match the value supplied by Microsoft.'
            }

            $process = Start-Process -FilePath $installerPath -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru -ErrorAction Stop
            if ($process.ExitCode -notin 0, 3010) {
                throw "The installer exited with code $($process.ExitCode)."
            }

            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $target.Display -Status 'Updated' -ExitCode $process.ExitCode -RebootRequired ($process.ExitCode -eq 3010) -ErrorMessage $null
            if ($CleanupStale) {
                $cleanupTargets.Add($sample)
            }
        }
        catch {
            Format-UpdateResult -Framework $sample.Framework -Architecture $sample.Architecture -Band $installedBand -CurrentVersion $currentVersion -TargetVersion $target.Display -Status 'Failed' -ExitCode $null -RebootRequired $false -ErrorMessage $_.Exception.Message
        }
        finally {
            if (Test-Path -LiteralPath $installerPath -PathType Leaf) {
                Remove-Item -LiteralPath $installerPath -Force -ErrorAction SilentlyContinue
            }
        }
    }

    if ($CleanupStale -and $cleanupTargets.Count -gt 0) {
        # Passing no path or band keeps the default that Remove-StaleDotNetRuntime can hand to the .NET Uninstall Tool.
        if ($KeepVersions -eq 1) {
            try {
                Remove-StaleDotNetRuntime -Confirm:$false | Out-Null
            }
            catch {
                Write-Warning "Failed to remove stale .NET runtime versions: $($_.Exception.Message)"
            }
        }
        else {
            foreach ($target in ($cleanupTargets | Select-Object FrameworkPath, Version -Unique)) {
                try {
                    Remove-StaleDotNetRuntime -Path $target.FrameworkPath -Band $target.Version.Band -KeepVersions $KeepVersions -Confirm:$false | Out-Null
                }
                catch {
                    Write-Warning "Failed to remove stale .NET runtime versions from $($target.FrameworkPath): $($_.Exception.Message)"
                }
            }
        }
    }
}