core/scripts/update-operatingsystems.ps1
|
<#
.SYNOPSIS Downloads, validates, and publishes the Windows 11 25H2 operating system catalog .DESCRIPTION Queries the Microsoft Update Metadata Service for the current Windows 11 25H2 products catalog, verifies the downloaded CAB size and SHA256 digest, and validates the extracted MCT XML before changing repository content. Publishes the validated catalog to core\operatingsystems using the name <build>-win11-25h2.xml. Existing catalogs remain in that directory and are not moved or deleted. Existing catalog names are never overwritten with different content. WhatIf still performs the network request, CAB download, extraction, and validation. It previews only the repository publication operation. .PARAMETER MinimumItemCount Specifies the minimum number of ESD records required before the catalog is accepted. This safety threshold detects incomplete catalogs. The default is 50. Valid values are 1 through 100000. .EXAMPLE PS> & .\OSDeploy\core\scripts\update-operatingsystems.ps1 -WhatIf Downloads and validates the current catalog, then previews publication without changing repository catalog files. .EXAMPLE PS> & .\OSDeploy\core\scripts\update-operatingsystems.ps1 -MinimumItemCount 75 -Confirm Requires at least 75 ESD records and requests confirmation before publishing the new catalog. .INPUTS None. This script does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject. Returns the detected build, destination path, publication status, ESD item count, and source XML SHA256 hash. Under WhatIf, the destination path is prospective and Published remains False. .NOTES Author: David Segura Company: Recast Software Version: 1.0.0 Date: 2026-09-17 Requires Windows, PowerShell 7.4 or later, internet access, expand.exe, writable temporary storage, and write access to the catalog directories when publishing. This script accepts only Windows 11 25H2 catalogs with build major 26200. #> #Requires -PSEdition Core #Requires -Version 7.4 [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param ( [Parameter()] [ValidateRange(1, 100000)] [int] $MinimumItemCount = 50 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $IsWindows) { throw 'This script requires Windows.' } $metadataUri = 'https://fe3.delivery.mp.microsoft.com/UpdateMetadataService/updates/search/v1/bydeviceinfo' $products = 'PN=Windows.Products.Cab.amd64&V=0.0.0.0' $deviceAttributes = 'DUScan=1;OSVersion=10.0.26100.1' $expectedBuildMajor = 26200 $coreDirectory = Split-Path -Path $PSScriptRoot -Parent $catalogDirectory = Join-Path -Path $coreDirectory -ChildPath 'operatingsystems' $temporaryDirectory = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('osdeploy-catalog-' + [guid]::NewGuid()) $publishedPath = $null try { $null = New-Item -Path $temporaryDirectory -ItemType Directory -WhatIf:$false $body = [ordered]@{ Products = $products DeviceAttributes = $deviceAttributes } | ConvertTo-Json -Compress $response = Invoke-RestMethod ` -Uri $metadataUri ` -Method Post ` -ContentType 'application/json' ` -Headers @{ Accept = '*/*' } ` -Body $body # Normalize the API response before enforcing the single-payload contract. if ($response -is [array]) { if ($response.Count -ne 1) { throw "Microsoft metadata returned $($response.Count) response records; expected one." } $response = $response[0] } # Establish the metadata trust boundary before downloading any catalog content. $cabRecords = @($response.FileLocations | Where-Object FileName -EQ 'products.cab') if ($cabRecords.Count -ne 1) { throw "Microsoft metadata returned $($cabRecords.Count) products.cab records; expected one." } $cabRecord = $cabRecords[0] $cabUri = [uri]$cabRecord.Url if (-not $cabUri.IsAbsoluteUri -or $cabUri.Scheme -notin @('http', 'https')) { throw "Microsoft metadata returned an invalid products.cab URL: $($cabRecord.Url)" } [long]$expectedCabSize = 0 if (-not [long]::TryParse([string]$cabRecord.Size, [ref]$expectedCabSize) -or $expectedCabSize -le 0) { throw "Microsoft metadata returned an invalid products.cab size: $($cabRecord.Size)" } try { $expectedDigest = [Convert]::FromBase64String([string]$cabRecord.Digest) } catch { throw 'Microsoft metadata returned an invalid products.cab SHA256 digest.' } if ($expectedDigest.Length -ne 32) { throw "Microsoft metadata returned a $($expectedDigest.Length)-byte digest; expected SHA256." } $cabPath = Join-Path -Path $temporaryDirectory -ChildPath 'products.cab' Invoke-WebRequest -Uri $cabUri -OutFile $cabPath -Headers @{ Accept = '*/*' } $actualCabSize = (Get-Item -LiteralPath $cabPath).Length if ($actualCabSize -ne $expectedCabSize) { throw "Downloaded products.cab size mismatch. Expected $expectedCabSize bytes, got $actualCabSize bytes." } $actualDigest = [System.Security.Cryptography.SHA256]::HashData( [System.IO.File]::ReadAllBytes($cabPath) ) if ([Convert]::ToBase64String($actualDigest) -cne [Convert]::ToBase64String($expectedDigest)) { throw 'Downloaded products.cab SHA256 digest does not match Microsoft metadata.' } $extractDirectory = Join-Path -Path $temporaryDirectory -ChildPath 'expanded' $null = New-Item -Path $extractDirectory -ItemType Directory -WhatIf:$false $expandPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\expand.exe' if (-not (Test-Path -LiteralPath $expandPath -PathType Leaf)) { throw "Windows expand.exe was not found at '$expandPath'." } $processInfo = [System.Diagnostics.ProcessStartInfo]::new() $processInfo.FileName = $expandPath $processInfo.UseShellExecute = $false $processInfo.RedirectStandardOutput = $true $processInfo.RedirectStandardError = $true $processInfo.ArgumentList.Add($cabPath) $sourceXmlPath = Join-Path -Path $extractDirectory -ChildPath 'products.xml' $processInfo.ArgumentList.Add($sourceXmlPath) $process = [System.Diagnostics.Process]::Start($processInfo) $standardOutput = $process.StandardOutput.ReadToEnd() $standardError = $process.StandardError.ReadToEnd() $process.WaitForExit() if ($process.ExitCode -ne 0) { throw "expand.exe failed with exit code $($process.ExitCode): $standardError $standardOutput" } if (-not (Test-Path -LiteralPath $sourceXmlPath -PathType Leaf)) { throw 'expand.exe did not produce products.xml.' } [xml]$catalog = Get-Content -LiteralPath $sourceXmlPath -Raw $fileNodes = @($catalog.MCT.Catalogs.Catalog.PublishedMedia.Files.File) $esdNodes = @($fileNodes | Where-Object { [string]$_.FileName -like '*.esd' }) # Reject incomplete or malformed catalogs before evaluating repository changes. if ($esdNodes.Count -lt $MinimumItemCount) { throw "Catalog contains $($esdNodes.Count) ESD records; expected at least $MinimumItemCount." } $requiredProperties = @('FileName', 'LanguageCode', 'Edition', 'Architecture', 'Size', 'Sha256', 'FilePath') $builds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($node in $esdNodes) { foreach ($property in $requiredProperties) { if ([string]::IsNullOrWhiteSpace([string]$node.$property)) { throw "Catalog record '$($node.FileName)' is missing required property '$property'." } } if ([string]$node.Sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw "Catalog record '$($node.FileName)' has an invalid SHA256 value." } [long]$fileSize = 0 if (-not [long]::TryParse([string]$node.Size, [ref]$fileSize) -or $fileSize -le 0) { throw "Catalog record '$($node.FileName)' has an invalid size." } $buildMatch = [regex]::Match([string]$node.FileName, '^(?<Build>\d{5}\.\d+)\..+\.esd$', 'IgnoreCase') if (-not $buildMatch.Success) { throw "Catalog record '$($node.FileName)' does not contain a supported build identity." } $null = $builds.Add($buildMatch.Groups['Build'].Value) } if ($builds.Count -ne 1) { throw "Catalog contains $($builds.Count) build identities; expected one." } $build = @($builds)[0] $buildParts = $build.Split('.') if ([int]$buildParts[0] -ne $expectedBuildMajor) { throw "Catalog contains build '$build'; expected Windows 11 25H2 build $expectedBuildMajor.x." } # OSDeploy requires both architectures for its en-US Enterprise download targets. foreach ($architecture in @('x64', 'ARM64')) { $target = @( $esdNodes | Where-Object { [string]$_.LanguageCode -eq 'en-us' -and [string]$_.Edition -eq 'Enterprise' -and [string]$_.Architecture -eq $architecture } ) if ($target.Count -lt 1) { throw "Catalog does not contain an en-us Enterprise $architecture ESD record." } } $destinationName = "$build-win11-25h2.xml" $destinationPath = Join-Path -Path $catalogDirectory -ChildPath $destinationName $sourceHash = (Get-FileHash -LiteralPath $sourceXmlPath -Algorithm SHA256).Hash $destinationExists = Test-Path -LiteralPath $destinationPath -PathType Leaf # Catalog names are immutable; identical content makes repeated runs idempotent. if ($destinationExists) { $destinationHash = (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256).Hash if ($sourceHash -cne $destinationHash) { throw "Catalog '$destinationName' already exists with different content." } } $existingCatalogs = @( Get-ChildItem -LiteralPath $catalogDirectory -Filter '*-win11-25h2.xml' -File | Where-Object Name -NE $destinationName ) # Keep existing catalogs in place, but reject a stale response when a newer build exists. foreach ($existingCatalog in $existingCatalogs) { if ($existingCatalog.BaseName -notmatch '^(?<Major>\d+)\.(?<Ubr>\d+)-win11-25h2$') { throw "Existing catalog '$($existingCatalog.Name)' does not use the expected naming convention." } $existingBuild = [version]("$($Matches['Major']).$($Matches['Ubr'])") if ($existingBuild -gt [version]$build) { throw "Existing catalog '$($existingCatalog.Name)' is newer than downloaded build $build." } } $operation = "Publish $destinationName" # WhatIf gates repository mutations only; acquisition and validation have already run. if ($PSCmdlet.ShouldProcess($catalogDirectory, $operation)) { if (-not $destinationExists) { # Stage beside the destination so the final move cannot expose a partial copy. $stagedPath = Join-Path -Path $catalogDirectory -ChildPath ('.' + $destinationName + '.' + [guid]::NewGuid() + '.tmp') [System.IO.File]::Copy($sourceXmlPath, $stagedPath, $false) Move-Item -LiteralPath $stagedPath -Destination $destinationPath $publishedPath = $destinationPath } } [pscustomobject]@{ Build = $build DestinationPath = $destinationPath Published = [bool]$publishedPath ItemCount = $esdNodes.Count Sha256 = $sourceHash } } finally { # Temporary acquisition files are cleaned even when repository changes use WhatIf. if (Test-Path -LiteralPath $temporaryDirectory) { Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false } } |