Skip to content

Instantly share code, notes, and snippets.

@tcartwright
Created April 7, 2026 15:05
Show Gist options
  • Select an option

  • Save tcartwright/be35869bf5070bf531858f5bfdb70465 to your computer and use it in GitHub Desktop.

Select an option

Save tcartwright/be35869bf5070bf531858f5bfdb70465 to your computer and use it in GitHub Desktop.
POWERSHELL: Install / Update Dotnet sdks or runtimes
<#
.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