From 25bac34acbbe08029584082f3b7b614d4067367e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 19 Aug 2026 15:31:06 +0200 Subject: [PATCH] fix(ci): upload raw bytes, not a stringified array The first CI run overwrote all four customer-facing scripts on the download host and the legacy mirror with their own byte values as decimal text (Install-AutopilotMonitor.ps1 went from 27331 to 95679 bytes). PowerShell unrolls an array returned from a function into the output stream, so the caller receives Object[] rather than byte[]. Invoke-RestMethod serialises an Object[] body as space-separated decimals. Every hash check stayed green because [byte[]] parameter coercion repairs the type everywhere except on the wire. build-agent.yml's Send-Blob was never affected: it read the bytes inside the function. Proven with a local HttpListener: the old shape puts 50 bytes starting 54,48,32,51,53 ("60 35 13 1...") on the wire where 17 bytes starting 60,35,13,10 ("<#\r\n.SYN") are expected. Fixes: - ',' on both byte-array returns, plus an explicit [byte[]] at the upload site. - Read each blob back from storage right after its PUT and abort before the mirror and the remaining files. Verifying only at the end left every blob corrupted. - The version-bump guard tolerates an unparseable published copy with a warning instead of throwing: that is the state a republish exists to repair, and a guard that blocks the repair is worse than no guard. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/Publish-BootstrapScripts.ps1 | 42 ++++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/scripts/Publish-BootstrapScripts.ps1 b/.github/scripts/Publish-BootstrapScripts.ps1 index aa2a0e4a..688d0ea0 100644 --- a/.github/scripts/Publish-BootstrapScripts.ps1 +++ b/.github/scripts/Publish-BootstrapScripts.ps1 @@ -104,8 +104,11 @@ function Get-PublishedBytes { if ((Get-HttpStatus $_) -eq 404) { return $null } throw } - if ($resp.Content -is [byte[]]) { return $resp.Content } - return [System.Text.Encoding]::UTF8.GetBytes([string]$resp.Content) + # Comma operator: PowerShell unrolls an array returned from a function into the output + # stream and reassembles it as Object[], which would silently defeat every [byte[]] use + # downstream. ',' wraps it so the byte[] survives the return. + if ($resp.Content -is [byte[]]) { return ,$resp.Content } + return ,[System.Text.Encoding]::UTF8.GetBytes([string]$resp.Content) } # Deterministic publish form: CRLF, UTF-8 without BOM. Git normalises line endings on @@ -116,12 +119,19 @@ function Get-PublishedBytes { function Get-PublishBytes { param([string]$Text) $normalised = $Text.Replace("`r`n", "`n").Replace("`n", "`r`n") - return (New-Object System.Text.UTF8Encoding $false).GetBytes($normalised) + # ',' keeps this a byte[]: without it PowerShell unrolls the array into the output stream + # and the caller receives Object[]. Invoke-RestMethod then serialises an Object[] body as + # space-separated decimals instead of raw bytes -- a silent corruption of the upload. + return ,(New-Object System.Text.UTF8Encoding $false).GetBytes($normalised) } function Get-BootstrapScriptVersion { - param([string]$Content, [string]$Origin) + param([string]$Content, [string]$Origin, [switch]$Optional) if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] } + # Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly + # the state a republish is meant to repair, and a guard that blocks the repair is worse + # than no guard. + if ($Optional) { return $null } throw "Could not parse ScriptVersion from $Origin" } @@ -168,9 +178,11 @@ if ($null -eq $publishedBytes) { Write-Host 'Published bootstrap is already byte-identical to the source' } else { $publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes) - $publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" + $publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" -Optional - if ($publishedVersion -ne $scriptVersion) { + if ($null -eq $publishedVersion) { + Write-Host "::warning::The published $BootstrapBlob is not a readable bootstrap script (no ScriptVersion found). Republishing over it." + } elseif ($publishedVersion -ne $scriptVersion) { Write-Host "Version bump: $publishedVersion -> $scriptVersion" } elseif ((Get-CodeFingerprint -Text $publishedText -Origin "$AliasUrl/$BootstrapBlob") -eq (Get-CodeFingerprint -Text $sourceContent -Origin $BootstrapSource)) { @@ -209,7 +221,10 @@ $legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' } $writeSas = if ($SasToken) { $SasToken.TrimStart('?') } else { '' } foreach ($item in $publishSet) { - $bytes = $item.Bytes + # Explicit type, not a convenience: an Object[] body is uploaded as space-separated + # decimals rather than raw bytes, and every hash check still passes because [byte[]] + # parameter coercion repairs it everywhere EXCEPT the wire. + [byte[]]$bytes = $item.Bytes $item | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-Sha256 $bytes) -Force if ($DryRun) { @@ -228,7 +243,18 @@ foreach ($item in $publishSet) { # impossible even if caching is ever re-enabled. $headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = $ScriptContentType; 'x-ms-blob-cache-control' = 'no-cache' } Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)?$writeSas" -Method Put -Headers $headers -Body $bytes | Out-Null - Write-Host " uploaded $($item.BlobName) ($($bytes.Length) bytes)" + + # Read straight back from the blob (authoritative, no CDN in the way) before touching the + # mirror or the next file. The alias verification at the end would catch a bad body too, + # but only after every blob was already overwritten -- these are live customer downloads. + $written = Get-PublishedBytes "$ContainerUrl/$($item.BlobName)" + if ($null -eq $written -or (Get-Sha256 $written) -ne $item.Sha256) { + $writtenLength = if ($null -eq $written) { 'missing' } else { "$($written.Length) bytes" } + throw ("Upload of $($item.BlobName) did not round-trip: sent $($bytes.Length) bytes, " + + "blob now holds $writtenLength. Publishing stopped before the mirror and the " + + 'remaining files, so no further blob was touched.') + } + Write-Host " uploaded $($item.BlobName) ($($bytes.Length) bytes, round-trip verified)" if ($legacySas) { try {