Get-WindowsServerReleases.ps1
<#PSScriptInfo
.VERSION 1.0.0 .GUID 857dbda9-4724-4c09-8969-8e2a576657ef .AUTHOR Erlend Westervik .COMPANYNAME .COPYRIGHT .TAGS Windows, Server, Update, Patch, Upgrade, Operating System, OS, Win, Release, Version, Servicing, LTSC, AC, Lifecycle, Build, OOB .LICENSEURI .PROJECTURI https://github.com/erlwes/Get-WindowsServerReleases .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES Version: 1.0.0 - Original published version #> <# .SYNOPSIS Get Windows server release history .DESCRIPTION Explain the steps .NOTES 1. Windows Server 2012 R2 or older OS are end-of-life (no longer extended support). Release history for these OS are no longer published, and is therefore not supported in this script. The data would be static. 2. There is a "public" API with this information and more, but it requires a M365 tenant to access + auth - https://learn.microsoft.com/en-us/graph/api/resources/windowsupdates-product?view=graph-rest-beta .PARAMETER PathLocalStore Specify path to save local cache of Windows server releases. Defaults to module/script location. .PARAMETER ForceRebuild Re-creates the local CSV-files, regardless of how recent they are. By default, it uses the local cache if its less than one day old. .PARAMETER VerboseLogging Show activity in console .EXAMPLE .\Get-WindowsServerReleases.ps1 | Out-GridView .EXAMPLE .\Get-WindowsServerReleases.ps1 -WindowsServerVersion 'Server 2025' -VerboseLogging | Format-Table .EXAMPLE .\Get-WindowsServerReleases.ps1 -WindowsServerVersion 'Server 2016' | Format-Table .EXAMPLE .\Get-WindowsServerReleases.ps1 -WindowsServerVersion -ForceRebuild #> Param( [ValidateSet('Server 2025', 'Server 2022', 'Server 2019', 'Server 2016')] [String]$WindowsServerVersion, [String]$PathLocalStore = $PSScriptRoot, [Switch]$VerboseLogging, [Switch]$ForceRebuild, [Switch]$ShowCache ) # DECLARATIONS $Time = (Get-Date) # FUNCTIONS function ParseHtml($string) { $unicode = [System.Text.Encoding]::Unicode.GetBytes($string) $html = New-Object -Com 'HTMLFile' if ($html.PSObject.Methods.Name -Contains 'IHTMLDocument2_Write') { $html.IHTMLDocument2_Write($unicode) } else { $html.write($Unicode) } $html.Close() $html } Function Write-Log { param( [ValidateSet(0, 1, 2, 3, 4)] [int]$Level, [Parameter(Mandatory=$true)] [string]$Message ) $Message = $Message.Replace("`r",'').Replace("`n",' ') switch ($Level) { 0 { $Status = 'Info' ;$FGColor = 'White' } 1 { $Status = 'Success' ;$FGColor = 'Green' } 2 { $Status = 'Warning' ;$FGColor = 'Yellow' } 3 { $Status = 'Error' ;$FGColor = 'Red' } 4 { $Status = 'Console' ;$FGColor = 'Gray' } Default { $Status = '' ;$FGColor = 'Black' } } if ($VerboseLogging) { Write-Host "$((Get-Date).ToString()) " -ForegroundColor 'DarkGray' -NoNewline Write-Host "$Status" -ForegroundColor $FGColor -NoNewline if ($level -eq 4) { Write-Host ("`t " + $Message) -ForegroundColor 'Cyan' } else { Write-Host ("`t " + $Message) -ForegroundColor 'White' } } if ($Level -eq 3) { $LogErrors += $Message } } Write-Log -Level 0 -Message "Start" Write-Log -Level 0 -Message "Local cache - Using '$PathLocalStore' as local store" if ($ShowCache) { Write-Log -Level 0 -Message "Local cache - Parameter -ShowCache used. Looking for existing CSV-cacge in this path: '$PathLocalStore'." $Cache = @() if ($WindowsServerVersion) { $CSVFiles = Get-Item -Path "$PathLocalStore\Windows $WindowsServerVersion (OS build*.csv" } else { $CSVFiles = Get-Item -Path "$PathLocalStore\Windows Server * (OS build*.csv" } if ($CSVFiles.count -ge 1) { Write-Log -Level 1 -Message "Local cache - $($CSVFiles.count) CSV-files found." Foreach ($File in $CSVFiles) { $Cache += Import-Csv -Path $File.FullName -Delimiter ';' -Encoding utf8 } $Cache } else { Write-Log -Level 0 -Message "Local cache - Not found (while using -ShowCache). Can not continue." } Write-Log -Level 0 -Message "End" Break } else { # WEB REQUEST $WR = Invoke-WebRequest -Uri 'https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info' if ($WR.StatusCode -eq 200) { Write-Log -Level 1 -Message 'Invoke-WebRequest - Status 200. Content received.' } else { Write-Log -Level 3 -Message "Invoke-WebRequest - Status $($WR.StatusCode)" } # HIGH LEVEL PARSING - TABLES + HEADERS if ($host.version.Major -gt 5) { Write-Log -Level 0 -Message "Parse HTML - PowerShell Core detected ($($host.version.Major).$($host.version.Minor)). Parsing HTML using 'ParseHTLM function'" $Document = ParseHtml $WR.Content $Tables = $Document.getElementsByTagName('table') | Where-Object {$_.id -match 'historyTable'} $ServerOSTitles = $Document.getElementsByTagName('strong') | Where-Object {$_.innerText -match 'Windows Server'} | Select-Object -ExpandProperty innerText -Unique } else { Write-Log -Level 0 -Message "Parse HTML - Windows PowerShell detected ($($host.version.Major).$($host.version.Minor)). Using built in 'parsedHtml'" $Tables = $WR.ParsedHtml.getElementsByTagName('table') | Where-Object {$_.id -match 'historyTable'} $ServerOSTitles = $WR.ParsedHtml.getElementsByTagName('strong') | Where-Object {$_.IHTMLElement_innerText -match 'Windows Server'} | Select-Object -ExpandProperty IHTMLElement_innerText -Unique } Write-Log -Level 0 -Message "Parse HTML - Found $($Tables.count) relevant tables" Write-Log -Level 0 -Message "Parse HTML - Found $($ServerOSTitles.count) relevant headers" # SANITY CHECKS ON RESULTS if ($Tables.count -lt 1 -or $ServerOSTitles.count -lt 1) { Write-Log -Level 2 -Message 'Parse HTML - Missing headers and/or titles. Can not continue.' Break } if ($Tables.count -ne $ServerOSTitles.count) { Write-Log -Level 2 -Message 'Parse HTML - In-equal count of headers vs. tables. Will not continue.' Break } # MATCH SERVER VERSION TO TABLES AND DETERMINE CACHE LOCATIONS $CSVFiles = @() if ($WindowsServerVersion -eq 'Server 2016') { $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_3'} $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv" } elseif ($WindowsServerVersion -eq 'Server 2019') { $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_2'} $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv" } elseif ($WindowsServerVersion -eq 'Server 2022') { $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_1'} $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv" } elseif ($WindowsServerVersion -eq 'Server 2025') { $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_0'} $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv" } else { $ServerOSTitles | Where-Object {$_ -match 'Windows Server'} | % { $CSVFiles += "$PathLocalStore\$_.csv" } } # DETERMINE IF CACHE EXIST AND CAN BE RE-USED if (!$ForceRebuild) { $ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion} | ForEach-Object { $ServerOSTitle = $_ $CSVFile = "$PathLocalStore\$ServerOSTitle.csv" if (Test-Path $CSVFile) { Write-Log -Level 1 -Message "Check cache - $ServerOSTitle - File exist ('$CSVFile')" [datetime]$FileDate = Get-Item -Path $CSVFile | Select-Object -ExpandProperty LastWriteTime $TimeDiff = ($Time - $FileDate) if ($TimeDiff.TotalHours -ge 24) { Write-Log -Level 2 -Message "Check cache date - $ServerOSTitle - Older than 24h ($($TimeDiff.TotalHours)). Setting '-ForceRebuild' switch." $ForceRebuild = $true } else { Write-Log -Level 1 -Message "Check cache date - $ServerOSTitle - More recent than 24h ($($TimeDiff.TotalHours))." } } else { Write-Log -Level 2 -Message "Check cache - $ServerOSTitle - No file ('$CSVFile'). Setting '-ForceRebuild' switch." $ForceRebuild = $true } Clear-Variable ServerOSTitle, CSVFile } } # BUILD/RE-BUILD CACHE $Result = @() if ($ForceRebuild) { foreach ($table in $Tables) { # Get the headers (cells in row 0) $Headers = ($Table.Rows[0].Cells | Select-Object -ExpandProperty innerText).Trim() # Find matching Windows Server version for the table contents $Index = [int]($Table.id -replace '^.+_') $ServerTitle = $ServerOSTitles[$Index] Write-Log -Level 0 -Message "Convert HTML to PSObject - $ServerTitle - Begin" $M = Measure-Command { $Object = [System.Collections.Generic.List[object]]::new() # Loop through rows (skip header) foreach ($Tablerow in ($Table.Rows | Select-Object -Skip 1)) { $Cells = ($Tablerow.Cells | Select-Object -ExpandProperty innerText).Trim() # Build hashtable $RowHash = @{} $RowHash['Windows server version'] = $ServerTitle for ($i = 0; $i -lt $Headers.Count; $i++) { if ($i -lt $Cells.Count) { $RowHash[$Headers[$i]] = $Cells[$i] } } # Convert to PSObject and add to list $Object.Add([PSCustomObject]$RowHash) } } Write-Log -Level 0 -Message "Convert HTML to PSObject - $ServerTitle - Done in $($M.TotalSeconds) seconds ($($Object.Count) releases)" #Sanity checks before saving and poteltially overwriting last cache if ($Object.Build -notmatch '\d') { Write-Log -Level 3 -Message "Convert HTML to PSObject - $ServerTitle - The produced PSObject is not valid. Build-property is not present or has invalid values. Aborting script." Break } # Export to CSV (save cache) try { $Object | Export-Csv "$PathLocalStore\$ServerTitle.csv" -Delimiter ';' -Encoding utf8 -Confirm:$false -Force -ErrorAction Stop $Result += $Object Write-Log -Level 1 -Message "Export-Csv - Saved '$PathLocalStore\$ServerTitle.csv'" } catch { Write-Log -Level 3 -Message "Export-Csv - Failed to save '$PathLocalStore\$ServerTitle.csv'. Error: $($_.Exception.Message)" } # Clear variables for next table in loop Clear-Variable headers, index, serverTitle, m } } else { # If all needed caches are newer than 24h, just import the cache and present the data. $CSVFiles | ForEach-Object { $Result += Import-Csv -Path $_ -Delimiter ';' -Encoding utf8 } } Write-Log -Level 0 -Message "End" $Result } |