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/.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/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 } } } 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() + } +} diff --git a/templates/templates/cli/release/install.ps1.stmpl b/templates/templates/cli/release/install.ps1.stmpl index 14204893f..e2ec41d12 100644 --- a/templates/templates/cli/release/install.ps1.stmpl +++ b/templates/templates/cli/release/install.ps1.stmpl @@ -129,8 +129,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..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:9dcf40ea00ecb8ceda83d5056f57235b43cf658a + 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 909525b12..68551a458 100644 --- a/zSDKs/sdk-cli/scripts/install.ps1 +++ b/zSDKs/sdk-cli/scripts/install.ps1 @@ -129,8 +129,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 } } }