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/5] 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/5] 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/5] 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/5] 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" } From be34f0d6368259ebffb96546107d7fb822b5a6ff Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:27:51 +0000 Subject: [PATCH 5/5] ci(cli): Smoke test the bash installer and drop legacy release coverage Run the bash installer on Ubuntu and macOS runners alongside the Windows PowerShell jobs, and trigger the workflow on changes to either script. Drop the pinned legacy getsentry/cli release job and shared scenario. --- .github/workflows/install-scripts.yml | 40 ++++++++++++++++------ packages/cli/install | 4 +-- packages/cli/install.ps1 | 3 +- packages/cli/test/lib/install-scenarios.ts | 22 ++---------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/.github/workflows/install-scripts.yml b/.github/workflows/install-scripts.yml index 7e5b36bb2..4a686f99f 100644 --- a/.github/workflows/install-scripts.yml +++ b/.github/workflows/install-scripts.yml @@ -8,34 +8,55 @@ on: branches: [main] paths: - ".github/workflows/install-scripts.yml" + - "packages/cli/install" - "packages/cli/install.ps1" pull_request: paths: - ".github/workflows/install-scripts.yml" + - "packages/cli/install" - "packages/cli/install.ps1" jobs: + unix-bash: + name: bash installs latest (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + env: + SENTRY_CLI_NO_TELEMETRY: "1" + steps: + - uses: actions/checkout@v4 + + - name: Install through curl | bash + run: | + export SENTRY_INSTALL_DIR="$RUNNER_TEMP/sentry-bin" + bash < packages/cli/install + installed=$("$SENTRY_INSTALL_DIR/sentry" --version) + echo "Installed sentry $installed" + + - name: Find sentry on PATH in a later step + run: | + echo "Resolved $(command -v sentry)" + sentry --version + windows-powershell: - name: ${{ matrix.shell }} installs ${{ matrix.version || 'latest' }} + name: ${{ matrix.shell }} installs 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. + # ships with Windows. 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 @@ -45,9 +66,6 @@ jobs: 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 diff --git a/packages/cli/install b/packages/cli/install index 18c2d4a4a..e4cf5777a 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -275,8 +275,8 @@ if [[ "$requested_version" == "nightly" ]]; then else # 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. + # versions, download URLs, and checksums. GitHub is only a fallback for + # registry outages. if [[ -n "$requested_version" ]]; then version="${requested_version#cli@}" version="${version#v}" diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 6056eb638..9ec5f7bdc 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -208,8 +208,7 @@ 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. + # URLs, 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' diff --git a/packages/cli/test/lib/install-scenarios.ts b/packages/cli/test/lib/install-scenarios.ts index 7efe2698e..81316951a 100644 --- a/packages/cli/test/lib/install-scenarios.ts +++ b/packages/cli/test/lib/install-scenarios.ts @@ -28,10 +28,6 @@ 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"); } @@ -46,8 +42,6 @@ export interface ServedAsset { 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. */ @@ -73,9 +67,8 @@ export interface InstallScenario { /** Build a registry response template in the release registry's shape. */ export function registryTemplate( served: ServedAsset, - options: { repo?: "toolkit" | "legacy"; tamper?: boolean; missing?: boolean }, + options: { tamper?: boolean; missing?: boolean }, ): string { - const download = options.repo === "legacy" ? legacyDownload : toolkitDownload; const entries: [string, string | Buffer][] = options.missing ? [["sentry-other-platform", "other"]] : [ @@ -85,7 +78,7 @@ export function registryTemplate( const fileUrls: Record = {}; const files: Record = {}; for (const [name, bytes] of entries) { - const url = download("__VERSION__", name); + const url = toolkitDownload("__VERSION__", name); fileUrls[name] = url; files[name] = { checksums: { @@ -127,7 +120,6 @@ export function applyScenario( body, scenario.registry.status === 200 ? registryTemplate(served, { - repo: scenario.registry.repo, tamper: scenario.registry.tamper, missing: scenario.registry.missingAsset, }) @@ -157,16 +149,6 @@ export const INSTALL_SCENARIOS: InstallScenario[] = [ 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",