From 2e933f89d56282af0bf389f56bcdd542c35c52fd Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Thu, 22 Oct 2026 17:42:28 +0100 Subject: [PATCH 1/4] fix(cli): make temporary directory cleanup non-fatal in the Windows install script The installer deleted its download directory with Remove-Item -Recurse inside a finally block. On machines where TEMP resolves to an 8.3 short path (a user profile such as C:\Users\USERNA~1.EXT), Windows PowerShell's filesystem provider fails that delete with "An object at the specified path ... does not exist", and the throw was reported as "Installation failed" after the binary and PATH were already in place. Resolve TEMP to its long path before building the temporary directory, delete it through System.IO.Directory.Delete, and downgrade any cleanup error to a warning so a leftover temporary folder never masks a completed install. --- .changesets/1792687232-e2151e8b.yaml | 10 ++++++++++ templates/templates/cli/release/install.ps1.stmpl | 12 +++++++++--- zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/scripts/install.ps1 | 12 +++++++++--- 4 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 .changesets/1792687232-e2151e8b.yaml diff --git a/.changesets/1792687232-e2151e8b.yaml b/.changesets/1792687232-e2151e8b.yaml new file mode 100644 index 000000000..16b6d9029 --- /dev/null +++ b/.changesets/1792687232-e2151e8b.yaml @@ -0,0 +1,10 @@ +id: 1792687232-e2151e8b +features: + - core +targets: + - cli +type: fix +bump: patch +description: keep a failed temporary directory cleanup in the Windows install script from being reported as an installation failure +author: AshGodfrey +date: "2026-10-22" diff --git a/templates/templates/cli/release/install.ps1.stmpl b/templates/templates/cli/release/install.ps1.stmpl index 14204893f..1393afc13 100644 --- a/templates/templates/cli/release/install.ps1.stmpl +++ b/templates/templates/cli/release/install.ps1.stmpl @@ -73,7 +73,8 @@ function Install-CLI { Write-ColorOutput "Downloading from: $downloadUrl" -Color Cyan # Create temporary directory - $tempDir = Join-Path $env:TEMP "{{sanitizeCliName}}-install-$(New-Guid)" + $tempRoot = (Get-Item -LiteralPath $env:TEMP).FullName + $tempDir = Join-Path $tempRoot "{{sanitizeCliName}}-install-$(New-Guid)" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null try { @@ -129,8 +130,13 @@ function Install-CLI { } finally { # Cleanup - if (Test-Path $tempDir) { - Remove-Item $tempDir -Recurse -Force + try { + if (Test-Path -LiteralPath $tempDir) { + [System.IO.Directory]::Delete($tempDir, $true) + } + } + catch { + Write-ColorOutput "Warning: could not remove temporary directory ${tempDir}: $_" -Color Yellow } } } diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index f9b725c8f..f8ed045b9 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -662,7 +662,7 @@ trackedFiles: internal/usage/schema.go: last_write_checksum: sha1:92fdc3775fa21a6a8283bcaa762beac6faf40e57 scripts/install.ps1: - last_write_checksum: sha1:9dcf40ea00ecb8ceda83d5056f57235b43cf658a + last_write_checksum: sha1:8e03835c51fac3f0c60c086e79e3fdc067a6bca6 scripts/install.sh: last_write_checksum: sha1:ba255d220bc830375d4f2bc34a7ca01e0a0a8fe2 tests/artifact_test.go: diff --git a/zSDKs/sdk-cli/scripts/install.ps1 b/zSDKs/sdk-cli/scripts/install.ps1 index 909525b12..c7f511d38 100644 --- a/zSDKs/sdk-cli/scripts/install.ps1 +++ b/zSDKs/sdk-cli/scripts/install.ps1 @@ -73,7 +73,8 @@ function Install-CLI { Write-ColorOutput "Downloading from: $downloadUrl" -Color Cyan # Create temporary directory - $tempDir = Join-Path $env:TEMP "cli-install-$(New-Guid)" + $tempRoot = (Get-Item -LiteralPath $env:TEMP).FullName + $tempDir = Join-Path $tempRoot "cli-install-$(New-Guid)" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null try { @@ -129,8 +130,13 @@ function Install-CLI { } finally { # Cleanup - if (Test-Path $tempDir) { - Remove-Item $tempDir -Recurse -Force + try { + if (Test-Path -LiteralPath $tempDir) { + [System.IO.Directory]::Delete($tempDir, $true) + } + } + catch { + Write-ColorOutput "Warning: could not remove temporary directory ${tempDir}: $_" -Color Yellow } } } From 266ec433ece99acc6ea4d130aafae3523e390d73 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Thu, 22 Oct 2026 17:50:04 +0100 Subject: [PATCH 2/4] fix(cli): build the install script temporary directory from the raw TEMP path Resolving TEMP through Get-Item threw before the cleanup guard when the directory did not exist, and its FullName does not reliably expand 8.3 short names. The guarded Directory.Delete already tolerates short paths. --- templates/templates/cli/release/install.ps1.stmpl | 3 +-- zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/scripts/install.ps1 | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/templates/templates/cli/release/install.ps1.stmpl b/templates/templates/cli/release/install.ps1.stmpl index 1393afc13..e2ec41d12 100644 --- a/templates/templates/cli/release/install.ps1.stmpl +++ b/templates/templates/cli/release/install.ps1.stmpl @@ -73,8 +73,7 @@ function Install-CLI { Write-ColorOutput "Downloading from: $downloadUrl" -Color Cyan # Create temporary directory - $tempRoot = (Get-Item -LiteralPath $env:TEMP).FullName - $tempDir = Join-Path $tempRoot "{{sanitizeCliName}}-install-$(New-Guid)" + $tempDir = Join-Path $env:TEMP "{{sanitizeCliName}}-install-$(New-Guid)" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null try { diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index f8ed045b9..6a4503519 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -662,7 +662,7 @@ trackedFiles: internal/usage/schema.go: last_write_checksum: sha1:92fdc3775fa21a6a8283bcaa762beac6faf40e57 scripts/install.ps1: - last_write_checksum: sha1:8e03835c51fac3f0c60c086e79e3fdc067a6bca6 + last_write_checksum: sha1:c03368407505979c2b4498d0d754c7bcda82769a scripts/install.sh: last_write_checksum: sha1:ba255d220bc830375d4f2bc34a7ca01e0a0a8fe2 tests/artifact_test.go: diff --git a/zSDKs/sdk-cli/scripts/install.ps1 b/zSDKs/sdk-cli/scripts/install.ps1 index c7f511d38..68551a458 100644 --- a/zSDKs/sdk-cli/scripts/install.ps1 +++ b/zSDKs/sdk-cli/scripts/install.ps1 @@ -73,8 +73,7 @@ function Install-CLI { Write-ColorOutput "Downloading from: $downloadUrl" -Color Cyan # Create temporary directory - $tempRoot = (Get-Item -LiteralPath $env:TEMP).FullName - $tempDir = Join-Path $tempRoot "cli-install-$(New-Guid)" + $tempDir = Join-Path $env:TEMP "cli-install-$(New-Guid)" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null try { From 48f0bc8928cc2e2e1b04124d8eb834e341007d61 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Thu, 22 Oct 2026 17:53:46 +0100 Subject: [PATCH 3/4] test(cli): update release snapshots for the install script cleanup change --- pkg/generate/snapshots/cli_release_go_test.go | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/pkg/generate/snapshots/cli_release_go_test.go b/pkg/generate/snapshots/cli_release_go_test.go index 98469dd1f..2a008002b 100644 --- a/pkg/generate/snapshots/cli_release_go_test.go +++ b/pkg/generate/snapshots/cli_release_go_test.go @@ -784,8 +784,13 @@ function Install-CLI { } finally { # Cleanup - if (Test-Path $tempDir) { - Remove-Item $tempDir -Recurse -Force + try { + if (Test-Path -LiteralPath $tempDir) { + [System.IO.Directory]::Delete($tempDir, $true) + } + } + catch { + Write-ColorOutput "Warning: could not remove temporary directory ${tempDir}: $_" -Color Yellow } } } @@ -1829,8 +1834,13 @@ function Install-CLI { } finally { # Cleanup - if (Test-Path $tempDir) { - Remove-Item $tempDir -Recurse -Force + try { + if (Test-Path -LiteralPath $tempDir) { + [System.IO.Directory]::Delete($tempDir, $true) + } + } + catch { + Write-ColorOutput "Warning: could not remove temporary directory ${tempDir}: $_" -Color Yellow } } } @@ -2933,8 +2943,13 @@ function Install-CLI { } finally { # Cleanup - if (Test-Path $tempDir) { - Remove-Item $tempDir -Recurse -Force + try { + if (Test-Path -LiteralPath $tempDir) { + [System.IO.Directory]::Delete($tempDir, $true) + } + } + catch { + Write-ColorOutput "Warning: could not remove temporary directory ${tempDir}: $_" -Color Yellow } } } From 3771d0df6d37d3baecf1f42d4545328b0bfbcd78 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Thu, 22 Oct 2026 20:07:10 +0100 Subject: [PATCH 4/4] ci: smoke test the generated Windows installer under powershell and pwsh --- .github/workflows/test.yml | 20 +++++ scripts/test-windows-installer.ps1 | 113 +++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 scripts/test-windows-installer.ps1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 507b517eb..d5b75aece 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -176,10 +176,30 @@ jobs: uses: ./.github/workflows/test-cli.yml with: test-group: "review" + test-cli-windows-installer: + needs: [detect-targets] + if: needs.detect-targets.outputs.run-cli == 'true' + name: Test CLI - windows installer (${{ matrix.shell }}) + timeout-minutes: 15 + runs-on: windows-latest + permissions: + contents: "read" + strategy: + fail-fast: false + matrix: + shell: [powershell, pwsh] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test generated Windows installer + shell: ${{ matrix.shell }} + run: ./scripts/test-windows-installer.ps1 test-cli: if: always() && needs.detect-targets.outputs.run-cli == 'true' needs: - detect-targets + - test-cli-windows-installer - test-cli-primary - test-cli-secondary - test-cli-tertiary diff --git a/scripts/test-windows-installer.ps1 b/scripts/test-windows-installer.ps1 new file mode 100644 index 000000000..c95aa9d09 --- /dev/null +++ b/scripts/test-windows-installer.ps1 @@ -0,0 +1,113 @@ +#Requires -Version 5.1 +<# +Exercises the generated Windows installer offline: the release download and +version lookup are replaced by local functions, the installer is run against a +locally built archive, and the installed binary, download URL, and temporary +directory cleanup are checked. On Windows a second run holds a file in the +temporary directory open to confirm a cleanup failure does not fail the install. +#> +[CmdletBinding()] +param( + [string]$InstallerPath = (Join-Path $PSScriptRoot '..\zSDKs\sdk-cli\scripts\install.ps1') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$InstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path +$isWindowsHost = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { + throw "Assertion failed: $Message" + } + Write-Host "ok: $Message" +} + +function New-InstallerSandbox { + $root = Join-Path ([System.IO.Path]::GetTempPath()) "installer-test-$([guid]::NewGuid())" + foreach ($name in 'temp', 'localappdata', 'install', 'payload') { + New-Item -ItemType Directory -Path (Join-Path $root $name) -Force | Out-Null + } + $binary = Join-Path (Join-Path $root 'payload') 'cli.exe' + Set-Content -LiteralPath $binary -Value "stub binary $([guid]::NewGuid())" -NoNewline -Encoding ascii + $archive = Join-Path $root 'cli_Windows.zip' + Compress-Archive -LiteralPath $binary -DestinationPath $archive + [pscustomobject]@{ + Root = $root + Temp = Join-Path $root 'temp' + InstallDir = Join-Path $root 'install' + Binary = $binary + Archive = $archive + } +} + +function Invoke-Installer { + param($Sandbox) + $env:TEMP = $Sandbox.Temp + $env:LOCALAPPDATA = Join-Path $Sandbox.Root 'localappdata' + $env:CLI_INSTALL_DIR = $Sandbox.InstallDir + $env:CLI_VERSION = 'v0.0.0-test' + if (-not $env:PROCESSOR_ARCHITECTURE) { + $env:PROCESSOR_ARCHITECTURE = 'AMD64' + } + $global:InstallerTestArchive = $Sandbox.Archive + $global:InstallerTestDownloads = @() + & $InstallerPath *>&1 | ForEach-Object { "$_" } +} + +function global:Invoke-RestMethod { + throw 'the installer must not look up the latest release when CLI_VERSION is set' +} + +function global:Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing) + $global:InstallerTestDownloads += $Uri + Copy-Item -LiteralPath $global:InstallerTestArchive -Destination $OutFile +} + +$global:InstallerTestOpenHandle = $null +function global:Expand-Archive { + param([string]$Path, [string]$DestinationPath, [switch]$Force) + Microsoft.PowerShell.Archive\Expand-Archive -Path $Path -DestinationPath $DestinationPath -Force:$Force + if ($global:InstallerTestHoldExtractedBinary) { + $extracted = Join-Path $DestinationPath 'cli.exe' + $global:InstallerTestOpenHandle = [System.IO.File]::Open($extracted, 'Open', 'Read', 'Read') + } +} + +try { + Write-Host '--- install succeeds and cleans up' + $global:InstallerTestHoldExtractedBinary = $false + $sandbox = New-InstallerSandbox + $output = Invoke-Installer $sandbox + $installed = Join-Path $sandbox.InstallDir 'cli.exe' + Assert-True (Test-Path -LiteralPath $installed) 'binary is copied into CLI_INSTALL_DIR' + Assert-True ((Get-Content -LiteralPath $installed -Raw) -eq (Get-Content -LiteralPath $sandbox.Binary -Raw)) 'installed binary matches the archived one' + Assert-True ($global:InstallerTestDownloads.Count -eq 1) 'exactly one archive download' + Assert-True ($global:InstallerTestDownloads[0] -like '*/releases/download/v0.0.0-test/cli_Windows_*.zip') "download URL names the pinned version: $($global:InstallerTestDownloads[0])" + Assert-True (@(Get-ChildItem -LiteralPath $sandbox.Temp -Force).Count -eq 0) 'temporary directory is removed' + Assert-True (($output -join "`n") -match 'Installation successful') 'installer reports success' + + if ($isWindowsHost) { + Write-Host '--- install succeeds when the temporary directory cannot be removed' + $global:InstallerTestHoldExtractedBinary = $true + $sandbox = New-InstallerSandbox + $output = Invoke-Installer $sandbox + $installed = Join-Path $sandbox.InstallDir 'cli.exe' + Assert-True (Test-Path -LiteralPath $installed) 'binary is copied despite the held temporary file' + Assert-True (($output -join "`n") -match 'Warning: could not remove temporary directory') 'installer warns about the leftover temporary directory' + Assert-True (($output -join "`n") -match 'Installation successful') 'installer still reports success' + Assert-True (@(Get-ChildItem -LiteralPath $sandbox.Temp -Force).Count -eq 1) 'temporary directory is left in place' + $global:InstallerTestOpenHandle.Dispose() + } + else { + Write-Host '--- skipping held-file cleanup case: open files can be deleted on this platform' + } +} +finally { + if ($global:InstallerTestOpenHandle) { + $global:InstallerTestOpenHandle.Dispose() + } +}