From 4cbee9b3ca3bb454a65fdcf748d89c50554accec Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:05:27 +0100 Subject: [PATCH 01/10] ci: pin Pester to v5 and fail on zero discovered tests Install-Module -MinimumVersion 5.5 resolved the new Pester 6.0.0, which discovers this v5 suite as zero tests and exits green. Pin to 5.x and throw when discovery finds nothing. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8bfca8..bb3aad4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -152,27 +152,38 @@ jobs: with: persist-credentials: false + # The suite is written for Pester v5; v6 discovers zero tests, so the + # version is pinned and every run fails if nothing was discovered. - name: Install Pester shell: pwsh - run: Install-Module -Name Pester -MinimumVersion 5.5 -Force -SkipPublisherCheck + run: Install-Module -Name Pester -MinimumVersion 5.5 -MaximumVersion 5.99 -Force -SkipPublisherCheck - name: Run Pester (PowerShell 7) shell: pwsh run: | + Import-Module Pester -MinimumVersion 5.5 -MaximumVersion 5.99 -Force $config = New-PesterConfiguration $config.Run.Path = "tests/Install.Tests.ps1" $config.Run.Exit = $true + $config.Run.PassThru = $true $config.Output.Verbosity = "Detailed" $config.Should.ErrorAction = "Stop" - Invoke-Pester -Configuration $config + $result = Invoke-Pester -Configuration $config + if (-not $result -or $result.TotalCount -lt 1) { + throw "Pester discovered zero tests" + } - name: Run Pester (Windows PowerShell 5.1) shell: powershell run: | - Import-Module Pester -MinimumVersion 5.5 -Force + Import-Module Pester -MinimumVersion 5.5 -MaximumVersion 5.99 -Force $config = New-PesterConfiguration $config.Run.Path = "tests/Install.Tests.ps1" $config.Run.Exit = $true + $config.Run.PassThru = $true $config.Output.Verbosity = "Detailed" $config.Should.ErrorAction = "Stop" - Invoke-Pester -Configuration $config + $result = Invoke-Pester -Configuration $config + if (-not $result -or $result.TotalCount -lt 1) { + throw "Pester discovered zero tests" + } From 7d9c9a9fcbeb0c9d7c49d5b4a2a54f2ae4cc0408 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:09:06 +0100 Subject: [PATCH 02/10] test: fix Pester discovery; ci: bump actions/checkout to v6.0.3 Candidate hosts were computed in BeforeAll (run phase) while the foreach creating the per-host Contexts executes at discovery, so the suite always discovered zero tests. Hosts are now enumerated at discovery and passed to the run phase via -ForEach. checkout bumped for the Node 20 deprecation. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 10 +++++----- .github/workflows/zizmor.yml | 2 +- tests/Install.Tests.ps1 | 14 ++++++++------ 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 185348c..724c37e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: contents: write # needed to create the GitHub Release and upload its assets via `gh` steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -117,7 +117,7 @@ jobs: vendor_path: scripts/vendor/ steps: - name: Checkout installer repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb3aad4..8bb5965 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -100,7 +100,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -133,7 +133,7 @@ jobs: run: apk add --no-cache git gcompat libstdc++ - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -148,7 +148,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index c8423c6..86fa42f 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index f313ec0..347372c 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -15,10 +15,6 @@ Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $sc $script:DefaultTarget = 'windows-x86_64' - $script:CandidateHosts = @() - if (Get-Command 'pwsh' -ErrorAction SilentlyContinue) { $script:CandidateHosts += 'pwsh' } - if (Get-Command 'powershell.exe' -ErrorAction SilentlyContinue) { $script:CandidateHosts += 'powershell.exe' } - # Runs install.ps1 as a child process of $HostExe. -EnvironmentOverrides sets env vars for # the duration of the call ($null removes one) and restores the previous values afterward. function Invoke-Installer { @@ -108,9 +104,15 @@ Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $sc ) } - foreach ($currentHostExe in $script:CandidateHosts) { + # Runs at discovery (BeforeAll has not executed yet); -ForEach carries the + # host into the run phase as $currentHostExe. + $candidateHosts = @() + if (Get-Command 'pwsh' -ErrorAction SilentlyContinue) { $candidateHosts += 'pwsh' } + if (Get-Command 'powershell.exe' -ErrorAction SilentlyContinue) { $candidateHosts += 'powershell.exe' } + + foreach ($currentHostExe in $candidateHosts) { - Context "Host: $currentHostExe" { + Context "Host: $currentHostExe" -ForEach @(@{ currentHostExe = $currentHostExe }) { BeforeEach { $script:FixtureDir = Join-Path $TestDrive ("fixture-" + [Guid]::NewGuid().ToString('N')) From 243ce711ec8aefd38bba68724ecf6e31fc9d2004 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:22:01 +0100 Subject: [PATCH 03/10] test: bounded fixture-server shutdown; ci: job timeouts Stop() blocked forever in PowerShell.Stop()/EndInvoke when the server's GetContext() had never received a request (first test that skips the server hangs in AfterEach). Shutdown now stops the listener and waits at most 5s, leaking the daemon runspace instead of deadlocking. Job-level timeout-minutes added so any future hang fails fast. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 5 +++++ tests/helpers/Fixture.psm1 | 16 +++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bb5965..bf9e282 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: lint-shell: name: Lint shell runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -51,6 +52,7 @@ jobs: lint-powershell: name: Lint PowerShell runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -98,6 +100,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -126,6 +129,7 @@ jobs: test-shell-musl: name: Test shell (musl / alpine) runs-on: ubuntu-latest + timeout-minutes: 20 container: image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc steps: @@ -146,6 +150,7 @@ jobs: test-powershell: name: Test PowerShell runs-on: windows-latest + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/tests/helpers/Fixture.psm1 b/tests/helpers/Fixture.psm1 index 33e8aa5..7c0d72b 100644 --- a/tests/helpers/Fixture.psm1 +++ b/tests/helpers/Fixture.psm1 @@ -367,13 +367,19 @@ function Start-FixtureServer { Add-Member -InputObject $server -MemberType ScriptMethod -Name Stop -Value { if ($this.Stopped) { return } $this.Stopped = $true + # Stopping the listener makes a blocked GetContext() throw, ending the loop. try { $this.Listener.Stop() } catch { } try { $this.Listener.Close() } catch { } - try { $this.PowerShell.Stop() } catch { } - try { [void]$this.PowerShell.EndInvoke($this.AsyncResult) } catch { } - try { $this.PowerShell.Dispose() } catch { } - try { $this.Runspace.Close() } catch { } - try { $this.Runspace.Dispose() } catch { } + # Wait bounded; if the loop somehow stays blocked, leak the background + # runspace (a daemon thread) rather than deadlock the test run. + $finished = $false + try { $finished = $this.AsyncResult.AsyncWaitHandle.WaitOne(5000) } catch { } + if ($finished) { + try { [void]$this.PowerShell.EndInvoke($this.AsyncResult) } catch { } + try { $this.PowerShell.Dispose() } catch { } + try { $this.Runspace.Close() } catch { } + try { $this.Runspace.Dispose() } catch { } + } } return $server From c928fbed13b381f4e624203106f47dbc71f59301 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:30:09 +0100 Subject: [PATCH 04/10] test: serve fixtures from a python child process The in-process HttpListener ran on a foreground runspace thread; when a test never sent it a request the thread stayed blocked in GetContext() and kept the PowerShell host alive after the run finished. A child process is killed deterministically on Stop(). Co-Authored-By: Claude Fable 5 --- tests/helpers/Fixture.psm1 | 153 +++++++++++++------------------------ 1 file changed, 54 insertions(+), 99 deletions(-) diff --git a/tests/helpers/Fixture.psm1 b/tests/helpers/Fixture.psm1 index 7c0d72b..3f4c9f7 100644 --- a/tests/helpers/Fixture.psm1 +++ b/tests/helpers/Fixture.psm1 @@ -254,11 +254,10 @@ function Set-FixtureManifestValue { [System.IO.File]::WriteAllLines($ManifestPath, $newLines, $utf8NoBom) } -# Starts a loopback-only HTTP file server rooted at -Directory on a free port, serving files as -# they exist at request time so tests can mutate them (e.g. corrupt the archive) afterward. Uses -# "localhost" rather than 127.0.0.1 because it's reserved for the current user on Windows, so no -# admin URL ACL reservation is needed. Returns an object with .Url, .Directory, .Port, and an -# idempotent .Stop() method. +# Starts a loopback-only HTTP file server rooted at -Directory on a free port, backed by a +# python child process (present on all CI runners) so shutdown is a deterministic process kill. +# Serves files as they exist at request time so tests can mutate them (e.g. corrupt the archive) +# afterward. Returns an object with .Url, .Directory, .Port, and an idempotent .Stop() method. function Start-FixtureServer { [CmdletBinding()] param( @@ -269,117 +268,73 @@ function Start-FixtureServer { New-Item -ItemType Directory -Path $Directory -Force | Out-Null } - # Find a free loopback port via the classic "bind to port 0" trick. - $probe = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) - $probe.Start() - $port = $probe.LocalEndpoint.Port - $probe.Stop() + $python = Get-Command 'python' -ErrorAction SilentlyContinue + if (-not $python) { $python = Get-Command 'python3' -ErrorAction SilentlyContinue } + if (-not $python) { throw 'Fixture.psm1: python is required to run the fixture HTTP server' } - $prefix = "http://localhost:$port/" - $listener = New-Object System.Net.HttpListener - $listener.Prefixes.Add($prefix) - $listener.Start() + $id = [Guid]::NewGuid().ToString('N') + $portFile = Join-Path ([System.IO.Path]::GetTempPath()) "fixture-port-$id.txt" + $scriptPath = Join-Path ([System.IO.Path]::GetTempPath()) "fixture-server-$id.py" - # A background runspace (not Start-Job) shares process memory, so it sees test file writes directly. - $runspace = [runspacefactory]::CreateRunspace() - $runspace.Open() - $powershell = [System.Management.Automation.PowerShell]::Create() - $powershell.Runspace = $runspace + $serverScript = @' +import http.server +import os +import socketserver +import sys - $serverLoop = { - param($listener, $rootDirectory) +os.chdir(sys.argv[1]) - $rootFull = [System.IO.Path]::GetFullPath($rootDirectory) - while ($listener.IsListening) { - try { - $context = $listener.GetContext() - } - catch { - break # Stop()/Close() was called while GetContext() was blocked; expected shutdown. - } +class Handler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): + pass - $response = $context.Response - try { - $relative = $context.Request.Url.LocalPath.TrimStart('/') - $candidate = Join-Path $rootDirectory $relative - $candidateFull = [System.IO.Path]::GetFullPath($candidate) - - if (-not $candidateFull.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase) -or - -not (Test-Path -LiteralPath $candidateFull -PathType Leaf)) { - $response.StatusCode = 404 - } - else { - $served = $false - $attempts = 0 - # Retry briefly on sharing violations: the file may be mid-write from a test. - while (-not $served -and $attempts -lt 40) { - $attempts++ - try { - $stream = [System.IO.File]::Open( - $candidateFull, - [System.IO.FileMode]::Open, - [System.IO.FileAccess]::Read, - [System.IO.FileShare]::ReadWrite) - try { - $response.ContentType = "application/octet-stream" - $response.ContentLength64 = $stream.Length - $stream.CopyTo($response.OutputStream) - } - finally { - $stream.Dispose() - } - $served = $true - } - catch [System.IO.IOException] { - Start-Sleep -Milliseconds 50 - } - } - if (-not $served) { - $response.StatusCode = 503 - } - } - } - catch { - try { $response.StatusCode = 500 } catch { } - } - finally { - try { $response.OutputStream.Close() } catch { } - } + +httpd = socketserver.TCPServer(("127.0.0.1", 0), Handler) +with open(sys.argv[2], "w") as f: + f.write(str(httpd.server_address[1])) +httpd.serve_forever() +'@ + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($scriptPath, $serverScript, $utf8NoBom) + + $startArgs = @{ + FilePath = $python.Source + ArgumentList = @('"' + $scriptPath + '"', '"' + $Directory + '"', '"' + $portFile + '"') + PassThru = $true + } + if ($env:OS -eq 'Windows_NT') { $startArgs['WindowStyle'] = 'Hidden' } + $process = Start-Process @startArgs + + $port = $null + for ($i = 0; $i -lt 100; $i++) { + if ($process.HasExited) { break } + if ((Test-Path -LiteralPath $portFile) -and ($content = Get-Content -LiteralPath $portFile -Raw -ErrorAction SilentlyContinue)) { + $port = [int]$content.Trim() + break } + Start-Sleep -Milliseconds 100 + } + if (-not $port) { + try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch { } + throw 'Fixture.psm1: fixture HTTP server failed to start' } - - [void]$powershell.AddScript($serverLoop) - [void]$powershell.AddArgument($listener) - [void]$powershell.AddArgument($Directory) - $asyncResult = $powershell.BeginInvoke() $server = [PSCustomObject]@{ - Url = "http://localhost:$port" + Url = "http://127.0.0.1:$port" Directory = $Directory Port = $port } - Add-Member -InputObject $server -MemberType NoteProperty -Name Listener -Value $listener - Add-Member -InputObject $server -MemberType NoteProperty -Name PowerShell -Value $powershell - Add-Member -InputObject $server -MemberType NoteProperty -Name Runspace -Value $runspace - Add-Member -InputObject $server -MemberType NoteProperty -Name AsyncResult -Value $asyncResult + Add-Member -InputObject $server -MemberType NoteProperty -Name Process -Value $process + Add-Member -InputObject $server -MemberType NoteProperty -Name PortFile -Value $portFile + Add-Member -InputObject $server -MemberType NoteProperty -Name ScriptPath -Value $scriptPath Add-Member -InputObject $server -MemberType NoteProperty -Name Stopped -Value $false Add-Member -InputObject $server -MemberType ScriptMethod -Name Stop -Value { if ($this.Stopped) { return } $this.Stopped = $true - # Stopping the listener makes a blocked GetContext() throw, ending the loop. - try { $this.Listener.Stop() } catch { } - try { $this.Listener.Close() } catch { } - # Wait bounded; if the loop somehow stays blocked, leak the background - # runspace (a daemon thread) rather than deadlock the test run. - $finished = $false - try { $finished = $this.AsyncResult.AsyncWaitHandle.WaitOne(5000) } catch { } - if ($finished) { - try { [void]$this.PowerShell.EndInvoke($this.AsyncResult) } catch { } - try { $this.PowerShell.Dispose() } catch { } - try { $this.Runspace.Close() } catch { } - try { $this.Runspace.Dispose() } catch { } - } + try { Stop-Process -Id $this.Process.Id -Force -ErrorAction SilentlyContinue } catch { } + try { Remove-Item -LiteralPath $this.PortFile -Force -ErrorAction SilentlyContinue } catch { } + try { Remove-Item -LiteralPath $this.ScriptPath -Force -ErrorAction SilentlyContinue } catch { } } return $server From 8f436723f7d21ad5defa47ae3a7ae50841aae737 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:32:45 +0100 Subject: [PATCH 05/10] fix: literal directory moves; test: surface stderr on exit-code failures Move-Item -Destination parses wildcard characters, so install roots containing e.g. [ ] failed during activation. Directory.Move treats both paths literally. Co-Authored-By: Claude Fable 5 --- install.ps1 | 10 ++++++---- tests/Install.Tests.ps1 | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/install.ps1 b/install.ps1 index 85aee94..510c424 100644 --- a/install.ps1 +++ b/install.ps1 @@ -443,19 +443,21 @@ try { $utf8WithoutBom = New-Object System.Text.UTF8Encoding($false) [IO.File]::WriteAllLines((Join-Path $stagedDirectory ".cloudsmith-installation"), $installationMetadata, $utf8WithoutBom) + # Directory.Move treats both paths literally; Move-Item -Destination chokes + # on wildcard characters like [ ] in the install root. $temporaryFinal = Join-Path $finalParent (".cloudsmith.new." + [Guid]::NewGuid().ToString("N")) $oldFinal = Join-Path $finalParent (".cloudsmith.old." + [Guid]::NewGuid().ToString("N")) - Move-Item -LiteralPath $stagedDirectory -Destination $temporaryFinal + [System.IO.Directory]::Move($stagedDirectory, $temporaryFinal) if (Test-Path -LiteralPath $binDirectory) { - Move-Item -LiteralPath $binDirectory -Destination $oldFinal + [System.IO.Directory]::Move($binDirectory, $oldFinal) } try { - Move-Item -LiteralPath $temporaryFinal -Destination $binDirectory + [System.IO.Directory]::Move($temporaryFinal, $binDirectory) } catch { if (Test-Path -LiteralPath $oldFinal) { - Move-Item -LiteralPath $oldFinal -Destination $binDirectory -ErrorAction SilentlyContinue + try { [System.IO.Directory]::Move($oldFinal, $binDirectory) } catch { } } throw } diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index 347372c..bb273e3 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -226,10 +226,10 @@ Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $sc ) $first = Invoke-Installer -HostExe $currentHostExe -ScriptArguments $scriptArgs - $first.ExitCode | Should -Be 0 + $first.ExitCode | Should -Be 0 -Because "stderr: $($first.StdErr)" $second = Invoke-Installer -HostExe $currentHostExe -ScriptArguments ($scriptArgs + '-Force') - $second.ExitCode | Should -Be 0 + $second.ExitCode | Should -Be 0 -Because "stderr: $($second.StdErr)" $expectedBinDir = Get-BinDirectory -InstallRoot $installRoot -Version '3.2.1' -Target $script:DefaultTarget $expectedExecutable = Join-Path $expectedBinDir 'cloudsmith.exe' @@ -576,8 +576,10 @@ Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $sc $proc1.WaitForExit(60000) | Out-Null $proc2.WaitForExit(60000) | Out-Null - $proc1.ExitCode | Should -Be 0 - $proc2.ExitCode | Should -Be 0 + $stderr1 = if (Test-Path -LiteralPath $err1) { Get-Content -Raw -LiteralPath $err1 } else { '' } + $stderr2 = if (Test-Path -LiteralPath $err2) { Get-Content -Raw -LiteralPath $err2 } else { '' } + $proc1.ExitCode | Should -Be 0 -Because "stderr: $stderr1" + $proc2.ExitCode | Should -Be 0 -Because "stderr: $stderr2" $expectedExecutable = Join-Path (Get-BinDirectory -InstallRoot $installRoot -Version '7.7.7' -Target $script:DefaultTarget) 'cloudsmith.exe' Test-Path -LiteralPath $expectedExecutable | Should -BeTrue From 1647b785e1e5060b7b4053867a5485afe0972bae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:35:44 +0000 Subject: [PATCH 06/10] fix: add warning to empty catch block in rollback path (PSAvoidUsingEmptyCatchBlock) --- install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 510c424..c932622 100644 --- a/install.ps1 +++ b/install.ps1 @@ -457,7 +457,7 @@ try { } catch { if (Test-Path -LiteralPath $oldFinal) { - try { [System.IO.Directory]::Move($oldFinal, $binDirectory) } catch { } + try { [System.IO.Directory]::Move($oldFinal, $binDirectory) } catch { Write-Warning "Failed to restore previous installation directory during rollback: $_" } } throw } From 368a12cefd7e83717de0f6bf42fcbc649ab364d6 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:43:10 +0100 Subject: [PATCH 07/10] fix: avoid wildcard-sensitive -OutFile and cmdlet discovery for hashing Windows PowerShell applies wildcard matching to Invoke-WebRequest -OutFile, so the insecure download path now writes to a temp file and copies it to the destination literally. Checksum verification hashes via .NET instead of Get-FileHash, whose discovery can fail when concurrent Windows PowerShell startups race on the module analysis cache. Co-Authored-By: Claude Fable 5 --- install.ps1 | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index c932622..3cc5a3d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -170,7 +170,16 @@ function Invoke-Download { if ($env:CLOUDSMITH_CLI_ALLOW_INSECURE_URLS -eq "true") { Invoke-WithRetry { - Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Destination -TimeoutSec 300 | Out-Null + # Windows PowerShell applies wildcard matching to -OutFile, so + # download to a temp path and copy to the destination literally. + $tempFile = [System.IO.Path]::GetTempFileName() + try { + Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $tempFile -TimeoutSec 300 | Out-Null + [System.IO.File]::Copy($tempFile, $Destination, $true) + } + finally { + [System.IO.File]::Delete($tempFile) + } } return } @@ -412,7 +421,18 @@ try { Write-InstallerLog "downloading Cloudsmith CLI $resolvedVersion" Invoke-Download -Uri $archiveUrl -Destination $archiveFile - $actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $archiveFile).Hash.ToLowerInvariant() + # Hash via .NET rather than Get-FileHash: concurrent Windows PowerShell + # startups can race on the module analysis cache and fail cmdlet discovery. + $sha256 = [System.Security.Cryptography.SHA256]::Create() + $archiveStream = [System.IO.File]::OpenRead($archiveFile) + try { + $hashBytes = $sha256.ComputeHash($archiveStream) + } + finally { + $archiveStream.Dispose() + $sha256.Dispose() + } + $actualSha256 = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant() if ($actualSha256 -ne $expectedSha256) { throw "install.ps1: archive checksum mismatch" } From b653385e50b58018c77ff20338921775a6e964f9 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:48:24 +0100 Subject: [PATCH 08/10] test: nest Join-Path calls for Windows PowerShell 5.1 compatibility The multi-segment Join-Path form is PowerShell 6+ only and fails discovery when the suite itself runs under Windows PowerShell 5.1. Co-Authored-By: Claude Fable 5 --- tests/Install.Tests.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index bb273e3..53234cb 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -10,8 +10,9 @@ $script:RunningOnWindows = if ($PSVersionTable.PSVersion.Major -ge 6) { $IsWindo Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $script:RunningOnWindows) { BeforeAll { - $script:InstallScriptPath = (Resolve-Path (Join-Path $PSScriptRoot '..' 'install.ps1')).ProviderPath - Import-Module (Join-Path $PSScriptRoot 'helpers' 'Fixture.psm1') -Force + # Nested Join-Path: the multi-segment form is not available in Windows PowerShell 5.1. + $script:InstallScriptPath = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'install.ps1')).ProviderPath + Import-Module (Join-Path (Join-Path $PSScriptRoot 'helpers') 'Fixture.psm1') -Force $script:DefaultTarget = 'windows-x86_64' From 372ba9cbca8e7d6271114b897fdc8b76730c9a0c Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:53:28 +0100 Subject: [PATCH 09/10] test: keep child installer stderr from terminating tests on Windows PowerShell Windows PowerShell converts redirected native stderr into error records, so the installer's first diagnostic line became a terminating error under Pester's ErrorAction Stop. Relax the preference inside Invoke-Installer. Co-Authored-By: Claude Fable 5 --- tests/Install.Tests.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index 53234cb..a392fb5 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -34,6 +34,9 @@ Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $sc } try { + # Windows PowerShell turns redirected native stderr into error records, + # which terminate under Pester's ErrorAction Stop; scoped to this function. + $ErrorActionPreference = 'Continue' $stdOutLines = @(& $HostExe -NoProfile -NoLogo -ExecutionPolicy Bypass -File $script:InstallScriptPath @ScriptArguments 2> $stdErrPath) $exitCode = $LASTEXITCODE } From 04dc4f59e91bc2883a626a427fe5c8f1bde5a30d Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 7 Jul 2026 12:58:00 +0100 Subject: [PATCH 10/10] test: cache process handles so ExitCode survives on Windows PowerShell Start-Process -PassThru returns a null ExitCode in Windows PowerShell unless the handle is cached before the child exits. Co-Authored-By: Claude Fable 5 --- tests/Install.Tests.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index a392fb5..d689747 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -576,6 +576,9 @@ Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $sc -RedirectStandardOutput $out1 -RedirectStandardError $err1 $proc2 = Start-Process -FilePath $currentHostExe -ArgumentList $argumentString -NoNewWindow -PassThru ` -RedirectStandardOutput $out2 -RedirectStandardError $err2 + # Cache handles now, or ExitCode is null in Windows PowerShell once the process exits. + $null = $proc1.Handle + $null = $proc2.Handle $proc1.WaitForExit(60000) | Out-Null $proc2.WaitForExit(60000) | Out-Null