From d7a9d4b7cab2a8fcb273c4b6c54ba9eaf25edfc6 Mon Sep 17 00:00:00 2001 From: neng Date: Mon, 14 Sep 2026 18:02:55 +0800 Subject: [PATCH] fix(ci): select an available published Windows bootstrap release --- .github/scripts/select-bootstrap-version.ps1 | 79 +++++++ .../scripts/test-select-bootstrap-version.ps1 | 213 ++++++++++++++++++ .github/workflows/ci.yml | 13 +- 3 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/select-bootstrap-version.ps1 create mode 100644 .github/scripts/test-select-bootstrap-version.ps1 diff --git a/.github/scripts/select-bootstrap-version.ps1 b/.github/scripts/select-bootstrap-version.ps1 new file mode 100644 index 0000000..0d774c0 --- /dev/null +++ b/.github/scripts/select-bootstrap-version.ps1 @@ -0,0 +1,79 @@ +# CI-only selection. A Git tag alone does not prove that COS upload completed. +[CmdletBinding()] +param( + [AllowEmptyCollection()][string[]]$Tags, + [string]$DownloadBaseUrl = $env:LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL +) + +$ErrorActionPreference = "Stop" +if (-not $DownloadBaseUrl) { + $DownloadBaseUrl = "https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/browser-cli" +} +$DownloadBaseUrl = $DownloadBaseUrl.TrimEnd('/') +if (-not $PSBoundParameters.ContainsKey('Tags')) { + $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $Tags = @(git -C $repositoryRoot tag --list "v*") + if ($LASTEXITCODE -ne 0) { throw "Could not list release tags" } +} +$candidates = @($Tags | Where-Object { $_ -cmatch '^v[0-9]+\.[0-9]+\.[0-9]+$' } | + Sort-Object { [version]$_.Substring(1) } -Descending -Unique) + +function Invoke-ReleaseProbe { + param([string]$Uri, [string]$Method) + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -Method $Method -TimeoutSec 15 + if ([int]$response.StatusCode -ne 200) { + throw "Unexpected HTTP status $($response.StatusCode) for $Uri" + } + return $response + } catch { + # Only a missing object means this candidate is not ready. Do not conceal + # outages, authentication failures, TLS errors or timeouts by downgrading. + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { + return $null + } + throw + } +} + +$previousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol +try { + [Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + foreach ($tag in $candidates) { + $version = $tag.Substring(1) + $asset = "browser-cli-v$version-x86_64-pc-windows-msvc.exe" + $releaseUrl = "$DownloadBaseUrl/$tag" + # The release uploader publishes SHA256SUMS last, after the binaries. + $manifest = Invoke-ReleaseProbe "$releaseUrl/SHA256SUMS" "GET" + if ($null -eq $manifest) { + Write-Warning "Skipping ${tag}: checksum manifest is not published." + continue + } + $content = if ($manifest.Content -is [byte[]]) { + [Text.Encoding]::UTF8.GetString($manifest.Content) + } else { [string]$manifest.Content } + $lines = @($content -split '\r?\n' | Where-Object { $_ -notmatch '^\s*$' }) + if ($lines.Count -eq 0 -or @($lines | Where-Object { $_ -notmatch '^[a-fA-F0-9]{64}\s+\*?\S+$' }).Count -gt 0) { + throw "Invalid checksum manifest for $tag" + } + $assetPattern = [regex]::Escape($asset) + $entries = @($lines | Where-Object { $_ -match "\s+\*?$assetPattern`$" }) + if ($entries.Count -eq 0) { + Write-Warning "Skipping ${tag}: no Windows checksum entry is published." + continue + } + if ($entries.Count -ne 1 -or $entries[0] -notmatch "^[a-fA-F0-9]{64}\s+\*?$assetPattern`$") { + throw "Invalid or duplicate checksum entry for $asset" + } + if ($null -eq (Invoke-ReleaseProbe "$releaseUrl/$asset" "HEAD")) { + Write-Warning "Skipping ${tag}: Windows binary is not published." + continue + } + # Select once. The caller must still run the real bootstrap and its hash + # check; an installation failure must not retry an older version. + return $version + } + throw "No published Windows binary with a checksum found among $($candidates.Count) release tags." +} finally { + [Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol +} diff --git a/.github/scripts/test-select-bootstrap-version.ps1 b/.github/scripts/test-select-bootstrap-version.ps1 new file mode 100644 index 0000000..7f93373 --- /dev/null +++ b/.github/scripts/test-select-bootstrap-version.ps1 @@ -0,0 +1,213 @@ +# Dependency-free Windows PowerShell 5.1 regression tests using loopback HTTP. +$ErrorActionPreference = "Stop" +$selector = Join-Path $PSScriptRoot 'select-bootstrap-version.ps1' +$rules = [hashtable]::Synchronized(@{}) +$requests = [Collections.ArrayList]::Synchronized([Collections.ArrayList]::new()) +$state = [hashtable]::Synchronized(@{ Stop = $false; Failure = $null }) +$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) +$listener.Start() +$baseUrl = "http://127.0.0.1:$($listener.LocalEndpoint.Port)" +$server = [PowerShell]::Create() +[void]$server.AddScript({ + param($listener, $rules, $requests, $state) + try { + while (-not $state.Stop) { + if (-not $listener.Pending()) { [Threading.Thread]::Sleep(10); continue } + $client = $listener.AcceptTcpClient() + try { + $stream = $client.GetStream() + $stream.ReadTimeout = 5000 + $stream.WriteTimeout = 5000 + $reader = [IO.StreamReader]::new($stream) + $request = $reader.ReadLine() + if (-not $request) { continue } + while ($reader.ReadLine()) { } + $parts = $request.Split(' ') + $key = "$($parts[0]) $($parts[1])" + [void]$requests.Add($key) + $spec = if ($rules.ContainsKey($key)) { $rules[$key] } else { @{ Status = 404; Body = 'missing' } } + if ($spec.Disconnect) { continue } + $body = [Text.Encoding]::UTF8.GetBytes([string]$spec.Body) + $type = if ($spec.Type) { $spec.Type } else { 'text/plain; charset=utf-8' } + $header = "HTTP/1.1 $($spec.Status) Fixture`r`nContent-Type: $type`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n" + $bytes = [Text.Encoding]::ASCII.GetBytes($header) + $stream.Write($bytes, 0, $bytes.Length) + if ($parts[0] -ne 'HEAD') { $stream.Write($body, 0, $body.Length) } + $stream.Flush() + } finally { $client.Close() } + } + } catch { if (-not $state.Stop) { $state.Failure = $_.Exception.Message } } +}).AddArgument($listener).AddArgument($rules).AddArgument($requests).AddArgument($state) +$handle = $server.BeginInvoke() +$passed = [Collections.Generic.List[string]]::new() +$fixtureParent = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } +$fixtureParent = [IO.Path]::GetFullPath($fixtureParent) +$fixtureRoot = Join-Path $fixtureParent ("browser-cli-selector-" + [Guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $fixtureRoot | Out-Null + +function Assert-Equal($Actual, $Expected) { + if ($Actual -ne $Expected) { throw "Expected '$Expected', got '$Actual'" } +} +function Assert-Throws([scriptblock]$Body, [string]$Pattern) { + try { & $Body | Out-Null } catch { + if ($_.Exception.Message -match $Pattern) { return } + throw + } + throw "Expected failure matching '$Pattern'" +} +function Set-Published([string]$Version) { + $asset = "browser-cli-v$Version-x86_64-pc-windows-msvc.exe" + $rules["GET /v$Version/SHA256SUMS"] = @{ Status = 200; Body = ('a' * 64) + " $asset`n" } + $rules["HEAD /v$Version/$asset"] = @{ Status = 200; Body = 'fixture' } +} +function Select-Version([string[]]$Tags) { + & $selector -Tags $Tags -DownloadBaseUrl $baseUrl -WarningAction SilentlyContinue +} +function Test-Case([string]$Name, [scriptblock]$Body) { + $rules.Clear() + $requests.Clear() + $tlsBefore = [Net.ServicePointManager]::SecurityProtocol + & $Body + Assert-Equal ([Net.ServicePointManager]::SecurityProtocol) $tlsBefore + if ($state.Failure) { throw "Fixture server failed: $($state.Failure)" } + $passed.Add($Name) + Write-Host "PASS: $Name" +} + +# Validate each exact deletion target, including the unchanged bootstrap's temp +# cleanup. This function exists only in this test process, not in the product. +function Remove-Item { + [CmdletBinding()] + param([Parameter(Position = 0)][string]$Path, [switch]$Recurse, [switch]$Force) + $absolute = [IO.Path]::GetFullPath($Path) + if (-not $absolute.StartsWith($fixtureRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing cleanup outside the isolated test directory" + } + Microsoft.PowerShell.Management\Remove-Item -LiteralPath $absolute -Recurse:$Recurse -Force:$Force +} + +try { + Test-Case 'select newest available, not the second tag' { + Set-Published '1.1.15' + Set-Published '1.1.13' + Assert-Equal (Select-Version @('v1.1.13', 'v1.1.14', 'v1.1.15')) '1.1.15' + Assert-Equal $requests.Count 2 + } + Test-Case 'tag before manifest publication falls back' { + $rules['HEAD /v1.1.15/browser-cli-v1.1.15-x86_64-pc-windows-msvc.exe'] = @{ Status = 200 } + Set-Published '1.1.14' + Assert-Equal (Select-Version @('v1.1.15', 'v1.1.14')) '1.1.14' + Assert-Equal $requests.Count 3 + } + Test-Case 'manifest exists but binary 404 falls back past missing releases' { + Set-Published '1.1.15' + $rules.Remove('HEAD /v1.1.15/browser-cli-v1.1.15-x86_64-pc-windows-msvc.exe') + Set-Published '1.1.13' + Assert-Equal (Select-Version @('v1.1.15', 'v1.1.14', 'v1.1.13')) '1.1.13' + Assert-Equal $requests.Count 5 + } + Test-Case 'missing Windows checksum falls back' { + $rules['GET /v1.1.15/SHA256SUMS'] = @{ Status = 200; Body = ('b' * 64) + ' linux-binary' } + Set-Published '1.1.13' + Assert-Equal (Select-Version @('v1.1.15', 'v1.1.13')) '1.1.13' + Assert-Equal $requests.Count 3 + } + Test-Case 'numeric ordering, duplicate tags and prerelease filtering' { + Set-Published '1.10.0' + Assert-Equal (Select-Version @('v1.9.0', 'v1.10.0', 'v1.10.0', 'v99.0.0-rc1', 'main', 'v1.2.3/evil')) '1.10.0' + Assert-Equal $requests.Count 2 + } + Test-Case 'GNU binary checksum marker and CRLF manifest' { + Set-Published '1.1.15' + $rules['GET /v1.1.15/SHA256SUMS'] = @{ Status = 200; Type = 'application/octet-stream'; Body = + ('a' * 64) + " *browser-cli-v1.1.15-x86_64-pc-windows-msvc.exe`r`n" } + Assert-Equal (Select-Version @('v1.1.15')) '1.1.15' + } + Test-Case 'empty tag list fails without HTTP requests' { + Assert-Throws { Select-Version @() } 'No published Windows binary' + Assert-Equal $requests.Count 0 + } + Test-Case 'all candidates missing is a failure, not a skip' { + Assert-Throws { Select-Version @('v1.1.15', 'v1.1.14') } 'No published Windows binary' + Assert-Equal $requests.Count 2 + } + Test-Case 'malformed checksum does not downgrade' { + Set-Published '1.1.13' + $rules['GET /v1.1.15/SHA256SUMS'] = @{ Status = 200; Body = 'bad-hash browser-cli-v1.1.15-x86_64-pc-windows-msvc.exe' } + Assert-Throws { Select-Version @('v1.1.15', 'v1.1.13') } 'Invalid checksum manifest' + Assert-Equal $requests.Count 1 + } + Test-Case 'duplicate checksum does not downgrade' { + Set-Published '1.1.15' + Set-Published '1.1.13' + $rules['GET /v1.1.15/SHA256SUMS'].Body *= 2 + Assert-Throws { Select-Version @('v1.1.15', 'v1.1.13') } 'Invalid or duplicate checksum' + Assert-Equal $requests.Count 1 + } + foreach ($manifestBody in @('', 'upstream failure')) { + Test-Case "invalid manifest content [$manifestBody] does not downgrade" { + Set-Published '1.1.13' + $rules['GET /v1.1.15/SHA256SUMS'] = @{ Status = 200; Body = $manifestBody } + Assert-Throws { Select-Version @('v1.1.15', 'v1.1.13') } 'Invalid checksum manifest' + Assert-Equal $requests.Count 1 + } + } + Test-Case 'connection failure does not downgrade' { + Set-Published '1.1.13' + $rules['GET /v1.1.15/SHA256SUMS'] = @{ Disconnect = $true } + Assert-Throws { Select-Version @('v1.1.15', 'v1.1.13') } '.' + Assert-Equal @($requests | Where-Object { $_ -match '/v1.1.13/' }).Count 0 + } + foreach ($status in @(403, 429, 500)) { + Test-Case "manifest HTTP $status fails instead of downgrading" { + Set-Published '1.1.13' + $rules['GET /v1.1.15/SHA256SUMS'] = @{ Status = $status; Body = 'failure' } + Assert-Throws { Select-Version @('v1.1.15', 'v1.1.13') } "$status" + Assert-Equal $requests.Count 1 + } + } + foreach ($status in @(403, 500)) { + Test-Case "binary HTTP $status fails instead of downgrading" { + Set-Published '1.1.15' + Set-Published '1.1.13' + $rules['HEAD /v1.1.15/browser-cli-v1.1.15-x86_64-pc-windows-msvc.exe'] = @{ Status = $status; Body = 'failure' } + Assert-Throws { Select-Version @('v1.1.15', 'v1.1.13') } "$status" + Assert-Equal $requests.Count 2 + } + } + Test-Case 'real bootstrap rejects a hash mismatch without installing or downgrading' { + Set-Published '1.1.15' + Set-Published '1.1.13' + $rules['GET /v1.1.15/browser-cli-v1.1.15-x86_64-pc-windows-msvc.exe'] = @{ Status = 200; Body = 'corrupt binary' } + $saved = @{} + foreach ($name in @('LEXMOUNT_BROWSER_CLI_VERSION', 'LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL', 'LEXMOUNT_BROWSER_CLI_INSTALL_DIR', 'TEMP', 'TMP')) { + $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + } + try { + $env:LEXMOUNT_BROWSER_CLI_VERSION = Select-Version @('v1.1.15', 'v1.1.13') + $env:LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL = $baseUrl + $env:LEXMOUNT_BROWSER_CLI_INSTALL_DIR = Join-Path $fixtureRoot 'install' + $env:TEMP = $fixtureRoot + $env:TMP = $fixtureRoot + $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + Assert-Throws { & (Join-Path $repositoryRoot 'skills/lexmount-browser/scripts/bootstrap.ps1') } 'SHA-256 mismatch' + Assert-Equal (Test-Path -LiteralPath (Join-Path $env:LEXMOUNT_BROWSER_CLI_INSTALL_DIR 'browser-cli.exe')) $false + Assert-Equal @($requests | Where-Object { $_ -match '/v1.1.13/' }).Count 0 + } finally { + foreach ($name in $saved.Keys) { [Environment]::SetEnvironmentVariable($name, $saved[$name], 'Process') } + } + } + [pscustomobject]@{ passed = $passed.Count; powershell = $PSVersionTable.PSVersion.ToString(); cases = $passed } | ConvertTo-Json -Compress +} finally { + $state.Stop = $true + $listener.Stop() + [void]$server.EndInvoke($handle) + $server.Dispose() + $absolute = [IO.Path]::GetFullPath($fixtureRoot) + $parentPrefix = $fixtureParent.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (-not $absolute.StartsWith($parentPrefix, [StringComparison]::OrdinalIgnoreCase) -or + (Split-Path -Leaf $absolute) -notmatch '^browser-cli-selector-[0-9a-f-]{36}$') { + throw "Refusing cleanup of an unexpected fixture directory" + } + Microsoft.PowerShell.Management\Remove-Item -LiteralPath $absolute -Recurse -Force +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 610ac25..5a05f4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,22 +68,25 @@ jobs: windows-bootstrap: runs-on: windows-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v5 with: fetch-depth: 0 + - name: Test published bootstrap version selection + shell: powershell + run: .\.github\scripts\test-select-bootstrap-version.ps1 - name: Bootstrap from COS with Windows PowerShell 5.1 shell: powershell run: | if ($PSVersionTable.PSVersion.Major -ne 5) { throw "Expected Windows PowerShell 5.1" } - # The current release tag may exist before its COS assets are uploaded. - # Bootstrap against the previous published tag to avoid that race. - $publishedTag = git tag --list "v*" --sort=-version:refname | Select-Object -Skip 1 -First 1 - if (-not $publishedTag) { throw "No previous published version tag found" } - $env:LEXMOUNT_BROWSER_CLI_VERSION = $publishedTag.TrimStart('v') + # Tags can precede uploads or refer to releases with missing assets. + $env:LEXMOUNT_BROWSER_CLI_VERSION = & .\.github\scripts\select-bootstrap-version.ps1 + Write-Host "Testing published browser-cli $env:LEXMOUNT_BROWSER_CLI_VERSION" $env:LEXMOUNT_BROWSER_CLI_INSTALL_DIR = Join-Path $env:RUNNER_TEMP "browser-cli-bootstrap" & .\skills\lexmount-browser\scripts\bootstrap.ps1 & (Join-Path $env:LEXMOUNT_BROWSER_CLI_INSTALL_DIR "browser-cli.exe") version + if ($LASTEXITCODE -ne 0) { throw "Installed browser-cli version check failed" } linux-release: runs-on: ubuntu-22.04