Created
April 7, 2026 15:05
-
-
Save tcartwright/be35869bf5070bf531858f5bfdb70465 to your computer and use it in GitHub Desktop.
POWERSHELL: Install / Update Dotnet sdks or runtimes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Cross-platform .NET runtime or SDK installer. | |
| .DESCRIPTION | |
| All-in-one script that queries Microsoft's release index, downloads | |
| the latest installers, and installs them. Skips both download and | |
| install when the target version is already present. | |
| Works on Windows, Linux, and macOS under PowerShell 7 (pwsh). | |
| .PARAMETER Type | |
| What to install: Runtime or SDK. Defaults to SDK. | |
| .PARAMETER Versions | |
| "auto" (default) discovers active versions from Microsoft's release | |
| index. Comma-separated major versions (e.g. "8,9,10") pins specific | |
| versions. EOL versions are downloaded and installed with a warning. | |
| .PARAMETER DownloadDir | |
| Directory to cache downloaded installers. Defaults to a | |
| "dotnet-installers" folder alongside this script. | |
| .PARAMETER InstallDir | |
| Directory to install into. Defaults to the standard dotnet location: | |
| Windows: $env:ProgramFiles\dotnet | |
| Linux/macOS: /usr/share/dotnet (if exists) or $HOME/.dotnet | |
| .PARAMETER WhatIf | |
| Preview what would be downloaded and installed without making changes. | |
| .PARAMETER Force | |
| Re-download and reinstall even if the version is already present. | |
| .EXAMPLE | |
| # Install latest active runtimes | |
| ./Install-DotNet.ps1 | |
| .EXAMPLE | |
| # Install SDKs for .NET 8 and 9 | |
| ./Install-DotNet.ps1 -Type SDK -Versions "8,9" | |
| .EXAMPLE | |
| # Preview what would happen | |
| ./Install-DotNet.ps1 -WhatIf | |
| .NOTES | |
| Requires PowerShell 7+ (pwsh) for cross-platform support. | |
| May require elevated permissions when installing to system directories. | |
| On Windows, installers are .exe (silent install). On Linux/macOS, | |
| binaries are .tar.gz (extracted to InstallDir). | |
| #> | |
| [CmdletBinding(SupportsShouldProcess)] | |
| param( | |
| [Parameter()] | |
| [ValidateSet('Runtime', 'SDK')] | |
| [string]$Type = 'SDK', | |
| [Parameter()] | |
| [string]$Versions = 'auto', | |
| [Parameter()] | |
| [string]$DownloadDir, | |
| [Parameter()] | |
| [string]$InstallDir, | |
| [Parameter()] | |
| [switch]$Force | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = "Stop" | |
| # --- Platform detection ---------------------------------------------------- | |
| function Get-PlatformInfo { | |
| $Arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLower() | |
| if ($Arch -eq 'x64' -or $Arch -eq 'amd64') { $Arch = 'x64' } | |
| elseif ($Arch -eq 'arm64') { $Arch = 'arm64' } | |
| if ($IsWindows) { | |
| return @{ Os = 'win'; Arch = $Arch; Ext = 'exe'; Rid = "win-$Arch" } | |
| } | |
| if ($IsMacOS) { | |
| return @{ Os = 'osx'; Arch = $Arch; Ext = 'tar.gz'; Rid = "osx-$Arch" } | |
| } | |
| return @{ Os = 'linux'; Arch = $Arch; Ext = 'tar.gz'; Rid = "linux-$Arch" } | |
| } | |
| function Get-DefaultInstallDir { | |
| if ($IsWindows) { | |
| return Join-Path $env:ProgramFiles "dotnet" | |
| } | |
| $systemDir = "/usr/share/dotnet" | |
| if (Test-Path $systemDir) { | |
| return $systemDir | |
| } | |
| return Join-Path $HOME ".dotnet" | |
| } | |
| function Get-DefaultDownloadDir { | |
| $root = $PSScriptRoot | |
| if (-not $root) { $root = $PWD.Path } | |
| return Join-Path $root "dotnet-installers" | |
| } | |
| function Get-DotnetExePath { | |
| param([string]$Dir) | |
| if ($IsWindows) { return Join-Path $Dir "dotnet.exe" } | |
| return Join-Path $Dir "dotnet" | |
| } | |
| # --- Version queries ------------------------------------------------------- | |
| function Get-InstalledVersions { | |
| param([string]$DotnetExe, [string]$VersionType) | |
| $installed = @() | |
| if (-not (Test-Path -Path $DotnetExe)) { | |
| return $installed | |
| } | |
| if ($VersionType -eq 'SDK') { | |
| $lines = & $DotnetExe --list-sdks 2>&1 | |
| } | |
| else { | |
| $lines = & $DotnetExe --list-runtimes 2>&1 | |
| } | |
| foreach ($line in $lines) { | |
| $text = $line.ToString().Trim() | |
| if ($text.Length -gt 0) { | |
| $installed += $text | |
| } | |
| } | |
| return $installed | |
| } | |
| function Test-VersionInstalled { | |
| param([string[]]$InstalledList, [string]$Version) | |
| $pattern = [regex]::Escape($Version) | |
| foreach ($line in $InstalledList) { | |
| if ($line -match $pattern) { | |
| return $true | |
| } | |
| } | |
| return $false | |
| } | |
| # --- Download & install ---------------------------------------------------- | |
| function Get-DownloadUrl { | |
| param([string]$VersionType, [string]$FullVersion, [hashtable]$Platform) | |
| $typeToken = if ($VersionType -eq 'SDK') { 'sdk' } else { 'runtime' } | |
| $cdnBase = "https://dotnetcli.azureedge.net/dotnet" | |
| $rid = $Platform.Rid | |
| if ($VersionType -eq 'SDK') { | |
| $fileName = "dotnet-sdk-$FullVersion-$rid.$($Platform.Ext)" | |
| return @{ | |
| Url = "$cdnBase/Sdk/$FullVersion/$fileName" | |
| FileName = $fileName | |
| } | |
| } | |
| else { | |
| $fileName = "dotnet-runtime-$FullVersion-$rid.$($Platform.Ext)" | |
| return @{ | |
| Url = "$cdnBase/Runtime/$FullVersion/$fileName" | |
| FileName = $fileName | |
| } | |
| } | |
| } | |
| function Install-FromFile { | |
| param([string]$FilePath, [string]$TargetDir, [string]$FullVersion) | |
| if ($IsWindows) { | |
| Write-Host " Running: $([System.IO.Path]::GetFileName($FilePath)) /install /quiet /norestart" | |
| $result = Start-Process -FilePath $FilePath -ArgumentList "/install", "/quiet", "/norestart" -Wait -PassThru | |
| $exitCode = $result.ExitCode | |
| Write-Host " Exit code: $exitCode" | |
| if ($exitCode -eq 3010) { | |
| Write-Host " Note: Succeeded but a reboot may be required." | |
| } | |
| elseif ($exitCode -ne 0) { | |
| Write-Warning "Non-zero exit code $exitCode for .NET $FullVersion" | |
| return $false | |
| } | |
| } | |
| else { | |
| New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null | |
| Write-Host " Extracting to: $TargetDir" | |
| & tar -xzf $FilePath -C $TargetDir | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Warning "Extraction failed for .NET $FullVersion" | |
| return $false | |
| } | |
| } | |
| return $true | |
| } | |
| # --- Main ------------------------------------------------------------------ | |
| # | |
| # Step 1: Detect platform (OS + architecture) and resolve directories | |
| # Step 2: Query Microsoft's release index to discover .NET channels | |
| # Step 3: Check which versions are already installed locally | |
| # Step 4: Build a work list — for each channel, determine if the latest | |
| # version needs to be downloaded, installed, or can be skipped | |
| # Step 5: Display the plan and exit early if everything is current (or -WhatIf) | |
| # Step 6: Download missing installers from Microsoft's CDN to the cache dir | |
| # (skip if the file already exists in the cache) | |
| # Step 7: Install each downloaded file that isn't already installed | |
| # - Windows: run .exe silently (/install /quiet /norestart) | |
| # - Linux/macOS: extract .tar.gz into the install directory | |
| # Step 8: Verify all target versions are now present via dotnet --list-runtimes | |
| # or dotnet --list-sdks | |
| # --------------------------------------------------------------------------- | |
| # Step 1: Detect platform and resolve directories | |
| $platform = Get-PlatformInfo | |
| Write-Host "=============================================" | |
| Write-Host "Install .NET ${Type}s ($($platform.Rid))" | |
| Write-Host "=============================================" | |
| Write-Host "" | |
| if (-not $InstallDir) { $InstallDir = Get-DefaultInstallDir } | |
| if (-not $DownloadDir) { $DownloadDir = Get-DefaultDownloadDir } | |
| Write-Host "Install directory: $InstallDir" | |
| Write-Host "Download cache: $DownloadDir" | |
| $dotnetExe = Get-DotnetExePath -Dir $InstallDir | |
| # Step 2: Query Microsoft's release index to discover .NET channels | |
| # "auto" mode selects only channels with support-phase "active", which | |
| # automatically skips end-of-life and preview releases. Manual mode | |
| # matches the user-supplied major version numbers against channel-version. | |
| $indexUrl = "https://dotnetcli.azureedge.net/dotnet/release-metadata/releases-index.json" | |
| Write-Host "Fetching release index from: $indexUrl" | |
| $index = Invoke-RestMethod -Uri $indexUrl | |
| if ($Versions -eq 'auto') { | |
| Write-Host "Mode: auto — active versions only (skipping eol and preview)" | |
| $channels = @($index.'releases-index' | Where-Object { $_.'support-phase' -eq 'active' }) | |
| } | |
| else { | |
| $requestedVersions = $Versions -split ',' | ForEach-Object { $_.Trim() + '.0' } | |
| Write-Host "Mode: manual — versions: $Versions" | |
| $channels = @($index.'releases-index' | Where-Object { $requestedVersions -contains $_.'channel-version' }) | |
| } | |
| if ($channels.Count -eq 0) { | |
| Write-Warning "No matching .NET channels found." | |
| exit 0 | |
| } | |
| Write-Host "" | |
| Write-Host "Channels to process:" | |
| foreach ($ch in $channels) { | |
| $ver = $ch.'channel-version' | |
| $phase = $ch.'support-phase' | |
| $latest = if ($Type -eq 'SDK') { $ch.'latest-sdk' } else { $ch.'latest-runtime' } | |
| Write-Host " .NET $ver ($phase) — latest ${Type}: $latest" | |
| } | |
| # Step 3: Check which versions are already installed locally | |
| # Uses "dotnet --list-runtimes" or "dotnet --list-sdks" to get the full | |
| # list of installed versions. Each line looks like: | |
| # Microsoft.NETCore.App 9.0.14 [C:\Program Files\dotnet\shared\...] | |
| $installedList = @(Get-InstalledVersions -DotnetExe $dotnetExe -VersionType $Type) | |
| if ($installedList.Count -gt 0) { | |
| Write-Host "" | |
| Write-Host "Currently installed:" | |
| foreach ($line in $installedList) { | |
| Write-Host " $line" | |
| } | |
| } | |
| Write-Host "" | |
| # Step 4: Build work list — for each channel, check three things: | |
| # a) Is the latest version already installed? → status "installed" (skip entirely) | |
| # b) Is the installer already in the download cache? → status "downloaded" (install only) | |
| # c) Neither? → status "needed" (download + install) | |
| # The -Force flag bypasses both checks and re-downloads/reinstalls everything. | |
| $workItems = @() | |
| foreach ($ch in $channels) { | |
| $ver = $ch.'channel-version' | |
| $phase = $ch.'support-phase' | |
| $targetVersion = if ($Type -eq 'SDK') { $ch.'latest-sdk' } else { $ch.'latest-runtime' } | |
| if ($phase -eq 'eol') { | |
| Write-Warning ".NET $ver is end-of-life. Processing anyway (manual override)." | |
| } | |
| $dlInfo = Get-DownloadUrl -VersionType $Type -FullVersion $targetVersion -Platform $platform | |
| $localPath = Join-Path $DownloadDir $dlInfo.FileName | |
| $alreadyDownloaded = (Test-Path $localPath) -and (-not $Force) | |
| $alreadyInstalled = (-not $Force) -and (Test-VersionInstalled -InstalledList $installedList -Version $targetVersion) | |
| $status = if ($alreadyInstalled) { "installed" } | |
| elseif ($alreadyDownloaded) { "downloaded" } | |
| else { "needed" } | |
| $workItems += @{ | |
| Channel = $ver | |
| Phase = $phase | |
| TargetVersion = $targetVersion | |
| Url = $dlInfo.Url | |
| FileName = $dlInfo.FileName | |
| LocalPath = $localPath | |
| Downloaded = $alreadyDownloaded | |
| Installed = $alreadyInstalled | |
| Status = $status | |
| } | |
| } | |
| # Step 5: Display the plan and exit early if nothing to do | |
| $totalInstall = @($workItems | Where-Object { $_.Status -ne 'installed' }).Count | |
| Write-Host "Plan:" | |
| foreach ($item in $workItems) { | |
| $label = switch ($item.Status) { | |
| 'installed' { "SKIP (already installed)" } | |
| 'downloaded' { "INSTALL (already downloaded)" } | |
| 'needed' { "DOWNLOAD + INSTALL" } | |
| } | |
| Write-Host " .NET $($item.Channel) $Type $($item.TargetVersion) — $label" | |
| } | |
| if ($totalInstall -eq 0) { | |
| Write-Host "" | |
| Write-Host "Everything is up to date. Nothing to do." | |
| exit 0 | |
| } | |
| if ($WhatIfPreference) { | |
| Write-Host "" | |
| Write-Host "[WhatIf] Would process the above. No changes made." | |
| exit 0 | |
| } | |
| # Step 6: Download missing installers from Microsoft's CDN | |
| # Only downloads files with status "needed" (not already in cache and not | |
| # already installed). Files are saved to DownloadDir so subsequent runs | |
| # can skip the download if the version hasn't changed. | |
| New-Item -ItemType Directory -Path $DownloadDir -Force | Out-Null | |
| foreach ($item in $workItems) { | |
| if ($item.Status -ne 'needed') { | |
| continue | |
| } | |
| Write-Host "" | |
| Write-Host "--- Downloading .NET $($item.Channel) $Type $($item.TargetVersion) ---" | |
| Write-Host " URL: $($item.Url)" | |
| try { | |
| Invoke-WebRequest -Uri $item.Url -OutFile $item.LocalPath -UseBasicParsing | |
| $sizeMB = [math]::Round((Get-Item $item.LocalPath).Length / 1MB, 1) | |
| Write-Host " Downloaded: $($item.FileName) ($sizeMB MB)" | |
| $item.Downloaded = $true | |
| } | |
| catch { | |
| Write-Warning "Failed to download .NET $($item.Channel) ${Type}: $_" | |
| Write-Warning "Skipping." | |
| } | |
| } | |
| # Step 7: Install each version that isn't already installed | |
| # Skips items that were already installed or failed to download. | |
| # Windows: runs the .exe installer silently (exit code 3010 = reboot needed) | |
| # Linux/macOS: extracts the .tar.gz archive into the install directory | |
| foreach ($item in $workItems) { | |
| if ($item.Installed -or -not $item.Downloaded) { | |
| continue | |
| } | |
| Write-Host "" | |
| Write-Host "--- Installing .NET $($item.Channel) $Type $($item.TargetVersion) ---" | |
| $success = Install-FromFile ` | |
| -FilePath $item.LocalPath ` | |
| -TargetDir $InstallDir ` | |
| -FullVersion $item.TargetVersion | |
| if ($success) { | |
| $item.Installed = $true | |
| } | |
| } | |
| # Step 8: Verify all target versions are now present | |
| # Re-queries dotnet to confirm each version appears in the installed list. | |
| Write-Host "" | |
| Write-Host "Verifying installations..." | |
| $verifyList = @(Get-InstalledVersions -DotnetExe $dotnetExe -VersionType $Type) | |
| $allVerified = $true | |
| foreach ($item in $workItems) { | |
| $verified = Test-VersionInstalled -InstalledList $verifyList -Version $item.TargetVersion | |
| if ($verified) { | |
| Write-Host " Verified .NET $($item.Channel) ${Type} $($item.TargetVersion)" | |
| } | |
| else { | |
| Write-Warning ".NET $($item.Channel) ${Type} $($item.TargetVersion) — NOT found after install" | |
| $allVerified = $false | |
| } | |
| } | |
| Write-Host "" | |
| if ($allVerified) { | |
| Write-Host "All .NET ${Type}s installed and verified successfully." | |
| } | |
| else { | |
| Write-Warning "Some installations could not be verified. Check output above." | |
| exit 1 | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment