From 6d76b7505a536d664f7eb0f07fe33ac149f647ed Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:05:59 +0000 Subject: [PATCH 1/4] feat(cli): Add a PowerShell installer for Windows Serve install.ps1 from cli.sentry.dev so Windows users can install the standalone binary with `irm https://cli.sentry.dev/install.ps1 | iex` without Git Bash or WSL. Refs #1510 Co-Authored-By: Burak Yigit Kaya --- apps/cli-docs/public/install.ps1 | 1 + .../src/content/docs/getting-started.mdx | 22 +- apps/cli-docs/src/content/docs/index.mdx | 1 + packages/cli/install.ps1 | 309 ++++++++++++++++++ packages/cli/script/generate-docs-sections.ts | 2 +- .../cli/test/lib/install-ps1-script.test.ts | 305 +++++++++++++++++ 6 files changed, 638 insertions(+), 2 deletions(-) create mode 120000 apps/cli-docs/public/install.ps1 create mode 100644 packages/cli/install.ps1 create mode 100644 packages/cli/test/lib/install-ps1-script.test.ts diff --git a/apps/cli-docs/public/install.ps1 b/apps/cli-docs/public/install.ps1 new file mode 120000 index 000000000..51c0be15e --- /dev/null +++ b/apps/cli-docs/public/install.ps1 @@ -0,0 +1 @@ +../../../packages/cli/install.ps1 \ No newline at end of file diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index b9dc05975..d7144b97a 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -55,6 +55,26 @@ You can also set `SENTRY_INSTALL_DIR` to override the binary installation direct curl https://cli.sentry.dev/install -fsS | SENTRY_INSTALL_DIR="$HOME/.local/bin" bash ``` +### Windows (PowerShell) + +On Windows, install the latest stable release from PowerShell: + +```powershell +irm https://cli.sentry.dev/install.ps1 | iex +``` + +The PowerShell installer adds the install directory to your user `PATH`. It +reads the same `SENTRY_VERSION`, `SENTRY_INSTALL_DIR`, and `SENTRY_INIT` +environment variables as the install script. Set `SENTRY_NO_MODIFY_PATH=1` to +leave `PATH` unchanged: + +```powershell +$env:SENTRY_VERSION = "0.42.2"; irm https://cli.sentry.dev/install.ps1 | iex +``` + +It installs stable releases only. To switch to nightly afterwards, run +`sentry cli upgrade nightly`. + ### Supported Platforms {/* GENERATED:START platform-support */} @@ -81,7 +101,7 @@ curl https://cli.sentry.dev/install -fsS | SENTRY_INSTALL_DIR="$HOME/.local/bin" Windows x64 - Via Git Bash, MSYS2, or WSL + Via PowerShell, Git Bash, MSYS2, or WSL diff --git a/apps/cli-docs/src/content/docs/index.mdx b/apps/cli-docs/src/content/docs/index.mdx index 4fbff5831..4711c8c3d 100644 --- a/apps/cli-docs/src/content/docs/index.mdx +++ b/apps/cli-docs/src/content/docs/index.mdx @@ -23,6 +23,7 @@ import sectionBg3 from '../../assets/section-bg-3.png';
+ +# Everything runs inside a function so `irm ... | iex` never changes the +# caller's session preferences, and failures throw instead of calling `exit` +# (which would close the user's terminal when run through iex). +function Install-SentryCli { + $ErrorActionPreference = 'Stop' + # Invoke-WebRequest progress rendering makes downloads dramatically slower + # on Windows PowerShell 5.1. + $ProgressPreference = 'SilentlyContinue' + + # Windows PowerShell 5.1 may default to TLS 1.0/1.1, which GitHub rejects. + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch { + Write-Verbose "Could not enable TLS 1.2: $_" + } + + $state = @{ Step = 'start'; Version = 'unknown'; Arch = 'unknown' } + + # Fire-and-forget error report via the envelope API, mirroring the bash + # installer. Uses the CLI's public write-only DSN. No PII collected. + function Send-InstallError([string]$Message) { + if ($env:SENTRY_CLI_NO_TELEMETRY -eq '1') { return } + try { + $dsnKey = '1188a86f3f8168f089450587b00bca66' + $projectId = '4510776311808000' + $eventId = [guid]::NewGuid().ToString('N') + $header = @{ + event_id = $eventId + dsn = "https://$dsnKey@o1.ingest.us.sentry.io/$projectId" + } | ConvertTo-Json -Compress + $sentryEvent = @{ + event_id = $eventId + timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + platform = 'other' + level = 'error' + logger = 'install' + server_name = 'install-script' + message = @{ formatted = $Message } + tags = @{ + os = 'windows' + arch = $state.Arch + channel = 'stable' + step = $state.Step + 'install.version' = $state.Version + } + contexts = @{ + runtime = @{ name = 'powershell'; version = "$($PSVersionTable.PSVersion)" } + } + } | ConvertTo-Json -Compress -Depth 5 + $envelope = "$header`n{`"type`":`"event`"}`n$sentryEvent" + Invoke-WebRequest -UseBasicParsing -Method Post -TimeoutSec 2 ` + -Uri "https://o1.ingest.us.sentry.io/api/$projectId/envelope/" ` + -ContentType 'application/x-sentry-envelope' ` + -Headers @{ 'X-Sentry-Auth' = "Sentry sentry_key=$dsnKey,sentry_version=7" } ` + -Body $envelope | Out-Null + } catch { + Write-Verbose "Failed to report installer error: $_" + } + } + + function Stop-Install([string]$Message) { + Send-InstallError $Message + throw $Message + } + + # Returns the HTTP status code for a URL without throwing on 4xx/5xx. + function Get-HttpStatus([string]$Uri) { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 30 + return [int]$response.StatusCode + } catch { + $response = $_.Exception.Response + if ($null -ne $response -and $null -ne $response.StatusCode) { + return [int]$response.StatusCode + } + return 0 + } + } + + # Resolve the latest stable version: release registry first, then the newest + # `cli@x.y.z` Toolkit release, then the legacy getsentry/cli repository. + function Resolve-LatestVersion { + try { + $metadata = Invoke-RestMethod -UseBasicParsing -TimeoutSec 10 ` + -Uri 'https://release-registry.services.sentry.io/apps/sentry/latest' + if ("$($metadata.version)" -match '^\d+\.\d+\.\d+$') { + return "$($metadata.version)" + } + } catch { + Write-Verbose "Release registry lookup failed: $_" + } + + Write-Host "Could not resolve latest version from Sentry's release registry. Trying GitHub..." -ForegroundColor DarkGray + # GitHub may have many pages of MCP releases before its newest CLI release. + for ($page = 1; $page -le 20; $page++) { + try { + $releases = @(Invoke-RestMethod -UseBasicParsing -TimeoutSec 30 ` + -Uri "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=$page") + } catch { + Stop-Install 'Failed to list Toolkit releases' + } + if ($releases.Count -eq 0) { break } + foreach ($release in $releases) { + if (-not $release.prerelease -and "$($release.tag_name)" -match '^cli@\d+\.\d+\.\d+$') { + return "$($release.tag_name)".Substring(4) + } + } + } + + try { + $legacy = Invoke-RestMethod -UseBasicParsing -TimeoutSec 30 ` + -Uri 'https://api.github.com/repos/getsentry/cli/releases/latest' + $tag = "$($legacy.tag_name)" -replace '^v', '' + if ($tag -match '^\d+\.\d+\.\d+$') { return $tag } + } catch { + Write-Verbose "Legacy release lookup failed: $_" + } + Stop-Install "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or set SENTRY_VERSION to install a specific version." + } + + # Compute the directory `sentry cli setup` will install into, matching + # determineInstallDir() in src/lib/binary.ts, so the installer and setup agree + # on which directory to put on PATH. + function Resolve-InstallDir([string]$HomeDir) { + if ($env:SENTRY_INSTALL_DIR) { return $env:SENTRY_INSTALL_DIR } + if ($env:XDG_BIN_HOME -and [System.IO.Path]::IsPathRooted($env:XDG_BIN_HOME)) { + return $env:XDG_BIN_HOME + } + $pathDirs = "$env:PATH" -split [System.IO.Path]::PathSeparator | + ForEach-Object { $_.TrimEnd('\', '/') } + foreach ($dir in @((Join-Path $HomeDir '.local\bin'), (Join-Path $HomeDir 'bin'))) { + if ((Test-Path -LiteralPath $dir -PathType Container) -and ($pathDirs -contains $dir.TrimEnd('\', '/'))) { + return $dir + } + } + return (Join-Path $HomeDir '.local\bin') + } + + # Persist $Dir on the user PATH without expanding existing %VAR% entries. + function Add-ToUserPath([string]$Dir) { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + try { + $current = "$($key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames))" + $entries = @($current -split ';' | Where-Object { $_ }) + if ($entries | Where-Object { $_.TrimEnd('\') -eq $Dir.TrimEnd('\') }) { + return $false + } + $key.SetValue('Path', (@($Dir) + $entries) -join ';', [Microsoft.Win32.RegistryValueKind]::ExpandString) + } finally { + $key.Dispose() + } + # Setting any user variable through .NET broadcasts WM_SETTINGCHANGE, so + # newly opened terminals pick up the updated PATH. + [Environment]::SetEnvironmentVariable('SENTRY_CLI_PATH_REFRESH', '1', 'User') + [Environment]::SetEnvironmentVariable('SENTRY_CLI_PATH_REFRESH', $null, 'User') + return $true + } + + $state.Step = 'detect-os' + if ($env:OS -ne 'Windows_NT') { + Stop-Install 'This installer is for Windows. On macOS and Linux, run: curl https://cli.sentry.dev/install -fsS | bash' + } + + $state.Step = 'detect-arch' + # 32-bit PowerShell on 64-bit Windows reports x86 here and sets + # PROCESSOR_ARCHITEW6432 to the real architecture. + $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } + $state.Arch = "$arch" + if ($arch -ne 'AMD64') { + Stop-Install "Unsupported: windows-$arch (only windows-x64 is supported). Install Node.js and run: npm install -g sentry" + } + $state.Arch = 'x64' + + $state.Step = 'resolve-version' + if ($env:SENTRY_VERSION -eq 'nightly') { + Stop-Install 'The PowerShell installer only installs stable releases. Install stable, then run: sentry cli upgrade nightly' + } + if ($env:SENTRY_VERSION) { + $version = $env:SENTRY_VERSION -replace '^cli@', '' -replace '^v', '' + } else { + $version = Resolve-LatestVersion + } + $state.Version = $version + if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') { + Stop-Install "Invalid release version: $version" + } + + # Fall back to legacy only when GitHub confirms the exact Toolkit tag is absent. + $state.Step = 'gh-version' + $toolkitStatus = Get-HttpStatus "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%40$version" + if ($toolkitStatus -eq 200) { + $releaseRepo = 'getsentry/toolkit' + $releaseTag = "cli@$version" + } elseif ($toolkitStatus -eq 404) { + $legacyStatus = Get-HttpStatus "https://api.github.com/repos/getsentry/cli/releases/tags/$version" + if ($legacyStatus -ne 200) { + Stop-Install "Legacy CLI release unavailable (HTTP $legacyStatus)" + } + $releaseRepo = 'getsentry/cli' + $releaseTag = $version + } else { + Stop-Install "Toolkit release check failed (HTTP $toolkitStatus)" + } + + $state.Step = 'download' + $url = "https://github.com/$releaseRepo/releases/download/$releaseTag/sentry-windows-x64.exe" + $tmpBinary = Join-Path ([System.IO.Path]::GetTempPath()) "sentry-install-$([guid]::NewGuid().ToString('N')).exe" + $tmpArchive = "$tmpBinary.gz" + + Write-Host "Downloading sentry v$version..." -ForegroundColor DarkGray + try { + # Prefer the gzip asset (~60% smaller); fall back to the raw binary. + try { + Invoke-WebRequest -UseBasicParsing -Uri "$url.gz" -OutFile $tmpArchive + $source = [System.IO.File]::OpenRead($tmpArchive) + try { + $gzip = New-Object System.IO.Compression.GZipStream($source, [System.IO.Compression.CompressionMode]::Decompress) + $target = [System.IO.File]::Create($tmpBinary) + try { $gzip.CopyTo($target) } finally { $target.Dispose(); $gzip.Dispose() } + } finally { + $source.Dispose() + } + } catch { + Write-Verbose "Compressed download failed, trying raw binary: $_" + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $tmpBinary + } + } catch { + Stop-Install "Failed to download $url" + } finally { + Remove-Item -LiteralPath $tmpArchive -Force -ErrorAction SilentlyContinue + } + + # Resolve the install directory once and pass it to setup explicitly so PATH + # handling below targets the same directory setup installs into. + $homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME } + $installDir = Resolve-InstallDir $homeDir + $previousInstallDir = $env:SENTRY_INSTALL_DIR + $env:SENTRY_INSTALL_DIR = $installDir + + try { + if ($env:SENTRY_NO_MODIFY_PATH -ne '1') { + $state.Step = 'path' + try { + if (Add-ToUserPath $installDir) { + Write-Host "PATH: Added $installDir to your user PATH. Open a new terminal to use it everywhere." + } + } catch { + Write-Warning "Could not update your user PATH. Add $installDir to PATH manually." + } + # GitHub Actions does not read the registry between steps. + if ($env:GITHUB_PATH) { + Add-Content -LiteralPath $env:GITHUB_PATH -Value $installDir + } + # Make the CLI available in this session too. + $sessionDirs = "$env:PATH" -split [System.IO.Path]::PathSeparator + if ($sessionDirs -notcontains $installDir) { + $env:PATH = "$installDir$([System.IO.Path]::PathSeparator)$env:PATH" + } + } + + # Delegate placement, completions, agent skills, install-method recording, + # and first-run login to the binary itself. PATH is handled above because + # setup only knows how to edit POSIX shell config files. + $state.Step = 'setup' + $setupArgs = @('cli', 'setup', '--install', '--method', 'curl', '--channel', 'stable', '--no-modify-path') + & $tmpBinary @setupArgs + if ($LASTEXITCODE -ne 0) { + Stop-Install "sentry cli setup failed with exit code $LASTEXITCODE" + } + + if ($env:SENTRY_INIT -eq '1') { + $state.Step = 'init' + $installedBinary = Join-Path $installDir 'sentry.exe' + if (-not (Test-Path -LiteralPath $installedBinary)) { + Stop-Install 'Cannot find installed sentry binary' + } + & $installedBinary init + } + } finally { + $env:SENTRY_INSTALL_DIR = $previousInstallDir + # The running temp binary cannot always be deleted on Windows; try anyway. + Remove-Item -LiteralPath $tmpBinary -Force -ErrorAction SilentlyContinue + } +} + +Install-SentryCli diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index 93e9b54cb..dabba3ad5 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -528,7 +528,7 @@ function generateSelfHostedEnvVarsTable(): string { const PLATFORM_ROWS: readonly [string, string, string][] = [ ["macOS", "x64, arm64 (Apple Silicon)", ""], ["Linux", "x64, arm64", "Standalone binary requires glibc"], - ["Windows", "x64", "Via Git Bash, MSYS2, or WSL"], + ["Windows", "x64", "Via PowerShell, Git Bash, MSYS2, or WSL"], ]; /** Generate the platform support table for getting-started.mdx. */ diff --git a/packages/cli/test/lib/install-ps1-script.test.ts b/packages/cli/test/lib/install-ps1-script.test.ts new file mode 100644 index 000000000..36e82e66a --- /dev/null +++ b/packages/cli/test/lib/install-ps1-script.test.ts @@ -0,0 +1,305 @@ +/** + * Exercises the PowerShell installer through `Invoke-Expression`, the same + * path as `irm https://cli.sentry.dev/install.ps1 | iex`. Network cmdlets are + * replaced with PowerShell functions (functions take precedence over cmdlets), + * and the downloaded "binary" is a shell script, so the suite runs wherever + * `pwsh` is available. It is skipped otherwise. + */ + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +const installScript = join(import.meta.dirname, "..", "..", "install.ps1"); +const hasPwsh = + spawnSync("pwsh", ["-NoProfile", "-Command", "exit 0"], { stdio: "ignore" }) + .status === 0; + +const downloadedExecutable = `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "cli" && "$2" == "setup" ]]; then + printf '%s\\n' "$@" > "$SENTRY_TEST_DIR/setup-args" + printf '%s\\n' "$SENTRY_INSTALL_DIR" > "$SENTRY_TEST_DIR/setup-install-dir" + printf '%s\\n' "$PATH" > "$SENTRY_TEST_DIR/setup-path" + mkdir -p "$SENTRY_INSTALL_DIR" + cp "$0" "$SENTRY_INSTALL_DIR/sentry.exe" + exit "\${SENTRY_TEST_SETUP_EXIT:-0}" +fi +printf '%s\\n' "$@" >> "$SENTRY_TEST_DIR/post-args" +printf '%s\\n' "$0" >> "$SENTRY_TEST_DIR/post-binary" +`; + +// Mocks for the cmdlets the installer uses, followed by the iex invocation. +const harness = String.raw` +$ErrorActionPreference = 'Stop' +function Write-Record([string]$Name, [string]$Value) { + Add-Content -LiteralPath (Join-Path $env:SENTRY_TEST_DIR $Name) -Value $Value +} +function New-HttpError([int]$Status) { + $ex = [System.Exception]::new("HTTP $Status") + Add-Member -InputObject $ex -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = $Status }) + return $ex +} +function Invoke-RestMethod { + param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec) + Write-Record 'urls' $Uri + if ($Uri -eq 'https://release-registry.services.sentry.io/apps/sentry/latest') { + if ($env:SENTRY_TEST_REGISTRY_FAIL -eq '1') { throw (New-HttpError 503) } + return ($env:SENTRY_TEST_REGISTRY_RESPONSE | ConvertFrom-Json) + } + if ($Uri -like 'https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=*') { + $page = $Uri.Split('=')[-1] + $file = [Environment]::GetEnvironmentVariable("SENTRY_TEST_RELEASES_PAGE_$page") + if ($file) { return (Get-Content -Raw -LiteralPath $file | ConvertFrom-Json) } + return ,@() + } + if ($Uri -eq 'https://api.github.com/repos/getsentry/cli/releases/latest') { + if ($env:SENTRY_TEST_GITHUB_FAIL -eq '1') { throw (New-HttpError 503) } + return ($env:SENTRY_TEST_GITHUB_RESPONSE | ConvertFrom-Json) + } + throw "Unexpected Invoke-RestMethod $Uri" +} +function Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec, + [string]$Method, $Headers, [string]$ContentType, $Body) + Write-Record 'urls' $Uri + if ($Uri -like 'https://api.github.com/repos/getsentry/toolkit/releases/tags/*') { + $status = [int]$env:SENTRY_TEST_TOOLKIT_STATUS + if ($status -ne 200) { throw (New-HttpError $status) } + return [pscustomobject]@{ StatusCode = 200 } + } + if ($Uri -like 'https://api.github.com/repos/getsentry/cli/releases/tags/*') { + $status = [int]$env:SENTRY_TEST_LEGACY_STATUS + if ($status -ne 200) { throw (New-HttpError $status) } + return [pscustomobject]@{ StatusCode = 200 } + } + if ($Uri -like 'https://github.com/*/releases/download/*') { + Write-Record 'download-urls' $Uri + if ($Uri.EndsWith('.gz')) { + if ($env:SENTRY_TEST_GZ_MISSING -eq '1') { throw (New-HttpError 404) } + Copy-Item -LiteralPath $env:SENTRY_TEST_FAKE_GZ -Destination $OutFile + # The installer decompresses into the existing path; pre-create it + # executable so the fake binary can run on POSIX test hosts. + $target = $OutFile.Substring(0, $OutFile.Length - 3) + New-Item -ItemType File -Path $target -Force | Out-Null + chmod +x $target + return + } + Copy-Item -LiteralPath $env:SENTRY_TEST_FAKE_BINARY -Destination $OutFile + chmod +x $OutFile + return + } + throw "Unexpected Invoke-WebRequest $Uri" +} +Get-Content -Raw -LiteralPath $env:SENTRY_TEST_INSTALL_SCRIPT | Invoke-Expression +`; + +describe.skipIf(!hasPwsh)("PowerShell install script", () => { + let testDir: string; + let installDir: string; + let env: NodeJS.ProcessEnv; + let harnessPath: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "sentry-install-ps1-test-")); + installDir = join(testDir, "installed bin"); + mkdirSync(join(testDir, "home")); + harnessPath = join(testDir, "harness.ps1"); + writeFileSync(harnessPath, harness); + + const fakeBinary = join(testDir, "fake-binary"); + writeFileSync(fakeBinary, downloadedExecutable); + chmodSync(fakeBinary, 0o755); + const fakeGz = join(testDir, "fake-binary.gz"); + writeFileSync(fakeGz, gzipSync(downloadedExecutable)); + + env = { + PATH: process.env.PATH, + HOME: join(testDir, "home"), + USERPROFILE: join(testDir, "home"), + TMPDIR: testDir, + OS: "Windows_NT", + PROCESSOR_ARCHITECTURE: "AMD64", + SENTRY_INSTALL_DIR: installDir, + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_TEST_DIR: testDir, + SENTRY_TEST_INSTALL_SCRIPT: installScript, + SENTRY_TEST_FAKE_BINARY: fakeBinary, + SENTRY_TEST_FAKE_GZ: fakeGz, + SENTRY_TEST_REGISTRY_RESPONSE: '{"version":"0.47.0"}', + SENTRY_TEST_TOOLKIT_STATUS: "200", + SENTRY_TEST_LEGACY_STATUS: "200", + GITHUB_PATH: join(testDir, "github-path"), + }; + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + function run() { + return spawnSync( + "pwsh", + ["-NoProfile", "-NonInteractive", "-File", harnessPath], + { env, encoding: "utf8", timeout: 60_000 }, + ); + } + + function recorded(name: string): string[] { + const path = join(testDir, name); + return existsSync(path) + ? readFileSync(path, "utf8").trim().split("\n") + : []; + } + + test("installs the latest stable release from the release registry", () => { + const result = run(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("download-urls")).toEqual([ + "https://github.com/getsentry/toolkit/releases/download/cli@0.47.0/sentry-windows-x64.exe.gz", + ]); + expect(recorded("setup-args")).toEqual([ + "cli", + "setup", + "--install", + "--method", + "curl", + "--channel", + "stable", + "--no-modify-path", + ]); + expect(recorded("setup-install-dir")).toEqual([installDir]); + expect(recorded("setup-path")[0]?.startsWith(`${installDir}:`)).toBe(true); + expect(recorded("github-path")).toEqual([installDir]); + expect(existsSync(join(installDir, "sentry.exe"))).toBe(true); + expect( + readdirSync(testDir).filter((name) => name.startsWith("sentry-install-")), + ).toEqual([]); + }); + + test("falls back to the newest stable Toolkit CLI release on GitHub", () => { + env.SENTRY_TEST_REGISTRY_FAIL = "1"; + const page1 = join(testDir, "page-1.json"); + writeFileSync( + page1, + JSON.stringify([ + { tag_name: "mcp@1.2.3", prerelease: false }, + { tag_name: "cli@0.48.0", prerelease: true }, + ]), + ); + const page2 = join(testDir, "page-2.json"); + writeFileSync( + page2, + JSON.stringify([{ tag_name: "cli@0.46.0", prerelease: false }]), + ); + env.SENTRY_TEST_RELEASES_PAGE_1 = page1; + env.SENTRY_TEST_RELEASES_PAGE_2 = page2; + + const result = run(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain("Trying GitHub"); + expect(recorded("download-urls")).toEqual([ + "https://github.com/getsentry/toolkit/releases/download/cli@0.46.0/sentry-windows-x64.exe.gz", + ]); + }); + + test("installs a pinned version from the legacy repository", () => { + env.SENTRY_VERSION = "v0.42.2"; + env.SENTRY_TEST_TOOLKIT_STATUS = "404"; + + const result = run(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("urls")).not.toContain( + "https://release-registry.services.sentry.io/apps/sentry/latest", + ); + expect(recorded("download-urls")).toEqual([ + "https://github.com/getsentry/cli/releases/download/0.42.2/sentry-windows-x64.exe.gz", + ]); + }); + + test("falls back to the uncompressed binary", () => { + env.SENTRY_TEST_GZ_MISSING = "1"; + + const result = run(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("download-urls")).toEqual([ + "https://github.com/getsentry/toolkit/releases/download/cli@0.47.0/sentry-windows-x64.exe.gz", + "https://github.com/getsentry/toolkit/releases/download/cli@0.47.0/sentry-windows-x64.exe", + ]); + expect(existsSync(join(installDir, "sentry.exe"))).toBe(true); + }); + + test("runs sentry init with the installed binary when SENTRY_INIT=1", () => { + env.SENTRY_INIT = "1"; + + const result = run(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("post-args")).toEqual(["init"]); + expect(recorded("post-binary")).toEqual([join(installDir, "sentry.exe")]); + }); + + test("leaves PATH alone when SENTRY_NO_MODIFY_PATH=1", () => { + env.SENTRY_NO_MODIFY_PATH = "1"; + + const result = run(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("github-path")).toEqual([]); + expect(recorded("setup-path")[0]?.startsWith(`${installDir}:`)).toBe(false); + }); + + test.each([ + { + name: "non-Windows hosts", + setup: () => { + delete env.OS; + }, + message: "This installer is for Windows", + }, + { + name: "unsupported architectures", + setup: () => { + env.PROCESSOR_ARCHITECTURE = "ARM64"; + }, + message: "only windows-x64 is supported", + }, + { + name: "the nightly channel", + setup: () => { + env.SENTRY_VERSION = "nightly"; + }, + message: "sentry cli upgrade nightly", + }, + { + name: "a failed setup", + setup: () => { + env.SENTRY_TEST_SETUP_EXIT = "3"; + }, + message: "sentry cli setup failed with exit code 3", + }, + ])("fails clearly for $name", ({ setup, message }) => { + setup(); + + const result = run(); + + expect(result.status).not.toBe(0); + expect(result.stdout + result.stderr).toContain(message); + }); +}); From 2e70ada0c48390c6fd0b75e3d042b139cdb23ba5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:21:12 +0000 Subject: [PATCH 2/4] fix(cli): Fail the PowerShell installer when sentry init fails --- packages/cli/install.ps1 | 5 +++++ packages/cli/test/lib/install-ps1-script.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 0e5329b57..33685e000 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -298,6 +298,11 @@ function Install-SentryCli { Stop-Install 'Cannot find installed sentry binary' } & $installedBinary init + if ($LASTEXITCODE -ne 0) { + # Like the bash installer, report wizard failures to the caller but not + # to installer telemetry: init errors are not installation failures. + throw "sentry init failed with exit code $LASTEXITCODE" + } } } finally { $env:SENTRY_INSTALL_DIR = $previousInstallDir diff --git a/packages/cli/test/lib/install-ps1-script.test.ts b/packages/cli/test/lib/install-ps1-script.test.ts index 36e82e66a..568beccd6 100644 --- a/packages/cli/test/lib/install-ps1-script.test.ts +++ b/packages/cli/test/lib/install-ps1-script.test.ts @@ -39,6 +39,7 @@ if [[ "$1" == "cli" && "$2" == "setup" ]]; then fi printf '%s\\n' "$@" >> "$SENTRY_TEST_DIR/post-args" printf '%s\\n' "$0" >> "$SENTRY_TEST_DIR/post-binary" +exit "\${SENTRY_TEST_POST_EXIT:-0}" `; // Mocks for the cmdlets the installer uses, followed by the iex invocation. @@ -255,6 +256,19 @@ describe.skipIf(!hasPwsh)("PowerShell install script", () => { expect(recorded("post-binary")).toEqual([join(installDir, "sentry.exe")]); }); + test("fails when sentry init fails", () => { + env.SENTRY_INIT = "1"; + env.SENTRY_TEST_POST_EXIT = "7"; + + const result = run(); + + expect(result.status).not.toBe(0); + expect(result.stdout + result.stderr).toContain( + "sentry init failed with exit code 7", + ); + expect(recorded("post-args")).toEqual(["init"]); + }); + test("leaves PATH alone when SENTRY_NO_MODIFY_PATH=1", () => { env.SENTRY_NO_MODIFY_PATH = "1"; From 37ac1b49f9590132ed2bb0a775dfe1cf917d39a1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:33:33 +0000 Subject: [PATCH 3/4] ref(cli): Resolve installer downloads from the release registry Both installers now take the version, download URL, and sha256 checksum for each asset from Sentry's release registry, which covers legacy getsentry/cli releases too. The GitHub tag probing and legacy fallbacks are gone; GitHub is only an unverified fallback while the registry is unreachable. A shared scenario table runs against both scripts so their behavior cannot drift. Refs #1510 --- packages/cli/install | 164 ++++++---- packages/cli/install.ps1 | 145 +++++---- .../cli/test/lib/install-ps1-script.test.ts | 169 ++++------ packages/cli/test/lib/install-scenarios.ts | 261 +++++++++++++++ packages/cli/test/lib/install-script.test.ts | 301 ++++++------------ 5 files changed, 610 insertions(+), 430 deletions(-) create mode 100644 packages/cli/test/lib/install-scenarios.ts diff --git a/packages/cli/install b/packages/cli/install index 6a7063aa0..18c2d4a4a 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -187,10 +187,11 @@ tmpdir="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}" tmp_binary="${tmpdir}/sentry-install-$$${suffix}" blob_headers="${tmpdir}/sentry-install-headers-$$" blob_file="${tmpdir}/sentry-install-blob-$$" +metadata_file="${tmpdir}/sentry-install-metadata-$$" version="" # Clean up temporary files after setup or on failure. -trap 'rm -f "$tmp_binary" "$blob_headers" "$blob_file"' EXIT +trap 'rm -f "$tmp_binary" "$blob_headers" "$blob_file" "$metadata_file"' EXIT if [[ "$requested_version" == "nightly" ]]; then # Nightly build: download from GHCR via OCI blob protocol. @@ -273,19 +274,78 @@ if [[ "$requested_version" == "nightly" ]]; then esac else - # Stable build: resolve version and download from GitHub Releases. - - if [[ -z "$requested_version" ]]; then - # Craft publishes this app's stable version to the release registry. - # Consume the full response: an early parser exit can cause SIGPIPE under pipefail. - if metadata=$(curl -fsSL --max-time 10 https://release-registry.services.sentry.io/apps/sentry/latest); then - version=$(printf '%s\n' "$metadata" \ - | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p') + # Stable build. Sentry's release registry is the source of truth for + # versions, download URLs (including legacy getsentry/cli releases), and + # checksums. GitHub is only a fallback for registry outages. + if [[ -n "$requested_version" ]]; then + version="${requested_version#cli@}" + version="${version#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + die "Invalid release version: ${version}" "resolve-version" fi - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo -e "${MUTED}Could not resolve latest version from Sentry's release registry. Trying GitHub...${NC}" >&2 + registry_path="$version" + else + registry_path="latest" + fi + + asset="sentry-${os}-${arch}${suffix}" + gz_url="" + gz_sha="" + raw_url="" + raw_sha="" + registry_version="" + registry_status=$(curl -sS --max-time 10 -o "$metadata_file" -w '%{http_code}' \ + "https://release-registry.services.sentry.io/apps/sentry/${registry_path}" 2>/dev/null) \ + || registry_status="000" + if [[ "$registry_status" == "200" ]]; then + # Asset names, URLs, and checksums contain no whitespace, so stripping it + # lets the parser below treat compact and pretty-printed JSON the same. + metadata=$(tr -d ' \t\r\n' < "$metadata_file") + registry_version=$(printf '%s\n' "$metadata" \ + | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') + if [[ "$registry_path" == "latest" && ! "$registry_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + registry_version="" + elif [[ "$registry_path" != "latest" && "$registry_version" != "$version" ]]; then + registry_version="" + fi + fi + + if [[ -n "$registry_version" ]]; then + version="$registry_version" + # Print the url and sha256 of one `files` entry, one per line. + registry_file() { + printf '%s\n' "$metadata" | awk -v key="\"$1\":{" ' + { + i = index($0, key) + if (!i) exit + s = substr($0, i + length(key) - 1) + n = length(s) + depth = 0 + for (j = 1; j <= n; j++) { + c = substr(s, j, 1) + if (c == "{") depth++ + else if (c == "}" && --depth == 0) break + } + obj = substr(s, 1, j) + url = "" + sha = "" + if (match(obj, /"url":"https:\/\/[^"]*"/)) url = substr(obj, RSTART + 7, RLENGTH - 8) + if (match(obj, /"sha256-hex":"[0-9a-f]+"/)) sha = substr(obj, RSTART + 14, RLENGTH - 15) + print url + print sha + }' + } + { read -r gz_url || true; read -r gz_sha || true; } <<< "$(registry_file "${asset}.gz")" + { read -r raw_url || true; read -r raw_sha || true; } <<< "$(registry_file "$asset")" + if [[ -z "$gz_url" && -z "$raw_url" ]]; then + die "Sentry CLI ${version} has no ${asset} build" "resolve-asset" + fi + elif [[ "$registry_status" == "404" && "$registry_path" != "latest" ]]; then + die "Unknown Sentry CLI version: ${version}" "resolve-version" + else + echo -e "${MUTED}Could not resolve ${registry_path} from Sentry's release registry. Trying GitHub...${NC}" >&2 + if [[ "$registry_path" == "latest" ]]; then release_tag="" - # GitHub may have many pages of MCP releases before its newest CLI release. for page in {1..20}; do if ! releases=$(curl -fsSL "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=${page}"); then die "Failed to list Toolkit releases" "gh-version" @@ -337,65 +397,45 @@ else fi done if [[ -z "$release_tag" ]]; then - if [[ "$releases" != "[]" ]]; then - die "Toolkit release pagination exceeded 20 pages" "gh-version" - fi - # Before the first Toolkit CLI release, use the legacy version only - # after checking whether that exact Toolkit tag exists. - if ! legacy_release=$(curl -fsSL 'https://api.github.com/repos/getsentry/cli/releases/latest'); then - die "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or install a specific version with --version ." "resolve-version" - fi - release_tag=$(printf '%s\n' "$legacy_release" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p') - if [[ ! "${release_tag#v}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - die "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or install a specific version with --version ." "resolve-version" - fi + die "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or install a specific version with --version ." "resolve-version" fi version="${release_tag#cli@}" - version="${version#v}" fi - else - version="$requested_version" - version="${version#cli@}" - version="${version#v}" - fi - - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - die "Invalid release version: ${version}" "gh-version" + gz_url="https://github.com/getsentry/toolkit/releases/download/cli@${version}/${asset}.gz" + raw_url="${gz_url%.gz}" + echo -e "${MUTED}Warning: GitHub fallback downloads are not checksum-verified.${NC}" >&2 fi - # Fall back to legacy only when GitHub confirms the exact Toolkit tag is absent. - if ! release_status=$(curl -sS -o /dev/null -w '%{http_code}' \ - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%40${version}"); then - die "Failed to check Toolkit release" "gh-version" - fi - case "$release_status" in - 200) release_repo="getsentry/toolkit"; release_tag="cli@${version}" ;; - 404) - if ! legacy_status=$(curl -sS -o /dev/null -w '%{http_code}' \ - "https://api.github.com/repos/getsentry/cli/releases/tags/${version}"); then - die "Failed to check legacy CLI release" "gh-version" - fi - if [[ "$legacy_status" != "200" ]]; then - die "Legacy CLI release unavailable (HTTP ${legacy_status})" "gh-version" - fi - release_repo="getsentry/cli" - release_tag="$version" - ;; - *) die "Toolkit release check failed (HTTP ${release_status})" "gh-version" ;; - esac - - filename="sentry-${os}-${arch}${suffix}" - url="https://github.com/${release_repo}/releases/download/${release_tag}/${filename}" + # Usage: verify_sha256 ; a missing checksum is a no-op. + verify_sha256() { + [[ -z "$2" ]] && return 0 + local actual + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$1" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$1" | awk '{print $1}') + else + echo -e "${MUTED}Warning: sha256sum and shasum are unavailable; skipping checksum verification.${NC}" >&2 + return 0 + fi + if [[ "$actual" != "$2" ]]; then + die "Checksum mismatch for ${asset} ${version}: expected $2, got ${actual}" "checksum" + fi + } echo -e "${MUTED}Downloading sentry v${version}...${NC}" - # Try gzip-compressed download first (~60% smaller, ~37 MB vs ~99 MB). - # gunzip is POSIX and available on all Unix systems. - # Falls back to raw binary if the .gz asset doesn't exist yet. - if curl -fsSL "${url}.gz" 2>/dev/null | gunzip > "$tmp_binary" 2>/dev/null; then - : # Compressed download succeeded + # Prefer the gzip asset (~60% smaller, ~37 MB vs ~99 MB); fall back to the + # raw binary when it is missing. + if [[ -n "$gz_url" ]] && curl -fsSL -o "$blob_file" "$gz_url" 2>/dev/null; then + verify_sha256 "$blob_file" "$gz_sha" + gunzip -c "$blob_file" > "$tmp_binary" + elif [[ -n "$raw_url" ]]; then + curl -fsSL --progress-bar -o "$tmp_binary" "$raw_url" \ + || die "Failed to download ${raw_url}" "download" + verify_sha256 "$tmp_binary" "$raw_sha" else - curl -fsSL --progress-bar "$url" -o "$tmp_binary" + die "Failed to download ${gz_url}" "download" fi fi diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 33685e000..6056eb638 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -86,39 +86,61 @@ function Install-SentryCli { throw $Message } - # Returns the HTTP status code for a URL without throwing on 4xx/5xx. - function Get-HttpStatus([string]$Uri) { - try { - $response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 30 + # Returns the HTTP status of a failed web request, or 0 without a response. + function Get-ErrorStatus($ErrorRecord) { + $response = $ErrorRecord.Exception.Response + if ($null -ne $response -and $null -ne $response.StatusCode) { return [int]$response.StatusCode - } catch { - $response = $_.Exception.Response - if ($null -ne $response -and $null -ne $response.StatusCode) { - return [int]$response.StatusCode - } - return 0 } + return 0 } - # Resolve the latest stable version: release registry first, then the newest - # `cli@x.y.z` Toolkit release, then the legacy getsentry/cli repository. - function Resolve-LatestVersion { + # Fetch release metadata (download URLs and checksums) from Sentry's release + # registry. Returns $null when the registry is unreachable or returns + # something unusable; stops when a pinned version does not exist. + function Get-RegistryRelease([string]$Path) { try { $metadata = Invoke-RestMethod -UseBasicParsing -TimeoutSec 10 ` - -Uri 'https://release-registry.services.sentry.io/apps/sentry/latest' - if ("$($metadata.version)" -match '^\d+\.\d+\.\d+$') { - return "$($metadata.version)" - } + -Uri "https://release-registry.services.sentry.io/apps/sentry/$Path" } catch { + if ((Get-ErrorStatus $_) -eq 404 -and $Path -ne 'latest') { + Stop-Install "Unknown Sentry CLI version: $Path" + } Write-Verbose "Release registry lookup failed: $_" + return $null } + $registryVersion = "$($metadata.version)" + if ($Path -eq 'latest' -and $registryVersion -notmatch '^\d+\.\d+\.\d+$') { return $null } + if ($Path -ne 'latest' -and $registryVersion -ne $Path) { return $null } + return $metadata + } + + # Returns @{ Url; Sha256 } for one registry file entry, or $null. + function Get-RegistryFile($Metadata, [string]$Name) { + $entry = $Metadata.files.PSObject.Properties[$Name] + if ($null -eq $entry -or "$($entry.Value.url)" -notmatch '^https://') { return $null } + return @{ Url = "$($entry.Value.url)"; Sha256 = "$($entry.Value.checksums.'sha256-hex')" } + } - Write-Host "Could not resolve latest version from Sentry's release registry. Trying GitHub..." -ForegroundColor DarkGray + # Usage: Assert-Sha256 ; a missing checksum is a no-op. + function Assert-Sha256([string]$Path, [string]$Expected) { + if (-not $Expected) { return } + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() + if ($actual -ne $Expected.ToLowerInvariant()) { + Stop-Install "Checksum mismatch for $asset ${version}: expected $Expected, got $actual" + } + } + + # Registry outage fallback: the newest `cli@x.y.z` Toolkit release. + function Find-LatestToolkitVersion { # GitHub may have many pages of MCP releases before its newest CLI release. for ($page = 1; $page -le 20; $page++) { try { + # Windows PowerShell 5.1 emits a JSON array as one object; piping + # through ForEach-Object flattens it so an empty page has no items. $releases = @(Invoke-RestMethod -UseBasicParsing -TimeoutSec 30 ` - -Uri "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=$page") + -Uri "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=$page" | + ForEach-Object { $_ }) } catch { Stop-Install 'Failed to list Toolkit releases' } @@ -129,15 +151,6 @@ function Install-SentryCli { } } } - - try { - $legacy = Invoke-RestMethod -UseBasicParsing -TimeoutSec 30 ` - -Uri 'https://api.github.com/repos/getsentry/cli/releases/latest' - $tag = "$($legacy.tag_name)" -replace '^v', '' - if ($tag -match '^\d+\.\d+\.\d+$') { return $tag } - } catch { - Write-Verbose "Legacy release lookup failed: $_" - } Stop-Install "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or set SENTRY_VERSION to install a specific version." } @@ -194,47 +207,62 @@ function Install-SentryCli { } $state.Arch = 'x64' + # Sentry's release registry is the source of truth for versions, download + # URLs (including legacy getsentry/cli releases), and checksums. GitHub is + # only a fallback for registry outages. $state.Step = 'resolve-version' if ($env:SENTRY_VERSION -eq 'nightly') { Stop-Install 'The PowerShell installer only installs stable releases. Install stable, then run: sentry cli upgrade nightly' } + $registryPath = 'latest' + $version = '' if ($env:SENTRY_VERSION) { $version = $env:SENTRY_VERSION -replace '^cli@', '' -replace '^v', '' - } else { - $version = Resolve-LatestVersion - } - $state.Version = $version - if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') { - Stop-Install "Invalid release version: $version" + $state.Version = $version + if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') { + Stop-Install "Invalid release version: $version" + } + $registryPath = $version } - # Fall back to legacy only when GitHub confirms the exact Toolkit tag is absent. - $state.Step = 'gh-version' - $toolkitStatus = Get-HttpStatus "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%40$version" - if ($toolkitStatus -eq 200) { - $releaseRepo = 'getsentry/toolkit' - $releaseTag = "cli@$version" - } elseif ($toolkitStatus -eq 404) { - $legacyStatus = Get-HttpStatus "https://api.github.com/repos/getsentry/cli/releases/tags/$version" - if ($legacyStatus -ne 200) { - Stop-Install "Legacy CLI release unavailable (HTTP $legacyStatus)" + $asset = 'sentry-windows-x64.exe' + $metadata = Get-RegistryRelease $registryPath + if ($null -ne $metadata) { + $version = "$($metadata.version)" + $gz = Get-RegistryFile $metadata "$asset.gz" + $raw = Get-RegistryFile $metadata $asset + if ($null -eq $gz -and $null -eq $raw) { + Stop-Install "Sentry CLI $version has no $asset build" } - $releaseRepo = 'getsentry/cli' - $releaseTag = $version } else { - Stop-Install "Toolkit release check failed (HTTP $toolkitStatus)" + Write-Host "Could not resolve $registryPath from Sentry's release registry. Trying GitHub..." -ForegroundColor DarkGray + if (-not $version) { $version = Find-LatestToolkitVersion } + $base = "https://github.com/getsentry/toolkit/releases/download/cli@$version/$asset" + $gz = @{ Url = "$base.gz"; Sha256 = '' } + $raw = @{ Url = $base; Sha256 = '' } + Write-Warning 'GitHub fallback downloads are not checksum-verified.' } + $state.Version = $version $state.Step = 'download' - $url = "https://github.com/$releaseRepo/releases/download/$releaseTag/sentry-windows-x64.exe" $tmpBinary = Join-Path ([System.IO.Path]::GetTempPath()) "sentry-install-$([guid]::NewGuid().ToString('N')).exe" $tmpArchive = "$tmpBinary.gz" Write-Host "Downloading sentry v$version..." -ForegroundColor DarkGray try { - # Prefer the gzip asset (~60% smaller); fall back to the raw binary. - try { - Invoke-WebRequest -UseBasicParsing -Uri "$url.gz" -OutFile $tmpArchive + # Prefer the gzip asset (~60% smaller); fall back to the raw binary when it + # is missing. A checksum mismatch is fatal, never a reason to fall back. + $downloaded = $false + if ($null -ne $gz) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $gz.Url -OutFile $tmpArchive + $downloaded = $true + } catch { + Write-Verbose "Compressed download failed, trying raw binary: $_" + } + } + if ($downloaded) { + Assert-Sha256 $tmpArchive $gz.Sha256 $source = [System.IO.File]::OpenRead($tmpArchive) try { $gzip = New-Object System.IO.Compression.GZipStream($source, [System.IO.Compression.CompressionMode]::Decompress) @@ -243,12 +271,19 @@ function Install-SentryCli { } finally { $source.Dispose() } - } catch { - Write-Verbose "Compressed download failed, trying raw binary: $_" - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $tmpBinary + } elseif ($null -ne $raw) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $raw.Url -OutFile $tmpBinary + } catch { + Stop-Install "Failed to download $($raw.Url)" + } + Assert-Sha256 $tmpBinary $raw.Sha256 + } else { + Stop-Install "Failed to download $($gz.Url)" } } catch { - Stop-Install "Failed to download $url" + Remove-Item -LiteralPath $tmpBinary -Force -ErrorAction SilentlyContinue + throw } finally { Remove-Item -LiteralPath $tmpArchive -Force -ErrorAction SilentlyContinue } diff --git a/packages/cli/test/lib/install-ps1-script.test.ts b/packages/cli/test/lib/install-ps1-script.test.ts index 568beccd6..d6c1c276e 100644 --- a/packages/cli/test/lib/install-ps1-script.test.ts +++ b/packages/cli/test/lib/install-ps1-script.test.ts @@ -8,7 +8,6 @@ import { spawnSync } from "node:child_process"; import { - chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -21,6 +20,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { gzipSync } from "node:zlib"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + applyScenario, + INSTALL_SCENARIOS, + LATEST_VERSION, + type ServedAsset, + toolkitDownload, +} from "./install-scenarios.js"; const installScript = join(import.meta.dirname, "..", "..", "install.ps1"); const hasPwsh = @@ -56,49 +62,39 @@ function New-HttpError([int]$Status) { function Invoke-RestMethod { param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec) Write-Record 'urls' $Uri - if ($Uri -eq 'https://release-registry.services.sentry.io/apps/sentry/latest') { - if ($env:SENTRY_TEST_REGISTRY_FAIL -eq '1') { throw (New-HttpError 503) } - return ($env:SENTRY_TEST_REGISTRY_RESPONSE | ConvertFrom-Json) + if ($Uri -like 'https://release-registry.services.sentry.io/apps/sentry/*') { + $status = [int]$env:SENTRY_TEST_REGISTRY_STATUS + if ($status -ne 200) { throw (New-HttpError $status) } + $version = $Uri.Split('/')[-1] + if ($version -eq 'latest') { $version = $env:SENTRY_TEST_LATEST_VERSION } + $body = (Get-Content -Raw -LiteralPath $env:SENTRY_TEST_REGISTRY_BODY).Replace('__VERSION__', $version) + return ($body | ConvertFrom-Json) } if ($Uri -like 'https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=*') { $page = $Uri.Split('=')[-1] $file = [Environment]::GetEnvironmentVariable("SENTRY_TEST_RELEASES_PAGE_$page") - if ($file) { return (Get-Content -Raw -LiteralPath $file | ConvertFrom-Json) } + # Like Windows PowerShell 5.1, emit each JSON array as a single object. + if ($file) { return ,(Get-Content -Raw -LiteralPath $file | ConvertFrom-Json -NoEnumerate) } return ,@() } - if ($Uri -eq 'https://api.github.com/repos/getsentry/cli/releases/latest') { - if ($env:SENTRY_TEST_GITHUB_FAIL -eq '1') { throw (New-HttpError 503) } - return ($env:SENTRY_TEST_GITHUB_RESPONSE | ConvertFrom-Json) - } throw "Unexpected Invoke-RestMethod $Uri" } function Invoke-WebRequest { param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec, [string]$Method, $Headers, [string]$ContentType, $Body) Write-Record 'urls' $Uri - if ($Uri -like 'https://api.github.com/repos/getsentry/toolkit/releases/tags/*') { - $status = [int]$env:SENTRY_TEST_TOOLKIT_STATUS - if ($status -ne 200) { throw (New-HttpError $status) } - return [pscustomobject]@{ StatusCode = 200 } - } - if ($Uri -like 'https://api.github.com/repos/getsentry/cli/releases/tags/*') { - $status = [int]$env:SENTRY_TEST_LEGACY_STATUS - if ($status -ne 200) { throw (New-HttpError $status) } - return [pscustomobject]@{ StatusCode = 200 } + if ($Uri -like 'https://github.com/*/releases/download/*.gz') { + if ($env:SENTRY_TEST_GZ_MISSING -eq '1') { throw (New-HttpError 404) } + Copy-Item -LiteralPath $env:SENTRY_TEST_GZ_FILE -Destination $OutFile + # The installer decompresses into the existing path; pre-create it + # executable so the fake binary can run on POSIX test hosts. + $target = $OutFile.Substring(0, $OutFile.Length - 3) + New-Item -ItemType File -Path $target -Force | Out-Null + chmod +x $target + return } if ($Uri -like 'https://github.com/*/releases/download/*') { - Write-Record 'download-urls' $Uri - if ($Uri.EndsWith('.gz')) { - if ($env:SENTRY_TEST_GZ_MISSING -eq '1') { throw (New-HttpError 404) } - Copy-Item -LiteralPath $env:SENTRY_TEST_FAKE_GZ -Destination $OutFile - # The installer decompresses into the existing path; pre-create it - # executable so the fake binary can run on POSIX test hosts. - $target = $OutFile.Substring(0, $OutFile.Length - 3) - New-Item -ItemType File -Path $target -Force | Out-Null - chmod +x $target - return - } - Copy-Item -LiteralPath $env:SENTRY_TEST_FAKE_BINARY -Destination $OutFile + Copy-Item -LiteralPath $env:SENTRY_TEST_RAW_FILE -Destination $OutFile chmod +x $OutFile return } @@ -107,6 +103,13 @@ function Invoke-WebRequest { Get-Content -Raw -LiteralPath $env:SENTRY_TEST_INSTALL_SCRIPT | Invoke-Expression `; +const asset = "sentry-windows-x64.exe"; +const served: ServedAsset = { + name: asset, + gz: gzipSync(downloadedExecutable), + raw: downloadedExecutable, +}; + describe.skipIf(!hasPwsh)("PowerShell install script", () => { let testDir: string; let installDir: string; @@ -120,12 +123,6 @@ describe.skipIf(!hasPwsh)("PowerShell install script", () => { harnessPath = join(testDir, "harness.ps1"); writeFileSync(harnessPath, harness); - const fakeBinary = join(testDir, "fake-binary"); - writeFileSync(fakeBinary, downloadedExecutable); - chmodSync(fakeBinary, 0o755); - const fakeGz = join(testDir, "fake-binary.gz"); - writeFileSync(fakeGz, gzipSync(downloadedExecutable)); - env = { PATH: process.env.PATH, HOME: join(testDir, "home"), @@ -137,13 +134,14 @@ describe.skipIf(!hasPwsh)("PowerShell install script", () => { SENTRY_CLI_NO_TELEMETRY: "1", SENTRY_TEST_DIR: testDir, SENTRY_TEST_INSTALL_SCRIPT: installScript, - SENTRY_TEST_FAKE_BINARY: fakeBinary, - SENTRY_TEST_FAKE_GZ: fakeGz, - SENTRY_TEST_REGISTRY_RESPONSE: '{"version":"0.47.0"}', - SENTRY_TEST_TOOLKIT_STATUS: "200", - SENTRY_TEST_LEGACY_STATUS: "200", GITHUB_PATH: join(testDir, "github-path"), }; + applyScenario( + { ...INSTALL_SCENARIOS[0], name: "default" }, + served, + testDir, + env, + ); }); afterEach(() => { @@ -165,13 +163,35 @@ describe.skipIf(!hasPwsh)("PowerShell install script", () => { : []; } - test("installs the latest stable release from the release registry", () => { + test.each(INSTALL_SCENARIOS)("$name", (scenario) => { + applyScenario(scenario, served, testDir, env); + + const result = run(); + + const output = result.stdout + result.stderr; + if (scenario.ok) { + expect(result.status, output).toBe(0); + expect(existsSync(join(installDir, "sentry.exe"))).toBe(true); + } else { + expect(result.status, output).not.toBe(0); + expect(recorded("setup-args")).toEqual([]); + } + for (const expected of scenario.output ?? []) { + expect(output).toContain(expected); + } + expect(recorded("urls")).toEqual(scenario.requests(asset)); + expect( + readdirSync(testDir).filter((name) => name.startsWith("sentry-install-")), + ).toEqual([]); + }); + + test("hands off to setup and puts the install dir on PATH", () => { const result = run(); expect(result.status, result.stdout + result.stderr).toBe(0); - expect(recorded("download-urls")).toEqual([ - "https://github.com/getsentry/toolkit/releases/download/cli@0.47.0/sentry-windows-x64.exe.gz", - ]); + expect(recorded("urls").at(-1)).toBe( + toolkitDownload(LATEST_VERSION, `${asset}.gz`), + ); expect(recorded("setup-args")).toEqual([ "cli", "setup", @@ -185,65 +205,6 @@ describe.skipIf(!hasPwsh)("PowerShell install script", () => { expect(recorded("setup-install-dir")).toEqual([installDir]); expect(recorded("setup-path")[0]?.startsWith(`${installDir}:`)).toBe(true); expect(recorded("github-path")).toEqual([installDir]); - expect(existsSync(join(installDir, "sentry.exe"))).toBe(true); - expect( - readdirSync(testDir).filter((name) => name.startsWith("sentry-install-")), - ).toEqual([]); - }); - - test("falls back to the newest stable Toolkit CLI release on GitHub", () => { - env.SENTRY_TEST_REGISTRY_FAIL = "1"; - const page1 = join(testDir, "page-1.json"); - writeFileSync( - page1, - JSON.stringify([ - { tag_name: "mcp@1.2.3", prerelease: false }, - { tag_name: "cli@0.48.0", prerelease: true }, - ]), - ); - const page2 = join(testDir, "page-2.json"); - writeFileSync( - page2, - JSON.stringify([{ tag_name: "cli@0.46.0", prerelease: false }]), - ); - env.SENTRY_TEST_RELEASES_PAGE_1 = page1; - env.SENTRY_TEST_RELEASES_PAGE_2 = page2; - - const result = run(); - - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(result.stdout).toContain("Trying GitHub"); - expect(recorded("download-urls")).toEqual([ - "https://github.com/getsentry/toolkit/releases/download/cli@0.46.0/sentry-windows-x64.exe.gz", - ]); - }); - - test("installs a pinned version from the legacy repository", () => { - env.SENTRY_VERSION = "v0.42.2"; - env.SENTRY_TEST_TOOLKIT_STATUS = "404"; - - const result = run(); - - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(recorded("urls")).not.toContain( - "https://release-registry.services.sentry.io/apps/sentry/latest", - ); - expect(recorded("download-urls")).toEqual([ - "https://github.com/getsentry/cli/releases/download/0.42.2/sentry-windows-x64.exe.gz", - ]); - }); - - test("falls back to the uncompressed binary", () => { - env.SENTRY_TEST_GZ_MISSING = "1"; - - const result = run(); - - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(recorded("download-urls")).toEqual([ - "https://github.com/getsentry/toolkit/releases/download/cli@0.47.0/sentry-windows-x64.exe.gz", - "https://github.com/getsentry/toolkit/releases/download/cli@0.47.0/sentry-windows-x64.exe", - ]); - expect(existsSync(join(installDir, "sentry.exe"))).toBe(true); }); test("runs sentry init with the installed binary when SENTRY_INIT=1", () => { diff --git a/packages/cli/test/lib/install-scenarios.ts b/packages/cli/test/lib/install-scenarios.ts new file mode 100644 index 000000000..7efe2698e --- /dev/null +++ b/packages/cli/test/lib/install-scenarios.ts @@ -0,0 +1,261 @@ +/** + * Shared behavior table for the bash (`install`) and PowerShell + * (`install.ps1`) installers. Both suites run every scenario against their own + * HTTP mocks, which follow the same contract, so any behavioral drift between + * the two scripts fails CI. + * + * Mock contract (environment variables read by both mocks): + * - SENTRY_TEST_REGISTRY_STATUS: HTTP status for release registry requests + * - SENTRY_TEST_REGISTRY_BODY: path to a registry response template; every + * `__VERSION__` is replaced by the requested version (or + * SENTRY_TEST_LATEST_VERSION for `latest`) + * - SENTRY_TEST_RELEASES_PAGE_: path to GitHub releases page n (else `[]`) + * - SENTRY_TEST_GZ_MISSING=1: gzip asset downloads fail + * - SENTRY_TEST_GZ_FILE / SENTRY_TEST_RAW_FILE: bytes served for downloads + * Every requested URL is recorded, in order, for `requests` assertions. + */ + +import { createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export const LATEST_VERSION = "0.47.0"; +const REGISTRY = "https://release-registry.services.sentry.io/apps/sentry"; +const RELEASES_PAGE = + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page="; + +export function toolkitDownload(version: string, asset: string): string { + return `https://github.com/getsentry/toolkit/releases/download/cli@${version}/${asset}`; +} + +function legacyDownload(version: string, asset: string): string { + return `https://github.com/getsentry/cli/releases/download/${version}/${asset}`; +} + +export function sha256(data: string | Buffer): string { + return createHash("sha256").update(data).digest("hex"); +} + +/** Bytes a runner serves for the gzip and raw downloads of its asset. */ +export interface ServedAsset { + name: string; + gz: Buffer; + raw: string | Buffer; +} + +type RegistryResponse = + | { + status: 200; + /** Which repository the registry's download URLs point at. */ + repo?: "toolkit" | "legacy"; + /** Advertise checksums that do not match the served bytes. */ + tamper?: boolean; + /** Omit this platform's files from the release. */ + missingAsset?: boolean; + } + | { status: 404 | 503 }; + +export interface InstallScenario { + name: string; + /** Value of SENTRY_VERSION, if any. */ + version?: string; + registry: RegistryResponse; + /** GitHub releases pages served during a registry outage. */ + releases?: { tag_name: string; prerelease: boolean }[][]; + gzMissing?: boolean; + ok: boolean; + /** Every URL the installer requests, in order. */ + requests: (asset: string) => string[]; + /** Substrings expected in combined stdout and stderr. */ + output?: string[]; +} + +/** Build a registry response template in the release registry's shape. */ +export function registryTemplate( + served: ServedAsset, + options: { repo?: "toolkit" | "legacy"; tamper?: boolean; missing?: boolean }, +): string { + const download = options.repo === "legacy" ? legacyDownload : toolkitDownload; + const entries: [string, string | Buffer][] = options.missing + ? [["sentry-other-platform", "other"]] + : [ + [`${served.name}.gz`, served.gz], + [served.name, served.raw], + ]; + const fileUrls: Record = {}; + const files: Record = {}; + for (const [name, bytes] of entries) { + const url = download("__VERSION__", name); + fileUrls[name] = url; + files[name] = { + checksums: { + "sha256-hex": options.tamper ? sha256("tampered") : sha256(bytes), + }, + url, + }; + } + return JSON.stringify( + { + canonical: "app:sentry", + file_urls: fileUrls, + files, + name: "Sentry CLI", + version: "__VERSION__", + }, + null, + 2, + ); +} + +/** Write a scenario's fixtures to `dir` and point the mock env at them. */ +export function applyScenario( + scenario: InstallScenario, + served: ServedAsset, + dir: string, + env: NodeJS.ProcessEnv, +): void { + const gzFile = join(dir, "served-asset.gz"); + const rawFile = join(dir, "served-asset"); + writeFileSync(gzFile, served.gz); + writeFileSync(rawFile, served.raw); + env.SENTRY_TEST_GZ_FILE = gzFile; + env.SENTRY_TEST_RAW_FILE = rawFile; + env.SENTRY_TEST_LATEST_VERSION = LATEST_VERSION; + env.SENTRY_TEST_REGISTRY_STATUS = String(scenario.registry.status); + const body = join(dir, "registry.json"); + writeFileSync( + body, + scenario.registry.status === 200 + ? registryTemplate(served, { + repo: scenario.registry.repo, + tamper: scenario.registry.tamper, + missing: scenario.registry.missingAsset, + }) + : "", + ); + env.SENTRY_TEST_REGISTRY_BODY = body; + for (const [index, page] of (scenario.releases ?? []).entries()) { + const file = join(dir, `releases-${index + 1}.json`); + writeFileSync(file, JSON.stringify(page, null, 2)); + env[`SENTRY_TEST_RELEASES_PAGE_${index + 1}`] = file; + } + if (scenario.gzMissing) { + env.SENTRY_TEST_GZ_MISSING = "1"; + } + if (scenario.version !== undefined) { + env.SENTRY_VERSION = scenario.version; + } +} + +export const INSTALL_SCENARIOS: InstallScenario[] = [ + { + name: "installs the latest release from the registry", + registry: { status: 200 }, + ok: true, + requests: (asset) => [ + `${REGISTRY}/latest`, + toolkitDownload(LATEST_VERSION, `${asset}.gz`), + ], + }, + { + name: "installs a pinned legacy release from the registry", + version: "v0.42.2", + registry: { status: 200, repo: "legacy" }, + ok: true, + requests: (asset) => [ + `${REGISTRY}/0.42.2`, + legacyDownload("0.42.2", `${asset}.gz`), + ], + }, + { + name: "strips a cli@ prefix from pinned versions", + version: "cli@0.46.0", + registry: { status: 200 }, + ok: true, + requests: (asset) => [ + `${REGISTRY}/0.46.0`, + toolkitDownload("0.46.0", `${asset}.gz`), + ], + }, + { + name: "falls back to the raw binary when the gzip asset is missing", + registry: { status: 200 }, + gzMissing: true, + ok: true, + requests: (asset) => [ + `${REGISTRY}/latest`, + toolkitDownload(LATEST_VERSION, `${asset}.gz`), + toolkitDownload(LATEST_VERSION, asset), + ], + }, + { + name: "rejects a download whose checksum does not match the registry", + registry: { status: 200, tamper: true }, + ok: false, + output: ["Checksum mismatch"], + requests: (asset) => [ + `${REGISTRY}/latest`, + toolkitDownload(LATEST_VERSION, `${asset}.gz`), + ], + }, + { + name: "stops when the release has no build for this platform", + registry: { status: 200, missingAsset: true }, + ok: false, + output: [`Sentry CLI ${LATEST_VERSION} has no`], + requests: () => [`${REGISTRY}/latest`], + }, + { + name: "stops on an unknown pinned version", + version: "9.9.9", + registry: { status: 404 }, + ok: false, + output: ["Unknown Sentry CLI version: 9.9.9"], + requests: () => [`${REGISTRY}/9.9.9`], + }, + { + name: "rejects an invalid pinned version before any request", + version: "1.2", + registry: { status: 200 }, + ok: false, + output: ["Invalid release version: 1.2"], + requests: () => [], + }, + { + name: "falls back to the newest stable Toolkit CLI release when the registry is down", + registry: { status: 503 }, + releases: [ + [ + { tag_name: "mcp@1.2.3", prerelease: false }, + { tag_name: "cli@0.48.0", prerelease: true }, + ], + [{ tag_name: "cli@0.46.0", prerelease: false }], + ], + ok: true, + output: ["Trying GitHub", "not checksum-verified"], + requests: (asset) => [ + `${REGISTRY}/latest`, + `${RELEASES_PAGE}1`, + `${RELEASES_PAGE}2`, + toolkitDownload("0.46.0", `${asset}.gz`), + ], + }, + { + name: "uses the pinned Toolkit release when the registry is down", + version: "0.46.0", + registry: { status: 503 }, + ok: true, + output: ["Trying GitHub", "not checksum-verified"], + requests: (asset) => [ + `${REGISTRY}/0.46.0`, + toolkitDownload("0.46.0", `${asset}.gz`), + ], + }, + { + name: "stops when neither the registry nor GitHub has a stable release", + registry: { status: 503 }, + ok: false, + output: ["Failed to fetch latest stable version"], + requests: () => [`${REGISTRY}/latest`, `${RELEASES_PAGE}1`], + }, +]; diff --git a/packages/cli/test/lib/install-script.test.ts b/packages/cli/test/lib/install-script.test.ts index 62f20c550..2825b6379 100644 --- a/packages/cli/test/lib/install-script.test.ts +++ b/packages/cli/test/lib/install-script.test.ts @@ -18,6 +18,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { gzipSync } from "node:zlib"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + applyScenario, + INSTALL_SCENARIOS, + LATEST_VERSION, + registryTemplate, + type ServedAsset, + toolkitDownload, +} from "./install-scenarios.js"; const installScript = join(import.meta.dirname, "..", "..", "install"); const downloadedExecutable = `#!/usr/bin/env bash @@ -54,6 +62,13 @@ record_tty 3> "$SENTRY_TEST_DIR/post-tty" exit "\${SENTRY_TEST_POST_EXIT:-0}" `; +const hostAsset = `sentry-${process.platform === "darwin" ? "darwin" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}`; +const served: ServedAsset = { + name: hostAsset, + gz: gzipSync(downloadedExecutable), + raw: downloadedExecutable, +}; + describe("install script", () => { let testDir: string; let binDir: string; @@ -73,186 +88,118 @@ describe("install script", () => { SENTRY_CLI_NO_TELEMETRY: "1", SENTRY_TEST_DIR: testDir, SENTRY_TEST_INSTALL_SCRIPT: installScript, - SENTRY_TEST_GITHUB_FAIL: "22", - SENTRY_TEST_GITHUB_RESPONSE: '{"tag_name":"0.42.2"}', - SENTRY_TEST_TOOLKIT_STATUS: "404", TMPDIR: testDir, }; + applyScenario( + { ...INSTALL_SCENARIOS[0], name: "default" }, + served, + testDir, + env, + ); writeFileSync( join(binDir, "curl"), `#!/usr/bin/env bash set -euo pipefail url="\${!#}" +out="" +args=("$@") +for ((i = 0; i < \${#args[@]}; i++)); do + if [[ "\${args[i]}" == "-o" ]]; then out="\${args[i + 1]}"; fi +done +emit() { if [[ -n "$out" ]]; then cat > "$out"; else cat; fi; } printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/curl-urls" case "$url" in - https://release-registry.services.sentry.io/apps/sentry/latest) - printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls" - if [[ "\${SENTRY_TEST_REGISTRY_FAIL:-0}" != "0" ]]; then - exit "$SENTRY_TEST_REGISTRY_FAIL" + https://release-registry.services.sentry.io/apps/sentry/*) + version="\${url##*/}" + if [[ "$version" == "latest" ]]; then version="$SENTRY_TEST_LATEST_VERSION"; fi + if [[ "$SENTRY_TEST_REGISTRY_STATUS" == "200" ]]; then + sed "s/__VERSION__/$version/g" "$SENTRY_TEST_REGISTRY_BODY" | emit + else + : | emit fi - printf '%s\\n' "\${SENTRY_TEST_REGISTRY_RESPONSE:-}" - ;; - *"/repos/getsentry/toolkit/releases/tags/"*) - printf '%s' "\${SENTRY_TEST_TOOLKIT_STATUS:-200}" - ;; - *"/repos/getsentry/cli/releases/tags/"*) - printf '%s' "\${SENTRY_TEST_LEGACY_STATUS:-200}" + printf '%s' "$SENTRY_TEST_REGISTRY_STATUS" ;; *"/repos/getsentry/toolkit/releases?per_page=100&page="*) - page="\${url##*=}" - case "$page" in - 1) if [[ -n "\${SENTRY_TEST_RELEASES_PAGE_1:-}" ]]; then cat "$SENTRY_TEST_RELEASES_PAGE_1"; else printf '[]'; fi ;; - 2) if [[ -n "\${SENTRY_TEST_RELEASES_PAGE_2:-}" ]]; then cat "$SENTRY_TEST_RELEASES_PAGE_2"; else printf '[]'; fi ;; - *) printf '[]' ;; - esac + page_file="SENTRY_TEST_RELEASES_PAGE_\${url##*=}" + if [[ -n "\${!page_file:-}" ]]; then cat "\${!page_file}"; else printf '[]'; fi ;; - *"/repos/getsentry/cli/releases/latest") - printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls" - if [[ "\${SENTRY_TEST_GITHUB_FAIL:-0}" != "0" ]]; then - exit "$SENTRY_TEST_GITHUB_FAIL" - fi - printf '%s\\n' "$SENTRY_TEST_GITHUB_RESPONSE" + https://github.com/*/releases/download/*.gz) + if [[ "\${SENTRY_TEST_GZ_MISSING:-}" == "1" ]]; then exit 22; fi + emit < "$SENTRY_TEST_GZ_FILE" + ;; + https://github.com/*/releases/download/*) + emit < "$SENTRY_TEST_RAW_FILE" ;; *) - printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls" - cat <<'SCRIPT' -${downloadedExecutable}SCRIPT + exit 91 ;; esac `, ); chmodSync(join(binDir, "curl"), 0o755); - writeFileSync(join(binDir, "gunzip"), "#!/usr/bin/env bash\ncat\n"); - chmodSync(join(binDir, "gunzip"), 0o755); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); - test.each([ - '{"canonical":"app:sentry","version":"0.45.0"}', - '{\n "canonical": "app:sentry",\n "version": "0.45.0"\n}', - JSON.stringify( - { version: "0.45.0", description: "x".repeat(96 * 1024) }, - null, - 2, - ), - ])( - "installs the latest stable release when GitHub API access is blocked", - (metadata) => { - env.SENTRY_TEST_REGISTRY_RESPONSE = metadata; - const result = spawnSync("bash", [installScript], { - env, - encoding: "utf8", - timeout: 10_000, - }); - - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(recorded("download-urls")).toEqual([ - "https://release-registry.services.sentry.io/apps/sentry/latest", - expect.stringMatching( - /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/, - ), - ]); - expect(existsSync(join(installDir, "sentry"))).toBe(true); - }, - ); - - test.each([ - { name: "HTTP error", metadata: "", failure: "22" }, - { name: "timeout", metadata: "", failure: "28" }, - { name: "missing version", metadata: "{}" }, - { name: "non-JSON response", metadata: "Unavailable" }, - { name: "nonstable version", metadata: '{"version":"nightly"}' }, - ])("falls back to GitHub after a registry $name", ({ metadata, failure }) => { - env.SENTRY_TEST_REGISTRY_RESPONSE = metadata; - env.SENTRY_TEST_REGISTRY_FAIL = failure; - env.SENTRY_TEST_GITHUB_FAIL = "0"; - env.SENTRY_TEST_GITHUB_RESPONSE = '{"tag_name":"0.45.0"}'; - const result = spawnSync("bash", [installScript], { + test.each(INSTALL_SCENARIOS)("$name", (scenario) => { + applyScenario(scenario, served, testDir, env); + const result = spawnSync("bash", [installScript, "--no-modify-path"], { env, encoding: "utf8", timeout: 10_000, }); - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(result.stderr).toContain("Trying GitHub"); - expect(recorded("download-urls")).toEqual([ - "https://release-registry.services.sentry.io/apps/sentry/latest", - "https://api.github.com/repos/getsentry/cli/releases/latest", - expect.stringMatching( - /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/, - ), - ]); - expect(existsSync(join(installDir, "sentry"))).toBe(true); + const output = result.stdout + result.stderr; + if (scenario.ok) { + expect(result.status, output).toBe(0); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + } else { + expect(result.status, output).not.toBe(0); + expect(output).not.toContain("Unexpected failure at line"); + expect(recorded("setup-args")).toEqual([]); + } + for (const expected of scenario.output ?? []) { + expect(output).toContain(expected); + } + expect(recorded("curl-urls")).toEqual(scenario.requests(hostAsset)); + expect(installerTempFiles()).toEqual([]); }); test.each([ - { name: "a leading v", metadata: '{\n "tag_name": "v0.45.0"\n}' }, { - name: "large release metadata", - metadata: JSON.stringify( - { tag_name: "0.45.0", body: "x".repeat(96 * 1024) }, - null, - 2, - ), + name: "compact", + format: (json: string) => JSON.stringify(JSON.parse(json)), }, - ])("accepts a GitHub release with $name", ({ metadata }) => { - env.SENTRY_TEST_REGISTRY_FAIL = "22"; - env.SENTRY_TEST_GITHUB_FAIL = "0"; - env.SENTRY_TEST_GITHUB_RESPONSE = metadata; - const result = spawnSync("bash", [installScript], { + { name: "pretty-printed", format: (json: string) => json }, + { + name: "large", + format: (json: string) => + JSON.stringify( + { description: "x".repeat(96 * 1024), ...JSON.parse(json) }, + null, + 2, + ), + }, + ])("parses $name registry metadata", ({ format }) => { + writeFileSync( + env.SENTRY_TEST_REGISTRY_BODY as string, + format(registryTemplate(served, {})), + ); + const result = spawnSync("bash", [installScript, "--no-modify-path"], { env, encoding: "utf8", timeout: 10_000, }); expect(result.status, result.stdout + result.stderr).toBe(0); - expect(recorded("download-urls").at(-1)).toMatch( - /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/, + expect(recorded("curl-urls").at(-1)).toBe( + toolkitDownload(LATEST_VERSION, `${hostAsset}.gz`), ); - expect(existsSync(join(installDir, "sentry"))).toBe(true); }); - test.each([ - { name: "HTTP error", metadata: "", failure: "22" }, - { name: "timeout", metadata: "", failure: "28" }, - { name: "missing tag", metadata: "{}", failure: "0" }, - { - name: "non-JSON response", - metadata: "Unavailable", - failure: "0", - }, - { name: "nonstable tag", metadata: '{"tag_name":"nightly"}', failure: "0" }, - ])( - "stops when the registry and GitHub fail: $name", - ({ metadata, failure }) => { - env.SENTRY_TEST_REGISTRY_FAIL = "22"; - env.SENTRY_TEST_GITHUB_RESPONSE = metadata; - env.SENTRY_TEST_GITHUB_FAIL = failure; - const result = spawnSync("bash", [installScript], { - env, - encoding: "utf8", - timeout: 10_000, - }); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "Failed to fetch latest stable version from Sentry's release registry and GitHub", - ); - expect(result.stderr).toContain("--version "); - expect(result.stderr).not.toContain("Unexpected failure at line"); - expect(recorded("download-urls")).toEqual([ - "https://release-registry.services.sentry.io/apps/sentry/latest", - "https://api.github.com/repos/getsentry/cli/releases/latest", - ]); - expect(recorded("setup-args")).toEqual([]); - expect(existsSync(join(installDir, "sentry"))).toBe(false); - }, - ); - function recorded(name: string): string[] { const path = join(testDir, name); return existsSync(path) @@ -429,11 +376,9 @@ process.exitCode = result.status ?? 1; expect(recorded("post-args")).toEqual([]); expect(existsSync(join(installDir, "sentry"))).toBe(true); expect(installerTempFiles()).toEqual([]); - expect(recorded("download-urls")).toEqual([ - expect.stringMatching( - /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.31\.0\/sentry-.+\.gz$/, - ), - ]); + expect(recorded("curl-urls").at(-1)).toBe( + toolkitDownload("0.31.0", `${hostAsset}.gz`), + ); }); test.each([ @@ -505,38 +450,6 @@ process.exitCode = result.status ?? 1; }, ); - test("uses the legacy release only after a Toolkit tag returns HTTP 404", () => { - env.SENTRY_TEST_TOOLKIT_STATUS = "404"; - const result = spawnSync( - "bash", - [installScript, "--version", "0.42.2", "--no-modify-path"], - { env, encoding: "utf8", timeout: 10_000 }, - ); - - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(recorded("curl-urls")).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.42.2", - "https://api.github.com/repos/getsentry/cli/releases/tags/0.42.2", - `https://github.com/getsentry/cli/releases/download/0.42.2/sentry-${process.platform === "darwin" ? "darwin" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}.gz`, - ]); - expect(existsSync(join(installDir, "sentry"))).toBe(true); - }); - - test("never falls back to legacy for a Toolkit server failure", () => { - env.SENTRY_TEST_TOOLKIT_STATUS = "500"; - const result = spawnSync("bash", [installScript, "--version", "0.42.2"], { - env, - encoding: "utf8", - timeout: 10_000, - }); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("Toolkit release check failed (HTTP 500)"); - expect(recorded("curl-urls")).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.42.2", - ]); - }); - test("checks further release pages before selecting a stable Toolkit CLI", () => { const firstPage = join(testDir, "releases-1.json"); const secondPage = join(testDir, "releases-2.json"); @@ -550,7 +463,7 @@ process.exitCode = result.status ?? 1; ); env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; env.SENTRY_TEST_RELEASES_PAGE_2 = secondPage; - env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + env.SENTRY_TEST_REGISTRY_STATUS = "503"; const result = spawnSync("bash", [installScript, "--no-modify-path"], { env, encoding: "utf8", @@ -564,10 +477,7 @@ process.exitCode = result.status ?? 1; "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=2", ]); expect(recorded("curl-urls")[3]).toBe( - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0", - ); - expect(recorded("curl-urls")[4]).toContain( - "/getsentry/toolkit/releases/download/cli@0.46.0/", + toolkitDownload("0.46.0", `${hostAsset}.gz`), ); }); @@ -578,7 +488,7 @@ process.exitCode = result.status ?? 1; '[\n {\n "tag_name": "cli@0.47.0-dev.1",\n "prerelease": false\n },\n {\n "tag_name": "cli@0.46.0",\n "prerelease": false\n }\n]\n', ); env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; - env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + env.SENTRY_TEST_REGISTRY_STATUS = "503"; const result = spawnSync("bash", [installScript, "--no-modify-path"], { env, encoding: "utf8", @@ -587,10 +497,7 @@ process.exitCode = result.status ?? 1; expect(result.status, result.stdout + result.stderr).toBe(0); expect(recorded("curl-urls")[2]).toBe( - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0", - ); - expect(recorded("curl-urls")[3]).toContain( - "/getsentry/toolkit/releases/download/cli@0.46.0/", + toolkitDownload("0.46.0", `${hostAsset}.gz`), ); expect(recorded("setup-args")).toContain("stable"); }); @@ -617,7 +524,7 @@ process.exitCode = result.status ?? 1; ]), ); env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; - env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + env.SENTRY_TEST_REGISTRY_STATUS = "503"; const result = spawnSync("bash", [installScript, "--no-modify-path"], { env, encoding: "utf8", @@ -626,10 +533,7 @@ process.exitCode = result.status ?? 1; expect(result.status, result.stdout + result.stderr).toBe(0); expect(recorded("curl-urls")[2]).toBe( - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0", - ); - expect(recorded("curl-urls")[3]).toContain( - "/getsentry/toolkit/releases/download/cli@0.46.0/", + toolkitDownload("0.46.0", `${hostAsset}.gz`), ); }); @@ -650,7 +554,7 @@ process.exitCode = result.status ?? 1; ]`, ); env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; - env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + env.SENTRY_TEST_REGISTRY_STATUS = "503"; const result = spawnSync("bash", [installScript, "--no-modify-path"], { env, encoding: "utf8", @@ -659,7 +563,7 @@ process.exitCode = result.status ?? 1; expect(result.status, result.stdout + result.stderr).toBe(0); expect(recorded("curl-urls")).toContain( - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0", + toolkitDownload("0.46.0", `${hostAsset}.gz`), ); expect(existsSync(join(installDir, "sentry"))).toBe(true); }); @@ -683,27 +587,6 @@ process.exitCode = result.status ?? 1; expect(recorded("curl-urls")).toEqual([]); }); - test("resolves the latest legacy version until Toolkit has a CLI release", () => { - env.SENTRY_TEST_TOOLKIT_STATUS = "404"; - env.SENTRY_TEST_GITHUB_FAIL = "0"; - const result = spawnSync("bash", [installScript, "--no-modify-path"], { - env, - encoding: "utf8", - timeout: 10_000, - }); - - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(recorded("curl-urls").slice(0, 4)).toEqual([ - "https://release-registry.services.sentry.io/apps/sentry/latest", - "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=1", - "https://api.github.com/repos/getsentry/cli/releases/latest", - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.42.2", - ]); - expect(recorded("curl-urls")[4]).toBe( - "https://api.github.com/repos/getsentry/cli/releases/tags/0.42.2", - ); - }); - test("connects setup to the controlling terminal and cleans up after setup", () => { env.SENTRY_TEST_KEEP_TEMP_BINARY = "1"; const result = runInTerminal(); From 9c946639a5fb5bf287b20a27f38a8b723519ec53 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:36:00 +0000 Subject: [PATCH 4/4] ci(cli): Smoke test the PowerShell installer on Windows --- .github/workflows/install-scripts.yml | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/install-scripts.yml diff --git a/.github/workflows/install-scripts.yml b/.github/workflows/install-scripts.yml new file mode 100644 index 000000000..7e5b36bb2 --- /dev/null +++ b/.github/workflows/install-scripts.yml @@ -0,0 +1,58 @@ +name: Install Scripts + +permissions: + contents: read + +on: + push: + branches: [main] + paths: + - ".github/workflows/install-scripts.yml" + - "packages/cli/install.ps1" + pull_request: + paths: + - ".github/workflows/install-scripts.yml" + - "packages/cli/install.ps1" + +jobs: + windows-powershell: + name: ${{ matrix.shell }} installs ${{ matrix.version || 'latest' }} + runs-on: windows-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # pwsh is PowerShell 7; powershell is the Windows PowerShell 5.1 that + # ships with Windows. `version` empty means latest stable. + shell: [pwsh, powershell] + version: [""] + include: + # A legacy getsentry/cli release, resolved through the registry. + - shell: pwsh + version: "0.42.2" + defaults: + run: + shell: ${{ matrix.shell }} + env: + SENTRY_CLI_NO_TELEMETRY: "1" + SENTRY_VERSION: ${{ matrix.version }} + steps: + - uses: actions/checkout@v4 + + - name: Install through Invoke-Expression + run: | + $env:SENTRY_INSTALL_DIR = Join-Path $env:RUNNER_TEMP 'sentry-bin' + Get-Content -Raw -LiteralPath packages/cli/install.ps1 | Invoke-Expression + $installed = & (Join-Path $env:SENTRY_INSTALL_DIR 'sentry.exe') --version + if ($LASTEXITCODE -ne 0) { throw "sentry --version exited with $LASTEXITCODE" } + if ($env:SENTRY_VERSION -and "$installed".Trim() -ne $env:SENTRY_VERSION) { + throw "Installed $installed, expected $env:SENTRY_VERSION" + } + "Installed sentry $installed" + + - name: Find sentry on PATH in a later step + run: | + $command = Get-Command sentry -ErrorAction Stop + "Resolved $($command.Source)" + sentry --version + if ($LASTEXITCODE -ne 0) { throw "sentry --version exited with $LASTEXITCODE" }