Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/scripts/select-bootstrap-version.ps1
Original file line number Diff line number Diff line change
@@ -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`$" })
Comment on lines +56 to +60
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
}
213 changes: 213 additions & 0 deletions .github/scripts/test-select-bootstrap-version.ps1
Original file line number Diff line number Diff line change
@@ -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 @('', '<html>upstream failure</html>')) {
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
}
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down