From 04867eaf9b412a62d4b1ea1c59c2b382c557bd0b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 9 Sep 2026 13:42:44 +0000 Subject: [PATCH 01/19] feat(cli): add toolkit upgrade bridge --- .github/workflows/ci.yml | 1 + package.json | 3 +- packages/cli/install | 192 ++++++++-- packages/cli/package.json | 3 +- packages/cli/src/commands/cli/upgrade.ts | 114 ++++-- packages/cli/src/lib/binary.ts | 146 +++++++- packages/cli/src/lib/delta-upgrade.ts | 81 +++-- packages/cli/src/lib/ghcr.ts | 48 ++- packages/cli/src/lib/release-notes.ts | 119 +++++-- packages/cli/src/lib/upgrade.ts | 313 ++++++++++++---- packages/cli/src/lib/version-check.ts | 21 +- .../cli/test/commands/cli/upgrade.test.ts | 71 +++- packages/cli/test/lib/binary.test.ts | 128 ++++++- packages/cli/test/lib/delta-upgrade.test.ts | 173 ++++++++- packages/cli/test/lib/ghcr.test.ts | 26 ++ packages/cli/test/lib/install-script.test.ts | 333 ++++++++++++++++-- packages/cli/test/lib/release-notes.test.ts | 113 +++++- packages/cli/test/lib/upgrade.test.ts | 249 +++++++++++-- packages/cli/test/lib/version-check.test.ts | 125 ++++++- 19 files changed, 1933 insertions(+), 326 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67ec710672..6f018db432 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,6 +245,7 @@ jobs: - run: pnpm run check:errors - run: pnpm run check:patches - run: pnpm run check:stale-refs + - run: pnpm run check:upgrade-sources test-unit: name: Unit Tests diff --git a/package.json b/package.json index cb501ad192..3b109cd2ca 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "check:errors": "pnpm --filter sentry run check:errors", "check:patches": "pnpm --filter sentry run check:patches", "check:docs-sections": "pnpm --filter sentry run check:docs-sections", - "check:stale-refs": "pnpm --filter sentry run check:stale-refs" + "check:stale-refs": "pnpm --filter sentry run check:stale-refs", + "check:upgrade-sources": "pnpm --filter sentry run check:upgrade-sources" }, "pnpm": { "patchedDependencies": { diff --git a/packages/cli/install b/packages/cli/install index e05cef2018..802aa9b161 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -12,6 +12,9 @@ SENTRY_DSN_KEY="1188a86f3f8168f089450587b00bca66" SENTRY_INGEST="https://o1.ingest.us.sentry.io" SENTRY_PROJECT_ID="4510776311808000" +# UPGRADE_SOURCES_SYNC: keep these github|ghcr|tag-prefix entries in sync with src/lib/binary.ts. +UPGRADE_SOURCES=('getsentry/toolkit|getsentry/toolkit|cli@' 'getsentry/cli|getsentry/cli|') + # Generate a UUID for the event. Tries /proc, uuidgen, then awk fallback. gen_uuid() { if [[ -r /proc/sys/kernel/random/uuid ]]; then @@ -67,7 +70,6 @@ report_error() { die() { echo -e "${RED}$1${NC}" >&2 report_error "$1" "${2:-unknown}" - wait 2>/dev/null || true # Let the background curl finish; ignore its exit status exit 1 } @@ -205,10 +207,151 @@ fi # Download binary to a temp location tmpdir="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}" tmp_binary="${tmpdir}/sentry-install-$$${suffix}" +github_response="${tmpdir}/sentry-install-github-response-$$" +nightly_manifest_file="${tmpdir}/sentry-install-nightly-manifest-$$" version="" # Clean up temp binary on failure (setup handles cleanup on success) -trap 'rm -f "$tmp_binary"' EXIT +trap 'rm -f "$tmp_binary" "$github_response" "$nightly_manifest_file"' EXIT + +# Fetch a GitHub API endpoint without collapsing HTTP failures into one curl +# error. The caller may fall through on a genuine 404; every transport failure +# and every other HTTP status stops source selection. +github_get() { + local url="$1" + if ! http_status=$(curl -sS -L -o "$github_response" -w '%{http_code}' "$url"); then + die "Failed to connect to GitHub while fetching ${url}" "gh-fetch" + fi +} + +source_tag_prefix() { + printf '%s' "$1" | cut -d'|' -f3 +} + +source_github_repo() { + printf '%s' "$1" | cut -d'|' -f1 +} + +source_ghcr_repo() { + printf '%s' "$1" | cut -d'|' -f2 +} + +# Select the first source whose GitHub probe succeeds. A 404 alone advances to +# the next source. The successful source remains fixed for all later requests. +select_nightly_source() { + local source + local manifest_status + local github_repo + local ghcr_repo + local url + for source in "${UPGRADE_SOURCES[@]}"; do + github_repo=$(source_github_repo "$source") + ghcr_repo=$(source_ghcr_repo "$source") + url="https://api.github.com/repos/${github_repo}" + github_get "$url" + if [[ "$http_status" == "404" ]]; then + continue + fi + if [[ ! "$http_status" =~ ^2[0-9][0-9]$ ]]; then + die "Failed to fetch ${url}: HTTP ${http_status}" "gh-source" + fi + if ! GHCR_TOKEN=$(curl -sf \ + "https://ghcr.io/token?scope=repository:${ghcr_repo}:pull" \ + | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="token"){print $(i+2);exit}}'); then + die "Failed to get GHCR token" "ghcr-token" + fi + if [[ -z "$GHCR_TOKEN" ]]; then + die "Failed to get GHCR token" "ghcr-token" + fi + if ! manifest_status=$(curl -sS -o "$nightly_manifest_file" -w '%{http_code}' \ + -H "Authorization: Bearer $GHCR_TOKEN" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + "https://ghcr.io/v2/${ghcr_repo}/manifests/nightly"); then + die "Failed to connect to GHCR while fetching the nightly manifest" "ghcr-manifest" + fi + if [[ "$manifest_status" == "404" ]]; then + continue + fi + if [[ ! "$manifest_status" =~ ^2[0-9][0-9]$ ]]; then + die "Failed to fetch nightly manifest from GHCR: HTTP ${manifest_status}" "ghcr-manifest" + fi + MANIFEST=$(<"$nightly_manifest_file") + if [[ -z "$MANIFEST" ]]; then + die "Failed to fetch nightly manifest from GHCR" "ghcr-manifest" + fi + selected_source="$github_repo" + selected_ghcr_source="$ghcr_repo" + rm -f "$nightly_manifest_file" + return 0 + done + rm -f "$nightly_manifest_file" + die "No CLI upgrade source was found: every source returned HTTP 404" "gh-source" +} + +select_stable_source() { + local source + local tag + local tag_path + local tag_prefix + local github_repo + local url + + for source in "${UPGRADE_SOURCES[@]}"; do + github_repo=$(source_github_repo "$source") + tag_prefix=$(source_tag_prefix "$source") + if [[ -z "$requested_version" ]]; then + if [[ -n "$tag_prefix" ]]; then + url="https://api.github.com/repos/${github_repo}/releases?per_page=100" + else + url="https://api.github.com/repos/${github_repo}/releases/latest" + fi + else + tag="${tag_prefix}${version}" + tag_path="${tag//@/%40}" + url="https://api.github.com/repos/${github_repo}/releases/tags/${tag_path}" + fi + + github_get "$url" + if [[ "$http_status" == "404" ]]; then + continue + fi + if [[ ! "$http_status" =~ ^2[0-9][0-9]$ ]]; then + die "Failed to fetch ${url}: HTTP ${http_status}" "gh-version" + fi + + selected_source="$github_repo" + selected_tag_prefix="$tag_prefix" + if [[ -z "$requested_version" ]]; then + if [[ -n "$tag_prefix" ]]; then + version=$(awk -F'"' '{ + for (i = 1; i <= NF; i++) { + if ($i == "tag_name" && $(i + 2) ~ /^cli@v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9A-Za-z.-]+)?$/) { + sub(/^cli@/, "", $(i + 2)) + print $(i + 2) + exit + } + } + }' "$github_response") + else + version=$(awk -F'"' '{ + for (i = 1; i <= NF; i++) { + if ($i == "tag_name") { + print $(i + 2) + exit + } + } + }' "$github_response") + fi + if [[ -z "$version" ]]; then + die "Failed to find a CLI release in ${github_repo}" "gh-version" + fi + version="${version#v}" + fi + return 0 + done + + die "No CLI release was found: every source returned HTTP 404" "gh-version" +} if [[ "$requested_version" == "nightly" ]]; then # Nightly build: download from GHCR via OCI blob protocol. @@ -219,24 +362,13 @@ if [[ "$requested_version" == "nightly" ]]; then echo -e "${MUTED}Fetching nightly build from GHCR...${NC}" - # Step 1: Get anonymous pull token - GHCR_TOKEN=$(curl -sf \ - "https://ghcr.io/token?scope=repository:getsentry/cli:pull" \ - | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="token"){print $(i+2);exit}}') - if [[ -z "$GHCR_TOKEN" ]]; then - die "Failed to get GHCR token" "ghcr-token" - fi - - # Step 2: Fetch the OCI manifest for the :nightly tag - MANIFEST=$(curl -sf \ - -H "Authorization: Bearer $GHCR_TOKEN" \ - -H "Accept: application/vnd.oci.image.manifest.v1+json" \ - "https://ghcr.io/v2/getsentry/cli/manifests/nightly") - if [[ -z "$MANIFEST" ]]; then - die "Failed to fetch nightly manifest from GHCR" "ghcr-manifest" - fi + # Probe GitHub first because it provides an unambiguous 404. GHCR token + # failures may be 401 or 403 for both missing and inaccessible packages. + selected_source="" + selected_ghcr_source="" + select_nightly_source - # Step 3: Extract version from manifest annotation + # Extract version from the selected source's manifest. version=$(echo "$MANIFEST" \ | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="version"){print $(i+2);exit}}') if [[ -z "$version" ]]; then @@ -265,7 +397,7 @@ if [[ "$requested_version" == "nightly" ]]; then # header must NOT be forwarded to the Azure Blob Storage redirect target) redir_url=$(curl -s -w '\n%{redirect_url}' -o /dev/null \ -H "Authorization: Bearer $GHCR_TOKEN" \ - "https://ghcr.io/v2/getsentry/cli/blobs/${digest}" | tail -1) + "https://ghcr.io/v2/${selected_ghcr_source}/blobs/${digest}" | tail -1) if [[ -z "$redir_url" ]]; then die "Failed to get blob redirect URL from GHCR" "ghcr-redirect" fi @@ -275,21 +407,14 @@ if [[ "$requested_version" == "nightly" ]]; then else # Stable build: resolve version and download from GitHub Releases. + version="${requested_version#v}" + selected_source="" + selected_tag_prefix="" + select_stable_source - if [[ -z "$requested_version" ]]; then - version=$(curl -fsSL https://api.github.com/repos/getsentry/cli/releases/latest \ - | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p') - if [[ -z "$version" ]]; then - die "Failed to fetch latest version" "gh-version" - fi - else - version="$requested_version" - fi - - # Strip leading 'v' if present (releases use version without 'v' prefix) - version="${version#v}" filename="sentry-${os}-${arch}${libc_suffix}${suffix}" - url="https://github.com/getsentry/cli/releases/download/${version}/${filename}" + tag="${selected_tag_prefix}${version}" + url="https://github.com/${selected_source}/releases/download/${tag}/${filename}" echo -e "${MUTED}Downloading sentry v${version}...${NC}" @@ -327,6 +452,7 @@ if [[ "$no_agent_skills" == "true" ]]; then fi # Remove trap — setup will handle temp cleanup on success +rm -f "$github_response" trap - EXIT # shellcheck disable=SC2086 diff --git a/packages/cli/package.json b/packages/cli/package.json index 9bf937c4ac..fd8b463f8e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,7 +83,8 @@ "check:patches": "pnpm tsx script/check-patches.ts", "check:docs-sections": "pnpm tsx script/generate-docs-sections.ts --check", "check:env-coverage": "pnpm tsx script/check-env-coverage.ts", - "check:stale-refs": "pnpm tsx script/check-stale-references.ts" + "check:stale-refs": "pnpm tsx script/check-stale-references.ts", + "check:upgrade-sources": "vitest run test/lib/install-script.test.ts -t 'embeds the shared ordered upgrade source list'" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 8d82823683..610a120258 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -25,6 +25,7 @@ import { LEGACY_INSTALL_SUBDIR, releaseLock, samePath, + type UpgradeSource, } from "../../lib/binary.js"; import { buildCommand } from "../../lib/command.js"; import { CLI_VERSION } from "../../lib/constants.js"; @@ -54,6 +55,8 @@ import { NIGHTLY_TAG, type OfflineMode, parseInstallationMethod, + resolveExistingUpgradeVersion, + resolveLatestUpgradeVersion, VERSION_PREFIX_REGEX, versionExists, } from "../../lib/upgrade.js"; @@ -172,8 +175,13 @@ async function resolveTargetWithFallback(opts: { * clearing the version cache before the offline path can read it). */ persistChannelFn: () => void; }): Promise< - | { kind: "target"; target: string; offline: OfflineMode } - | { kind: "done"; result: UpgradeResult } + | { + kind: "target"; + target: string; + offline: OfflineMode; + source?: UpgradeSource; + } + | { kind: "done"; result: UpgradeResult; source?: UpgradeSource } > { const { resolveOpts, versionArg, offline, method, persistChannelFn } = opts; @@ -203,7 +211,12 @@ async function resolveTargetWithFallback(opts: { if (resolved.kind === "done") { return resolved; } - return { kind: "target", target: resolved.target, offline: false }; + return { + kind: "target", + target: resolved.target, + offline: false, + source: resolved.source, + }; } catch (error) { // Automatic offline fallback: only for curl-installed binaries (package // managers need the network for the actual install, not just version @@ -273,8 +286,28 @@ type ResolveTargetOptions = { * (check-only mode, or already up to date) */ type ResolveResult = - | { kind: "target"; target: string } - | { kind: "done"; result: UpgradeResult }; + | { kind: "target"; target: string; source?: UpgradeSource } + | { kind: "done"; result: UpgradeResult; source?: UpgradeSource }; + +async function resolvePinnedVersion( + lookupMethod: InstallationMethod, + target: string +): Promise { + if (lookupMethod !== "curl") { + if (!(await versionExists(lookupMethod, target))) { + throw new UpgradeError( + "version_not_found", + `Version ${target} not found` + ); + } + return; + } + const resolved = await resolveExistingUpgradeVersion(target); + if (!resolved) { + throw new UpgradeError("version_not_found", `Version ${target} not found`); + } + return resolved.source; +} /** * Resolve the target version and handle check-only mode. @@ -286,8 +319,16 @@ async function resolveTargetVersion( opts: ResolveTargetOptions ): Promise { const { method, channel, versionArg, channelChanged, flags } = opts; - const latest = await fetchLatestVersion(method, channel); + const standalone = + channel === "nightly" || method === "curl" || method === "brew"; + const latestResolution = standalone + ? await resolveLatestUpgradeVersion(channel) + : undefined; + const latest = latestResolution + ? latestResolution.version + : await fetchLatestVersion(method, channel); const target = versionArg?.replace(VERSION_PREFIX_REGEX, "") ?? latest; + let source = latestResolution?.source; log.debug(`Channel: ${channel}`); log.debug(`Latest version: ${latest}`); @@ -299,6 +340,7 @@ async function resolveTargetVersion( return { kind: "done", result: buildCheckResult({ target, versionArg, method, channel, flags }), + source: latestResolution?.source, }; } @@ -322,16 +364,10 @@ async function resolveTargetVersion( // nightly channel regardless of the current install method. if (versionArg && !CHANNEL_VERSIONS.has(versionArg)) { const lookupMethod = channel === "nightly" ? "curl" : method; - const exists = await versionExists(lookupMethod, target); - if (!exists) { - throw new UpgradeError( - "version_not_found", - `Version ${target} not found` - ); - } + source = (await resolvePinnedVersion(lookupMethod, target)) ?? source; } - return { kind: "target", target }; + return { kind: "target", target, source }; } /** @@ -617,6 +653,7 @@ async function executeStandardUpgrade(opts: { offline?: OfflineMode; json?: boolean; noAgentSkills: boolean; + source?: UpgradeSource; }): Promise { const { method, @@ -629,6 +666,7 @@ async function executeStandardUpgrade(opts: { offline, json, noAgentSkills, + source, } = opts; // Use the rolling "nightly" tag only when upgrading to latest nightly @@ -639,7 +677,7 @@ async function executeStandardUpgrade(opts: { const downloadResult = await withProgress( { message: `Downloading ${target}...`, json }, async (setMessage) => - executeUpgrade(method, target, downloadTag, offline, setMessage) + executeUpgrade(method, target, downloadTag, offline, setMessage, source) ); if (downloadResult?.patchBytes) { @@ -713,8 +751,9 @@ async function migrateToStandaloneForNightly(opts: { versionArg: string | undefined; noAgentSkills: boolean; json?: boolean; + source?: UpgradeSource; }): Promise { - const { method, target, versionArg, noAgentSkills, json } = opts; + const { method, target, versionArg, noAgentSkills, json, source } = opts; log.info("Nightly builds are only available as standalone binaries."); log.info("Migrating to standalone installation..."); @@ -724,7 +763,7 @@ async function migrateToStandaloneForNightly(opts: { const downloadResult = await withProgress( { message: `Downloading ${target}...`, json }, async (setMessage) => - executeUpgrade("curl", target, downloadTag, undefined, setMessage) + executeUpgrade("curl", target, downloadTag, undefined, setMessage, source) ); if (downloadResult?.patchBytes) { @@ -822,12 +861,14 @@ function persistChannel( * Returns a promise that resolves to the changelog or undefined. Never * throws — errors are swallowed so the upgrade is not blocked. */ -function startChangelogFetch( - channel: ReleaseChannel, - currentVersion: string, - targetVersion: string, - offline: OfflineMode -): Promise { +function startChangelogFetch(options: { + channel: ReleaseChannel; + currentVersion: string; + targetVersion: string; + offline: OfflineMode; + source?: UpgradeSource; +}): Promise { + const { channel, currentVersion, targetVersion, offline, source } = options; if (offline || currentVersion === targetVersion) { return Promise.resolve(undefined); } @@ -835,6 +876,7 @@ function startChangelogFetch( channel, fromVersion: currentVersion, toVersion: targetVersion, + source, }) .then((result) => result ?? undefined) .catch(() => undefined as undefined); @@ -955,25 +997,27 @@ export const upgradeCommand = buildCommand({ result.action === "checked" && result.currentVersion !== result.targetVersion ) { - result.changelog = await startChangelogFetch( + result.changelog = await startChangelogFetch({ channel, - CLI_VERSION, - result.targetVersion, - false - ); + currentVersion: CLI_VERSION, + targetVersion: result.targetVersion, + offline: false, + source: resolved.source, + }); } return yield new CommandOutput(result); } - const { target, offline } = resolved; + const { target, offline, source } = resolved; // Start changelog fetch early — it runs in parallel with the download. - const changelogPromise = startChangelogFetch( + const changelogPromise = startChangelogFetch({ channel, - CLI_VERSION, - target, - offline - ); + currentVersion: CLI_VERSION, + targetVersion: target, + offline, + source, + }); // --check with offline fallback: resolveTargetWithFallback returns // kind: "target" for offline check, so guard against actual upgrade. @@ -1017,6 +1061,7 @@ export const upgradeCommand = buildCommand({ versionArg, noAgentSkills: flags["no-agent-skills"], json: flags.json, + source, }); } else { await executeStandardUpgrade({ @@ -1030,6 +1075,7 @@ export const upgradeCommand = buildCommand({ offline, json: flags.json, noAgentSkills: flags["no-agent-skills"], + source, }); } diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index 75582612a3..e30633c7e7 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -102,6 +102,33 @@ export type InstallationMethod = | "yarn" | "unknown"; +/** A repository pair that hosts CLI stable releases and nightly OCI images. */ +export type UpgradeSource = { + /** GitHub `owner/repository` containing CLI release assets. */ + readonly githubRepo: string; + /** GHCR `owner/package` containing CLI nightly images and delta patches. */ + readonly ghcrRepo: string; + /** Prefix attached to CLI release tags in this repository. */ + readonly tagPrefix: string; +}; + +/** Ordered CLI release sources. The resolver falls through only on HTTP 404. */ +export const UPGRADE_SOURCES = [ + { + githubRepo: "getsentry/toolkit", + ghcrRepo: "getsentry/toolkit", + tagPrefix: "cli@", + }, + { + githubRepo: "getsentry/cli", + ghcrRepo: "getsentry/cli", + tagPrefix: "", + }, +] as const satisfies readonly [UpgradeSource, ...UpgradeSource[]]; + +/** The first source used by direct helper calls that do not resolve a source. */ +export const PRIMARY_UPGRADE_SOURCE = UPGRADE_SOURCES[0]; + /** Valid methods that can be specified via --method flag */ const VALID_METHODS: InstallationMethod[] = [ "curl", @@ -204,13 +231,120 @@ export function getPlatformBinaryName(): string { * @param version - Version to download (without 'v' prefix) * @returns Download URL for the binary */ -export function getBinaryDownloadUrl(version: string): string { - return `https://github.com/getsentry/cli/releases/download/${version}/${getPlatformBinaryName()}`; +export function getBinaryDownloadUrl( + version: string, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE +): string { + const tag = `${source.tagPrefix}${version}`; + return `https://github.com/${source.githubRepo}/releases/download/${tag}/${getPlatformBinaryName()}`; +} + +/** Build the GitHub API base URL for a release source. */ +export function getGitHubReleasesUrl( + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE +): string { + return `https://api.github.com/repos/${source.githubRepo}/releases`; +} + +/** Build the GitHub API URL for one source-specific release tag. */ +export function getGitHubReleaseByTagUrl( + version: string, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE +): string { + const tag = `${source.tagPrefix}${version}`; + return `${getGitHubReleasesUrl(source)}/tags/${encodeURIComponent(tag)}`; +} + +/** Build the GitHub API URL used to discover a source's latest CLI release. */ +export function getGitHubLatestReleaseUrl( + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE +): string { + return source.tagPrefix + ? `${getGitHubReleasesUrl(source)}?per_page=100` + : `${getGitHubReleasesUrl(source)}/latest`; +} + +/** Build the GitHub API URL used to verify that a source repository exists. */ +export function getGitHubRepositoryUrl( + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE +): string { + return `https://api.github.com/repos/${source.githubRepo}`; +} + +/** GitHub API base URL for the primary release source. */ +export const GITHUB_RELEASES_URL = getGitHubReleasesUrl(); + +/** Result of selecting one source for an upgrade operation. */ +export type ResolvedUpgradeSource = { + /** The selected release source. */ + readonly source: UpgradeSource; + /** The successful response from the source probe. */ + readonly response: Response; +}; + +/** Configuration for selecting the first available upgrade source. */ +export type ResolveUpgradeSourceOptions = { + /** Build the source-specific URL whose response proves source availability. */ + readonly getProbeUrl: (source: UpgradeSource) => string; + /** Fetch implementation used for the probe. Defaults to the CLI CA-aware fetch. */ + readonly fetch?: typeof fetch; + /** Optional cancellation signal shared by every source probe. */ + readonly signal?: AbortSignal; + /** Ordered sources to probe. Defaults to all configured upgrade sources. */ + readonly sources?: readonly UpgradeSource[]; +}; + +async function fetchUpgradeProbe( + source: UpgradeSource, + options: ResolveUpgradeSourceOptions +): Promise { + try { + return await (options.fetch ?? customFetch)(options.getProbeUrl(source), { + headers: getGitHubHeaders(), + signal: options.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw error; + } + if (error instanceof Error && isTlsCertError(error)) { + throw new UpgradeError("network_error", buildTlsErrorDetail(error)); + } + throw new UpgradeError( + "network_error", + `Failed to connect to GitHub: ${stringifyUnknown(error)}` + ); + } } -/** GitHub API base URL for releases */ -export const GITHUB_RELEASES_URL = - "https://api.github.com/repos/getsentry/cli/releases"; +/** + * Select the first available upgrade source. + * + * The caller receives the successful probe response so it never repeats the + * request. Only HTTP 404 advances to the next source. Every other HTTP or + * network failure aborts immediately. + */ +export async function resolveUpgradeSource( + options: ResolveUpgradeSourceOptions +): Promise { + for (const source of options.sources ?? UPGRADE_SOURCES) { + const response = await fetchUpgradeProbe(source, options); + if (response.ok) { + return { source, response }; + } + if (response.status !== 404) { + throw new UpgradeError( + "network_error", + `Failed to fetch from GitHub: HTTP ${response.status}` + ); + } + } + + throw new UpgradeError( + "network_error", + "No CLI upgrade source was found: every source returned HTTP 404" + ); +} /** * Detect whether a version string identifies a nightly build. @@ -228,7 +362,7 @@ export function isNightlyVersion(version: string): boolean { /** * Compare two version strings and return their ordering. * - * Uses `Bun.semver.order` which handles both stable (`X.Y.Z`) and + * Uses `semver.compare` which handles both stable (`X.Y.Z`) and * nightly (`X.Y.Z-dev.`) versions correctly — the numeric * pre-release identifier is compared numerically per SemVer spec. * diff --git a/packages/cli/src/lib/delta-upgrade.ts b/packages/cli/src/lib/delta-upgrade.ts index ec709b8c8d..d7f2e3bfa0 100644 --- a/packages/cli/src/lib/delta-upgrade.ts +++ b/packages/cli/src/lib/delta-upgrade.ts @@ -32,16 +32,17 @@ import { } from "binpatch"; import { compareVersions, - GITHUB_RELEASES_URL, + getGitHubReleasesUrl, getPlatformBinaryName, isDowngrade, isNightlyVersion, + PRIMARY_UPGRADE_SOURCE, + type UpgradeSource, } from "./binary.js"; import { CLI_VERSION } from "./constants.js"; import { customFetch } from "./custom-ca.js"; import { getConfigDir } from "./db/index.js"; import { formatBytes } from "./formatters/numbers.js"; -import { GHCR_REPO } from "./ghcr.js"; import { logger } from "./logger.js"; import { makeByteProgress, type SetMessage } from "./progress.js"; import { withTracing, withTracingSpan } from "./telemetry.js"; @@ -68,11 +69,15 @@ export type DeltaResult = { chainLength: number; }; -// GHCR publishes nightlies to ghcr.io/getsentry/cli (see src/lib/ghcr.ts -// GHCR_REPO). Importing as a named import keeps a single source of truth and -// avoids the silent 404 introduced when this was a string literal. const log = logger.withTag("delta-upgrade"); +function getPrimaryUpgradeSource(): UpgradeSource { + if (!PRIMARY_UPGRADE_SOURCE) { + throw new Error("No primary upgrade source is configured"); + } + return PRIMARY_UPGRADE_SOURCE; +} + const instrument: InstrumentHook = (name, fn) => withTracing(name, "http.client", fn); @@ -119,9 +124,9 @@ function getPatchCache(): PatchCache { return instrumentCache(makeCache(join(getConfigDir(), "patch-cache"))); } -function stableSource(): SourceStrategy { +function stableSource(source: UpgradeSource): SourceStrategy { return githubReleaseSource({ - releasesUrl: GITHUB_RELEASES_URL, + releasesUrl: getGitHubReleasesUrl(source), binaryName: getPlatformBinaryName(), userAgent: `sentry-cli/${CLI_VERSION}`, fetch: customFetch, @@ -129,10 +134,10 @@ function stableSource(): SourceStrategy { }); } -function nightlySource(): SourceStrategy { +function nightlySource(source: UpgradeSource): SourceStrategy { return ghcrSource({ registry: "https://ghcr.io", - repo: GHCR_REPO, + repo: source.ghcrRepo, binaryName: getPlatformBinaryName(), targetTag: (version) => `nightly-${version}`, compareVersions, @@ -153,16 +158,20 @@ export function canAttemptDelta(targetVersion: string): boolean { } export async function fetchRecentReleases( - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = getPrimaryUpgradeSource() ): Promise { try { - const response = await customFetch(`${GITHUB_RELEASES_URL}?per_page=12`, { - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": `sentry-cli/${CLI_VERSION}`, - }, - signal, - }); + const response = await customFetch( + `${getGitHubReleasesUrl(source)}?per_page=12`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": `sentry-cli/${CLI_VERSION}`, + }, + signal, + } + ); if (!response.ok) { return []; } @@ -270,9 +279,14 @@ export function validateChainStep( export function resolveStableChain( currentVersion: string, targetVersion: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = getPrimaryUpgradeSource() ): Promise { - return stableSource().resolveChain(currentVersion, targetVersion, signal); + return stableSource(source).resolveChain( + currentVersion, + targetVersion, + signal + ); } export async function resolveNightlyChain(opts: { @@ -282,10 +296,12 @@ export async function resolveNightlyChain(opts: { fullGzSize: number; preloadedTags?: string[]; signal?: AbortSignal; + source?: UpgradeSource; }): Promise { + const { source = getPrimaryUpgradeSource() } = opts; const client = new OciClient({ registry: "https://ghcr.io", - repo: GHCR_REPO, + repo: source.ghcrRepo, userAgent: `sentry-cli/${CLI_VERSION}`, fetch: customFetch, }); @@ -489,10 +505,11 @@ export function resolveStableDelta( oldBinaryPath: string, destPath: string, offline?: boolean, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = getPrimaryUpgradeSource() ): Promise { return resolveDelta( - stableSource(), + stableSource(source), targetVersion, oldBinaryPath, destPath, @@ -507,10 +524,11 @@ export function resolveNightlyDelta( oldBinaryPath: string, destPath: string, offline?: boolean, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = getPrimaryUpgradeSource() ): Promise { return resolveDelta( - nightlySource(), + nightlySource(source), targetVersion, oldBinaryPath, destPath, @@ -525,7 +543,8 @@ export function attemptDeltaUpgrade( oldBinaryPath: string, destPath: string, offline?: boolean, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = getPrimaryUpgradeSource() ): Promise { if (!canAttemptDelta(targetVersion)) { return Promise.resolve(null); @@ -540,7 +559,7 @@ export function attemptDeltaUpgrade( let chainSource: string | undefined; try { const resolved = await resolveDelta( - channel === "nightly" ? nightlySource() : stableSource(), + channel === "nightly" ? nightlySource(source) : stableSource(source), targetVersion, oldBinaryPath, destPath, @@ -614,14 +633,16 @@ async function prefetch( export function prefetchNightlyPatches( targetVersion: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = getPrimaryUpgradeSource() ): Promise { - return prefetch(nightlySource(), targetVersion, signal); + return prefetch(nightlySource(source), targetVersion, signal); } export function prefetchStablePatches( targetVersion: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = getPrimaryUpgradeSource() ): Promise { - return prefetch(stableSource(), targetVersion, signal); + return prefetch(stableSource(source), targetVersion, signal); } diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index 43d6deb7e6..c270d52ea7 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -17,6 +17,7 @@ * without the auth header. */ +import { PRIMARY_UPGRADE_SOURCE, type UpgradeSource } from "./binary.js"; import { getUserAgent } from "./constants.js"; import { customFetch } from "./custom-ca.js"; import { UpgradeError } from "./errors.js"; @@ -130,8 +131,8 @@ async function fetchWithRetry( ); } -/** GHCR repository for CLI distribution */ -export const GHCR_REPO = "getsentry/cli"; +/** Default GHCR repository for CLI distribution. */ +export const GHCR_REPO = PRIMARY_UPGRADE_SOURCE.ghcrRepo; /** OCI tag for nightly builds */ export const GHCR_TAG = "nightly"; @@ -189,13 +190,19 @@ export type OciManifest = { * @returns Bearer token string * @throws {UpgradeError} On network failure or malformed response */ -export async function getAnonymousToken(signal?: AbortSignal): Promise { - const url = `${GHCR_REGISTRY}/token?scope=repository:${GHCR_REPO}:pull`; +export async function getAnonymousToken( + sourceOrSignal: UpgradeSource | AbortSignal = PRIMARY_UPGRADE_SOURCE, + signal?: AbortSignal +): Promise { + const source = + "ghcrRepo" in sourceOrSignal ? sourceOrSignal : PRIMARY_UPGRADE_SOURCE; + const externalSignal = "ghcrRepo" in sourceOrSignal ? signal : sourceOrSignal; + const url = `${GHCR_REGISTRY}/token?scope=repository:${source.ghcrRepo}:pull`; const response = await fetchWithRetry( url, { headers: { "User-Agent": getUserAgent() } }, "Failed to connect to GHCR", - { signal } + { signal: externalSignal } ); if (!response.ok) { @@ -227,9 +234,10 @@ export async function getAnonymousToken(signal?: AbortSignal): Promise { export async function fetchManifest( token: string, tag: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { - const url = `${GHCR_REGISTRY}/v2/${GHCR_REPO}/manifests/${tag}`; + const url = `${GHCR_REGISTRY}/v2/${source.ghcrRepo}/manifests/${tag}`; const response = await fetchWithRetry( url, { @@ -263,9 +271,11 @@ export async function fetchManifest( * @throws {UpgradeError} On network failure or non-200 response */ export async function fetchNightlyManifest( - token: string + token: string, + signal?: AbortSignal, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { - return await fetchManifest(token, GHCR_TAG); + return await fetchManifest(token, GHCR_TAG, signal, source); } /** @@ -332,9 +342,10 @@ export function findLayerByFilename( export async function downloadNightlyBlob( token: string, digest: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { - const blobUrl = `${GHCR_REGISTRY}/v2/${GHCR_REPO}/blobs/${digest}`; + const blobUrl = `${GHCR_REGISTRY}/v2/${source.ghcrRepo}/blobs/${digest}`; // Step 1: GET blob URL with auth, but do NOT follow redirects. // ghcr.io returns 307 → Azure Blob Storage signed URL. @@ -427,9 +438,10 @@ const TAGS_PAGE_SIZE = 100; async function fetchTagPage( token: string, lastTag?: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { - let url = `${GHCR_REGISTRY}/v2/${GHCR_REPO}/tags/list?n=${TAGS_PAGE_SIZE}`; + let url = `${GHCR_REGISTRY}/v2/${source.ghcrRepo}/tags/list?n=${TAGS_PAGE_SIZE}`; if (lastTag) { url += `&last=${encodeURIComponent(lastTag)}`; } @@ -471,13 +483,14 @@ async function fetchTagPage( export async function listTags( token: string, prefix?: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { const allTags: string[] = []; let lastTag: string | undefined; for (;;) { - const tags = await fetchTagPage(token, lastTag, signal); + const tags = await fetchTagPage(token, lastTag, signal, source); if (tags.length === 0) { break; } @@ -513,8 +526,9 @@ export async function listTags( export async function downloadLayerBlob( token: string, digest: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { - const response = await downloadNightlyBlob(token, digest, signal); + const response = await downloadNightlyBlob(token, digest, signal, source); return response.arrayBuffer(); } diff --git a/packages/cli/src/lib/release-notes.ts b/packages/cli/src/lib/release-notes.ts index ddf617535e..fb32efd787 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -15,8 +15,10 @@ import { marked, type Token, type Tokens } from "marked"; import { compareVersions, - GITHUB_RELEASES_URL, getGitHubHeaders, + getGitHubReleasesUrl, + PRIMARY_UPGRADE_SOURCE, + type UpgradeSource, } from "./binary.js"; import { customFetch } from "./custom-ca.js"; import type { GitHubRelease } from "./delta-upgrade.js"; @@ -413,27 +415,31 @@ function mergeSectionsByCategory(releases: GitHubRelease[]): ChangeSection[] { return merged; } -/** - * Build a changelog summary from a list of GitHub releases. - * - * Filters releases within the version range (exclusive `fromVersion`, - * inclusive `toVersion`), extracts and merges sections by category, and - * optionally truncates to fit terminal constraints. - * - * @param releases - GitHub releases (newest first) - * @param fromVersion - Current version (exclusive lower bound) - * @param toVersion - Target version (inclusive upper bound) - * @param maxItems - Maximum total list items across all sections - * @returns Changelog summary, or null if no relevant changes found - */ -export function buildChangelogSummary( +/** Options for source-aware changelog summary construction. */ +type ChangelogBuildOptions = { + /** Maximum total list items across all sections, or unlimited when omitted. */ + maxItems?: number; + /** Selected release source whose tag prefix filters the release list. */ + source?: UpgradeSource; +}; + +/** Build a changelog summary while filtering source-specific release tags. */ +function buildChangelogSummaryForSource( releases: GitHubRelease[], fromVersion: string, toVersion: string, - maxItems?: number + options: ChangelogBuildOptions ): ChangelogSummary | null { - const inRange = releases.filter((r) => { - const version = r.tag_name.replace(VERSION_PREFIX_RE, ""); + const { maxItems, source } = options; + const inRange = releases.filter((release) => { + let tagName = release.tag_name; + if (source?.tagPrefix) { + if (!tagName.startsWith(source.tagPrefix)) { + return false; + } + tagName = tagName.slice(source.tagPrefix.length); + } + const version = tagName.replace(VERSION_PREFIX_RE, ""); return ( compareVersions(version, fromVersion) === 1 && compareVersions(version, toVersion) <= 0 @@ -453,6 +459,26 @@ export function buildChangelogSummary( ); } +/** + * Build a changelog summary from releases with legacy unprefixed tags. + * + * @param releases - GitHub releases (newest first) + * @param fromVersion - Current version (exclusive lower bound) + * @param toVersion - Target version (inclusive upper bound) + * @param maxItems - Maximum total list items across all sections + * @returns Changelog summary, or null if no relevant changes were found + */ +export function buildChangelogSummary( + releases: GitHubRelease[], + fromVersion: string, + toVersion: string, + maxItems?: number +): ChangelogSummary | null { + return buildChangelogSummaryForSource(releases, fromVersion, toVersion, { + maxItems, + }); +} + // ────────────────────────── Nightly Commit Parsing ───────────────────────── /** Conventional commit prefix → category mapping */ @@ -557,11 +583,13 @@ const CHANGELOG_MAX_RELEASES = 30; * * @returns Array of releases (newest first), or empty array on failure */ -async function fetchReleasesForChangelog(): Promise { +async function fetchReleasesForChangelog( + source: UpgradeSource +): Promise { let response: Response; try { response = await customFetch( - `${GITHUB_RELEASES_URL}?per_page=${CHANGELOG_MAX_RELEASES}`, + `${getGitHubReleasesUrl(source)}?per_page=${CHANGELOG_MAX_RELEASES}`, { headers: getGitHubHeaders() } ); } catch (error) { @@ -593,23 +621,23 @@ async function fetchReleasesForChangelog(): Promise { * back to fetching with a higher per_page than the delta-upgrade path * to cover larger version jumps. * - * @param fromVersion - Current version - * @param toVersion - Target version - * @param maxItems - Maximum list items to include - * @param prefetchedReleases - Optional releases already fetched by the caller + * @param options - Version range, selected source, limit, and optional releases * @returns Changelog summary, or null on failure */ async function fetchStableChangelog( - fromVersion: string, - toVersion: string, - maxItems?: number, - prefetchedReleases?: GitHubRelease[] + options: FetchChangelogOptions & { source: UpgradeSource } ): Promise { - const releases = prefetchedReleases ?? (await fetchReleasesForChangelog()); + const { fromVersion, toVersion, maxItems, prefetchedReleases, source } = + options; + const releases = + prefetchedReleases ?? (await fetchReleasesForChangelog(source)); if (releases.length === 0) { return null; } - return buildChangelogSummary(releases, fromVersion, toVersion, maxItems); + return buildChangelogSummaryForSource(releases, fromVersion, toVersion, { + maxItems, + source, + }); } /** @@ -636,12 +664,14 @@ function buildNightlyChangelogSummary( * * @param fromVersion - Current nightly version * @param toVersion - Target nightly version + * @param source - Release source selected during version discovery * @param maxItems - Maximum list items to include * @returns Changelog summary, or null on failure or invalid versions */ async function fetchNightlyChangelog( fromVersion: string, toVersion: string, + source: UpgradeSource, maxItems?: number ): Promise { const fromTs = extractNightlyTimestamp(fromVersion); @@ -659,7 +689,7 @@ async function fetchNightlyChangelog( const sinceDate = new Date((fromTs + 1) * 1000).toISOString(); const untilDate = new Date((toTs + 1) * 1000).toISOString(); - const url = `https://api.github.com/repos/getsentry/cli/commits?sha=main&since=${sinceDate}&until=${untilDate}&per_page=100`; + const url = `https://api.github.com/repos/${source.githubRepo}/commits?sha=main&since=${sinceDate}&until=${untilDate}&per_page=100`; let response: Response; try { @@ -705,6 +735,8 @@ export type FetchChangelogOptions = { maxItems?: number; /** Pre-fetched releases to avoid redundant API call (stable channel only) */ prefetchedReleases?: GitHubRelease[]; + /** Release source selected during version discovery; defaults to the primary source */ + source?: UpgradeSource; }; /** @@ -721,17 +753,30 @@ export async function fetchChangelog( opts: FetchChangelogOptions ): Promise { try { - const { channel, fromVersion, toVersion, maxItems, prefetchedReleases } = - opts; + const { + channel, + fromVersion, + toVersion, + maxItems, + prefetchedReleases, + source = PRIMARY_UPGRADE_SOURCE, + } = opts; if (channel === "nightly") { - return await fetchNightlyChangelog(fromVersion, toVersion, maxItems); + return await fetchNightlyChangelog( + fromVersion, + toVersion, + source, + maxItems + ); } - return await fetchStableChangelog( + return await fetchStableChangelog({ + channel, fromVersion, toVersion, maxItems, - prefetchedReleases - ); + prefetchedReleases, + source, + }); } catch (error) { log.debug("Changelog fetch failed:", error); return null; diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 945adc48d9..0b9637d8cc 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -26,16 +26,22 @@ import { cleanupOldBinary, determineInstallDir, fetchWithUpgradeError, - GITHUB_RELEASES_URL, getBinaryDownloadUrl, getBinaryFilename, getBinaryPaths, getGitHubHeaders, + getGitHubLatestReleaseUrl, + getGitHubReleaseByTagUrl, + getGitHubRepositoryUrl, getPlatformBinaryName, type InstallationMethod, isNightlyVersion, KNOWN_CURL_DIRS, + PRIMARY_UPGRADE_SOURCE, releaseLock, + resolveUpgradeSource, + UPGRADE_SOURCES, + type UpgradeSource, } from "./binary.js"; import { CLI_VERSION, NODE_MODULES_DIRNAME } from "./constants.js"; import { getInstallInfo, setInstallInfo } from "./db/install-info.js"; @@ -50,6 +56,7 @@ import { findLayerByFilename, getAnonymousToken, getNightlyVersion, + type OciManifest, } from "./ghcr.js"; import { logger } from "./logger.js"; import { clearPatchCache } from "./patch-cache.js"; @@ -87,6 +94,33 @@ const NPM_REGISTRY_URL = "https://registry.npmjs.org/sentry"; /** Regex to strip 'v' prefix from version strings */ export const VERSION_PREFIX_REGEX = /^v/; +/** A resolved standalone-binary version and the source that must serve it. */ +export type ResolvedUpgradeVersion = { + /** Version without a source-specific tag prefix. */ + readonly version: string; + /** Source selected for every later lookup and download in this operation. */ + readonly source: UpgradeSource; +}; + +function extractReleaseVersions( + data: + | { tag_name?: string } + | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>, + source: UpgradeSource +): string[] { + if (!Array.isArray(data)) { + return data.tag_name ? [data.tag_name] : []; + } + return data + .filter((release) => !(release.draft || release.prerelease)) + .map((release) => release.tag_name) + .filter( + (tag): tag is string => + typeof tag === "string" && tag.startsWith(source.tagPrefix) + ) + .map((tag) => tag.slice(source.tagPrefix.length)); +} + // Curl Binary Helpers /** @@ -396,32 +430,40 @@ export async function detectInstallationMethod(): Promise { * @throws {UpgradeError} When fetch fails or response is invalid * @throws {Error} AbortError if signal is aborted */ -export async function fetchLatestFromGitHub( - signal?: AbortSignal -): Promise { - const response = await fetchWithUpgradeError( - `${GITHUB_RELEASES_URL}/latest`, - { headers: getGitHubHeaders(), signal }, - "GitHub" - ); - - if (!response.ok) { - throw new UpgradeError( - "network_error", - `Failed to fetch from GitHub: ${response.status}` - ); - } - - const data = (await response.json()) as { tag_name?: string }; - - if (!data.tag_name) { +export async function fetchLatestFromGitHubWithSource( + signal?: AbortSignal, + sources: readonly UpgradeSource[] = UPGRADE_SOURCES +): Promise { + const { source, response } = await resolveUpgradeSource({ + getProbeUrl: getGitHubLatestReleaseUrl, + signal, + sources, + }); + const data = (await response.json()) as + | { tag_name?: string } + | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>; + const tags = extractReleaseVersions(data, source); + const version = tags[0]?.replace(VERSION_PREFIX_REGEX, ""); + if (!version) { throw new UpgradeError( "network_error", "No version found in GitHub release" ); } + return { version, source }; +} - return data.tag_name.replace(VERSION_PREFIX_REGEX, ""); +/** Fetch the latest standalone CLI version from the ordered GitHub sources. */ +export async function fetchLatestFromGitHub( + signal?: AbortSignal, + source?: UpgradeSource +): Promise { + return ( + await fetchLatestFromGitHubWithSource( + signal, + source ? [source] : UPGRADE_SOURCES + ) + ).version; } /** @@ -464,23 +506,76 @@ export async function fetchLatestFromNpm(): Promise { * @returns Latest nightly version string (e.g., "0.13.0-dev.1740000000") * @throws {UpgradeError} When fetch fails or the version annotation is missing */ -export async function fetchLatestNightlyVersion( - signal?: AbortSignal -): Promise { - // AbortSignal is not threaded through ghcr helpers, but checking it before - // each network call ensures we bail out promptly when the process exits. +export async function fetchLatestNightlyVersionWithSource( + signal?: AbortSignal, + sources: readonly UpgradeSource[] = UPGRADE_SOURCES +): Promise { if (signal?.aborted) { throw new AbortError(); } + const resolved = await resolveNightlyManifest("nightly", signal, sources); + return { + version: getNightlyVersion(resolved.manifest), + source: resolved.source, + }; +} - const token = await getAnonymousToken(); - - if (signal?.aborted) { - throw new AbortError(); +async function resolveNightlyManifest( + tag: string, + signal: AbortSignal | undefined, + sources: readonly UpgradeSource[] +): Promise<{ source: UpgradeSource; manifest: OciManifest }> { + for (const source of sources) { + try { + await resolveUpgradeSource({ + getProbeUrl: getGitHubRepositoryUrl, + signal, + sources: [source], + }); + } catch (error) { + if (isUpgradeSourceNotFound(error)) { + continue; + } + throw error; + } + try { + const token = await getAnonymousToken(source, signal); + const manifest = await fetchManifest(token, tag, signal, source); + return { source, manifest }; + } catch (error) { + if ( + error instanceof UpgradeError && + error.message.includes(`tag "${tag}": HTTP 404`) + ) { + continue; + } + throw error; + } } + throw new UpgradeError( + "network_error", + "No CLI upgrade source was found: every source returned HTTP 404" + ); +} - const manifest = await fetchNightlyManifest(token); - return getNightlyVersion(manifest); +function isUpgradeSourceNotFound(error: unknown): boolean { + return ( + error instanceof UpgradeError && + error.message.includes("every source returned HTTP 404") + ); +} + +/** Fetch the latest nightly version from the ordered release sources. */ +export async function fetchLatestNightlyVersion( + signal?: AbortSignal, + source?: UpgradeSource +): Promise { + return ( + await fetchLatestNightlyVersionWithSource( + signal, + source ? [source] : UPGRADE_SOURCES + ) + ).version; } /** @@ -507,6 +602,41 @@ export function fetchLatestVersion( : fetchLatestFromNpm(); } +/** Resolve the latest version and selected source for a standalone upgrade. */ +export function resolveLatestUpgradeVersion( + channel: ReleaseChannel, + signal?: AbortSignal +): Promise { + return channel === "nightly" + ? fetchLatestNightlyVersionWithSource(signal) + : fetchLatestFromGitHubWithSource(signal); +} + +/** Resolve and validate a pinned standalone version against ordered sources. */ +export async function resolveExistingUpgradeVersion( + version: string +): Promise { + try { + if (isNightlyVersion(version)) { + const resolved = await resolveNightlyManifest( + `nightly-${version}`, + undefined, + UPGRADE_SOURCES + ); + return { version, source: resolved.source }; + } + const selected = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl(version, source), + }); + return { version, source: selected.source }; + } catch (error) { + if (isUpgradeSourceNotFound(error)) { + return null; + } + throw error; + } +} + /** * Check if a versioned nightly tag exists in GHCR. * @@ -519,23 +649,42 @@ export function fetchLatestVersion( * @returns true if the nightly tag exists in GHCR, false if not found * @throws {UpgradeError} On network failure or GHCR unavailability */ -async function nightlyVersionExists(version: string): Promise { - const token = await getAnonymousToken(); +async function nightlyVersionExists( + version: string, + source: UpgradeSource +): Promise { + const token = await getAnonymousToken(source); try { - await fetchManifest(token, `nightly-${version}`); + await fetchManifest(token, `nightly-${version}`, undefined, source); return true; } catch (error) { - // 404 = tag doesn't exist; 403 = token lacks access to non-existent tag - if ( - error instanceof UpgradeError && - (error.message.includes("HTTP 404") || error.message.includes("HTTP 403")) - ) { + if (error instanceof UpgradeError && error.message.includes("HTTP 404")) { return false; } throw error; } } +async function standaloneVersionExists( + version: string, + source?: UpgradeSource +): Promise { + if (source) { + if (isNightlyVersion(version)) { + return nightlyVersionExists(version, source); + } + return ( + await fetchWithUpgradeError( + getGitHubReleaseByTagUrl(version, source), + { headers: getGitHubHeaders() }, + "GitHub" + ) + ).ok; + } + const resolved = await resolveExistingUpgradeVersion(version); + return resolved !== null; +} + /** * Check if a specific version exists in the appropriate registry. * @@ -550,20 +699,11 @@ async function nightlyVersionExists(version: string): Promise { */ export async function versionExists( method: InstallationMethod, - version: string + version: string, + source?: UpgradeSource ): Promise { - // Nightly versions are published to GHCR, not GitHub Releases or npm - if (isNightlyVersion(version)) { - return nightlyVersionExists(version); - } - - if (method === "curl" || method === "brew") { - const response = await fetchWithUpgradeError( - `${GITHUB_RELEASES_URL}/tags/${version}`, - { method: "HEAD", headers: getGitHubHeaders() }, - "GitHub" - ); - return response.ok; + if (isNightlyVersion(version) || method === "curl" || method === "brew") { + return standaloneVersionExists(version, source); } const response = await fetchWithUpgradeError( @@ -728,15 +868,21 @@ function getNightlyGzFilename(): string { async function downloadNightlyToPath( destPath: string, version?: string, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { - const token = await getAnonymousToken(); + const token = await getAnonymousToken(source); const manifest = version - ? await fetchManifest(token, `nightly-${version}`) - : await fetchNightlyManifest(token); + ? await fetchManifest(token, `nightly-${version}`, undefined, source) + : await fetchNightlyManifest(token, undefined, source); const filename = getNightlyGzFilename(); const layer = findLayerByFilename(manifest, filename); - const response = await downloadNightlyBlob(token, layer.digest); + const response = await downloadNightlyBlob( + token, + layer.digest, + undefined, + source + ); if (!response.body) { throw new UpgradeError( @@ -761,9 +907,10 @@ async function downloadNightlyToPath( async function downloadStableToPath( version: string, destPath: string, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { - const url = getBinaryDownloadUrl(version); + const url = getBinaryDownloadUrl(version, source); const headers = getGitHubHeaders(); // Try gzip-compressed download first (~60% smaller) @@ -899,11 +1046,13 @@ async function waitForBinaryVisible(path: string): Promise { * @returns The downloaded binary path and lock path to release * @throws {UpgradeError} When download fails */ +// biome-ignore lint/nursery/useMaxParams: compatibility API; source preserves one selected repository across the download. export async function downloadBinaryToTemp( version: string, downloadTag?: string, offline?: OfflineMode, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { const { tempPath, lockPath } = getCurlInstallPaths(); @@ -924,7 +1073,8 @@ export async function downloadBinaryToTemp( version, tempPath, !!offline, - setMessage + setMessage, + source ); let patchBytes: number | undefined; if (deltaResult) { @@ -940,7 +1090,13 @@ export async function downloadBinaryToTemp( ); } else { log.debug("Downloading full binary"); - await downloadFullBinary(version, downloadTag, tempPath, setMessage); + await downloadFullBinary( + version, + downloadTag, + tempPath, + setMessage, + source + ); } // Verify the download produced a real, non-empty file before the caller @@ -982,18 +1138,21 @@ export async function downloadBinaryToTemp( * @param destPath - Path to write the patched binary * @returns Delta result with SHA-256 and size info, or null if delta is unavailable */ +// biome-ignore lint/nursery/useMaxParams: mirrors the established download helper while forwarding source affinity. async function tryDeltaUpgrade( version: string, destPath: string, offline?: boolean, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { return await attemptDeltaUpgrade( version, process.execPath, destPath, offline, - setMessage + setMessage, + source ); } @@ -1004,16 +1163,23 @@ async function tryDeltaUpgrade( * @param downloadTag - Git tag override for the download URL * @param destPath - Path to write the binary */ +// biome-ignore lint/nursery/useMaxParams: internal dispatch retains the established download arguments plus source affinity. async function downloadFullBinary( version: string, downloadTag: string | undefined, destPath: string, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { if (isNightlyVersion(version)) { - await downloadNightlyToPath(destPath, version, setMessage); + await downloadNightlyToPath(destPath, version, setMessage, source); } else { - await downloadStableToPath(downloadTag ?? version, destPath, setMessage); + await downloadStableToPath( + downloadTag ?? version, + destPath, + setMessage, + source + ); } } @@ -1124,11 +1290,18 @@ export async function executeUpgrade( version: string, downloadTag?: string, offline?: OfflineMode, - setMessage?: SetMessage + setMessage?: SetMessage, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { switch (method) { case "curl": - return downloadBinaryToTemp(version, downloadTag, offline, setMessage); + return downloadBinaryToTemp( + version, + downloadTag, + offline, + setMessage, + source + ); case "brew": await executeUpgradeHomebrew(); return null; diff --git a/packages/cli/src/lib/version-check.ts b/packages/cli/src/lib/version-check.ts index c69ff04663..34393b1c77 100644 --- a/packages/cli/src/lib/version-check.ts +++ b/packages/cli/src/lib/version-check.ts @@ -10,6 +10,7 @@ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; import { compare as semverCompare } from "semver"; +import type { UpgradeSource } from "./binary.js"; import { CLI_VERSION } from "./constants.js"; import { getReleaseChannel } from "./db/release-channel.js"; import { @@ -27,7 +28,10 @@ import { cyan, muted } from "./formatters/colors.js"; import { GLOBAL_FLAGS } from "./global-flags.js"; import { logger } from "./logger.js"; import { cleanupPatchCache } from "./patch-cache.js"; -import { fetchLatestFromGitHub, fetchLatestNightlyVersion } from "./upgrade.js"; +import { + fetchLatestFromGitHubWithSource, + fetchLatestNightlyVersionWithSource, +} from "./upgrade.js"; /** Target check interval: ~24 hours */ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -227,16 +231,17 @@ export function abortPendingVersionCheck(): void { async function maybePrefetchPatches( channel: "stable" | "nightly", latestVersion: string, - signal: AbortSignal + signal: AbortSignal, + source: UpgradeSource ): Promise { if (semverCompare(latestVersion, CLI_VERSION) !== 1) { return; } try { if (channel === "nightly") { - await prefetchNightlyPatches(latestVersion, signal); + await prefetchNightlyPatches(latestVersion, signal, source); } else { - await prefetchStablePatches(latestVersion, signal); + await prefetchStablePatches(latestVersion, signal, source); } } catch (error) { logger.debug("Delta patch pre-fetch failed (best-effort)", error); @@ -281,14 +286,14 @@ function checkForUpdateInBackgroundImpl(): void { async (span) => { try { // Use GHCR for nightly channel; GitHub Releases for stable. - const latestVersion = + const { version: latestVersion, source } = channel === "nightly" - ? await fetchLatestNightlyVersion(signal) - : await fetchLatestFromGitHub(signal); + ? await fetchLatestNightlyVersionWithSource(signal) + : await fetchLatestFromGitHubWithSource(signal); setVersionCheckInfo(latestVersion); // Pre-fetch delta patches so `sentry cli upgrade` can apply them offline - await maybePrefetchPatches(channel, latestVersion, signal); + await maybePrefetchPatches(channel, latestVersion, signal, source); span.setStatus({ code: 1 }); // OK } catch (error) { diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index ca9688e467..b79a9c342d 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -172,6 +172,10 @@ function mockGhcrNightlyVersion(version: string): void { mockFetch(async (url) => { const urlStr = String(url); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } + // GHCR anonymous token exchange if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "test-token" }), { @@ -209,19 +213,17 @@ function mockGitHubVersion(version: string): void { mockFetch(async (url) => { const urlStr = String(url); - // GitHub latest release endpoint — returns JSON with tag_name - if (urlStr.includes("releases/latest")) { - return new Response(JSON.stringify({ tag_name: version }), { + if (urlStr.includes("getsentry/toolkit/releases?per_page=100")) { + return new Response(JSON.stringify([{ tag_name: `cli@${version}` }]), { status: 200, headers: { "content-type": "application/json" }, }); } - // GitHub tag check (for versionExists) — this repo uses un-prefixed tags if (urlStr.includes("/releases/tags/")) { const requested = urlStr.split("/releases/tags/")[1]; - if (requested === version) { - return new Response(JSON.stringify({ tag_name: version }), { + if (requested === `cli%40${version}`) { + return new Response(JSON.stringify({ tag_name: `cli@${version}` }), { status: 200, headers: { "content-type": "application/json" }, }); @@ -251,6 +253,9 @@ function mockGitHubVersion(version: string): void { function mockNightlyVersion(version: string): void { mockFetch(async (url) => { const urlStr = String(url); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "test-token" }), { status: 200, @@ -403,6 +408,42 @@ describe("sentry cli upgrade", () => { expect(combined).toContain("99.99.99"); expect(combined).toContain("Run 'sentry cli upgrade' to update."); }); + + test("uses the selected legacy source for the check-mode changelog", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + const request = String(url); + requests.push(request); + if (request.includes("getsentry/toolkit/releases?per_page=100")) { + return new Response("Not Found", { status: 404 }); + } + if (request.includes("getsentry/cli/releases/latest")) { + return new Response(JSON.stringify({ tag_name: "99.99.99" }), { + status: 200, + }); + } + if (request.includes("getsentry/cli/releases?per_page=30")) { + return new Response(JSON.stringify([]), { status: 200 }); + } + return new Response("Unexpected", { status: 500 }); + }); + + const { context, restore } = createMockContext({ homeDir: testDir }); + restoreStderr = restore; + + await run( + app, + ["cli", "upgrade", "--check", "--method", "brew"], + context + ); + + expect(requests).toContain( + "https://api.github.com/repos/getsentry/cli/releases?per_page=30" + ); + expect(requests).not.toContain( + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=30" + ); + }); }); describe("version validation", () => { @@ -410,8 +451,8 @@ describe("sentry cli upgrade", () => { // Mock: latest is 99.99.99, but 0.0.1 doesn't exist mockFetch(async (url) => { const urlStr = String(url); - if (urlStr.includes("releases/latest")) { - return new Response(JSON.stringify({ tag_name: "v99.99.99" }), { + if (urlStr.includes("getsentry/toolkit/releases?per_page=100")) { + return new Response(JSON.stringify([{ tag_name: "cli@99.99.99" }]), { status: 200, headers: { "content-type": "application/json" }, }); @@ -746,8 +787,8 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy const gzipped = gzipSync(fakeContent); mockFetch(async (url) => { const urlStr = String(url); - if (urlStr.includes("releases/latest")) { - return new Response(JSON.stringify({ tag_name: version }), { + if (urlStr.includes("getsentry/toolkit/releases?per_page=100")) { + return new Response(JSON.stringify([{ tag_name: `cli@${version}` }]), { status: 200, headers: { "content-type": "application/json" }, }); @@ -872,8 +913,8 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy const gzipped = gzipSync(fakeContent); mockFetch(async (url) => { const urlStr = String(url); - if (urlStr.includes("releases/latest")) { - return new Response(JSON.stringify({ tag_name: "99.99.99" }), { + if (urlStr.includes("getsentry/toolkit/releases?per_page=100")) { + return new Response(JSON.stringify([{ tag_name: "cli@99.99.99" }]), { status: 200, headers: { "content-type": "application/json" }, }); @@ -903,6 +944,9 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy mockFetch(async (url) => { const urlStr = String(url); capturedUrls.push(urlStr); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "test-token" }), { status: 200, @@ -1037,6 +1081,9 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp // Nightly is now distributed via GHCR (token → manifest → blob) mockFetch(async (url) => { const urlStr = String(url); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "test-token" }), { status: 200, diff --git a/packages/cli/test/lib/binary.test.ts b/packages/cli/test/lib/binary.test.ts index 6fcfb3b1aa..8f49abb41f 100644 --- a/packages/cli/test/lib/binary.test.ts +++ b/packages/cli/test/lib/binary.test.ts @@ -25,6 +25,7 @@ import { getBinaryDownloadUrl, getBinaryFilename, getBinaryPaths, + getGitHubReleaseByTagUrl, getLegacyInstallDirs, getPlatformBinaryName, installBinary, @@ -32,7 +33,9 @@ import { isMusl, releaseLock, replaceBinarySync, + resolveUpgradeSource, samePath, + UPGRADE_SOURCES, } from "../../src/lib/binary.js"; import { UpgradeError } from "../../src/lib/errors.js"; @@ -40,9 +43,9 @@ describe("getBinaryDownloadUrl", () => { test("builds correct URL for current platform", () => { const url = getBinaryDownloadUrl("1.0.0"); - expect(url).toContain("/1.0.0/"); + expect(url).toContain("/cli@1.0.0/"); expect(url).toStartWith( - "https://github.com/getsentry/cli/releases/download/" + "https://github.com/getsentry/toolkit/releases/download/" ); expect(url).toContain("sentry-"); @@ -61,6 +64,127 @@ describe("getBinaryDownloadUrl", () => { }); }); +describe("UPGRADE_SOURCES", () => { + test("checks Toolkit before the legacy CLI repository", () => { + expect(UPGRADE_SOURCES).toEqual([ + { + githubRepo: "getsentry/toolkit", + ghcrRepo: "getsentry/toolkit", + tagPrefix: "cli@", + }, + { + githubRepo: "getsentry/cli", + ghcrRepo: "getsentry/cli", + tagPrefix: "", + }, + ]); + }); +}); + +describe("resolveUpgradeSource", () => { + test("uses the first source when it exists", async () => { + const requests: string[] = []; + + const resolved = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response(JSON.stringify({ tag_name: "cli@0.45.0" }), { + status: 200, + }); + }, + }); + + expect(resolved).toEqual({ + source: UPGRADE_SOURCES[0], + response: expect.any(Response), + }); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + ]); + }); + + test("falls back to the legacy source only on HTTP 404", async () => { + const requests: string[] = []; + + const resolved = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response( + requests.length === 1 + ? "Not Found" + : JSON.stringify({ tag_name: "0.45.0" }), + { status: requests.length === 1 ? 404 : 200 } + ); + }, + }); + + expect(resolved.source).toBe(UPGRADE_SOURCES[1]); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + "https://api.github.com/repos/getsentry/cli/releases/tags/0.45.0", + ]); + }); + + test.each([ + 401, 403, 429, 500, + ])("does not fall back on HTTP %i", async (status) => { + const requests: string[] = []; + + await expect( + resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response("failure", { status }); + }, + }) + ).rejects.toThrow(`HTTP ${status}`); + + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + ]); + }); + + test("does not fall back on a network failure", async () => { + const requests: string[] = []; + + await expect( + resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + throw new TypeError("fetch failed"); + }, + }) + ).rejects.toThrow("Failed to connect to GitHub: fetch failed"); + + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + ]); + }); + + test("fails after every source returns 404", async () => { + const requests: string[] = []; + + await expect( + resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response("Not Found", { status: 404 }); + }, + }) + ).rejects.toThrow("No CLI upgrade source was found"); + + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + "https://api.github.com/repos/getsentry/cli/releases/tags/0.45.0", + ]); + }); +}); + describe("getBinaryFilename", () => { test("returns sentry on non-Windows", () => { if (process.platform !== "win32") { diff --git a/packages/cli/test/lib/delta-upgrade.test.ts b/packages/cli/test/lib/delta-upgrade.test.ts index 3752d4ae67..7375fd9ec4 100644 --- a/packages/cli/test/lib/delta-upgrade.test.ts +++ b/packages/cli/test/lib/delta-upgrade.test.ts @@ -11,8 +11,11 @@ import { existsSync, unlinkSync } from "node:fs"; import { access, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { getPlatformBinaryName } from "../../src/lib/binary.js"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + getPlatformBinaryName, + UPGRADE_SOURCES, +} from "../../src/lib/binary.js"; import { applyPatchChain, attemptDeltaUpgrade, @@ -38,6 +41,12 @@ import { validateChainStep, } from "../../src/lib/delta-upgrade.js"; import type { OciManifest } from "../../src/lib/ghcr.js"; +import { useTestConfigDir } from "../helpers.js"; + +const LEGACY_UPGRADE_SOURCE = UPGRADE_SOURCES[1]; +if (!LEGACY_UPGRADE_SOURCE) { + throw new Error("Legacy upgrade source is not configured"); +} // --------------------------------------------------------------------------- // Test helpers (file-scoped) @@ -825,7 +834,7 @@ describe("fetchRecentReleases", () => { mockFetch(async (url) => { expect(String(url)).toContain( - "api.github.com/repos/getsentry/cli/releases" + "api.github.com/repos/getsentry/toolkit/releases" ); expect(String(url)).toContain("per_page="); return new Response(JSON.stringify(releases), { status: 200 }); @@ -836,6 +845,20 @@ describe("fetchRecentReleases", () => { expect(result[0]?.tag_name).toBe("0.14.0"); }); + test("uses the selected legacy GitHub repository", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response(JSON.stringify([]), { status: 200 }); + }); + + await fetchRecentReleases(undefined, LEGACY_UPGRADE_SOURCE); + + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + }); + test("returns empty array on HTTP error", async () => { mockFetch(async () => new Response("Server Error", { status: 500 })); @@ -954,6 +977,22 @@ describe("resolveStableChain", () => { ]); }); + test("keeps stable resolution on the selected legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Not Found", { status: 404 }); + }); + + await expect( + resolveStableChain("0.13.0", "0.14.0", undefined, LEGACY_UPGRADE_SOURCE) + ).resolves.toBeNull(); + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + test("resolves multi-hop chain with parallel downloads", async () => { const binaryName = getPlatformBinaryName(); const patchA = new Uint8Array([1, 2]); @@ -1181,6 +1220,26 @@ describe("resolveNightlyChain", () => { ]); }); + test("keeps nightly resolution on the selected legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response(JSON.stringify({ tags: [] }), { status: 200 }); + }); + + await expect( + resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + source: LEGACY_UPGRADE_SOURCE, + }) + ).resolves.toBeNull(); + expect(urls).toEqual(["https://ghcr.io/v2/getsentry/cli/tags/list?n=100"]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + test("returns null when no matching patches in graph", async () => { setupNightlyMocks([], new Map(), new Map()); @@ -1844,3 +1903,111 @@ describe("prefetchStablePatches", () => { await prefetchStablePatches("0.14.0"); }); }); + +async function importDeltaUpgradeWithVersion(version: string) { + vi.resetModules(); + vi.doMock("../../src/lib/constants.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, CLI_VERSION: version }; + }); + return import("../../src/lib/delta-upgrade.js"); +} + +function restoreDeltaUpgradeModule(): void { + vi.doUnmock("../../src/lib/constants.js"); + vi.resetModules(); +} + +describe("selected source affinity", () => { + useTestConfigDir("delta-source-affinity-"); + afterEach(restoreDeltaUpgradeModule); + + test("attemptDeltaUpgrade keeps stable requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Not Found", { status: 404 }); + }); + const versionedDelta = await importDeltaUpgradeWithVersion("0.13.0"); + + await expect( + versionedDelta.attemptDeltaUpgrade( + "0.14.0", + "/tmp/fake-old", + "/tmp/fake-out", + false, + undefined, + LEGACY_UPGRADE_SOURCE + ) + ).resolves.toBeNull(); + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("attemptDeltaUpgrade keeps nightly requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Unauthorized", { status: 401 }); + }); + const versionedDelta = + await importDeltaUpgradeWithVersion("0.14.0-dev.100"); + + await expect( + versionedDelta.attemptDeltaUpgrade( + "0.14.0-dev.101", + "/tmp/fake-old", + "/tmp/fake-out", + false, + undefined, + LEGACY_UPGRADE_SOURCE + ) + ).resolves.toBeNull(); + expect(urls).toEqual([ + "https://ghcr.io/token?scope=repository:getsentry/cli:pull", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("prefetchStablePatches keeps requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Not Found", { status: 404 }); + }); + const versionedDelta = await importDeltaUpgradeWithVersion("0.13.0"); + + await versionedDelta.prefetchStablePatches( + "0.14.0", + undefined, + LEGACY_UPGRADE_SOURCE + ); + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("prefetchNightlyPatches keeps requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Unauthorized", { status: 401 }); + }); + const versionedDelta = + await importDeltaUpgradeWithVersion("0.14.0-dev.100"); + + await versionedDelta.prefetchNightlyPatches( + "0.14.0-dev.101", + undefined, + LEGACY_UPGRADE_SOURCE + ); + expect(urls).toEqual([ + "https://ghcr.io/token?scope=repository:getsentry/cli:pull", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index e5c81d5324..0ae92f8e0c 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; import { UpgradeError } from "../../src/lib/errors.js"; import { downloadLayerBlob, @@ -91,6 +92,19 @@ describe("getAnonymousToken", () => { expect(token).toBe("test-token-abc"); }); + test("uses the selected source's GHCR repository", async () => { + mockFetch(async (url) => { + expect(String(url)).toContain("scope=repository:getsentry/toolkit:pull"); + return new Response(JSON.stringify({ token: "toolkit-token" }), { + status: 200, + }); + }); + + await expect(getAnonymousToken(UPGRADE_SOURCES[0])).resolves.toBe( + "toolkit-token" + ); + }); + test("throws UpgradeError on HTTP error", async () => { mockFetch(async () => new Response("Unauthorized", { status: 401 })); @@ -153,6 +167,18 @@ describe("fetchNightlyManifest", () => { ); }); + test("uses the selected source's GHCR repository", async () => { + const manifest = makeManifest(); + mockFetch(async (url) => { + expect(String(url)).toContain("/v2/getsentry/toolkit/manifests/nightly"); + return new Response(JSON.stringify(manifest), { status: 200 }); + }); + + await expect( + fetchNightlyManifest("token", undefined, UPGRADE_SOURCES[0]) + ).resolves.toEqual(manifest); + }); + test("throws UpgradeError on HTTP error", async () => { mockFetch(async () => new Response("Not Found", { status: 404 })); diff --git a/packages/cli/test/lib/install-script.test.ts b/packages/cli/test/lib/install-script.test.ts index b1f22bbab8..5a626796f2 100644 --- a/packages/cli/test/lib/install-script.test.ts +++ b/packages/cli/test/lib/install-script.test.ts @@ -1,8 +1,8 @@ /** * Install Script Tests * - * Exercises the shell installer with fake download tools so argument parsing and - * setup delegation can be validated without network access. + * Exercises the shell installer with fake download tools so source selection, + * argument parsing, and setup delegation can be validated without network access. */ import { spawn } from "node:child_process"; @@ -17,6 +17,13 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; + +type InstallerResult = { + exitCode: number; + stderr: string; + stdout: string; +}; function noop(): void { // Intentionally empty — absorbs async spawn errors @@ -29,18 +36,129 @@ describe("install script", () => { let testDir: string; let binDir: string; let argsFile: string; + let requestsFile: string; beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "sentry-install-test-")); binDir = join(testDir, "bin"); argsFile = join(testDir, "setup-args.txt"); + requestsFile = join(testDir, "requests.txt"); mkdirSync(binDir, { recursive: true }); const fakeCurl = `#!/usr/bin/env bash -cat <<'SCRIPT' -#!/usr/bin/env bash -printf '%s\n' "$@" > "$SENTRY_TEST_ARGS_FILE" -SCRIPT +set -u + +url="" +output="" +write_format="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) + output="$2" + shift 2 + ;; + -w) + write_format="$2" + shift 2 + ;; + -H|-d|--max-time) + shift 2 + ;; + http://*|https://*) + url="$1" + shift + ;; + *) + shift + ;; + esac +done + +printf '%s\n' "$url" >> "$SENTRY_TEST_REQUESTS_FILE" + +status=200 +body="" +redirect_url="" +case "$url" in + https://api.github.com/repos/getsentry/toolkit/releases\\?per_page=100) + case "$SENTRY_TEST_SCENARIO" in + fallback|gzip-fallback) status=404 ;; + forbidden) status=403 ;; + network) exit 7 ;; + *) body='[{"tag_name":"mcp@9.0.0"},{"tag_name":"cli@0.51.0-dev.1","prerelease":true},{"tag_name":"cli@0.50.0"}]' ;; + esac + ;; + https://api.github.com/repos/getsentry/cli/releases/latest) + body='{"tag_name":"0.49.0"}' + ;; + https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%40*) + case "$SENTRY_TEST_SCENARIO" in + fallback|gzip-fallback) status=404 ;; + forbidden) status=429 ;; + network) exit 6 ;; + *) body='{"tag_name":"cli@0.31.0"}' ;; + esac + ;; + https://api.github.com/repos/getsentry/cli/releases/tags/*) + body='{"tag_name":"0.31.0"}' + ;; + https://api.github.com/repos/getsentry/toolkit) + if [[ "$SENTRY_TEST_SCENARIO" == "nightly-fallback" ]]; then + status=404 + else + body='{"full_name":"getsentry/toolkit"}' + fi + ;; + https://api.github.com/repos/getsentry/cli) + body='{"full_name":"getsentry/cli"}' + ;; + https://ghcr.io/token*) + body='{"token":"test-token"}' + ;; + https://ghcr.io/v2/*/manifests/nightly) + if [[ "$SENTRY_TEST_SCENARIO" == "nightly-manifest-fallback" && "$url" == *getsentry/toolkit* ]]; then + status=404 + elif [[ "$SENTRY_TEST_SCENARIO" == "nightly-manifest-forbidden" && "$url" == *getsentry/toolkit* ]]; then + status=403 + else + body='{"annotations":{"version":"0.51.0-dev.1"},"layers":[{"digest":"sha256:x64","annotations":{"org.opencontainers.image.title":"sentry-linux-x64.gz"}},{"digest":"sha256:arm64","annotations":{"org.opencontainers.image.title":"sentry-linux-arm64.gz"}}]}' + fi + ;; + https://ghcr.io/v2/*/blobs/*) + redirect_url='https://blob.example.test/sentry.gz' + ;; + https://blob.example.test/sentry.gz) + body='#!/usr/bin/env bash +for arg in "$@"; do echo "$arg"; done > "$SENTRY_TEST_ARGS_FILE"' + ;; + https://github.com/*/releases/download/*.gz) + if [[ "$SENTRY_TEST_SCENARIO" == "gzip-fallback" ]]; then + exit 22 + fi + body='#!/usr/bin/env bash +for arg in "$@"; do echo "$arg"; done > "$SENTRY_TEST_ARGS_FILE"' + ;; + https://github.com/*/releases/download/*) + body='#!/usr/bin/env bash +for arg in "$@"; do echo "$arg"; done > "$SENTRY_TEST_ARGS_FILE"' + ;; + *) + status=404 + ;; +esac + +if [[ -n "$output" && "$output" != "/dev/null" ]]; then + printf '%s\n' "$body" > "$output" +elif [[ -z "$output" ]]; then + printf '%s\n' "$body" +fi + +if [[ -n "$write_format" ]]; then + case "$write_format" in + '%{http_code}') printf '%s' "$status" ;; + *'%{redirect_url}'*) printf '\n%s' "$redirect_url" ;; + esac +fi `; writeFileSync(join(binDir, "curl"), fakeCurl); chmodSync(join(binDir, "curl"), 0o755); @@ -56,44 +174,178 @@ cat rmSync(testDir, { recursive: true, force: true }); }); - test("passes --no-agent-skills through to sentry cli setup", async () => { - const proc = spawn( - "bash", - [ - installScript, - "--version", - "0.31.0", - "--no-modify-path", - "--no-completions", - "--no-agent-skills", - ], - { - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH ?? ""}`, - SENTRY_TEST_ARGS_FILE: argsFile, - TMPDIR: testDir, - }, - stdio: ["pipe", "pipe", "pipe"], - } - ); + async function runInstaller( + args: readonly string[], + scenario: string + ): Promise { + const proc = spawn("bash", [installScript, ...args], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_TEST_ARGS_FILE: argsFile, + SENTRY_TEST_REQUESTS_FILE: requestsFile, + SENTRY_TEST_SCENARIO: scenario, + TMPDIR: testDir, + }, + stdio: ["pipe", "pipe", "pipe"], + }); proc.on("error", noop); let stdout = ""; let stderr = ""; - proc.stdout.on("data", (d: Buffer) => { - stdout += d; + proc.stdout.on("data", (data: Buffer) => { + stdout += data; }); - proc.stderr.on("data", (d: Buffer) => { - stderr += d; + proc.stderr.on("data", (data: Buffer) => { + stderr += data; }); const exitCode = await new Promise((resolve) => proc.on("close", (code) => resolve(code ?? 1)) ); + return { exitCode, stderr, stdout }; + } + + function requests(): string[] { + return readFileSync(requestsFile, "utf8").trim().split("\n"); + } + + function setupArgs(): string[] { + return readFileSync(argsFile, "utf8").trim().split("\n"); + } + + test("uses the primary source for the latest stable CLI release", async () => { + const result = await runInstaller([], "primary"); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(requests()).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + "https://github.com/getsentry/toolkit/releases/download/cli@0.50.0/sentry-linux-x64.gz", + ]); + }); + + test("filters Toolkit's latest releases by the CLI tag prefix", async () => { + const result = await runInstaller([], "latest-prefix"); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(requests()[1]).toContain("/cli@0.50.0/"); + expect(requests()[1]).not.toContain("/mcp@9.0.0/"); + expect(requests()[1]).not.toContain("/cli@0.51.0-dev.1/"); + }); + + test("falls through to the legacy stable source only after a 404", async () => { + const result = await runInstaller([], "fallback"); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(requests()).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + "https://api.github.com/repos/getsentry/cli/releases/latest", + "https://github.com/getsentry/cli/releases/download/0.49.0/sentry-linux-x64.gz", + ]); + }); + + test("stops source selection on a non-404 response", async () => { + const result = await runInstaller([], "forbidden"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("HTTP 403"); + expect(requests()).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + ]); + }); + + test("stops source selection on a network failure", async () => { + const result = await runInstaller([], "network"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Failed to connect to GitHub"); + expect(requests()).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + ]); + }); + + test("probes and downloads a pinned stable release from the same source", async () => { + const result = await runInstaller(["--version", "v0.31.0"], "pinned"); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(requests()).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.31.0", + "https://github.com/getsentry/toolkit/releases/download/cli@0.31.0/sentry-linux-x64.gz", + ]); + }); + + test("selects a nightly source before requesting source-specific GHCR data", async () => { + const result = await runInstaller( + ["--version", "nightly"], + "nightly-fallback" + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(requests()).toEqual([ + "https://api.github.com/repos/getsentry/toolkit", + "https://api.github.com/repos/getsentry/cli", + "https://ghcr.io/token?scope=repository:getsentry/cli:pull", + "https://ghcr.io/v2/getsentry/cli/manifests/nightly", + "https://ghcr.io/v2/getsentry/cli/blobs/sha256:x64", + "https://blob.example.test/sentry.gz", + ]); + expect(setupArgs()).toContain("nightly"); + }); + + test("falls back when Toolkit's nightly manifest returns 404", async () => { + const result = await runInstaller( + ["--version", "nightly"], + "nightly-manifest-fallback" + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(requests()).toContain( + "https://ghcr.io/v2/getsentry/toolkit/manifests/nightly" + ); + expect(requests()).toContain( + "https://ghcr.io/v2/getsentry/cli/manifests/nightly" + ); + }); + + test("stops when Toolkit's nightly manifest returns a non-404", async () => { + const result = await runInstaller( + ["--version", "nightly"], + "nightly-manifest-forbidden" + ); + + expect(result.exitCode).toBe(1); + expect(requests()).not.toContain( + "https://api.github.com/repos/getsentry/cli" + ); + }); - expect({ exitCode, stdout, stderr }).toMatchObject({ exitCode: 0 }); - expect(readFileSync(argsFile, "utf8").trim().split("\n")).toEqual([ + test("falls back from gzip to the raw asset without changing sources", async () => { + const result = await runInstaller(["--version", "0.31.0"], "gzip-fallback"); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(requests()).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.31.0", + "https://api.github.com/repos/getsentry/cli/releases/tags/0.31.0", + "https://github.com/getsentry/cli/releases/download/0.31.0/sentry-linux-x64.gz", + "https://github.com/getsentry/cli/releases/download/0.31.0/sentry-linux-x64", + ]); + }); + + test("passes setup options through to sentry cli setup", async () => { + const result = await runInstaller( + [ + "--version", + "0.31.0", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + "pinned" + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(setupArgs()).toEqual([ "cli", "setup", "--install", @@ -106,4 +358,19 @@ cat "--no-agent-skills", ]); }); + + test("embeds the shared ordered upgrade source list", () => { + const script = readFileSync(installScript, "utf8"); + const match = script.match(/^UPGRADE_SOURCES=\(([^)]*)\)$/m); + const sources = Array.from(match?.[1]?.matchAll(/'([^']*)'/g) ?? []).map( + (entry) => entry[1] + ); + + expect(sources).toEqual( + UPGRADE_SOURCES.map( + (source) => + `${source.githubRepo}|${source.ghcrRepo}|${source.tagPrefix}` + ) + ); + }); }); diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts index 78963b4716..d600668020 100644 --- a/packages/cli/test/lib/release-notes.test.ts +++ b/packages/cli/test/lib/release-notes.test.ts @@ -9,7 +9,8 @@ */ import { marked } from "marked"; -import { describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; import type { GitHubRelease } from "../../src/lib/delta-upgrade.js"; import { buildChangelogSummary, @@ -17,8 +18,10 @@ import { countListItems, extractNightlyTimestamp, extractSections, + fetchChangelog, parseCommitMessages, } from "../../src/lib/release-notes.js"; +import { mockFetch } from "../helpers.js"; // ─────────────────────────── Fixtures ────────────────────────────────────── @@ -293,3 +296,111 @@ describe("countListItems", () => { expect(countListItems([])).toBe(0); }); }); + +describe("fetchChangelog source affinity", () => { + const toolkitSource = UPGRADE_SOURCES[0]!; + const legacySource = UPGRADE_SOURCES[1]!; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("fetches stable releases only from the explicitly selected Toolkit source", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify([ + makeRelease( + "mcp@99.0.0", + "### New Features ✨\n\n- Unrelated Toolkit package release" + ), + makeRelease( + "cli@0.21.0", + "### Bug Fixes 🐛\n\n- Keep release stages source-affine" + ), + ]), + { status: 200 } + ); + }); + + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + }); + + expect(changelog?.totalItems).toBe(1); + expect(changelog?.sections[0]?.markdown).not.toContain( + "Unrelated Toolkit package release" + ); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=30", + ]); + expect(requestedUrls.some((url) => url.includes("getsentry/cli"))).toBe( + false + ); + }); + + test("fetches stable releases only from the explicitly selected legacy source", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify([ + makeRelease( + "0.21.0", + "### Bug Fixes 🐛\n\n- Keep the legacy bridge working" + ), + ]), + { status: 200 } + ); + }); + + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: legacySource, + }); + + expect(changelog?.totalItems).toBe(1); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=30", + ]); + }); + + test("fetches nightly commits only from the explicitly selected Toolkit source", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify([ + { commit: { message: "fix: keep nightly stages affine" } }, + ]), + { status: 200 } + ); + }); + + const changelog = await fetchChangelog({ + channel: "nightly", + fromVersion: "0.21.0-dev.100", + toVersion: "0.21.0-dev.200", + source: toolkitSource, + }); + + expect(changelog?.totalItems).toBe(1); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/commits?sha=main&since=1970-01-01T00:01:41.000Z&until=1970-01-01T00:03:21.000Z&per_page=100", + ]); + expect(requestedUrls.some((url) => url.includes("getsentry/cli"))).toBe( + false + ); + }); +}); diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 7c6f63a4bb..1b5a774902 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -120,6 +120,7 @@ import { getBinaryDownloadUrl, isNightlyVersion, releaseLock, + UPGRADE_SOURCES, } from "../../src/lib/binary.js"; import { clearInstallInfo, @@ -188,18 +189,65 @@ describe("parseInstallationMethod", () => { }); describe("fetchLatestFromGitHub", () => { + test("selects the latest CLI-prefixed Toolkit release", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response( + JSON.stringify([ + { tag_name: "mcp@9.0.0" }, + { tag_name: "cli@1.2.3" }, + { tag_name: "cli@1.3.0" }, + ]), + { status: 200 } + ); + }); + + await expect(fetchLatestFromGitHub()).resolves.toBe("1.2.3"); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + ]); + }); + + test("falls back to the legacy latest release only on Toolkit HTTP 404", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + if (requests.length === 1) { + return new Response("Not Found", { status: 404 }); + } + return new Response(JSON.stringify({ tag_name: "v1.2.3" }), { + status: 200, + }); + }); + + await expect(fetchLatestFromGitHub()).resolves.toBe("1.2.3"); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + "https://api.github.com/repos/getsentry/cli/releases/latest", + ]); + }); + + test("does not use an MCP release as the latest CLI release", async () => { + mockFetch( + async () => + new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { + status: 200, + }) + ); + + await expect(fetchLatestFromGitHub()).rejects.toThrow( + "No version found in GitHub release" + ); + }); + test("returns version from GitHub API", async () => { mockFetch( async () => - new Response( - JSON.stringify({ - tag_name: "v1.2.3", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ) + new Response(JSON.stringify([{ tag_name: "cli@v1.2.3" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) ); const version = await fetchLatestFromGitHub(); @@ -209,15 +257,10 @@ describe("fetchLatestFromGitHub", () => { test("strips v prefix from version", async () => { mockFetch( async () => - new Response( - JSON.stringify({ - tag_name: "v0.5.0", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ) + new Response(JSON.stringify([{ tag_name: "cli@v0.5.0" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) ); const version = await fetchLatestFromGitHub(); @@ -227,15 +270,10 @@ describe("fetchLatestFromGitHub", () => { test("handles version without v prefix", async () => { mockFetch( async () => - new Response( - JSON.stringify({ - tag_name: "1.0.0", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ) + new Response(JSON.stringify([{ tag_name: "cli@1.0.0" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) ); const version = await fetchLatestFromGitHub(); @@ -252,7 +290,7 @@ describe("fetchLatestFromGitHub", () => { await expect(fetchLatestFromGitHub()).rejects.toThrow(UpgradeError); await expect(fetchLatestFromGitHub()).rejects.toThrow( - "Failed to fetch from GitHub: 404" + "No CLI upgrade source was found: every source returned HTTP 404" ); }); @@ -270,7 +308,7 @@ describe("fetchLatestFromGitHub", () => { test("throws when no tag_name in response", async () => { mockFetch( async () => - new Response(JSON.stringify({}), { + new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -398,7 +436,7 @@ describe("fetchLatestVersion", () => { test("uses GitHub for curl method", async () => { mockFetch( async () => - new Response(JSON.stringify({ tag_name: "v2.0.0" }), { + new Response(JSON.stringify([{ tag_name: "cli@v2.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -463,7 +501,7 @@ describe("fetchLatestVersion", () => { test("uses GitHub for brew method", async () => { mockFetch( async () => - new Response(JSON.stringify({ tag_name: "v2.0.0" }), { + new Response(JSON.stringify([{ tag_name: "cli@v2.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -490,6 +528,9 @@ describe("fetchLatestVersion", () => { // Nightly version is now fetched from GHCR manifest annotation, not version.json mockFetch(async (url) => { const urlStr = String(url); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } @@ -512,6 +553,9 @@ describe("fetchLatestVersion", () => { // Even npm method uses GHCR when channel=nightly (nightly is curl-only distribution) mockFetch(async (url) => { const urlStr = String(url); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } @@ -533,7 +577,7 @@ describe("fetchLatestVersion", () => { test("defaults to stable channel (uses GitHub) when channel omitted", async () => { mockFetch( async () => - new Response(JSON.stringify({ tag_name: "v3.0.0" }), { + new Response(JSON.stringify([{ tag_name: "cli@v3.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -545,6 +589,52 @@ describe("fetchLatestVersion", () => { }); describe("versionExists", () => { + test("probes prefixed Toolkit tags and retains the selected source", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(JSON.stringify({ tag_name: "cli@1.0.0" }), { + status: 200, + }); + }); + + await expect(versionExists("curl", "1.0.0")).resolves.toBe(true); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%401.0.0", + ]); + }); + + test("falls back to an unprefixed legacy tag on Toolkit HTTP 404", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return requests.length === 1 + ? new Response("Not Found", { status: 404 }) + : new Response(JSON.stringify({ tag_name: "1.0.0" }), { status: 200 }); + }); + + await expect(versionExists("curl", "1.0.0")).resolves.toBe(true); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%401.0.0", + "https://api.github.com/repos/getsentry/cli/releases/tags/1.0.0", + ]); + }); + + test("does not fall back from an explicit selected source", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response("Not Found", { status: 404 }); + }); + + await expect( + versionExists("curl", "1.0.0", UPGRADE_SOURCES[0]) + ).resolves.toBe(false); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%401.0.0", + ]); + }); + test("checks GitHub for curl method - version exists", async () => { mockFetch(async () => new Response(null, { status: 200 })); @@ -634,6 +724,9 @@ describe("versionExists", () => { const manifest = { schemaVersion: 2, layers: [], annotations: {} }; mockFetch(async (url) => { const u = String(url); + if (u === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (u.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } @@ -650,6 +743,9 @@ describe("versionExists", () => { test("checks GHCR for nightly version - version does not exist", async () => { mockFetch(async (url) => { const u = String(url); + if (u === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (u.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } @@ -667,6 +763,9 @@ describe("versionExists", () => { const manifest = { schemaVersion: 2, layers: [], annotations: {} }; mockFetch(async (url) => { const u = String(url); + if (u === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (u.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } @@ -974,10 +1073,9 @@ describe("getBinaryDownloadUrl", () => { test("builds correct URL for current platform", () => { const url = getBinaryDownloadUrl("1.0.0"); - // URL should contain the version without 'v' prefix (this repo's tag format) - expect(url).toContain("/1.0.0/"); + expect(url).toContain("/cli@1.0.0/"); expect(url).toStartWith( - "https://github.com/getsentry/cli/releases/download/" + "https://github.com/getsentry/toolkit/releases/download/" ); expect(url).toContain("sentry-"); @@ -1513,12 +1611,82 @@ describe("isNightlyVersion", () => { }); describe("fetchLatestNightlyVersion", () => { + test("falls back to legacy when the Toolkit nightly manifest returns 404", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + const request = String(url); + requests.push(request); + if (request === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } + if (request === "https://api.github.com/repos/getsentry/cli") { + return new Response(null, { status: 200 }); + } + if (request.includes("scope=repository:getsentry/toolkit:pull")) { + return new Response(JSON.stringify({ token: "toolkit-token" }), { + status: 200, + }); + } + if (request.includes("/v2/getsentry/toolkit/manifests/nightly")) { + return new Response("Not Found", { status: 404 }); + } + if (request.includes("scope=repository:getsentry/cli:pull")) { + return new Response(JSON.stringify({ token: "cli-token" }), { + status: 200, + }); + } + if (request.includes("/v2/getsentry/cli/manifests/nightly")) { + return new Response( + JSON.stringify({ + schemaVersion: 2, + layers: [], + annotations: { version: "0.0.0-dev.1740000000" }, + }), + { status: 200 } + ); + } + return new Response("Unexpected", { status: 500 }); + }); + + await expect(fetchLatestNightlyVersion()).resolves.toBe( + "0.0.0-dev.1740000000" + ); + expect(requests).toContain( + "https://ghcr.io/v2/getsentry/cli/manifests/nightly" + ); + }); + + test("does not fall back from a non-404 Toolkit nightly failure", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + const request = String(url); + requests.push(request); + if (request === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } + if (request.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "toolkit-token" }), { + status: 200, + }); + } + return new Response("Forbidden", { status: 403 }); + }); + + await expect(fetchLatestNightlyVersion()).rejects.toThrow("HTTP 403"); + expect(requests).not.toContain( + "https://api.github.com/repos/getsentry/cli" + ); + }); + test("returns version from GHCR manifest annotation", async () => { // Mock the two requests: token exchange + manifest fetch let callCount = 0; mockFetch(async (url) => { - callCount += 1; const urlStr = String(url); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } + callCount += 1; if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "test-token" }), { status: 200, @@ -1549,7 +1717,11 @@ describe("fetchLatestNightlyVersion", () => { }); test("throws UpgradeError when GHCR token exchange fails", async () => { - mockFetch(async () => new Response("Unauthorized", { status: 401 })); + mockFetch(async (url) => + String(url) === "https://api.github.com/repos/getsentry/toolkit" + ? new Response(null, { status: 200 }) + : new Response("Unauthorized", { status: 401 }) + ); await expect(fetchLatestNightlyVersion()).rejects.toThrow(UpgradeError); await expect(fetchLatestNightlyVersion()).rejects.toThrow( @@ -1560,6 +1732,9 @@ describe("fetchLatestNightlyVersion", () => { test("throws UpgradeError when manifest has no version annotation", async () => { mockFetch(async (url) => { const urlStr = String(url); + if (urlStr === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } if (urlStr.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } diff --git a/packages/cli/test/lib/version-check.test.ts b/packages/cli/test/lib/version-check.test.ts index 20172cd839..dd047a5cb5 100644 --- a/packages/cli/test/lib/version-check.test.ts +++ b/packages/cli/test/lib/version-check.test.ts @@ -3,12 +3,15 @@ */ import { setTimeout as sleep } from "node:timers/promises"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; import { setReleaseChannel } from "../../src/lib/db/release-channel.js"; import { getVersionCheckInfo, setVersionCheckInfo, } from "../../src/lib/db/version-check.js"; +// biome-ignore lint/performance/noNamespaceImport: Vitest requires the module namespace to spy on an ESM export +import * as deltaUpgrade from "../../src/lib/delta-upgrade.js"; import { ApiError, ContextError, @@ -440,6 +443,7 @@ describe("maybeCheckForUpdateInBackground", () => { if (savedNoUpdateCheck !== undefined) { process.env.SENTRY_CLI_NO_UPDATE_CHECK = savedNoUpdateCheck; } + vi.restoreAllMocks(); }); test("does not throw when called", () => { @@ -498,6 +502,125 @@ describe("maybeCheckForUpdateInBackground", () => { expect(() => maybeCheckForUpdateInBackground()).not.toThrow(); abortPendingVersionCheck(); }); + + test("passes the Toolkit source from stable discovery to patch prefetch without legacy access", async () => { + const toolkitSource = UPGRADE_SOURCES[0]!; + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + const url = String(input); + requestedUrls.push(url); + if (url.includes("getsentry/cli")) { + throw new Error(`Unexpected legacy access: ${url}`); + } + return new Response( + JSON.stringify([{ tag_name: "cli@99.0.0", draft: false }]), + { status: 200 } + ); + }); + const prefetch = vi + .spyOn(deltaUpgrade, "prefetchStablePatches") + .mockResolvedValue(); + + maybeCheckForUpdateInBackground(); + + await vi.waitFor(() => { + expect(prefetch).toHaveBeenCalledWith( + "99.0.0", + expect.any(AbortSignal), + toolkitSource + ); + }); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + ]); + }); + + test("keeps stable patch prefetch on the legacy source selected after a Toolkit 404", async () => { + const legacySource = UPGRADE_SOURCES[1]!; + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + const url = String(input); + requestedUrls.push(url); + if (url.includes("getsentry/toolkit")) { + return new Response("not found", { status: 404 }); + } + if ( + url === "https://api.github.com/repos/getsentry/cli/releases/latest" + ) { + return new Response(JSON.stringify({ tag_name: "v99.0.0" }), { + status: 200, + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + const prefetch = vi + .spyOn(deltaUpgrade, "prefetchStablePatches") + .mockResolvedValue(); + + maybeCheckForUpdateInBackground(); + + await vi.waitFor(() => { + expect(prefetch).toHaveBeenCalledWith( + "99.0.0", + expect.any(AbortSignal), + legacySource + ); + }); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + "https://api.github.com/repos/getsentry/cli/releases/latest", + ]); + }); + + test("passes the Toolkit source from nightly discovery to patch prefetch without legacy access", async () => { + setReleaseChannel("nightly"); + const toolkitSource = UPGRADE_SOURCES[0]!; + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + const url = String(input); + requestedUrls.push(url); + if (url.includes("getsentry/cli")) { + throw new Error(`Unexpected legacy access: ${url}`); + } + if (url === "https://api.github.com/repos/getsentry/toolkit") { + return new Response("{}", { status: 200 }); + } + if (url.includes("/token?scope=repository:getsentry/toolkit:pull")) { + return new Response(JSON.stringify({ token: "toolkit-token" }), { + status: 200, + }); + } + if (url.endsWith("/v2/getsentry/toolkit/manifests/nightly")) { + return new Response( + JSON.stringify({ + schemaVersion: 2, + layers: [], + annotations: { version: "99.0.0-dev.200" }, + }), + { status: 200 } + ); + } + throw new Error(`Unexpected request: ${url}`); + }); + const prefetch = vi + .spyOn(deltaUpgrade, "prefetchNightlyPatches") + .mockResolvedValue(); + + maybeCheckForUpdateInBackground(); + + await vi.waitFor(() => { + expect(prefetch).toHaveBeenCalledWith( + "99.0.0-dev.200", + expect.any(AbortSignal), + toolkitSource + ); + }); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/toolkit", + "https://ghcr.io/token?scope=repository:getsentry/toolkit:pull", + "https://ghcr.io/v2/getsentry/toolkit/manifests/nightly", + ]); + }); }); describe("opt-out behavior", () => { From 9c874298e3b57515ba36d35140627a668a46dc7b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 9 Sep 2026 18:40:54 +0000 Subject: [PATCH 02/19] fix(cli): address toolkit bridge review --- .github/workflows/ci.yml | 1 - package.json | 3 +- packages/cli/install | 192 ++--------- packages/cli/package.json | 3 +- packages/cli/src/lib/binary.ts | 16 +- packages/cli/src/lib/delta-upgrade.ts | 60 +++- packages/cli/src/lib/ghcr.ts | 20 +- packages/cli/src/lib/upgrade.ts | 25 +- packages/cli/test/lib/binary.test.ts | 20 +- packages/cli/test/lib/delta-upgrade.test.ts | 28 +- packages/cli/test/lib/ghcr.test.ts | 14 +- packages/cli/test/lib/install-script.test.ts | 333 ++----------------- packages/cli/test/lib/upgrade.test.ts | 51 +++ 13 files changed, 246 insertions(+), 520 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f018db432..67ec710672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,6 @@ jobs: - run: pnpm run check:errors - run: pnpm run check:patches - run: pnpm run check:stale-refs - - run: pnpm run check:upgrade-sources test-unit: name: Unit Tests diff --git a/package.json b/package.json index 3b109cd2ca..cb501ad192 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "check:errors": "pnpm --filter sentry run check:errors", "check:patches": "pnpm --filter sentry run check:patches", "check:docs-sections": "pnpm --filter sentry run check:docs-sections", - "check:stale-refs": "pnpm --filter sentry run check:stale-refs", - "check:upgrade-sources": "pnpm --filter sentry run check:upgrade-sources" + "check:stale-refs": "pnpm --filter sentry run check:stale-refs" }, "pnpm": { "patchedDependencies": { diff --git a/packages/cli/install b/packages/cli/install index 802aa9b161..e05cef2018 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -12,9 +12,6 @@ SENTRY_DSN_KEY="1188a86f3f8168f089450587b00bca66" SENTRY_INGEST="https://o1.ingest.us.sentry.io" SENTRY_PROJECT_ID="4510776311808000" -# UPGRADE_SOURCES_SYNC: keep these github|ghcr|tag-prefix entries in sync with src/lib/binary.ts. -UPGRADE_SOURCES=('getsentry/toolkit|getsentry/toolkit|cli@' 'getsentry/cli|getsentry/cli|') - # Generate a UUID for the event. Tries /proc, uuidgen, then awk fallback. gen_uuid() { if [[ -r /proc/sys/kernel/random/uuid ]]; then @@ -70,6 +67,7 @@ report_error() { die() { echo -e "${RED}$1${NC}" >&2 report_error "$1" "${2:-unknown}" + wait 2>/dev/null || true # Let the background curl finish; ignore its exit status exit 1 } @@ -207,151 +205,10 @@ fi # Download binary to a temp location tmpdir="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}" tmp_binary="${tmpdir}/sentry-install-$$${suffix}" -github_response="${tmpdir}/sentry-install-github-response-$$" -nightly_manifest_file="${tmpdir}/sentry-install-nightly-manifest-$$" version="" # Clean up temp binary on failure (setup handles cleanup on success) -trap 'rm -f "$tmp_binary" "$github_response" "$nightly_manifest_file"' EXIT - -# Fetch a GitHub API endpoint without collapsing HTTP failures into one curl -# error. The caller may fall through on a genuine 404; every transport failure -# and every other HTTP status stops source selection. -github_get() { - local url="$1" - if ! http_status=$(curl -sS -L -o "$github_response" -w '%{http_code}' "$url"); then - die "Failed to connect to GitHub while fetching ${url}" "gh-fetch" - fi -} - -source_tag_prefix() { - printf '%s' "$1" | cut -d'|' -f3 -} - -source_github_repo() { - printf '%s' "$1" | cut -d'|' -f1 -} - -source_ghcr_repo() { - printf '%s' "$1" | cut -d'|' -f2 -} - -# Select the first source whose GitHub probe succeeds. A 404 alone advances to -# the next source. The successful source remains fixed for all later requests. -select_nightly_source() { - local source - local manifest_status - local github_repo - local ghcr_repo - local url - for source in "${UPGRADE_SOURCES[@]}"; do - github_repo=$(source_github_repo "$source") - ghcr_repo=$(source_ghcr_repo "$source") - url="https://api.github.com/repos/${github_repo}" - github_get "$url" - if [[ "$http_status" == "404" ]]; then - continue - fi - if [[ ! "$http_status" =~ ^2[0-9][0-9]$ ]]; then - die "Failed to fetch ${url}: HTTP ${http_status}" "gh-source" - fi - if ! GHCR_TOKEN=$(curl -sf \ - "https://ghcr.io/token?scope=repository:${ghcr_repo}:pull" \ - | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="token"){print $(i+2);exit}}'); then - die "Failed to get GHCR token" "ghcr-token" - fi - if [[ -z "$GHCR_TOKEN" ]]; then - die "Failed to get GHCR token" "ghcr-token" - fi - if ! manifest_status=$(curl -sS -o "$nightly_manifest_file" -w '%{http_code}' \ - -H "Authorization: Bearer $GHCR_TOKEN" \ - -H "Accept: application/vnd.oci.image.manifest.v1+json" \ - "https://ghcr.io/v2/${ghcr_repo}/manifests/nightly"); then - die "Failed to connect to GHCR while fetching the nightly manifest" "ghcr-manifest" - fi - if [[ "$manifest_status" == "404" ]]; then - continue - fi - if [[ ! "$manifest_status" =~ ^2[0-9][0-9]$ ]]; then - die "Failed to fetch nightly manifest from GHCR: HTTP ${manifest_status}" "ghcr-manifest" - fi - MANIFEST=$(<"$nightly_manifest_file") - if [[ -z "$MANIFEST" ]]; then - die "Failed to fetch nightly manifest from GHCR" "ghcr-manifest" - fi - selected_source="$github_repo" - selected_ghcr_source="$ghcr_repo" - rm -f "$nightly_manifest_file" - return 0 - done - rm -f "$nightly_manifest_file" - die "No CLI upgrade source was found: every source returned HTTP 404" "gh-source" -} - -select_stable_source() { - local source - local tag - local tag_path - local tag_prefix - local github_repo - local url - - for source in "${UPGRADE_SOURCES[@]}"; do - github_repo=$(source_github_repo "$source") - tag_prefix=$(source_tag_prefix "$source") - if [[ -z "$requested_version" ]]; then - if [[ -n "$tag_prefix" ]]; then - url="https://api.github.com/repos/${github_repo}/releases?per_page=100" - else - url="https://api.github.com/repos/${github_repo}/releases/latest" - fi - else - tag="${tag_prefix}${version}" - tag_path="${tag//@/%40}" - url="https://api.github.com/repos/${github_repo}/releases/tags/${tag_path}" - fi - - github_get "$url" - if [[ "$http_status" == "404" ]]; then - continue - fi - if [[ ! "$http_status" =~ ^2[0-9][0-9]$ ]]; then - die "Failed to fetch ${url}: HTTP ${http_status}" "gh-version" - fi - - selected_source="$github_repo" - selected_tag_prefix="$tag_prefix" - if [[ -z "$requested_version" ]]; then - if [[ -n "$tag_prefix" ]]; then - version=$(awk -F'"' '{ - for (i = 1; i <= NF; i++) { - if ($i == "tag_name" && $(i + 2) ~ /^cli@v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9A-Za-z.-]+)?$/) { - sub(/^cli@/, "", $(i + 2)) - print $(i + 2) - exit - } - } - }' "$github_response") - else - version=$(awk -F'"' '{ - for (i = 1; i <= NF; i++) { - if ($i == "tag_name") { - print $(i + 2) - exit - } - } - }' "$github_response") - fi - if [[ -z "$version" ]]; then - die "Failed to find a CLI release in ${github_repo}" "gh-version" - fi - version="${version#v}" - fi - return 0 - done - - die "No CLI release was found: every source returned HTTP 404" "gh-version" -} +trap 'rm -f "$tmp_binary"' EXIT if [[ "$requested_version" == "nightly" ]]; then # Nightly build: download from GHCR via OCI blob protocol. @@ -362,13 +219,24 @@ if [[ "$requested_version" == "nightly" ]]; then echo -e "${MUTED}Fetching nightly build from GHCR...${NC}" - # Probe GitHub first because it provides an unambiguous 404. GHCR token - # failures may be 401 or 403 for both missing and inaccessible packages. - selected_source="" - selected_ghcr_source="" - select_nightly_source + # Step 1: Get anonymous pull token + GHCR_TOKEN=$(curl -sf \ + "https://ghcr.io/token?scope=repository:getsentry/cli:pull" \ + | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="token"){print $(i+2);exit}}') + if [[ -z "$GHCR_TOKEN" ]]; then + die "Failed to get GHCR token" "ghcr-token" + fi + + # Step 2: Fetch the OCI manifest for the :nightly tag + MANIFEST=$(curl -sf \ + -H "Authorization: Bearer $GHCR_TOKEN" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + "https://ghcr.io/v2/getsentry/cli/manifests/nightly") + if [[ -z "$MANIFEST" ]]; then + die "Failed to fetch nightly manifest from GHCR" "ghcr-manifest" + fi - # Extract version from the selected source's manifest. + # Step 3: Extract version from manifest annotation version=$(echo "$MANIFEST" \ | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="version"){print $(i+2);exit}}') if [[ -z "$version" ]]; then @@ -397,7 +265,7 @@ if [[ "$requested_version" == "nightly" ]]; then # header must NOT be forwarded to the Azure Blob Storage redirect target) redir_url=$(curl -s -w '\n%{redirect_url}' -o /dev/null \ -H "Authorization: Bearer $GHCR_TOKEN" \ - "https://ghcr.io/v2/${selected_ghcr_source}/blobs/${digest}" | tail -1) + "https://ghcr.io/v2/getsentry/cli/blobs/${digest}" | tail -1) if [[ -z "$redir_url" ]]; then die "Failed to get blob redirect URL from GHCR" "ghcr-redirect" fi @@ -407,14 +275,21 @@ if [[ "$requested_version" == "nightly" ]]; then else # Stable build: resolve version and download from GitHub Releases. - version="${requested_version#v}" - selected_source="" - selected_tag_prefix="" - select_stable_source + if [[ -z "$requested_version" ]]; then + version=$(curl -fsSL https://api.github.com/repos/getsentry/cli/releases/latest \ + | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p') + if [[ -z "$version" ]]; then + die "Failed to fetch latest version" "gh-version" + fi + else + version="$requested_version" + fi + + # Strip leading 'v' if present (releases use version without 'v' prefix) + version="${version#v}" filename="sentry-${os}-${arch}${libc_suffix}${suffix}" - tag="${selected_tag_prefix}${version}" - url="https://github.com/${selected_source}/releases/download/${tag}/${filename}" + url="https://github.com/getsentry/cli/releases/download/${version}/${filename}" echo -e "${MUTED}Downloading sentry v${version}...${NC}" @@ -452,7 +327,6 @@ if [[ "$no_agent_skills" == "true" ]]; then fi # Remove trap — setup will handle temp cleanup on success -rm -f "$github_response" trap - EXIT # shellcheck disable=SC2086 diff --git a/packages/cli/package.json b/packages/cli/package.json index fd8b463f8e..9bf937c4ac 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,8 +83,7 @@ "check:patches": "pnpm tsx script/check-patches.ts", "check:docs-sections": "pnpm tsx script/generate-docs-sections.ts --check", "check:env-coverage": "pnpm tsx script/check-env-coverage.ts", - "check:stale-refs": "pnpm tsx script/check-stale-references.ts", - "check:upgrade-sources": "vitest run test/lib/install-script.test.ts -t 'embeds the shared ordered upgrade source list'" + "check:stale-refs": "pnpm tsx script/check-stale-references.ts" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index e30633c7e7..61e10568de 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -282,6 +282,17 @@ export type ResolvedUpgradeSource = { readonly response: Response; }; +/** All configured upgrade sources returned an HTTP 404 response. */ +export class UpgradeSourceNotFoundError extends UpgradeError { + constructor() { + super( + "network_error", + "No CLI upgrade source was found: every source returned HTTP 404" + ); + this.name = "UpgradeSourceNotFoundError"; + } +} + /** Configuration for selecting the first available upgrade source. */ export type ResolveUpgradeSourceOptions = { /** Build the source-specific URL whose response proves source availability. */ @@ -340,10 +351,7 @@ export async function resolveUpgradeSource( } } - throw new UpgradeError( - "network_error", - "No CLI upgrade source was found: every source returned HTTP 404" - ); + throw new UpgradeSourceNotFoundError(); } /** diff --git a/packages/cli/src/lib/delta-upgrade.ts b/packages/cli/src/lib/delta-upgrade.ts index d7f2e3bfa0..37f4b7d549 100644 --- a/packages/cli/src/lib/delta-upgrade.ts +++ b/packages/cli/src/lib/delta-upgrade.ts @@ -125,15 +125,59 @@ function getPatchCache(): PatchCache { } function stableSource(source: UpgradeSource): SourceStrategy { + const releasesUrl = getGitHubReleasesUrl(source); + const sourceFetch: typeof customFetch = async (input, init) => { + const response = await customFetch(input, init); + if ( + !( + response.ok && + source.tagPrefix && + String(input).startsWith(`${releasesUrl}?`) + ) + ) { + return response; + } + const data: unknown = await response.json(); + if (!Array.isArray(data)) { + return new Response(JSON.stringify(data), response); + } + const releases = data + .filter(isGitHubRelease) + .filter( + (release) => + !(release.draft || release.prerelease) && + release.tag_name.startsWith(source.tagPrefix) + ) + .map((release) => ({ + ...release, + tag_name: release.tag_name.slice(source.tagPrefix.length), + })); + return new Response(JSON.stringify(releases), response); + }; + return githubReleaseSource({ - releasesUrl: getGitHubReleasesUrl(source), + releasesUrl, binaryName: getPlatformBinaryName(), userAgent: `sentry-cli/${CLI_VERSION}`, - fetch: customFetch, + fetch: sourceFetch, instrument, }); } +function isGitHubRelease(value: unknown): value is GitHubRelease & { + draft?: boolean; + prerelease?: boolean; +} { + return ( + typeof value === "object" && + value !== null && + "tag_name" in value && + typeof value.tag_name === "string" && + "assets" in value && + Array.isArray(value.assets) + ); +} + function nightlySource(source: UpgradeSource): SourceStrategy { return ghcrSource({ registry: "https://ghcr.io", @@ -180,7 +224,17 @@ export async function fetchRecentReleases( log.debug("GitHub releases response is not an array", typeof data); return []; } - return data as GitHubRelease[]; + return data + .filter(isGitHubRelease) + .filter( + (release) => + !(release.draft || release.prerelease) && + release.tag_name.startsWith(source.tagPrefix) + ) + .map((release) => ({ + ...release, + tag_name: release.tag_name.slice(source.tagPrefix.length), + })); } catch (error) { log.debug("Failed to fetch recent releases from GitHub", error); return []; diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index c270d52ea7..5bc671a6a6 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -143,6 +143,21 @@ const GHCR_REGISTRY = "https://ghcr.io"; /** OCI manifest media type */ const OCI_MANIFEST_TYPE = "application/vnd.oci.image.manifest.v1+json"; +/** An OCI manifest request received a non-successful HTTP response. */ +export class GhcrManifestHttpError extends UpgradeError { + /** HTTP status returned by GHCR. */ + readonly status: number; + + constructor(tag: string, status: number) { + super( + "network_error", + `Failed to fetch manifest for tag "${tag}": HTTP ${status}` + ); + this.name = "GhcrManifestHttpError"; + this.status = status; + } +} + /** * A single layer entry from an OCI manifest. * @@ -252,10 +267,7 @@ export async function fetchManifest( ); if (!response.ok) { - throw new UpgradeError( - "network_error", - `Failed to fetch manifest for tag "${tag}": HTTP ${response.status}` - ); + throw new GhcrManifestHttpError(tag, response.status); } return (await response.json()) as OciManifest; diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 0b9637d8cc..21b039a6a3 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -42,6 +42,7 @@ import { resolveUpgradeSource, UPGRADE_SOURCES, type UpgradeSource, + UpgradeSourceNotFoundError, } from "./binary.js"; import { CLI_VERSION, NODE_MODULES_DIRNAME } from "./constants.js"; import { getInstallInfo, setInstallInfo } from "./db/install-info.js"; @@ -54,6 +55,7 @@ import { fetchManifest, fetchNightlyManifest, findLayerByFilename, + GhcrManifestHttpError, getAnonymousToken, getNightlyVersion, type OciManifest, @@ -533,7 +535,7 @@ async function resolveNightlyManifest( sources: [source], }); } catch (error) { - if (isUpgradeSourceNotFound(error)) { + if (error instanceof UpgradeSourceNotFoundError) { continue; } throw error; @@ -543,26 +545,13 @@ async function resolveNightlyManifest( const manifest = await fetchManifest(token, tag, signal, source); return { source, manifest }; } catch (error) { - if ( - error instanceof UpgradeError && - error.message.includes(`tag "${tag}": HTTP 404`) - ) { + if (error instanceof GhcrManifestHttpError && error.status === 404) { continue; } throw error; } } - throw new UpgradeError( - "network_error", - "No CLI upgrade source was found: every source returned HTTP 404" - ); -} - -function isUpgradeSourceNotFound(error: unknown): boolean { - return ( - error instanceof UpgradeError && - error.message.includes("every source returned HTTP 404") - ); + throw new UpgradeSourceNotFoundError(); } /** Fetch the latest nightly version from the ordered release sources. */ @@ -630,7 +619,7 @@ export async function resolveExistingUpgradeVersion( }); return { version, source: selected.source }; } catch (error) { - if (isUpgradeSourceNotFound(error)) { + if (error instanceof UpgradeSourceNotFoundError) { return null; } throw error; @@ -658,7 +647,7 @@ async function nightlyVersionExists( await fetchManifest(token, `nightly-${version}`, undefined, source); return true; } catch (error) { - if (error instanceof UpgradeError && error.message.includes("HTTP 404")) { + if (error instanceof GhcrManifestHttpError && error.status === 404) { return false; } throw error; diff --git a/packages/cli/test/lib/binary.test.ts b/packages/cli/test/lib/binary.test.ts index 8f49abb41f..5eaa53474e 100644 --- a/packages/cli/test/lib/binary.test.ts +++ b/packages/cli/test/lib/binary.test.ts @@ -36,6 +36,7 @@ import { resolveUpgradeSource, samePath, UPGRADE_SOURCES, + UpgradeSourceNotFoundError, } from "../../src/lib/binary.js"; import { UpgradeError } from "../../src/lib/errors.js"; @@ -168,15 +169,16 @@ describe("resolveUpgradeSource", () => { test("fails after every source returns 404", async () => { const requests: string[] = []; - await expect( - resolveUpgradeSource({ - getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), - fetch: async (url) => { - requests.push(String(url)); - return new Response("Not Found", { status: 404 }); - }, - }) - ).rejects.toThrow("No CLI upgrade source was found"); + const error = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response("Not Found", { status: 404 }); + }, + }).catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(UpgradeSourceNotFoundError); + expect(error).toMatchObject({ name: "UpgradeSourceNotFoundError" }); expect(requests).toEqual([ "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", diff --git a/packages/cli/test/lib/delta-upgrade.test.ts b/packages/cli/test/lib/delta-upgrade.test.ts index 7375fd9ec4..993e7c89a2 100644 --- a/packages/cli/test/lib/delta-upgrade.test.ts +++ b/packages/cli/test/lib/delta-upgrade.test.ts @@ -828,8 +828,9 @@ afterEach(() => { describe("fetchRecentReleases", () => { test("returns releases from GitHub API", async () => { const releases: GitHubRelease[] = [ - makeRelease("0.14.0", [makeAsset({ name: "sentry-linux-x64" })]), - makeRelease("0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), + makeRelease("cli@0.14.0", [makeAsset({ name: "sentry-linux-x64" })]), + makeRelease("cli@0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), + makeRelease("mcp@9.0.0", [makeAsset({ name: "sentry-linux-x64" })]), ]; mockFetch(async (url) => { @@ -944,13 +945,14 @@ describe("resolveStableChain", () => { }); } - test("resolves single-hop chain with mocked fetch", async () => { + test("resolves prefixed Toolkit CLI releases and ignores other products", async () => { const binaryName = getPlatformBinaryName(); const patchBytes = new Uint8Array([10, 20, 30]); - const patchUrl = `https://github.com/getsentry/cli/releases/download/0.14.0/${binaryName}.patch`; + const patchUrl = `https://github.com/getsentry/toolkit/releases/download/cli@0.14.0/${binaryName}.patch`; const releases: GitHubRelease[] = [ - makeRelease("0.14.0", [ + makeRelease("mcp@9.0.0", [makeAsset({ name: binaryName })]), + makeRelease("cli@0.14.0", [ makeAsset({ name: binaryName, digest: `sha256:${versionHex("0.14.0")}`, @@ -962,7 +964,7 @@ describe("resolveStableChain", () => { }), makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), ]), - makeRelease("0.13.0", [makeAsset({ name: binaryName })]), + makeRelease("cli@0.13.0", [makeAsset({ name: binaryName })]), ]; setupStableMocks(releases, new Map([[patchUrl, patchBytes]])); @@ -1001,7 +1003,7 @@ describe("resolveStableChain", () => { const urlB = "https://example.com/0.15.0.patch"; const releases: GitHubRelease[] = [ - makeRelease("0.15.0", [ + makeRelease("cli@0.15.0", [ makeAsset({ name: binaryName, digest: `sha256:${versionHex("0.15.0")}`, @@ -1013,7 +1015,7 @@ describe("resolveStableChain", () => { }), makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), ]), - makeRelease("0.14.0", [ + makeRelease("cli@0.14.0", [ makeAsset({ name: binaryName, digest: `sha256:${versionHex("0.14.0")}`, @@ -1025,7 +1027,7 @@ describe("resolveStableChain", () => { }), makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), ]), - makeRelease("0.13.0", [makeAsset({ name: binaryName })]), + makeRelease("cli@0.13.0", [makeAsset({ name: binaryName })]), ]; setupStableMocks( @@ -1050,7 +1052,7 @@ describe("resolveStableChain", () => { test("returns null when target not in releases", async () => { const releases: GitHubRelease[] = [ - makeRelease("0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), + makeRelease("cli@0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), ]; setupStableMocks(releases, new Map()); @@ -1068,7 +1070,7 @@ describe("resolveStableChain", () => { test("returns null when a patch download fails", async () => { const binaryName = getPlatformBinaryName(); const releases: GitHubRelease[] = [ - makeRelease("0.14.0", [ + makeRelease("cli@0.14.0", [ makeAsset({ name: binaryName, digest: `sha256:${versionHex("0.14.0")}`, @@ -1080,7 +1082,7 @@ describe("resolveStableChain", () => { }), makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), ]), - makeRelease("0.13.0", [makeAsset({ name: binaryName })]), + makeRelease("cli@0.13.0", [makeAsset({ name: binaryName })]), ]; // Only mock releases API, no patch data available @@ -1096,7 +1098,7 @@ describe("resolveStableChain", () => { const versions = Array.from({ length: 15 }, (_, i) => `0.${i + 1}.0`); versions.reverse(); // newest first const releases = versions.map((v) => - makeRelease(v, [ + makeRelease(`cli@${v}`, [ makeAsset({ name: binaryName, digest: `sha256:${versionHex(v)}` }), makeAsset({ name: `${binaryName}.patch`, diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index 0ae92f8e0c..b971c7e1e4 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -16,6 +16,7 @@ import { findLayerByFilename, GHCR_REPO, GHCR_TAG, + GhcrManifestHttpError, getAnonymousToken, getNightlyVersion, listTags, @@ -413,12 +414,15 @@ describe("fetchManifest", () => { test("throws UpgradeError on HTTP 404", async () => { mockFetch(async () => new Response("Not Found", { status: 404 })); - await expect(fetchManifest("token", "patch-0.13.0")).rejects.toThrow( - UpgradeError - ); - await expect(fetchManifest("token", "patch-0.13.0")).rejects.toThrow( - 'Failed to fetch manifest for tag "patch-0.13.0": HTTP 404' + const error = await fetchManifest("token", "patch-0.13.0").catch( + (reason: unknown) => reason ); + expect(error).toBeInstanceOf(GhcrManifestHttpError); + expect(error).toMatchObject({ + name: "GhcrManifestHttpError", + status: 404, + message: 'Failed to fetch manifest for tag "patch-0.13.0": HTTP 404', + }); }); test("throws UpgradeError on network failure", async () => { diff --git a/packages/cli/test/lib/install-script.test.ts b/packages/cli/test/lib/install-script.test.ts index 5a626796f2..b1f22bbab8 100644 --- a/packages/cli/test/lib/install-script.test.ts +++ b/packages/cli/test/lib/install-script.test.ts @@ -1,8 +1,8 @@ /** * Install Script Tests * - * Exercises the shell installer with fake download tools so source selection, - * argument parsing, and setup delegation can be validated without network access. + * Exercises the shell installer with fake download tools so argument parsing and + * setup delegation can be validated without network access. */ import { spawn } from "node:child_process"; @@ -17,13 +17,6 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; - -type InstallerResult = { - exitCode: number; - stderr: string; - stdout: string; -}; function noop(): void { // Intentionally empty — absorbs async spawn errors @@ -36,129 +29,18 @@ describe("install script", () => { let testDir: string; let binDir: string; let argsFile: string; - let requestsFile: string; beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "sentry-install-test-")); binDir = join(testDir, "bin"); argsFile = join(testDir, "setup-args.txt"); - requestsFile = join(testDir, "requests.txt"); mkdirSync(binDir, { recursive: true }); const fakeCurl = `#!/usr/bin/env bash -set -u - -url="" -output="" -write_format="" -while [[ $# -gt 0 ]]; do - case "$1" in - -o) - output="$2" - shift 2 - ;; - -w) - write_format="$2" - shift 2 - ;; - -H|-d|--max-time) - shift 2 - ;; - http://*|https://*) - url="$1" - shift - ;; - *) - shift - ;; - esac -done - -printf '%s\n' "$url" >> "$SENTRY_TEST_REQUESTS_FILE" - -status=200 -body="" -redirect_url="" -case "$url" in - https://api.github.com/repos/getsentry/toolkit/releases\\?per_page=100) - case "$SENTRY_TEST_SCENARIO" in - fallback|gzip-fallback) status=404 ;; - forbidden) status=403 ;; - network) exit 7 ;; - *) body='[{"tag_name":"mcp@9.0.0"},{"tag_name":"cli@0.51.0-dev.1","prerelease":true},{"tag_name":"cli@0.50.0"}]' ;; - esac - ;; - https://api.github.com/repos/getsentry/cli/releases/latest) - body='{"tag_name":"0.49.0"}' - ;; - https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%40*) - case "$SENTRY_TEST_SCENARIO" in - fallback|gzip-fallback) status=404 ;; - forbidden) status=429 ;; - network) exit 6 ;; - *) body='{"tag_name":"cli@0.31.0"}' ;; - esac - ;; - https://api.github.com/repos/getsentry/cli/releases/tags/*) - body='{"tag_name":"0.31.0"}' - ;; - https://api.github.com/repos/getsentry/toolkit) - if [[ "$SENTRY_TEST_SCENARIO" == "nightly-fallback" ]]; then - status=404 - else - body='{"full_name":"getsentry/toolkit"}' - fi - ;; - https://api.github.com/repos/getsentry/cli) - body='{"full_name":"getsentry/cli"}' - ;; - https://ghcr.io/token*) - body='{"token":"test-token"}' - ;; - https://ghcr.io/v2/*/manifests/nightly) - if [[ "$SENTRY_TEST_SCENARIO" == "nightly-manifest-fallback" && "$url" == *getsentry/toolkit* ]]; then - status=404 - elif [[ "$SENTRY_TEST_SCENARIO" == "nightly-manifest-forbidden" && "$url" == *getsentry/toolkit* ]]; then - status=403 - else - body='{"annotations":{"version":"0.51.0-dev.1"},"layers":[{"digest":"sha256:x64","annotations":{"org.opencontainers.image.title":"sentry-linux-x64.gz"}},{"digest":"sha256:arm64","annotations":{"org.opencontainers.image.title":"sentry-linux-arm64.gz"}}]}' - fi - ;; - https://ghcr.io/v2/*/blobs/*) - redirect_url='https://blob.example.test/sentry.gz' - ;; - https://blob.example.test/sentry.gz) - body='#!/usr/bin/env bash -for arg in "$@"; do echo "$arg"; done > "$SENTRY_TEST_ARGS_FILE"' - ;; - https://github.com/*/releases/download/*.gz) - if [[ "$SENTRY_TEST_SCENARIO" == "gzip-fallback" ]]; then - exit 22 - fi - body='#!/usr/bin/env bash -for arg in "$@"; do echo "$arg"; done > "$SENTRY_TEST_ARGS_FILE"' - ;; - https://github.com/*/releases/download/*) - body='#!/usr/bin/env bash -for arg in "$@"; do echo "$arg"; done > "$SENTRY_TEST_ARGS_FILE"' - ;; - *) - status=404 - ;; -esac - -if [[ -n "$output" && "$output" != "/dev/null" ]]; then - printf '%s\n' "$body" > "$output" -elif [[ -z "$output" ]]; then - printf '%s\n' "$body" -fi - -if [[ -n "$write_format" ]]; then - case "$write_format" in - '%{http_code}') printf '%s' "$status" ;; - *'%{redirect_url}'*) printf '\n%s' "$redirect_url" ;; - esac -fi +cat <<'SCRIPT' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$SENTRY_TEST_ARGS_FILE" +SCRIPT `; writeFileSync(join(binDir, "curl"), fakeCurl); chmodSync(join(binDir, "curl"), 0o755); @@ -174,178 +56,44 @@ cat rmSync(testDir, { recursive: true, force: true }); }); - async function runInstaller( - args: readonly string[], - scenario: string - ): Promise { - const proc = spawn("bash", [installScript, ...args], { - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH ?? ""}`, - SENTRY_CLI_NO_TELEMETRY: "1", - SENTRY_TEST_ARGS_FILE: argsFile, - SENTRY_TEST_REQUESTS_FILE: requestsFile, - SENTRY_TEST_SCENARIO: scenario, - TMPDIR: testDir, - }, - stdio: ["pipe", "pipe", "pipe"], - }); + test("passes --no-agent-skills through to sentry cli setup", async () => { + const proc = spawn( + "bash", + [ + installScript, + "--version", + "0.31.0", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + SENTRY_TEST_ARGS_FILE: argsFile, + TMPDIR: testDir, + }, + stdio: ["pipe", "pipe", "pipe"], + } + ); proc.on("error", noop); let stdout = ""; let stderr = ""; - proc.stdout.on("data", (data: Buffer) => { - stdout += data; + proc.stdout.on("data", (d: Buffer) => { + stdout += d; }); - proc.stderr.on("data", (data: Buffer) => { - stderr += data; + proc.stderr.on("data", (d: Buffer) => { + stderr += d; }); const exitCode = await new Promise((resolve) => proc.on("close", (code) => resolve(code ?? 1)) ); - return { exitCode, stderr, stdout }; - } - - function requests(): string[] { - return readFileSync(requestsFile, "utf8").trim().split("\n"); - } - - function setupArgs(): string[] { - return readFileSync(argsFile, "utf8").trim().split("\n"); - } - - test("uses the primary source for the latest stable CLI release", async () => { - const result = await runInstaller([], "primary"); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(requests()).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", - "https://github.com/getsentry/toolkit/releases/download/cli@0.50.0/sentry-linux-x64.gz", - ]); - }); - - test("filters Toolkit's latest releases by the CLI tag prefix", async () => { - const result = await runInstaller([], "latest-prefix"); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(requests()[1]).toContain("/cli@0.50.0/"); - expect(requests()[1]).not.toContain("/mcp@9.0.0/"); - expect(requests()[1]).not.toContain("/cli@0.51.0-dev.1/"); - }); - - test("falls through to the legacy stable source only after a 404", async () => { - const result = await runInstaller([], "fallback"); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(requests()).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", - "https://api.github.com/repos/getsentry/cli/releases/latest", - "https://github.com/getsentry/cli/releases/download/0.49.0/sentry-linux-x64.gz", - ]); - }); - - test("stops source selection on a non-404 response", async () => { - const result = await runInstaller([], "forbidden"); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("HTTP 403"); - expect(requests()).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", - ]); - }); - - test("stops source selection on a network failure", async () => { - const result = await runInstaller([], "network"); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Failed to connect to GitHub"); - expect(requests()).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", - ]); - }); - - test("probes and downloads a pinned stable release from the same source", async () => { - const result = await runInstaller(["--version", "v0.31.0"], "pinned"); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(requests()).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.31.0", - "https://github.com/getsentry/toolkit/releases/download/cli@0.31.0/sentry-linux-x64.gz", - ]); - }); - - test("selects a nightly source before requesting source-specific GHCR data", async () => { - const result = await runInstaller( - ["--version", "nightly"], - "nightly-fallback" - ); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(requests()).toEqual([ - "https://api.github.com/repos/getsentry/toolkit", - "https://api.github.com/repos/getsentry/cli", - "https://ghcr.io/token?scope=repository:getsentry/cli:pull", - "https://ghcr.io/v2/getsentry/cli/manifests/nightly", - "https://ghcr.io/v2/getsentry/cli/blobs/sha256:x64", - "https://blob.example.test/sentry.gz", - ]); - expect(setupArgs()).toContain("nightly"); - }); - - test("falls back when Toolkit's nightly manifest returns 404", async () => { - const result = await runInstaller( - ["--version", "nightly"], - "nightly-manifest-fallback" - ); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(requests()).toContain( - "https://ghcr.io/v2/getsentry/toolkit/manifests/nightly" - ); - expect(requests()).toContain( - "https://ghcr.io/v2/getsentry/cli/manifests/nightly" - ); - }); - - test("stops when Toolkit's nightly manifest returns a non-404", async () => { - const result = await runInstaller( - ["--version", "nightly"], - "nightly-manifest-forbidden" - ); - - expect(result.exitCode).toBe(1); - expect(requests()).not.toContain( - "https://api.github.com/repos/getsentry/cli" - ); - }); - test("falls back from gzip to the raw asset without changing sources", async () => { - const result = await runInstaller(["--version", "0.31.0"], "gzip-fallback"); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(requests()).toEqual([ - "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.31.0", - "https://api.github.com/repos/getsentry/cli/releases/tags/0.31.0", - "https://github.com/getsentry/cli/releases/download/0.31.0/sentry-linux-x64.gz", - "https://github.com/getsentry/cli/releases/download/0.31.0/sentry-linux-x64", - ]); - }); - - test("passes setup options through to sentry cli setup", async () => { - const result = await runInstaller( - [ - "--version", - "0.31.0", - "--no-modify-path", - "--no-completions", - "--no-agent-skills", - ], - "pinned" - ); - - expect(result).toMatchObject({ exitCode: 0 }); - expect(setupArgs()).toEqual([ + expect({ exitCode, stdout, stderr }).toMatchObject({ exitCode: 0 }); + expect(readFileSync(argsFile, "utf8").trim().split("\n")).toEqual([ "cli", "setup", "--install", @@ -358,19 +106,4 @@ cat "--no-agent-skills", ]); }); - - test("embeds the shared ordered upgrade source list", () => { - const script = readFileSync(installScript, "utf8"); - const match = script.match(/^UPGRADE_SOURCES=\(([^)]*)\)$/m); - const sources = Array.from(match?.[1]?.matchAll(/'([^']*)'/g) ?? []).map( - (entry) => entry[1] - ); - - expect(sources).toEqual( - UPGRADE_SOURCES.map( - (source) => - `${source.githubRepo}|${source.ghcrRepo}|${source.tagPrefix}` - ) - ); - }); }); diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 1b5a774902..3a49a47f71 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -141,6 +141,7 @@ const { fetchLatestVersion, getCurlInstallPaths, parseInstallationMethod, + resolveExistingUpgradeVersion, startCleanupOldBinary, versionExists, } = await import("../../src/lib/upgrade.js"); @@ -620,6 +621,18 @@ describe("versionExists", () => { ]); }); + test("does not classify transport error text as missing sources", async () => { + mockFetch(async () => { + throw new Error( + "No CLI upgrade source was found: every source returned HTTP 404" + ); + }); + + await expect(resolveExistingUpgradeVersion("1.0.0")).rejects.toThrow( + "Failed to connect to GitHub" + ); + }); + test("does not fall back from an explicit selected source", async () => { const requests: string[] = []; mockFetch(async (url) => { @@ -788,6 +801,19 @@ describe("versionExists", () => { ).rejects.toThrow(UpgradeError); }); + test("does not classify nightly transport error text as not found", async () => { + mockFetch(async (url) => { + if (String(url).includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); + } + throw new Error("HTTP 404"); + }); + + await expect( + versionExists("curl", "0.14.0-dev.1772661724", UPGRADE_SOURCES[0]) + ).rejects.toThrow("HTTP 404"); + }); + test("throws on GHCR server error for nightly version", async () => { mockFetch(async (url) => { const u = String(url); @@ -1678,6 +1704,31 @@ describe("fetchLatestNightlyVersion", () => { ); }); + test("does not fall back when nightly transport error text says HTTP 404", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + const request = String(url); + requests.push(request); + if (request === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } + if (request.includes("scope=repository:getsentry/toolkit:pull")) { + return new Response(JSON.stringify({ token: "toolkit-token" }), { + status: 200, + }); + } + if (request.includes("/v2/getsentry/toolkit/manifests/nightly")) { + throw new Error("HTTP 404"); + } + return new Response("Unexpected", { status: 500 }); + }); + + await expect(fetchLatestNightlyVersion()).rejects.toThrow("HTTP 404"); + expect(requests).not.toContain( + "https://api.github.com/repos/getsentry/cli" + ); + }); + test("returns version from GHCR manifest annotation", async () => { // Mock the two requests: token exchange + manifest fetch let callCount = 0; From 723375b6ce4e37884e760fa0f848e7e7e3effcc9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 9 Sep 2026 23:46:47 +0000 Subject: [PATCH 03/19] fix(cli): address upgrade review findings --- packages/cli/src/lib/upgrade.ts | 21 ++++++----- .../cli/test/lib/delta-upgrade.mocked.test.ts | 14 +++---- packages/cli/test/lib/upgrade.test.ts | 37 +++++++++++++++---- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 21b039a6a3..64228ba413 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -21,9 +21,11 @@ import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, sep } from "node:path"; import { setTimeout } from "node:timers/promises"; +import { valid as semverValid } from "semver"; import { acquireLock, cleanupOldBinary, + compareVersions, determineInstallDir, fetchWithUpgradeError, getBinaryDownloadUrl, @@ -106,21 +108,22 @@ export type ResolvedUpgradeVersion = { function extractReleaseVersions( data: - | { tag_name?: string } + | { tag_name?: string; draft?: boolean; prerelease?: boolean } | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>, source: UpgradeSource ): string[] { - if (!Array.isArray(data)) { - return data.tag_name ? [data.tag_name] : []; - } - return data + const releases = Array.isArray(data) ? data : [data]; + return releases .filter((release) => !(release.draft || release.prerelease)) .map((release) => release.tag_name) .filter( (tag): tag is string => typeof tag === "string" && tag.startsWith(source.tagPrefix) ) - .map((tag) => tag.slice(source.tagPrefix.length)); + .map((tag) => tag.slice(source.tagPrefix.length)) + .map((tag) => tag.replace(VERSION_PREFIX_REGEX, "")) + .filter((tag) => semverValid(tag) !== null) + .sort((a, b) => compareVersions(b, a)); } // Curl Binary Helpers @@ -445,7 +448,7 @@ export async function fetchLatestFromGitHubWithSource( | { tag_name?: string } | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>; const tags = extractReleaseVersions(data, source); - const version = tags[0]?.replace(VERSION_PREFIX_REGEX, ""); + const version = tags[0]; if (!version) { throw new UpgradeError( "network_error", @@ -631,8 +634,8 @@ export async function resolveExistingUpgradeVersion( * * Nightly builds are published to GHCR with tags like `nightly-0.14.0-dev.1772661724`. * This performs an anonymous token exchange + manifest fetch (2 HTTP requests). - * Returns false only for 404/403 (tag not found); network errors propagate as - * UpgradeError to match stable version check behavior. + * Returns false only for HTTP 404 (tag not found). Every other HTTP or network + * failure propagates as UpgradeError to match stable version check behavior. * * @param version - Nightly version string (e.g., "0.14.0-dev.1772661724") * @returns true if the nightly tag exists in GHCR, false if not found diff --git a/packages/cli/test/lib/delta-upgrade.mocked.test.ts b/packages/cli/test/lib/delta-upgrade.mocked.test.ts index cd0a2fddc2..f6186ab597 100644 --- a/packages/cli/test/lib/delta-upgrade.mocked.test.ts +++ b/packages/cli/test/lib/delta-upgrade.mocked.test.ts @@ -99,10 +99,10 @@ describe("resolveStableDelta", () => { // Set up fetch mocks — releases API + patch download // Since applyPatch will fail (we don't have a real TRDIFF10 matching this binary), // we expect resolveStableDelta to throw, but the chain resolution should succeed - const patchUrl = `https://github.com/getsentry/cli/releases/download/0.14.0/${BINARY_NAME}.patch`; + const patchUrl = `https://github.com/getsentry/toolkit/releases/download/cli@0.14.0/${BINARY_NAME}.patch`; const releases = [ { - tag_name: "0.14.0", + tag_name: "cli@0.14.0", assets: [ { name: BINARY_NAME, @@ -123,7 +123,7 @@ describe("resolveStableDelta", () => { ], }, { - tag_name: "0.13.0", + tag_name: "cli@0.13.0", assets: [ { name: BINARY_NAME, @@ -352,7 +352,7 @@ describe("attemptDeltaUpgrade", () => { const patchUrl = "https://example.com/patch"; const releases = [ { - tag_name: "0.14.0", + tag_name: "cli@0.14.0", assets: [ { name: BINARY_NAME, @@ -373,7 +373,7 @@ describe("attemptDeltaUpgrade", () => { ], }, { - tag_name: "0.13.0", + tag_name: "cli@0.13.0", assets: [ { name: BINARY_NAME, @@ -434,7 +434,7 @@ describe("attemptDeltaUpgrade", () => { const patchUrl = "https://example.com/small.patch"; const releases = [ { - tag_name: "0.14.0", + tag_name: "cli@0.14.0", assets: [ { name: BINARY_NAME, @@ -455,7 +455,7 @@ describe("attemptDeltaUpgrade", () => { ], }, { - tag_name: "0.13.0", + tag_name: "cli@0.13.0", assets: [ { name: BINARY_NAME, diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 3a49a47f71..bffea175b2 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -197,6 +197,7 @@ describe("fetchLatestFromGitHub", () => { return new Response( JSON.stringify([ { tag_name: "mcp@9.0.0" }, + { tag_name: "cli@not-a-version" }, { tag_name: "cli@1.2.3" }, { tag_name: "cli@1.3.0" }, ]), @@ -204,7 +205,7 @@ describe("fetchLatestFromGitHub", () => { ); }); - await expect(fetchLatestFromGitHub()).resolves.toBe("1.2.3"); + await expect(fetchLatestFromGitHub()).resolves.toBe("1.3.0"); expect(requests).toEqual([ "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", ]); @@ -230,16 +231,20 @@ describe("fetchLatestFromGitHub", () => { }); test("does not use an MCP release as the latest CLI release", async () => { - mockFetch( - async () => - new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { - status: 200, - }) - ); + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { + status: 200, + }); + }); await expect(fetchLatestFromGitHub()).rejects.toThrow( "No version found in GitHub release" ); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + ]); }); test("returns version from GitHub API", async () => { @@ -827,6 +832,24 @@ describe("versionExists", () => { versionExists("curl", "0.14.0-dev.1772661724") ).rejects.toThrow(UpgradeError); }); + + test("does not classify GHCR HTTP 403 as a missing nightly version", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + const urlString = String(url); + requests.push(urlString); + if (urlString.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); + } + return new Response(null, { status: 403 }); + }); + + await expect( + versionExists("curl", "0.14.0-dev.1772661724", UPGRADE_SOURCES[0]) + ).rejects.toThrow("HTTP 403"); + expect(requests).toHaveLength(2); + expect(requests.some((url) => url.includes("getsentry/cli"))).toBe(false); + }); }); describe("executeUpgrade", () => { From dbee7e9f2a4555e044f74dfebad002cccfa43a1f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 9 Sep 2026 23:55:19 +0000 Subject: [PATCH 04/19] test(cli): update toolkit upgrade fixtures --- packages/cli/test/commands/cli.test.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/cli/test/commands/cli.test.ts b/packages/cli/test/commands/cli.test.ts index cd1514eb0c..187ae70f4c 100644 --- a/packages/cli/test/commands/cli.test.ts +++ b/packages/cli/test/commands/cli.test.ts @@ -124,7 +124,7 @@ describe("upgradeCommand.func", () => { test("shows installation info with specified method", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v0.0.0-dev" }), { + new Response(JSON.stringify([{ tag_name: "cli@0.0.0-dev" }]), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch; @@ -144,9 +144,9 @@ describe("upgradeCommand.func", () => { }); test("check mode shows update available", async () => { - // curl uses GitHub API which returns { tag_name: "vX.X.X" } + // Curl uses the Toolkit GitHub release list with product-prefixed tags. globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v99.0.0" }), { + new Response(JSON.stringify([{ tag_name: "cli@99.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch; @@ -164,11 +164,15 @@ describe("upgradeCommand.func", () => { }); test("check mode with version shows versioned command", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v99.0.0" }), { + globalThis.fetch = (async (url) => { + const response = String(url).includes("/releases/tags/cli%402.0.0") + ? { tag_name: "cli@2.0.0" } + : [{ tag_name: "cli@99.0.0" }]; + return new Response(JSON.stringify(response), { status: 200, headers: { "Content-Type": "application/json" }, - })) as typeof fetch; + }); + }) as typeof fetch; const func = await upgradeCommand.loader(); const { context, getStdout, restore } = createMockContext(); @@ -189,7 +193,7 @@ describe("upgradeCommand.func", () => { test("check mode shows already on target when versions match", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v0.0.0-dev" }), { + new Response(JSON.stringify([{ tag_name: "cli@0.0.0-dev" }]), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch; @@ -208,14 +212,13 @@ describe("upgradeCommand.func", () => { }); test("throws UpgradeError when specified version does not exist", async () => { - // First call: fetch latest (returns 99.0.0) - // Second call: check if version exists (returns 404) + // First call fetches latest; both exact-tag probes return 404. let callCount = 0; globalThis.fetch = (async () => { callCount += 1; if (callCount === 1) { // Latest version check - return new Response(JSON.stringify({ tag_name: "v99.0.0" }), { + return new Response(JSON.stringify([{ tag_name: "cli@99.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }); From 5ffa5cd0fe02c0a5dc5bec9a8a099f4135c0b447 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 01:38:25 +0000 Subject: [PATCH 05/19] fix(cli): harden upgrade source resolution --- packages/cli/src/lib/ghcr.ts | 2 +- packages/cli/src/lib/upgrade.ts | 94 ++++++++++++++++++++++----- packages/cli/test/lib/ghcr.test.ts | 21 ++++++ packages/cli/test/lib/upgrade.test.ts | 77 ++++++++++++++++++++++ 4 files changed, 175 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index 5bc671a6a6..313ecced2c 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -116,7 +116,7 @@ async function fetchWithRetry( lastError = error instanceof Error ? error : new Error(String(error)); // Propagate external abort immediately — don't retry caller cancellation if (isExternalAbort(lastError, externalSignal)) { - break; + throw lastError; } // Only retry on timeout or network errors — not HTTP errors if (attempt >= GHCR_MAX_RETRIES || !isRetryableError(lastError)) { diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 64228ba413..376eb6f761 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -98,6 +98,9 @@ const NPM_REGISTRY_URL = "https://registry.npmjs.org/sentry"; /** Regex to strip 'v' prefix from version strings */ export const VERSION_PREFIX_REGEX = /^v/; +/** GitHub pagination link for the next page. */ +const NEXT_PAGE_LINK_REGEX = /<([^>]+)>;\s*rel="next"/; + /** A resolved standalone-binary version and the source that must serve it. */ export type ResolvedUpgradeVersion = { /** Version without a source-specific tag prefix. */ @@ -126,6 +129,29 @@ function extractReleaseVersions( .sort((a, b) => compareVersions(b, a)); } +function getNextGitHubReleasePage( + response: Response, + source: UpgradeSource +): string | undefined { + const link = response.headers.get("link"); + const match = link?.match(NEXT_PAGE_LINK_REGEX); + if (!match?.[1]) { + return; + } + const url = new URL(match[1]); + if ( + url.protocol !== "https:" || + url.hostname !== "api.github.com" || + url.pathname !== `/repos/${source.githubRepo}/releases` + ) { + throw new UpgradeError( + "network_error", + "GitHub returned an invalid release pagination URL" + ); + } + return url.href; +} + // Curl Binary Helpers /** @@ -439,23 +465,47 @@ export async function fetchLatestFromGitHubWithSource( signal?: AbortSignal, sources: readonly UpgradeSource[] = UPGRADE_SOURCES ): Promise { - const { source, response } = await resolveUpgradeSource({ + const resolved = await resolveUpgradeSource({ getProbeUrl: getGitHubLatestReleaseUrl, signal, sources, }); - const data = (await response.json()) as - | { tag_name?: string } - | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>; - const tags = extractReleaseVersions(data, source); - const version = tags[0]; - if (!version) { - throw new UpgradeError( - "network_error", - "No version found in GitHub release" + let response = resolved.response; + const visitedPages = new Set([getGitHubLatestReleaseUrl(resolved.source)]); + while (true) { + const data = (await response.json()) as + | { tag_name?: string } + | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>; + const version = extractReleaseVersions(data, resolved.source)[0]; + if (version) { + return { version, source: resolved.source }; + } + const nextPage = getNextGitHubReleasePage(response, resolved.source); + if (!nextPage) { + throw new UpgradeError( + "network_error", + "No version found in GitHub release" + ); + } + if (visitedPages.has(nextPage)) { + throw new UpgradeError( + "network_error", + "GitHub returned cyclic release pagination" + ); + } + visitedPages.add(nextPage); + response = await fetchWithUpgradeError( + nextPage, + { headers: getGitHubHeaders(), signal }, + "GitHub" ); + if (!response.ok) { + throw new UpgradeError( + "network_error", + `Failed to fetch from GitHub: HTTP ${response.status}` + ); + } } - return { version, source }; } /** Fetch the latest standalone CLI version from the ordered GitHub sources. */ @@ -665,13 +715,21 @@ async function standaloneVersionExists( if (isNightlyVersion(version)) { return nightlyVersionExists(version, source); } - return ( - await fetchWithUpgradeError( - getGitHubReleaseByTagUrl(version, source), - { headers: getGitHubHeaders() }, - "GitHub" - ) - ).ok; + const response = await fetchWithUpgradeError( + getGitHubReleaseByTagUrl(version, source), + { headers: getGitHubHeaders() }, + "GitHub" + ); + if (response.ok) { + return true; + } + if (response.status === 404) { + return false; + } + throw new UpgradeError( + "network_error", + `Failed to fetch from GitHub: HTTP ${response.status}` + ); } const resolved = await resolveExistingUpgradeVersion(version); return resolved !== null; diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index b971c7e1e4..f185c5234d 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -126,6 +126,27 @@ describe("getAnonymousToken", () => { ); }); + test("propagates caller cancellation without retrying", async () => { + const controller = new AbortController(); + let requests = 0; + mockFetch(async (_url, init) => { + requests += 1; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true } + ); + }); + }); + + const request = getAnonymousToken(undefined, controller.signal); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect(requests).toBe(1); + }); + test("throws UpgradeError when response has no token field", async () => { mockFetch( async () => diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index bffea175b2..3d4c8f4f2d 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -211,6 +211,73 @@ describe("fetchLatestFromGitHub", () => { ]); }); + test("follows Toolkit release pagination to find the latest CLI release", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + if (requests.length === 1) { + return new Response( + JSON.stringify( + Array.from({ length: 100 }, (_, index) => ({ + tag_name: `mcp@9.0.${index}`, + })) + ), + { + status: 200, + headers: { + Link: '; rel="next"', + }, + } + ); + } + return new Response(JSON.stringify([{ tag_name: "cli@1.2.3" }]), { + status: 200, + }); + }); + + await expect(fetchLatestFromGitHub()).resolves.toBe("1.2.3"); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=2", + ]); + }); + + test("rejects GitHub release pagination outside the selected source", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { + status: 200, + headers: { + Link: '; rel="next"', + }, + }); + }); + + await expect(fetchLatestFromGitHub()).rejects.toThrow( + "GitHub returned an invalid release pagination URL" + ); + expect(requests).toHaveLength(1); + }); + + test("rejects cyclic GitHub release pagination", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { + status: 200, + headers: { + Link: `<${String(url)}>; rel="next"`, + }, + }); + }); + + await expect(fetchLatestFromGitHub()).rejects.toThrow( + "GitHub returned cyclic release pagination" + ); + expect(requests).toHaveLength(1); + }); + test("falls back to the legacy latest release only on Toolkit HTTP 404", async () => { const requests: string[] = []; mockFetch(async (url) => { @@ -653,6 +720,16 @@ describe("versionExists", () => { ]); }); + test.each([ + 401, 403, 429, 500, + ])("does not classify explicit source HTTP %i as a missing version", async (status) => { + mockFetch(async () => new Response(null, { status })); + + await expect( + versionExists("curl", "1.0.0", UPGRADE_SOURCES[0]) + ).rejects.toThrow(`HTTP ${status}`); + }); + test("checks GitHub for curl method - version exists", async () => { mockFetch(async () => new Response(null, { status: 200 })); From c8c815bbecf472061ec02ab96d1a31f23f078d01 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 01:56:41 +0000 Subject: [PATCH 06/19] fix(cli): validate GitHub release pagination --- packages/cli/src/lib/release-notes.ts | 6 ++-- packages/cli/src/lib/upgrade.ts | 26 ++++++++++++++-- packages/cli/test/lib/release-notes.test.ts | 34 ++++++++++++++++++++- packages/cli/test/lib/upgrade.test.ts | 23 ++++++++++++-- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/release-notes.ts b/packages/cli/src/lib/release-notes.ts index fb32efd787..4050fa6b4d 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -13,6 +13,7 @@ */ import { marked, type Token, type Tokens } from "marked"; +import { valid as semverValid } from "semver"; import { compareVersions, getGitHubHeaders, @@ -434,10 +435,11 @@ function buildChangelogSummaryForSource( const inRange = releases.filter((release) => { let tagName = release.tag_name; if (source?.tagPrefix) { - if (!tagName.startsWith(source.tagPrefix)) { + if (tagName.startsWith(source.tagPrefix)) { + tagName = tagName.slice(source.tagPrefix.length); + } else if (semverValid(tagName.replace(VERSION_PREFIX_RE, "")) === null) { return false; } - tagName = tagName.slice(source.tagPrefix.length); } const version = tagName.replace(VERSION_PREFIX_RE, ""); return ( diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 376eb6f761..3f6c118ef9 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -101,6 +101,12 @@ export const VERSION_PREFIX_REGEX = /^v/; /** GitHub pagination link for the next page. */ const NEXT_PAGE_LINK_REGEX = /<([^>]+)>;\s*rel="next"/; +/** Canonical GitHub REST repository release-list path. */ +const CANONICAL_RELEASES_PATH_REGEX = /^\/repositories\/\d+\/releases$/; + +/** Positive GitHub pagination page number. */ +const PAGE_NUMBER_REGEX = /^[1-9]\d*$/; + /** A resolved standalone-binary version and the source that must serve it. */ export type ResolvedUpgradeVersion = { /** Version without a source-specific tag prefix. */ @@ -138,18 +144,34 @@ function getNextGitHubReleasePage( if (!match?.[1]) { return; } + if (!URL.canParse(match[1])) { + throw new UpgradeError( + "network_error", + "GitHub returned an invalid release pagination URL" + ); + } const url = new URL(match[1]); + const isSelectedSourcePath = + url.pathname === `/repos/${source.githubRepo}/releases`; + const isCanonicalRepositoryPath = CANONICAL_RELEASES_PATH_REGEX.test( + url.pathname + ); + const page = url.searchParams.get("page"); if ( url.protocol !== "https:" || url.hostname !== "api.github.com" || - url.pathname !== `/repos/${source.githubRepo}/releases` + !(isSelectedSourcePath || isCanonicalRepositoryPath) || + page === null || + !PAGE_NUMBER_REGEX.test(page) ) { throw new UpgradeError( "network_error", "GitHub returned an invalid release pagination URL" ); } - return url.href; + const nextPage = new URL(getGitHubLatestReleaseUrl(source)); + nextPage.searchParams.set("page", page); + return nextPage.href; } // Curl Binary Helpers diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts index d600668020..0b89ae985b 100644 --- a/packages/cli/test/lib/release-notes.test.ts +++ b/packages/cli/test/lib/release-notes.test.ts @@ -11,7 +11,10 @@ import { marked } from "marked"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; -import type { GitHubRelease } from "../../src/lib/delta-upgrade.js"; +import { + fetchRecentReleases, + type GitHubRelease, +} from "../../src/lib/delta-upgrade.js"; import { buildChangelogSummary, type ChangeCategory, @@ -348,6 +351,35 @@ describe("fetchChangelog source affinity", () => { ); }); + test("builds a Toolkit changelog from normalized prefetched releases", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify([ + makeRelease( + "cli@0.21.0", + "### Bug Fixes 🐛\n\n- Reuse prefetched releases" + ), + ]), + { status: 200 } + ) + ); + + const releases = await fetchRecentReleases(undefined, toolkitSource); + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + prefetchedReleases: releases, + }); + + expect(changelog?.totalItems).toBe(1); + expect(changelog?.sections[0]?.markdown).toContain( + "Reuse prefetched releases" + ); + }); + test("fetches stable releases only from the explicitly selected legacy source", async () => { const requestedUrls: string[] = []; globalThis.fetch = mockFetch(async (input) => { diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 3d4c8f4f2d..9dba28b320 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -225,7 +225,7 @@ describe("fetchLatestFromGitHub", () => { { status: 200, headers: { - Link: '; rel="next"', + Link: '; rel="next"', }, } ); @@ -260,6 +260,23 @@ describe("fetchLatestFromGitHub", () => { expect(requests).toHaveLength(1); }); + test("classifies malformed GitHub release pagination as a network error", async () => { + mockFetch( + async () => + new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { + status: 200, + headers: { + Link: '; rel="next"', + }, + }) + ); + + await expect(fetchLatestFromGitHub()).rejects.toMatchObject({ + reason: "network_error", + message: "GitHub returned an invalid release pagination URL", + }); + }); + test("rejects cyclic GitHub release pagination", async () => { const requests: string[] = []; mockFetch(async (url) => { @@ -267,7 +284,7 @@ describe("fetchLatestFromGitHub", () => { return new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { status: 200, headers: { - Link: `<${String(url)}>; rel="next"`, + Link: '; rel="next"', }, }); }); @@ -275,7 +292,7 @@ describe("fetchLatestFromGitHub", () => { await expect(fetchLatestFromGitHub()).rejects.toThrow( "GitHub returned cyclic release pagination" ); - expect(requests).toHaveLength(1); + expect(requests).toHaveLength(2); }); test("falls back to the legacy latest release only on Toolkit HTTP 404", async () => { From b6484e0fb1eb0f8bce68272d538f5c3b26cd5bd5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 02:19:10 +0000 Subject: [PATCH 07/19] fix(cli): validate resolved upgrade metadata --- packages/cli/src/commands/cli/upgrade.ts | 16 ++--- packages/cli/src/lib/ghcr.ts | 10 +++ packages/cli/src/lib/release-notes.ts | 20 +++--- packages/cli/src/lib/upgrade.ts | 18 ++--- .../cli/test/commands/cli/upgrade.test.ts | 65 ++++++++++++++++--- packages/cli/test/lib/ghcr.test.ts | 12 ++++ packages/cli/test/lib/release-notes.test.ts | 7 ++ packages/cli/test/lib/upgrade.test.ts | 20 ++++++ 8 files changed, 131 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 610a120258..274789d107 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -336,11 +336,17 @@ async function resolveTargetVersion( log.debug(`Target version: ${target}`); } + // Validate a pinned target before every return path, including --check. + if (versionArg && !CHANNEL_VERSIONS.has(versionArg)) { + const lookupMethod = channel === "nightly" ? "curl" : method; + source = (await resolvePinnedVersion(lookupMethod, target)) ?? source; + } + if (flags.check) { return { kind: "done", result: buildCheckResult({ target, versionArg, method, channel, flags }), - source: latestResolution?.source, + source, }; } @@ -359,14 +365,6 @@ async function resolveTargetVersion( }; } - // Validate that a specific pinned version actually exists. - // Nightly builds are GitHub-only, so always use curl (GitHub) lookup for - // nightly channel regardless of the current install method. - if (versionArg && !CHANNEL_VERSIONS.has(versionArg)) { - const lookupMethod = channel === "nightly" ? "curl" : method; - source = (await resolvePinnedVersion(lookupMethod, target)) ?? source; - } - return { kind: "target", target, source }; } diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index 313ecced2c..f556b35fc9 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -17,6 +17,7 @@ * without the auth header. */ +import { valid as semverValid } from "semver"; import { PRIMARY_UPGRADE_SOURCE, type UpgradeSource } from "./binary.js"; import { getUserAgent } from "./constants.js"; import { customFetch } from "./custom-ca.js"; @@ -28,6 +29,9 @@ const GHCR_REQUEST_TIMEOUT = 10_000; /** Maximum number of retry attempts for transient failures */ const GHCR_MAX_RETRIES = 1; +/** Nightly versions use a numeric build timestamp as the prerelease value. */ +const NIGHTLY_VERSION_REGEX = /^\d+\.\d+\.\d+-dev\.\d+$/; + /** Timeout for large blob downloads (30 seconds) */ const GHCR_BLOB_TIMEOUT = 30_000; @@ -307,6 +311,12 @@ export function getNightlyVersion(manifest: OciManifest): string { "Nightly manifest has no version annotation" ); } + if (semverValid(version) === null || !NIGHTLY_VERSION_REGEX.test(version)) { + throw new UpgradeError( + "network_error", + "Nightly manifest has invalid version annotation" + ); + } return version; } diff --git a/packages/cli/src/lib/release-notes.ts b/packages/cli/src/lib/release-notes.ts index 4050fa6b4d..07d43e54f2 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -13,7 +13,6 @@ */ import { marked, type Token, type Tokens } from "marked"; -import { valid as semverValid } from "semver"; import { compareVersions, getGitHubHeaders, @@ -431,17 +430,9 @@ function buildChangelogSummaryForSource( toVersion: string, options: ChangelogBuildOptions ): ChangelogSummary | null { - const { maxItems, source } = options; + const { maxItems } = options; const inRange = releases.filter((release) => { - let tagName = release.tag_name; - if (source?.tagPrefix) { - if (tagName.startsWith(source.tagPrefix)) { - tagName = tagName.slice(source.tagPrefix.length); - } else if (semverValid(tagName.replace(VERSION_PREFIX_RE, "")) === null) { - return false; - } - } - const version = tagName.replace(VERSION_PREFIX_RE, ""); + const version = release.tag_name.replace(VERSION_PREFIX_RE, ""); return ( compareVersions(version, fromVersion) === 1 && compareVersions(version, toVersion) <= 0 @@ -612,7 +603,12 @@ async function fetchReleasesForChangelog( log.debug("GitHub releases response is not an array", typeof data); return []; } - return data as GitHubRelease[]; + return (data as GitHubRelease[]) + .filter((release) => release.tag_name.startsWith(source.tagPrefix)) + .map((release) => ({ + ...release, + tag_name: release.tag_name.slice(source.tagPrefix.length), + })); } /** diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 3f6c118ef9..b669cd87b6 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -494,20 +494,22 @@ export async function fetchLatestFromGitHubWithSource( }); let response = resolved.response; const visitedPages = new Set([getGitHubLatestReleaseUrl(resolved.source)]); + const versions: string[] = []; while (true) { const data = (await response.json()) as | { tag_name?: string } | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>; - const version = extractReleaseVersions(data, resolved.source)[0]; - if (version) { - return { version, source: resolved.source }; - } + versions.push(...extractReleaseVersions(data, resolved.source)); const nextPage = getNextGitHubReleasePage(response, resolved.source); if (!nextPage) { - throw new UpgradeError( - "network_error", - "No version found in GitHub release" - ); + const version = versions.sort((a, b) => compareVersions(b, a))[0]; + if (!version) { + throw new UpgradeError( + "network_error", + "No version found in GitHub release" + ); + } + return { version, source: resolved.source }; } if (visitedPages.has(nextPage)) { throw new UpgradeError( diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index b79a9c342d..941ddb084d 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -335,7 +335,7 @@ describe("sentry cli upgrade", () => { }); test("shows version-specific upgrade hint when user-specified version", async () => { - mockGitHubVersion("99.99.99"); + mockGitHubVersion("88.88.88"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, @@ -354,6 +354,55 @@ describe("sentry cli upgrade", () => { "Run 'sentry cli upgrade 88.88.88' to update." ); }); + + test("resolves a pinned check target from its exact source", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + const request = String(url); + requests.push(request); + if (request.includes("getsentry/toolkit/releases?per_page=100")) { + return new Response(JSON.stringify([{ tag_name: "cli@99.99.99" }]), { + status: 200, + }); + } + if ( + request.includes("getsentry/toolkit/releases/tags/cli%4088.88.88") + ) { + return new Response("Not Found", { status: 404 }); + } + if (request.includes("getsentry/cli/releases/tags/88.88.88")) { + return new Response(JSON.stringify({ tag_name: "88.88.88" }), { + status: 200, + }); + } + if (request.includes("getsentry/cli/releases?per_page=30")) { + return new Response(JSON.stringify([]), { status: 200 }); + } + return new Response("Unexpected", { status: 500 }); + }); + + const { context, restore } = createMockContext({ homeDir: testDir }); + restoreStderr = restore; + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl", "88.88.88"], + context + ); + + expect(requests).toContain( + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%4088.88.88" + ); + expect(requests).toContain( + "https://api.github.com/repos/getsentry/cli/releases/tags/88.88.88" + ); + expect(requests).toContain( + "https://api.github.com/repos/getsentry/cli/releases?per_page=30" + ); + expect(requests).not.toContain( + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=30" + ); + }); }); describe("already up to date", () => { @@ -563,7 +612,7 @@ describe("sentry cli upgrade — nightly channel", () => { describe("resolveChannelAndVersion", () => { test("'nightly' positional sets channel to nightly", async () => { - mockNightlyVersion(CLI_VERSION); + mockNightlyVersion("0.0.0-dev.1"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, @@ -601,7 +650,7 @@ describe("sentry cli upgrade — nightly channel", () => { test("without positional, uses persisted channel", async () => { setReleaseChannel("nightly"); - mockNightlyVersion(CLI_VERSION); + mockNightlyVersion("0.0.0-dev.1"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, @@ -621,7 +670,7 @@ describe("sentry cli upgrade — nightly channel", () => { describe("channel persistence", () => { test("persists nightly channel when 'nightly' positional is passed", async () => { - mockNightlyVersion(CLI_VERSION); + mockNightlyVersion("0.0.0-dev.1"); const { context, restore } = createMockContext({ homeDir: testDir }); restoreStderr = restore; @@ -655,8 +704,8 @@ describe("sentry cli upgrade — nightly channel", () => { }); describe("nightly --check mode", () => { - test("shows 'already on target' when current matches nightly latest", async () => { - mockNightlyVersion(CLI_VERSION); + test("shows the valid nightly target", async () => { + mockNightlyVersion("0.0.0-dev.1"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, @@ -671,8 +720,8 @@ describe("sentry cli upgrade — nightly channel", () => { const combined = getOutput(); expect(combined).toContain("Channel: nightly"); - expect(combined).toContain(CLI_VERSION); - expect(combined).toContain("You are already on the target version"); + expect(combined).toContain("0.0.0-dev.1"); + expect(combined).toContain("Run 'sentry cli upgrade' to update."); }); test("shows upgrade hint when newer nightly available", async () => { diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index f185c5234d..c6f1035ded 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -240,6 +240,18 @@ describe("getNightlyVersion", () => { const manifest = makeManifest({ annotations: undefined }); expect(() => getNightlyVersion(manifest)).toThrow(UpgradeError); }); + + test.each([ + "not-semver", + "1.2.3", + "1.2.3-dev.foo", + ])("rejects invalid nightly version annotation %s", (version) => { + const manifest = makeManifest({ annotations: { version } }); + + expect(() => getNightlyVersion(manifest)).toThrow( + "Nightly manifest has invalid version annotation" + ); + }); }); describe("findLayerByFilename", () => { diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts index 0b89ae985b..859d39a611 100644 --- a/packages/cli/test/lib/release-notes.test.ts +++ b/packages/cli/test/lib/release-notes.test.ts @@ -323,6 +323,10 @@ describe("fetchChangelog source affinity", () => { "mcp@99.0.0", "### New Features ✨\n\n- Unrelated Toolkit package release" ), + makeRelease( + "0.21.0", + "### Bug Fixes 🐛\n\n- Unprefixed Toolkit release" + ), makeRelease( "cli@0.21.0", "### Bug Fixes 🐛\n\n- Keep release stages source-affine" @@ -343,6 +347,9 @@ describe("fetchChangelog source affinity", () => { expect(changelog?.sections[0]?.markdown).not.toContain( "Unrelated Toolkit package release" ); + expect(changelog?.sections[0]?.markdown).not.toContain( + "Unprefixed Toolkit release" + ); expect(requestedUrls).toEqual([ "https://api.github.com/repos/getsentry/toolkit/releases?per_page=30", ]); diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 9dba28b320..2427fc34d6 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -242,6 +242,26 @@ describe("fetchLatestFromGitHub", () => { ]); }); + test("selects the highest CLI SemVer across Toolkit release pages", async () => { + let requests = 0; + mockFetch(async () => { + requests += 1; + return requests === 1 + ? new Response(JSON.stringify([{ tag_name: "cli@1.2.1" }]), { + status: 200, + headers: { + Link: '; rel="next"', + }, + }) + : new Response(JSON.stringify([{ tag_name: "cli@1.3.0" }]), { + status: 200, + }); + }); + + await expect(fetchLatestFromGitHub()).resolves.toBe("1.3.0"); + expect(requests).toBe(2); + }); + test("rejects GitHub release pagination outside the selected source", async () => { const requests: string[] = []; mockFetch(async (url) => { From c6030fbf4d0c190f037c86d832edb6680d3e54ab Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 02:34:15 +0000 Subject: [PATCH 08/19] fix(cli): bind nightly manifests to versions --- packages/cli/src/lib/upgrade.ts | 17 ++++++ packages/cli/test/lib/upgrade.test.ts | 80 ++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index b669cd87b6..663adf7724 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -678,6 +678,19 @@ export function resolveLatestUpgradeVersion( : fetchLatestFromGitHubWithSource(signal); } +function validateNightlyManifestVersion( + manifest: OciManifest, + expectedVersion: string +): void { + const manifestVersion = getNightlyVersion(manifest); + if (manifestVersion !== expectedVersion) { + throw new UpgradeError( + "network_error", + `Nightly manifest version ${manifestVersion} does not match requested version ${expectedVersion}` + ); + } +} + /** Resolve and validate a pinned standalone version against ordered sources. */ export async function resolveExistingUpgradeVersion( version: string @@ -689,6 +702,7 @@ export async function resolveExistingUpgradeVersion( undefined, UPGRADE_SOURCES ); + validateNightlyManifestVersion(resolved.manifest, version); return { version, source: resolved.source }; } const selected = await resolveUpgradeSource({ @@ -949,6 +963,9 @@ async function downloadNightlyToPath( const manifest = version ? await fetchManifest(token, `nightly-${version}`, undefined, source) : await fetchNightlyManifest(token, undefined, source); + if (version) { + validateNightlyManifestVersion(manifest, version); + } const filename = getNightlyGzFilename(); const layer = findLayerByFilename(manifest, filename); const response = await downloadNightlyBlob( diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 2427fc34d6..cb3daa43cb 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -742,6 +742,41 @@ describe("versionExists", () => { ); }); + test.each([ + undefined, + "not-semver", + "0.14.0-dev.124", + ])("rejects pinned nightly manifest annotation %s without legacy fallback", async (annotation) => { + const requests: string[] = []; + mockFetch(async (url) => { + const request = String(url); + requests.push(request); + if (request === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } + if (request.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); + } + if (request.includes("/manifests/nightly-0.14.0-dev.123")) { + return new Response( + JSON.stringify({ + annotations: + annotation === undefined ? {} : { version: annotation }, + }), + { status: 200 } + ); + } + return new Response("Unexpected", { status: 500 }); + }); + + await expect( + resolveExistingUpgradeVersion("0.14.0-dev.123") + ).rejects.toMatchObject({ reason: "network_error" }); + expect(requests.some((request) => request.includes("getsentry/cli"))).toBe( + false + ); + }); + test("does not fall back from an explicit selected source", async () => { const requests: string[] = []; mockFetch(async (url) => { @@ -853,7 +888,11 @@ describe("versionExists", () => { }); test("checks GHCR for nightly version - version exists", async () => { - const manifest = { schemaVersion: 2, layers: [], annotations: {} }; + const manifest = { + schemaVersion: 2, + layers: [], + annotations: { version: "0.14.0-dev.1772661724" }, + }; mockFetch(async (url) => { const u = String(url); if (u === "https://api.github.com/repos/getsentry/toolkit") { @@ -892,7 +931,11 @@ describe("versionExists", () => { }); test("checks GHCR for nightly version regardless of install method", async () => { - const manifest = { schemaVersion: 2, layers: [], annotations: {} }; + const manifest = { + schemaVersion: 2, + layers: [], + annotations: { version: "0.14.0-dev.1772661724" }, + }; mockFetch(async (url) => { const u = String(url); if (u === "https://api.github.com/repos/getsentry/toolkit") { @@ -2036,6 +2079,39 @@ describe("executeUpgrade with curl method (nightly)", () => { const content = await readFile(result!.tempBinaryPath); expect(new Uint8Array(content)).toEqual(mockBinaryContent); }); + + test("rejects a mismatched versioned manifest before downloading its blob", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + const request = String(url); + requests.push(request); + if (request.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); + } + if (request.includes("/manifests/nightly-0.14.0-dev.123")) { + return new Response( + JSON.stringify({ + layers: [], + annotations: { version: "0.14.0-dev.124" }, + }), + { status: 200 } + ); + } + return new Response("Unexpected", { status: 500 }); + }); + + await expect( + executeUpgrade( + "curl", + "0.14.0-dev.123", + undefined, + false, + undefined, + UPGRADE_SOURCES[0] + ) + ).rejects.toMatchObject({ reason: "network_error" }); + expect(requests.some((request) => request.includes("/blobs/"))).toBe(false); + }); }); describe("downloadBinaryToTemp offline errors", () => { From 2f9b5d0d26124d445a916b8bfc584ad518720584 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 02:53:12 +0000 Subject: [PATCH 09/19] fix(cli): isolate pinned upgrade resolution --- packages/cli/src/commands/cli/upgrade.ts | 51 ++++++++++++------- packages/cli/src/lib/ghcr.ts | 11 ++++ packages/cli/src/lib/release-notes.ts | 37 +++++++++++--- .../cli/test/commands/cli/upgrade.test.ts | 8 ++- packages/cli/test/lib/ghcr.test.ts | 34 +++++++++++++ packages/cli/test/lib/release-notes.test.ts | 31 +++++++++++ 6 files changed, 140 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 274789d107..2d4f927d2d 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -321,43 +321,56 @@ async function resolveTargetVersion( const { method, channel, versionArg, channelChanged, flags } = opts; const standalone = channel === "nightly" || method === "curl" || method === "brew"; - const latestResolution = standalone - ? await resolveLatestUpgradeVersion(channel) - : undefined; - const latest = latestResolution - ? latestResolution.version - : await fetchLatestVersion(method, channel); - const target = versionArg?.replace(VERSION_PREFIX_REGEX, "") ?? latest; - let source = latestResolution?.source; + const pinnedTarget = + versionArg && !CHANNEL_VERSIONS.has(versionArg) + ? versionArg.replace(VERSION_PREFIX_REGEX, "") + : undefined; + let source: UpgradeSource | undefined; + + if (pinnedTarget) { + const lookupMethod = channel === "nightly" ? "curl" : method; + source = await resolvePinnedVersion(lookupMethod, pinnedTarget); + } + + const latestResolution = + pinnedTarget === undefined && standalone + ? await resolveLatestUpgradeVersion(channel) + : undefined; + const latest = + pinnedTarget ?? + latestResolution?.version ?? + (await fetchLatestVersion(method, channel)); + const resolvedTarget = pinnedTarget ?? latest; + source ??= latestResolution?.source; log.debug(`Channel: ${channel}`); log.debug(`Latest version: ${latest}`); if (versionArg) { - log.debug(`Target version: ${target}`); - } - - // Validate a pinned target before every return path, including --check. - if (versionArg && !CHANNEL_VERSIONS.has(versionArg)) { - const lookupMethod = channel === "nightly" ? "curl" : method; - source = (await resolvePinnedVersion(lookupMethod, target)) ?? source; + log.debug(`Target version: ${resolvedTarget}`); } if (flags.check) { return { kind: "done", - result: buildCheckResult({ target, versionArg, method, channel, flags }), + result: buildCheckResult({ + target: resolvedTarget, + versionArg, + method, + channel, + flags, + }), source, }; } // Skip if already on target — unless forced or switching channels - if (CLI_VERSION === target && !flags.force && !channelChanged) { + if (CLI_VERSION === resolvedTarget && !flags.force && !channelChanged) { return { kind: "done", result: { action: "up-to-date", currentVersion: CLI_VERSION, - targetVersion: target, + targetVersion: resolvedTarget, channel, method, forced: false, @@ -365,7 +378,7 @@ async function resolveTargetVersion( }; } - return { kind: "target", target, source }; + return { kind: "target", target: resolvedTarget, source }; } /** diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index f556b35fc9..dbdef1fb6e 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -80,6 +80,15 @@ function isExternalAbort(error: Error, externalSignal?: AbortSignal): boolean { return Boolean(externalSignal?.aborted && error.name === "AbortError"); } +function rethrowExternalAbort( + error: unknown, + externalSignal?: AbortSignal +): void { + if (error instanceof Error && isExternalAbort(error, externalSignal)) { + throw error; + } +} + type RetryOptions = { timeout?: number; signal?: AbortSignal; @@ -382,6 +391,7 @@ export async function downloadNightlyBlob( signal: buildSignal(GHCR_BLOB_TIMEOUT, signal), }); } catch (error) { + rethrowExternalAbort(error, signal); const msg = error instanceof Error ? error.message : String(error); throw new UpgradeError( "network_error", @@ -423,6 +433,7 @@ export async function downloadNightlyBlob( signal, }); } catch (error) { + rethrowExternalAbort(error, signal); const msg = error instanceof Error ? error.message : String(error); throw new UpgradeError( "network_error", diff --git a/packages/cli/src/lib/release-notes.ts b/packages/cli/src/lib/release-notes.ts index 07d43e54f2..8cf12b4d42 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -13,6 +13,7 @@ */ import { marked, type Token, type Tokens } from "marked"; +import { valid as semverValid } from "semver"; import { compareVersions, getGitHubHeaders, @@ -423,6 +424,30 @@ type ChangelogBuildOptions = { source?: UpgradeSource; }; +function normalizeChangelogReleases( + releases: GitHubRelease[], + source: UpgradeSource, + allowNormalized: boolean +): GitHubRelease[] { + return releases.flatMap((release) => { + if (release.tag_name.startsWith(source.tagPrefix)) { + return [ + { + ...release, + tag_name: release.tag_name.slice(source.tagPrefix.length), + }, + ]; + } + if ( + allowNormalized && + semverValid(release.tag_name.replace(VERSION_PREFIX_RE, "")) !== null + ) { + return [release]; + } + return []; + }); +} + /** Build a changelog summary while filtering source-specific release tags. */ function buildChangelogSummaryForSource( releases: GitHubRelease[], @@ -603,12 +628,7 @@ async function fetchReleasesForChangelog( log.debug("GitHub releases response is not an array", typeof data); return []; } - return (data as GitHubRelease[]) - .filter((release) => release.tag_name.startsWith(source.tagPrefix)) - .map((release) => ({ - ...release, - tag_name: release.tag_name.slice(source.tagPrefix.length), - })); + return normalizeChangelogReleases(data as GitHubRelease[], source, false); } /** @@ -627,8 +647,9 @@ async function fetchStableChangelog( ): Promise { const { fromVersion, toVersion, maxItems, prefetchedReleases, source } = options; - const releases = - prefetchedReleases ?? (await fetchReleasesForChangelog(source)); + const releases = prefetchedReleases + ? normalizeChangelogReleases(prefetchedReleases, source, true) + : await fetchReleasesForChangelog(source); if (releases.length === 0) { return null; } diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 941ddb084d..8e46686f04 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -360,11 +360,6 @@ describe("sentry cli upgrade", () => { mockFetch(async (url) => { const request = String(url); requests.push(request); - if (request.includes("getsentry/toolkit/releases?per_page=100")) { - return new Response(JSON.stringify([{ tag_name: "cli@99.99.99" }]), { - status: 200, - }); - } if ( request.includes("getsentry/toolkit/releases/tags/cli%4088.88.88") ) { @@ -402,6 +397,9 @@ describe("sentry cli upgrade", () => { expect(requests).not.toContain( "https://api.github.com/repos/getsentry/toolkit/releases?per_page=30" ); + expect( + requests.every((request) => !request.includes("per_page=100")) + ).toBe(true); }); }); diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index c6f1035ded..680851f68b 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -422,6 +422,40 @@ describe("downloadNightlyBlob", () => { "Failed to download from blob storage: fetch failed" ); }); + + test("preserves external cancellation during the GHCR blob request", async () => { + const controller = new AbortController(); + let requestCount = 0; + mockFetch(async () => { + requestCount += 1; + controller.abort(); + throw new DOMException("aborted", "AbortError"); + }); + + await expect( + downloadNightlyBlob("token", "sha256:abc", controller.signal) + ).rejects.toMatchObject({ name: "AbortError" }); + expect(requestCount).toBe(1); + }); + + test("preserves external cancellation during the redirect request", async () => { + const controller = new AbortController(); + const headers: Headers[] = []; + mockFetch(async (_url, init) => { + headers.push(new Headers(init?.headers)); + if (headers.length === 1) { + return Response.redirect("https://blob.storage.azure.com/file", 307); + } + controller.abort(); + throw new DOMException("aborted", "AbortError"); + }); + + await expect( + downloadNightlyBlob("token", "sha256:abc", controller.signal) + ).rejects.toMatchObject({ name: "AbortError" }); + expect(headers).toHaveLength(2); + expect(headers[1]?.has("authorization")).toBe(false); + }); }); // fetchManifest (generic tag variant) diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts index 859d39a611..e0a2e04bd0 100644 --- a/packages/cli/test/lib/release-notes.test.ts +++ b/packages/cli/test/lib/release-notes.test.ts @@ -387,6 +387,37 @@ describe("fetchChangelog source affinity", () => { ); }); + test("normalizes raw prefetched Toolkit releases without fetching", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response("Unexpected", { status: 500 }); + }); + + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + prefetchedReleases: [ + makeRelease("mcp@0.21.0", "- Unrelated MCP release"), + makeRelease( + "cli@0.21.0", + "### Bug Fixes 🐛\n\n- Raw prefetched release" + ), + ], + }); + + expect(changelog?.totalItems).toBe(1); + expect(changelog?.sections[0]?.markdown).toContain( + "Raw prefetched release" + ); + expect(changelog?.sections[0]?.markdown).not.toContain( + "Unrelated MCP release" + ); + expect(requestedUrls).toEqual([]); + }); + test("fetches stable releases only from the explicitly selected legacy source", async () => { const requestedUrls: string[] = []; globalThis.fetch = mockFetch(async (input) => { From 4a1d5bad105c006193c68459175f5eb3c7f78e08 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 03:02:18 +0000 Subject: [PATCH 10/19] test(cli): update pinned version fixture --- packages/cli/test/commands/cli.test.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/cli/test/commands/cli.test.ts b/packages/cli/test/commands/cli.test.ts index 187ae70f4c..124e91b8f2 100644 --- a/packages/cli/test/commands/cli.test.ts +++ b/packages/cli/test/commands/cli.test.ts @@ -212,18 +212,9 @@ describe("upgradeCommand.func", () => { }); test("throws UpgradeError when specified version does not exist", async () => { - // First call fetches latest; both exact-tag probes return 404. let callCount = 0; globalThis.fetch = (async () => { callCount += 1; - if (callCount === 1) { - // Latest version check - return new Response(JSON.stringify([{ tag_name: "cli@99.0.0" }]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - // Version exists check - return 404 return new Response("Not Found", { status: 404 }); }) as typeof fetch; @@ -235,5 +226,6 @@ describe("upgradeCommand.func", () => { await expect( func.call(context, { check: false, method: "curl" }, "999.0.0") ).rejects.toThrow("Version 999.0.0 not found"); + expect(callCount).toBe(2); }); }); From e565b1afb39a341605adc883de8812851de7d581 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 03:16:37 +0000 Subject: [PATCH 11/19] fix(cli): validate stable upgrade metadata --- packages/cli/src/commands/cli/upgrade.ts | 3 +- packages/cli/src/lib/upgrade.ts | 35 +++++++++++- packages/cli/test/commands/cli.test.ts | 15 +++-- .../cli/test/commands/cli/upgrade.test.ts | 55 ++++++++++--------- packages/cli/test/lib/upgrade.test.ts | 55 ++++++++++++++++++- 5 files changed, 124 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 2d4f927d2d..8c03fa3f45 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -22,6 +22,7 @@ import type { SentryContext } from "../../context.js"; import { determineInstallDir, isDowngrade, + isNightlyVersion, LEGACY_INSTALL_SUBDIR, releaseLock, samePath, @@ -1063,7 +1064,7 @@ export const upgradeCommand = buildCommand({ // Perform the actual upgrade let warnings: string[] | undefined; - if (channel === "nightly" && method !== "curl") { + if (isNightlyVersion(target) && method !== "curl") { // Nightly is GitHub-only. If the current install method is not curl, // migrate to a standalone binary — the migration handles setup internally. warnings = await migrateToStandaloneForNightly({ diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 663adf7724..4a23b4b25d 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -21,7 +21,7 @@ import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, sep } from "node:path"; import { setTimeout } from "node:timers/promises"; -import { valid as semverValid } from "semver"; +import { prerelease as semverPrerelease, valid as semverValid } from "semver"; import { acquireLock, cleanupOldBinary, @@ -131,7 +131,9 @@ function extractReleaseVersions( ) .map((tag) => tag.slice(source.tagPrefix.length)) .map((tag) => tag.replace(VERSION_PREFIX_REGEX, "")) - .filter((tag) => semverValid(tag) !== null) + .filter( + (tag) => semverValid(tag) !== null && semverPrerelease(tag) === null + ) .sort((a, b) => compareVersions(b, a)); } @@ -708,6 +710,27 @@ export async function resolveExistingUpgradeVersion( const selected = await resolveUpgradeSource({ getProbeUrl: (source) => getGitHubReleaseByTagUrl(version, source), }); + let release: unknown; + try { + release = await selected.response.json(); + } catch (error) { + throw new UpgradeError( + "network_error", + `GitHub returned invalid metadata for version ${version}: ${error instanceof Error ? error.message : String(error)}` + ); + } + const expectedTag = `${selected.source.tagPrefix}${version}`; + if ( + typeof release !== "object" || + release === null || + !("tag_name" in release) || + release.tag_name !== expectedTag + ) { + throw new UpgradeError( + "network_error", + `GitHub returned invalid metadata for version ${version}` + ); + } return { version, source: selected.source }; } catch (error) { if (error instanceof UpgradeSourceNotFoundError) { @@ -735,7 +758,13 @@ async function nightlyVersionExists( ): Promise { const token = await getAnonymousToken(source); try { - await fetchManifest(token, `nightly-${version}`, undefined, source); + const manifest = await fetchManifest( + token, + `nightly-${version}`, + undefined, + source + ); + validateNightlyManifestVersion(manifest, version); return true; } catch (error) { if (error instanceof GhcrManifestHttpError && error.status === 404) { diff --git a/packages/cli/test/commands/cli.test.ts b/packages/cli/test/commands/cli.test.ts index 124e91b8f2..4eec11e9d8 100644 --- a/packages/cli/test/commands/cli.test.ts +++ b/packages/cli/test/commands/cli.test.ts @@ -124,7 +124,7 @@ describe("upgradeCommand.func", () => { test("shows installation info with specified method", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify([{ tag_name: "cli@0.0.0-dev" }]), { + new Response(JSON.stringify([{ tag_name: "cli@1.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch; @@ -135,11 +135,11 @@ describe("upgradeCommand.func", () => { // Use method flag to bypass detection (curl uses GitHub). // Pass json: true so the output config renders structured JSON to stdout. - await func.call(context, { check: false, method: "curl", json: true }); + await func.call(context, { check: true, method: "curl", json: true }); // Final result is rendered as JSON to stdout by the output system const data = JSON.parse(getStdout()) as UpgradeResult; - expect(data.action).toBe("up-to-date"); + expect(data.action).toBe("checked"); expect(data.method).toBe("curl"); }); @@ -191,9 +191,9 @@ describe("upgradeCommand.func", () => { ); }); - test("check mode shows already on target when versions match", async () => { + test("check mode compares the current version with a stable target", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify([{ tag_name: "cli@0.0.0-dev" }]), { + new Response(JSON.stringify([{ tag_name: "cli@1.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch; @@ -206,9 +206,8 @@ describe("upgradeCommand.func", () => { const data = JSON.parse(getStdout()) as UpgradeResult; expect(data.action).toBe("checked"); - expect(data.currentVersion).toBe(data.targetVersion); - // No warnings when already on target - expect(data.warnings).toBeUndefined(); + expect(data.currentVersion).toBe("0.0.0-dev"); + expect(data.targetVersion).toBe("1.0.0"); }); test("throws UpgradeError when specified version does not exist", async () => { diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 8e46686f04..dbe33e5459 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -295,8 +295,8 @@ describe("sentry cli upgrade", () => { }); describe("--check mode", () => { - test("shows 'already on the target version' when current equals latest", async () => { - mockGitHubVersion(CLI_VERSION); + test("shows the current and latest stable versions", async () => { + mockGitHubVersion("1.0.0"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, @@ -311,8 +311,8 @@ describe("sentry cli upgrade", () => { const combined = getOutput(); expect(combined).toContain("Method: curl"); - expect(combined).toContain(CLI_VERSION); - expect(combined).toContain("You are already on the target version"); + expect(combined).toContain("1.0.0"); + expect(combined).toContain("Run 'sentry cli upgrade' to update."); }); test("shows upgrade command hint when newer version available", async () => { @@ -403,20 +403,24 @@ describe("sentry cli upgrade", () => { }); }); - describe("already up to date", () => { - test("reports already up to date when current equals target", async () => { - mockGitHubVersion(CLI_VERSION); + describe("stable target", () => { + test("reports the resolved stable target in check mode", async () => { + mockGitHubVersion("1.0.0"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, }); restoreStderr = restore; - await run(app, ["cli", "upgrade", "--method", "curl"], context); + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl"], + context + ); const combined = getOutput(); - expect(combined).toContain("Already up to date"); - expect(combined).not.toContain("Upgrading to"); + expect(combined).toContain("Latest:"); + expect(combined).toContain("1.0.0"); }); }); @@ -521,23 +525,23 @@ describe("sentry cli upgrade", () => { }); test("strips v prefix from user-specified version", async () => { - mockGitHubVersion(CLI_VERSION); + mockGitHubVersion("1.0.0"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, }); restoreStderr = restore; - // Pass "v" — should strip prefix and match current + // Pass a prefixed stable version and verify the normalized target. await run( app, - ["cli", "upgrade", "--method", "curl", `v${CLI_VERSION}`], + ["cli", "upgrade", "--check", "--method", "curl", "v1.0.0"], context ); const combined = getOutput(); - // Should match current version (after stripping v prefix) and report up to date - expect(combined).toContain("Already up to date"); + expect(combined).toContain("1.0.0"); + expect(combined).toContain("Run 'sentry cli upgrade 1.0.0' to update."); }); }); @@ -628,7 +632,7 @@ describe("sentry cli upgrade — nightly channel", () => { }); test("'stable' positional sets channel to stable", async () => { - mockGitHubVersion(CLI_VERSION); + mockGitHubVersion("1.0.0"); setReleaseChannel("nightly"); const { context, getOutput, restore } = createMockContext({ @@ -1050,8 +1054,8 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy ); }); - test("--force bypasses 'already up to date' and proceeds to download", async () => { - mockBinaryDownloadWithVersion(CLI_VERSION); // Same version — would normally short-circuit + test("--force proceeds to download the resolved target", async () => { + mockBinaryDownloadWithVersion("1.0.0"); const { context, getOutput, restore } = createMockContext({ homeDir: testDir, @@ -1064,9 +1068,9 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy // With --force, should NOT show "Already up to date" expect(combined).not.toContain("Already up to date"); // Should proceed to download and succeed (spinner messages on stdout) - expect(combined).toContain(`Downloading ${CLI_VERSION}`); + expect(combined).toContain("Downloading 1.0.0"); expect(combined).toContain("Upgraded to"); - expect(combined).toContain(CLI_VERSION); + expect(combined).toContain("1.0.0"); }); }); @@ -1121,7 +1125,7 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp clearInstallInfo(); }); - test("migrates npm install to standalone binary for nightly channel", async () => { + test("migrates npm install to standalone binary for a pinned nightly", async () => { const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); const gzipped = gzipSync(fakeContent); @@ -1173,15 +1177,16 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp return new Response("Not Found", { status: 404 }); }); - // Switch to nightly and use npm method → triggers migration - setReleaseChannel("nightly"); - const { context, getOutput, restore } = createMockContext({ homeDir: testDir, }); restoreStderr = restore; - await run(app, ["cli", "upgrade", "--method", "npm", "nightly"], context); + await run( + app, + ["cli", "upgrade", "--method", "npm", "0.99.0-dev.1234567890"], + context + ); const combined = getOutput(); expect(combined).toContain( diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index cb3daa43cb..6cfc7fae98 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -198,6 +198,7 @@ describe("fetchLatestFromGitHub", () => { JSON.stringify([ { tag_name: "mcp@9.0.0" }, { tag_name: "cli@not-a-version" }, + { tag_name: "cli@99.0.0-dev.1", prerelease: false }, { tag_name: "cli@1.2.3" }, { tag_name: "cli@1.3.0" }, ]), @@ -742,6 +743,26 @@ describe("versionExists", () => { ); }); + test.each([ + ["empty body", ""], + ["invalid JSON", "{"], + ["missing tag", JSON.stringify({})], + ["mismatched tag", JSON.stringify({ tag_name: "mcp@1.0.0" })], + ])("rejects pinned Toolkit %s without legacy fallback", async (_name, body) => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(body, { status: 200 }); + }); + + await expect(resolveExistingUpgradeVersion("1.0.0")).rejects.toMatchObject({ + reason: "network_error", + }); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%401.0.0", + ]); + }); + test.each([ undefined, "not-semver", @@ -803,7 +824,10 @@ describe("versionExists", () => { }); test("checks GitHub for curl method - version exists", async () => { - mockFetch(async () => new Response(null, { status: 200 })); + mockFetch( + async () => + new Response(JSON.stringify({ tag_name: "cli@1.0.0" }), { status: 200 }) + ); const exists = await versionExists("curl", "1.0.0"); expect(exists).toBe(true); @@ -845,7 +869,10 @@ describe("versionExists", () => { }); test("checks GitHub for brew method - version exists", async () => { - mockFetch(async () => new Response(null, { status: 200 })); + mockFetch( + async () => + new Response(JSON.stringify({ tag_name: "cli@1.0.0" }), { status: 200 }) + ); const exists = await versionExists("brew", "1.0.0"); expect(exists).toBe(true); @@ -954,6 +981,30 @@ describe("versionExists", () => { expect(exists).toBe(true); }); + test("rejects a mismatched nightly annotation for an explicit source", async () => { + mockFetch(async (url) => { + const request = String(url); + if (request.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); + } + if (request.includes("/manifests/nightly-0.14.0-dev.123")) { + return new Response( + JSON.stringify({ + annotations: { version: "0.14.0-dev.124" }, + }), + { status: 200 } + ); + } + return new Response("Unexpected", { status: 500 }); + }); + + await expect( + versionExists("curl", "0.14.0-dev.123", UPGRADE_SOURCES[0]) + ).rejects.toThrow( + "Nightly manifest version 0.14.0-dev.124 does not match requested version 0.14.0-dev.123" + ); + }); + test("throws on network failure for nightly version", async () => { mockFetch(async () => { throw new TypeError("fetch failed"); From f1c10a6cb1a753b8c4b8c613cd78735c3f12cba6 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 03:45:04 +0000 Subject: [PATCH 12/19] fix(cli): preserve pinned upgrade contracts --- packages/cli/src/commands/cli/upgrade.ts | 28 ++++-- packages/cli/src/lib/delta-upgrade.ts | 89 +++++++++++------- packages/cli/src/lib/ghcr.ts | 13 ++- packages/cli/src/lib/release-notes.ts | 28 ++---- .../cli/test/commands/cli/upgrade.test.ts | 94 +++++++++++++++++++ packages/cli/test/lib/delta-upgrade.test.ts | 19 ++++ packages/cli/test/lib/ghcr.test.ts | 16 ++++ packages/cli/test/lib/release-notes.test.ts | 34 ++++++- 8 files changed, 257 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 8c03fa3f45..4e855a1f0f 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -247,7 +247,6 @@ async function resolveTargetWithFallback(opts: { function validateMethod( method: InstallationMethod, versionArg: string | undefined, - channel: ReleaseChannel, offline: boolean ): void { if (method === "unknown") { @@ -255,7 +254,10 @@ function validateMethod( } // Homebrew manages versioning through the formula — pinning a specific // stable version is not supported via this command. - if (method === "brew" && versionArg && channel === "stable") { + const pinnedVersion = CHANNEL_VERSIONS.has(versionArg ?? "") + ? undefined + : versionArg?.replace(VERSION_PREFIX_REGEX, ""); + if (method === "brew" && pinnedVersion && !isNightlyVersion(pinnedVersion)) { throw new UpgradeError( "unsupported_operation", "Homebrew does not support installing a specific version. Run 'brew upgrade getsentry/tools/sentry' to upgrade to the latest formula version." @@ -271,6 +273,13 @@ function validateMethod( } } +function getArtifactChannel( + target: string, + trackingChannel: ReleaseChannel +): ReleaseChannel { + return isNightlyVersion(target) ? "nightly" : trackingChannel; +} + type ResolveTargetOptions = { method: InstallationMethod; channel: ReleaseChannel; @@ -329,7 +338,7 @@ async function resolveTargetVersion( let source: UpgradeSource | undefined; if (pinnedTarget) { - const lookupMethod = channel === "nightly" ? "curl" : method; + const lookupMethod = isNightlyVersion(pinnedTarget) ? "curl" : method; source = await resolvePinnedVersion(lookupMethod, pinnedTarget); } @@ -764,8 +773,10 @@ async function migrateToStandaloneForNightly(opts: { noAgentSkills: boolean; json?: boolean; source?: UpgradeSource; + channel: ReleaseChannel; }): Promise { - const { method, target, versionArg, noAgentSkills, json, source } = opts; + const { method, target, versionArg, noAgentSkills, json, source, channel } = + opts; log.info("Nightly builds are only available as standalone binaries."); log.info("Migrating to standalone installation..."); @@ -797,7 +808,7 @@ async function migrateToStandaloneForNightly(opts: { await runSetupOnNewBinary({ binaryPath: downloadResult.tempBinaryPath, method: "curl", - channel: "nightly", + channel, install: true, installDir, ensureAuthScopes: !json, @@ -847,7 +858,7 @@ async function resolveContext( const channelChanged = channel !== currentChannel; const method = flags.method ?? (await detectInstallationMethod()); - validateMethod(method, versionArg, channel, flags.offline); + validateMethod(method, versionArg, flags.offline); return { channel, versionArg, channelChanged, method }; } @@ -1010,7 +1021,7 @@ export const upgradeCommand = buildCommand({ result.currentVersion !== result.targetVersion ) { result.changelog = await startChangelogFetch({ - channel, + channel: getArtifactChannel(result.targetVersion, channel), currentVersion: CLI_VERSION, targetVersion: result.targetVersion, offline: false, @@ -1024,7 +1035,7 @@ export const upgradeCommand = buildCommand({ // Start changelog fetch early — it runs in parallel with the download. const changelogPromise = startChangelogFetch({ - channel, + channel: getArtifactChannel(target, channel), currentVersion: CLI_VERSION, targetVersion: target, offline, @@ -1074,6 +1085,7 @@ export const upgradeCommand = buildCommand({ noAgentSkills: flags["no-agent-skills"], json: flags.json, source, + channel, }); } else { await executeStandardUpgrade({ diff --git a/packages/cli/src/lib/delta-upgrade.ts b/packages/cli/src/lib/delta-upgrade.ts index 37f4b7d549..204875dce4 100644 --- a/packages/cli/src/lib/delta-upgrade.ts +++ b/packages/cli/src/lib/delta-upgrade.ts @@ -30,6 +30,7 @@ import { type SourceStrategy, type StableChainInfo, } from "binpatch"; +import { prerelease as semverPrerelease, valid as semverValid } from "semver"; import { compareVersions, getGitHubReleasesUrl, @@ -71,6 +72,54 @@ export type DeltaResult = { const log = logger.withTag("delta-upgrade"); +const NORMALIZED_RELEASE_SOURCE = Symbol("normalizedReleaseSource"); + +/** Stable GitHub releases normalized for one explicit upgrade source. */ +export type NormalizedGitHubReleases = GitHubRelease[] & { + /** Stable key for the source that produced these normalized tags. */ + readonly [NORMALIZED_RELEASE_SOURCE]: string; +}; + +function upgradeSourceKey(source: UpgradeSource): string { + return `${source.githubRepo}\0${source.ghcrRepo}\0${source.tagPrefix}`; +} + +/** Return whether a normalized release list belongs to the selected source. */ +export function isNormalizedForSource( + releases: GitHubRelease[], + source: UpgradeSource +): boolean { + return ( + (releases as Partial)[ + NORMALIZED_RELEASE_SOURCE + ] === upgradeSourceKey(source) + ); +} + +/** Filter and normalize raw stable GitHub releases for one upgrade source. */ +export function normalizeStableReleases( + releases: unknown[], + source: UpgradeSource +): NormalizedGitHubReleases { + const normalized = releases + .filter(isGitHubRelease) + .filter((release) => !(release.draft || release.prerelease)) + .filter((release) => release.tag_name.startsWith(source.tagPrefix)) + .map((release) => ({ + ...release, + tag_name: release.tag_name.slice(source.tagPrefix.length), + })) + .filter( + (release) => + semverValid(release.tag_name) !== null && + semverPrerelease(release.tag_name) === null + ) as NormalizedGitHubReleases; + Object.defineProperty(normalized, NORMALIZED_RELEASE_SOURCE, { + value: upgradeSourceKey(source), + }); + return normalized; +} + function getPrimaryUpgradeSource(): UpgradeSource { if (!PRIMARY_UPGRADE_SOURCE) { throw new Error("No primary upgrade source is configured"); @@ -128,30 +177,14 @@ function stableSource(source: UpgradeSource): SourceStrategy { const releasesUrl = getGitHubReleasesUrl(source); const sourceFetch: typeof customFetch = async (input, init) => { const response = await customFetch(input, init); - if ( - !( - response.ok && - source.tagPrefix && - String(input).startsWith(`${releasesUrl}?`) - ) - ) { + if (!(response.ok && String(input).startsWith(`${releasesUrl}?`))) { return response; } const data: unknown = await response.json(); if (!Array.isArray(data)) { return new Response(JSON.stringify(data), response); } - const releases = data - .filter(isGitHubRelease) - .filter( - (release) => - !(release.draft || release.prerelease) && - release.tag_name.startsWith(source.tagPrefix) - ) - .map((release) => ({ - ...release, - tag_name: release.tag_name.slice(source.tagPrefix.length), - })); + const releases = normalizeStableReleases(data, source); return new Response(JSON.stringify(releases), response); }; @@ -204,7 +237,7 @@ export function canAttemptDelta(targetVersion: string): boolean { export async function fetchRecentReleases( signal?: AbortSignal, source: UpgradeSource = getPrimaryUpgradeSource() -): Promise { +): Promise { try { const response = await customFetch( `${getGitHubReleasesUrl(source)}?per_page=12`, @@ -217,27 +250,17 @@ export async function fetchRecentReleases( } ); if (!response.ok) { - return []; + return normalizeStableReleases([], source); } const data = await response.json(); if (!Array.isArray(data)) { log.debug("GitHub releases response is not an array", typeof data); - return []; + return normalizeStableReleases([], source); } - return data - .filter(isGitHubRelease) - .filter( - (release) => - !(release.draft || release.prerelease) && - release.tag_name.startsWith(source.tagPrefix) - ) - .map((release) => ({ - ...release, - tag_name: release.tag_name.slice(source.tagPrefix.length), - })); + return normalizeStableReleases(data, source); } catch (error) { log.debug("Failed to fetch recent releases from GitHub", error); - return []; + return normalizeStableReleases([], source); } } diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index dbdef1fb6e..5c81521373 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -76,15 +76,22 @@ function buildSignal( * Returns true when the given error was triggered by the external * (caller-provided) abort signal rather than by our timeout. */ -function isExternalAbort(error: Error, externalSignal?: AbortSignal): boolean { - return Boolean(externalSignal?.aborted && error.name === "AbortError"); +function isExternalAbort( + error: unknown, + externalSignal?: AbortSignal +): boolean { + return Boolean( + externalSignal?.aborted && + (error === externalSignal.reason || + (error instanceof Error && error.name === "AbortError")) + ); } function rethrowExternalAbort( error: unknown, externalSignal?: AbortSignal ): void { - if (error instanceof Error && isExternalAbort(error, externalSignal)) { + if (isExternalAbort(error, externalSignal)) { throw error; } } diff --git a/packages/cli/src/lib/release-notes.ts b/packages/cli/src/lib/release-notes.ts index 8cf12b4d42..43d238abe7 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -13,7 +13,6 @@ */ import { marked, type Token, type Tokens } from "marked"; -import { valid as semverValid } from "semver"; import { compareVersions, getGitHubHeaders, @@ -22,7 +21,11 @@ import { type UpgradeSource, } from "./binary.js"; import { customFetch } from "./custom-ca.js"; -import type { GitHubRelease } from "./delta-upgrade.js"; +import { + type GitHubRelease, + isNormalizedForSource, + normalizeStableReleases, +} from "./delta-upgrade.js"; import { logger } from "./logger.js"; const log = logger.withTag("release-notes"); @@ -429,23 +432,10 @@ function normalizeChangelogReleases( source: UpgradeSource, allowNormalized: boolean ): GitHubRelease[] { - return releases.flatMap((release) => { - if (release.tag_name.startsWith(source.tagPrefix)) { - return [ - { - ...release, - tag_name: release.tag_name.slice(source.tagPrefix.length), - }, - ]; - } - if ( - allowNormalized && - semverValid(release.tag_name.replace(VERSION_PREFIX_RE, "")) !== null - ) { - return [release]; - } - return []; - }); + if (allowNormalized && isNormalizedForSource(releases, source)) { + return releases; + } + return normalizeStableReleases(releases, source); } /** Build a changelog summary while filtering source-specific release tags. */ diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index dbe33e5459..b67847fb7b 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -1199,6 +1199,100 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp "npm-installed sentry may still appear earlier in PATH" ); expect(combined).toContain("npm uninstall -g sentry"); + expect(getReleaseChannel()).toBe("stable"); + expect(migrateSpawnSpy).toHaveBeenCalledTimes(1); + expect(migrateSpawnSpy.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining(["--channel", "stable"]) + ); + }); + + test("allows a pinned nightly for a Homebrew installation", async () => { + mockFetch(async (url) => { + const request = String(url); + if (request === "https://api.github.com/repos/getsentry/toolkit") { + return new Response(null, { status: 200 }); + } + if (request.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "test-token" }), { + status: 200, + }); + } + if (request.includes("/manifests/nightly-0.99.0-dev.1234567890")) { + return new Response( + JSON.stringify({ + annotations: { version: "0.99.0-dev.1234567890" }, + }), + { status: 200 } + ); + } + return new Response("Unexpected", { status: 500 }); + }); + + const { context, getOutput, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + await run( + app, + [ + "cli", + "upgrade", + "--check", + "--method", + "brew", + "0.99.0-dev.1234567890", + ], + context + ); + + expect(getOutput()).toContain("0.99.0-dev.1234567890"); + expect(migrateSpawnSpy).not.toHaveBeenCalled(); + }); + + test("rejects a pinned stable for Homebrew before network access", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response("Unexpected", { status: 500 }); + }); + setReleaseChannel("nightly"); + + const { context, errors, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + await run(app, ["cli", "upgrade", "--method", "brew", "1.2.3"], context); + + expect(errors.join("\n")).toContain( + "Homebrew does not support installing a specific version" + ); + expect(requests).toEqual([]); + expect(migrateSpawnSpy).not.toHaveBeenCalled(); + }); + + test("validates an npm stable pin through npm while tracking nightly", async () => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(null, { status: 200 }); + }); + setReleaseChannel("nightly"); + + const { context, restore } = createMockContext({ homeDir: testDir }); + restoreStderr = restore; + + await run( + app, + ["cli", "upgrade", "--check", "--method", "npm", "1.2.3"], + context + ); + + expect(requests).toContain("https://registry.npmjs.org/sentry/1.2.3"); + expect(requests.some((request) => request.includes("api.github.com"))).toBe( + false + ); }); }); diff --git a/packages/cli/test/lib/delta-upgrade.test.ts b/packages/cli/test/lib/delta-upgrade.test.ts index 993e7c89a2..68d1cfbdfe 100644 --- a/packages/cli/test/lib/delta-upgrade.test.ts +++ b/packages/cli/test/lib/delta-upgrade.test.ts @@ -846,6 +846,25 @@ describe("fetchRecentReleases", () => { expect(result[0]?.tag_name).toBe("0.14.0"); }); + test.each([ + ["Toolkit", undefined, "cli@0.14.0-dev.1", "cli@0.14.0"], + ["legacy", LEGACY_UPGRADE_SOURCE, "0.14.0-dev.1", "0.14.0"], + ])("excludes semantic prereleases from the %s stable source", async (_name, source, prereleaseTag, stableTag) => { + mockFetch( + async () => + new Response( + JSON.stringify([ + { ...makeRelease(prereleaseTag, []), prerelease: false }, + makeRelease(stableTag, []), + ]), + { status: 200 } + ) + ); + + const result = await fetchRecentReleases(undefined, source); + expect(result.map((release) => release.tag_name)).toEqual(["0.14.0"]); + }); + test("uses the selected legacy GitHub repository", async () => { const urls: string[] = []; mockFetch(async (url) => { diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index 680851f68b..7e8129989b 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -438,6 +438,22 @@ describe("downloadNightlyBlob", () => { expect(requestCount).toBe(1); }); + test("preserves an arbitrary external cancellation reason", async () => { + const controller = new AbortController(); + const reason = new Error("cancelled"); + let requestCount = 0; + mockFetch(async () => { + requestCount += 1; + controller.abort(reason); + throw controller.signal.reason; + }); + + await expect( + downloadNightlyBlob("token", "sha256:abc", controller.signal) + ).rejects.toBe(reason); + expect(requestCount).toBe(1); + }); + test("preserves external cancellation during the redirect request", async () => { const controller = new AbortController(); const headers: Headers[] = []; diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts index e0a2e04bd0..fb44bc47ed 100644 --- a/packages/cli/test/lib/release-notes.test.ts +++ b/packages/cli/test/lib/release-notes.test.ts @@ -377,7 +377,7 @@ describe("fetchChangelog source affinity", () => { channel: "stable", fromVersion: "0.20.0", toVersion: "0.21.0", - source: toolkitSource, + source: { ...toolkitSource }, prefetchedReleases: releases, }); @@ -418,6 +418,38 @@ describe("fetchChangelog source affinity", () => { expect(requestedUrls).toEqual([]); }); + test("rejects unprefixed raw prefetched releases for Toolkit", async () => { + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + prefetchedReleases: [ + makeRelease("0.21.0", "### Bug Fixes 🐛\n\n- Legacy release"), + ], + }); + + expect(changelog).toBeNull(); + }); + + test("excludes semantic prereleases from stable changelogs", async () => { + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + prefetchedReleases: [ + makeRelease("cli@0.21.0-dev.1", "- Development release"), + makeRelease("cli@0.21.0", "### Bug Fixes 🐛\n\n- Stable release"), + ], + }); + + expect(changelog?.sections[0]?.markdown).toContain("Stable release"); + expect(changelog?.sections[0]?.markdown).not.toContain( + "Development release" + ); + }); + test("fetches stable releases only from the explicitly selected legacy source", async () => { const requestedUrls: string[] = []; globalThis.fetch = mockFetch(async (input) => { From dcf6719995800d28d6f88a8c709abe4aa3d0b6db Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 03:52:45 +0000 Subject: [PATCH 13/19] test(cli): validate GitHub request origin --- packages/cli/test/commands/cli/upgrade.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index b67847fb7b..bcde490a26 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -1290,9 +1290,11 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp ); expect(requests).toContain("https://registry.npmjs.org/sentry/1.2.3"); - expect(requests.some((request) => request.includes("api.github.com"))).toBe( - false - ); + expect( + requests.some( + (request) => new URL(request).origin === "https://api.github.com" + ) + ).toBe(false); }); }); From e22f0eba66298a7228a988a4adcf1ec5be85227b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 03:59:45 +0000 Subject: [PATCH 14/19] fix(cli): preserve source and cancellation provenance --- packages/cli/src/commands/cli/upgrade.ts | 11 +++--- packages/cli/src/lib/binary.ts | 3 ++ packages/cli/src/lib/ghcr.ts | 6 ++-- packages/cli/src/lib/release-notes.ts | 14 ++++---- packages/cli/src/lib/upgrade.ts | 4 +-- .../cli/test/commands/cli/upgrade.test.ts | 14 +++++--- packages/cli/test/lib/binary.test.ts | 15 ++++++++ packages/cli/test/lib/ghcr.test.ts | 15 ++++++++ packages/cli/test/lib/release-notes.test.ts | 34 +++++++++++-------- packages/cli/test/lib/upgrade.test.ts | 9 +++++ 10 files changed, 87 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 4e855a1f0f..39aa97928d 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -273,11 +273,8 @@ function validateMethod( } } -function getArtifactChannel( - target: string, - trackingChannel: ReleaseChannel -): ReleaseChannel { - return isNightlyVersion(target) ? "nightly" : trackingChannel; +function getArtifactChannel(target: string): ReleaseChannel { + return isNightlyVersion(target) ? "nightly" : "stable"; } type ResolveTargetOptions = { @@ -1021,7 +1018,7 @@ export const upgradeCommand = buildCommand({ result.currentVersion !== result.targetVersion ) { result.changelog = await startChangelogFetch({ - channel: getArtifactChannel(result.targetVersion, channel), + channel: getArtifactChannel(result.targetVersion), currentVersion: CLI_VERSION, targetVersion: result.targetVersion, offline: false, @@ -1035,7 +1032,7 @@ export const upgradeCommand = buildCommand({ // Start changelog fetch early — it runs in parallel with the download. const changelogPromise = startChangelogFetch({ - channel: getArtifactChannel(target, channel), + channel: getArtifactChannel(target), currentVersion: CLI_VERSION, targetVersion: target, offline, diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index 61e10568de..bb0cc56ada 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -315,6 +315,9 @@ async function fetchUpgradeProbe( signal: options.signal, }); } catch (error) { + if (options.signal?.aborted) { + throw options.signal.reason; + } if (error instanceof Error && error.name === "AbortError") { throw error; } diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index 5c81521373..b687d11f16 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -133,11 +133,11 @@ async function fetchWithRetry( }); return response; } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); // Propagate external abort immediately — don't retry caller cancellation - if (isExternalAbort(lastError, externalSignal)) { - throw lastError; + if (externalSignal?.aborted) { + throw externalSignal.reason; } + lastError = error instanceof Error ? error : new Error(String(error)); // Only retry on timeout or network errors — not HTTP errors if (attempt >= GHCR_MAX_RETRIES || !isRetryableError(lastError)) { break; diff --git a/packages/cli/src/lib/release-notes.ts b/packages/cli/src/lib/release-notes.ts index 43d238abe7..903ca51f67 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -24,6 +24,7 @@ import { customFetch } from "./custom-ca.js"; import { type GitHubRelease, isNormalizedForSource, + type NormalizedGitHubReleases, normalizeStableReleases, } from "./delta-upgrade.js"; import { logger } from "./logger.js"; @@ -429,13 +430,12 @@ type ChangelogBuildOptions = { function normalizeChangelogReleases( releases: GitHubRelease[], - source: UpgradeSource, - allowNormalized: boolean + source: UpgradeSource ): GitHubRelease[] { - if (allowNormalized && isNormalizedForSource(releases, source)) { + if (isNormalizedForSource(releases, source)) { return releases; } - return normalizeStableReleases(releases, source); + return []; } /** Build a changelog summary while filtering source-specific release tags. */ @@ -618,7 +618,7 @@ async function fetchReleasesForChangelog( log.debug("GitHub releases response is not an array", typeof data); return []; } - return normalizeChangelogReleases(data as GitHubRelease[], source, false); + return normalizeStableReleases(data as GitHubRelease[], source); } /** @@ -638,7 +638,7 @@ async function fetchStableChangelog( const { fromVersion, toVersion, maxItems, prefetchedReleases, source } = options; const releases = prefetchedReleases - ? normalizeChangelogReleases(prefetchedReleases, source, true) + ? normalizeChangelogReleases(prefetchedReleases, source) : await fetchReleasesForChangelog(source); if (releases.length === 0) { return null; @@ -743,7 +743,7 @@ export type FetchChangelogOptions = { /** Maximum list items to include */ maxItems?: number; /** Pre-fetched releases to avoid redundant API call (stable channel only) */ - prefetchedReleases?: GitHubRelease[]; + prefetchedReleases?: NormalizedGitHubReleases; /** Release source selected during version discovery; defaults to the primary source */ source?: UpgradeSource; }; diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 4a23b4b25d..a6fad4e9eb 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -50,7 +50,7 @@ import { CLI_VERSION, NODE_MODULES_DIRNAME } from "./constants.js"; import { getInstallInfo, setInstallInfo } from "./db/install-info.js"; import type { ReleaseChannel } from "./db/release-channel.js"; import { attemptDeltaUpgrade, type DeltaResult } from "./delta-upgrade.js"; -import { AbortError, UpgradeError } from "./errors.js"; +import { UpgradeError } from "./errors.js"; import { formatBytes } from "./formatters/numbers.js"; import { downloadNightlyBlob, @@ -592,7 +592,7 @@ export async function fetchLatestNightlyVersionWithSource( sources: readonly UpgradeSource[] = UPGRADE_SOURCES ): Promise { if (signal?.aborted) { - throw new AbortError(); + throw signal.reason; } const resolved = await resolveNightlyManifest("nightly", signal, sources); return { diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index bcde490a26..c082a4ca39 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -1276,7 +1276,9 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp const requests: string[] = []; mockFetch(async (url) => { requests.push(String(url)); - return new Response(null, { status: 200 }); + return String(url).includes("api.github.com") + ? new Response(JSON.stringify([]), { status: 200 }) + : new Response(null, { status: 200 }); }); setReleaseChannel("nightly"); @@ -1290,10 +1292,14 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp ); expect(requests).toContain("https://registry.npmjs.org/sentry/1.2.3"); + expect(requests).toContain( + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=30" + ); + expect(requests.some((request) => request.includes("/commits?"))).toBe( + false + ); expect( - requests.some( - (request) => new URL(request).origin === "https://api.github.com" - ) + requests.some((request) => request.includes("/releases/tags/")) ).toBe(false); }); }); diff --git a/packages/cli/test/lib/binary.test.ts b/packages/cli/test/lib/binary.test.ts index 5eaa53474e..be462848c4 100644 --- a/packages/cli/test/lib/binary.test.ts +++ b/packages/cli/test/lib/binary.test.ts @@ -454,6 +454,21 @@ describe("fetchWithUpgradeError", () => { } }); + test("preserves an arbitrary external abort reason", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + const request = resolveUpgradeSource({ + getProbeUrl: () => "https://example.com", + signal: controller.signal, + fetch: async () => { + controller.abort(reason); + throw reason; + }, + }); + + await expect(request).rejects.toBe(reason); + }); + test("wraps network errors as UpgradeError", async () => { globalThis.fetch = (async () => { throw new Error("ECONNREFUSED"); diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index 7e8129989b..f2805f31ac 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -147,6 +147,21 @@ describe("getAnonymousToken", () => { expect(requests).toBe(1); }); + test("preserves a primitive caller cancellation reason without retrying", async () => { + const controller = new AbortController(); + let requests = 0; + mockFetch(async () => { + requests += 1; + controller.abort("cancelled"); + throw controller.signal.reason; + }); + + await expect(getAnonymousToken(undefined, controller.signal)).rejects.toBe( + "cancelled" + ); + expect(requests).toBe(1); + }); + test("throws UpgradeError when response has no token field", async () => { mockFetch( async () => diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts index fb44bc47ed..ffccf49016 100644 --- a/packages/cli/test/lib/release-notes.test.ts +++ b/packages/cli/test/lib/release-notes.test.ts @@ -387,7 +387,20 @@ describe("fetchChangelog source affinity", () => { ); }); - test("normalizes raw prefetched Toolkit releases without fetching", async () => { + test("rejects normalized releases from another source", async () => { + const releases = await fetchRecentReleases(undefined, toolkitSource); + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: UPGRADE_SOURCES[1], + prefetchedReleases: releases, + }); + + expect(changelog).toBeNull(); + }); + + test("rejects raw prefetched Toolkit releases without fetching", async () => { const requestedUrls: string[] = []; globalThis.fetch = mockFetch(async (input) => { requestedUrls.push(String(input)); @@ -405,16 +418,10 @@ describe("fetchChangelog source affinity", () => { "cli@0.21.0", "### Bug Fixes 🐛\n\n- Raw prefetched release" ), - ], + ] as never, }); - expect(changelog?.totalItems).toBe(1); - expect(changelog?.sections[0]?.markdown).toContain( - "Raw prefetched release" - ); - expect(changelog?.sections[0]?.markdown).not.toContain( - "Unrelated MCP release" - ); + expect(changelog).toBeNull(); expect(requestedUrls).toEqual([]); }); @@ -426,7 +433,7 @@ describe("fetchChangelog source affinity", () => { source: toolkitSource, prefetchedReleases: [ makeRelease("0.21.0", "### Bug Fixes 🐛\n\n- Legacy release"), - ], + ] as never, }); expect(changelog).toBeNull(); @@ -441,13 +448,10 @@ describe("fetchChangelog source affinity", () => { prefetchedReleases: [ makeRelease("cli@0.21.0-dev.1", "- Development release"), makeRelease("cli@0.21.0", "### Bug Fixes 🐛\n\n- Stable release"), - ], + ] as never, }); - expect(changelog?.sections[0]?.markdown).toContain("Stable release"); - expect(changelog?.sections[0]?.markdown).not.toContain( - "Development release" - ); + expect(changelog).toBeNull(); }); test("fetches stable releases only from the explicitly selected legacy source", async () => { diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 6cfc7fae98..9991222d3a 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -1868,6 +1868,15 @@ describe("isNightlyVersion", () => { }); describe("fetchLatestNightlyVersion", () => { + test("preserves an already-aborted signal reason", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + controller.abort(reason); + + await expect(fetchLatestNightlyVersion(controller.signal)).rejects.toBe( + reason + ); + }); test("falls back to legacy when the Toolkit nightly manifest returns 404", async () => { const requests: string[] = []; mockFetch(async (url) => { From 878459c490576dbb7abb76d3a3dc1b5d9fc62c1f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 04:18:39 +0000 Subject: [PATCH 15/19] fix(cli): fail closed on upgrade metadata --- packages/cli/src/commands/cli/upgrade.ts | 7 +-- packages/cli/src/lib/binary.ts | 16 +++--- packages/cli/src/lib/errors.ts | 8 +++ packages/cli/src/lib/ghcr.ts | 7 +-- packages/cli/src/lib/upgrade.ts | 22 ++++++-- .../cli/test/commands/cli/upgrade.test.ts | 55 ++++++++++++++++++- packages/cli/test/lib/ghcr.test.ts | 7 ++- packages/cli/test/lib/upgrade.test.ts | 33 +++++++++++ 8 files changed, 130 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 39aa97928d..b3039d86b5 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -36,7 +36,7 @@ import { setReleaseChannel, } from "../../lib/db/release-channel.js"; import { getVersionCheckInfo } from "../../lib/db/version-check.js"; -import { UpgradeError } from "../../lib/errors.js"; +import { UpgradeError, UpgradeTransportError } from "../../lib/errors.js"; import { formatUpgradeResult } from "../../lib/formatters/human.js"; import { formatBytes } from "../../lib/formatters/numbers.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -222,10 +222,7 @@ async function resolveTargetWithFallback(opts: { // Automatic offline fallback: only for curl-installed binaries (package // managers need the network for the actual install, not just version // discovery), and only for network errors (not version_not_found etc.) - if ( - method !== "curl" || - !(error instanceof UpgradeError && error.reason === "network_error") - ) { + if (method !== "curl" || !(error instanceof UpgradeTransportError)) { throw error; } try { diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index bb0cc56ada..994626cfd5 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -23,7 +23,11 @@ import { customFetch, isTlsCertError, } from "./custom-ca.js"; -import { stringifyUnknown, UpgradeError } from "./errors.js"; +import { + stringifyUnknown, + UpgradeError, + UpgradeTransportError, +} from "./errors.js"; import { logger } from "./logger.js"; import { isProcessRunning } from "./process-utils.js"; /** Known directories where the curl installer may place the binary */ @@ -322,10 +326,9 @@ async function fetchUpgradeProbe( throw error; } if (error instanceof Error && isTlsCertError(error)) { - throw new UpgradeError("network_error", buildTlsErrorDetail(error)); + throw new UpgradeTransportError(buildTlsErrorDetail(error)); } - throw new UpgradeError( - "network_error", + throw new UpgradeTransportError( `Failed to connect to GitHub: ${stringifyUnknown(error)}` ); } @@ -503,11 +506,10 @@ export async function fetchWithUpgradeError( throw error; } if (error instanceof Error && isTlsCertError(error)) { - throw new UpgradeError("network_error", buildTlsErrorDetail(error)); + throw new UpgradeTransportError(buildTlsErrorDetail(error)); } const msg = stringifyUnknown(error); - throw new UpgradeError( - "network_error", + throw new UpgradeTransportError( `Failed to connect to ${serviceName}: ${msg}` ); } diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index f81b8c70c6..b21505cd11 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -618,6 +618,14 @@ export class UpgradeError extends CliError { } } +/** Upgrade failure caused by transport rather than an HTTP or metadata error. */ +export class UpgradeTransportError extends UpgradeError { + constructor(message: string) { + super("network_error", message); + this.name = "UpgradeTransportError"; + } +} + // Seer Errors export type SeerErrorReason = "not_enabled" | "no_budget" | "ai_disabled"; diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index b687d11f16..3cba357f87 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -21,7 +21,7 @@ import { valid as semverValid } from "semver"; import { PRIMARY_UPGRADE_SOURCE, type UpgradeSource } from "./binary.js"; import { getUserAgent } from "./constants.js"; import { customFetch } from "./custom-ca.js"; -import { UpgradeError } from "./errors.js"; +import { UpgradeError, UpgradeTransportError } from "./errors.js"; /** Default timeout for GHCR HTTP requests (10 seconds) */ const GHCR_REQUEST_TIMEOUT = 10_000; @@ -92,7 +92,7 @@ function rethrowExternalAbort( externalSignal?: AbortSignal ): void { if (isExternalAbort(error, externalSignal)) { - throw error; + throw externalSignal?.reason; } } @@ -145,8 +145,7 @@ async function fetchWithRetry( } } - throw new UpgradeError( - "network_error", + throw new UpgradeTransportError( `${context}: ${lastError?.message ?? "unknown error"}` ); } diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index a6fad4e9eb..06bd856462 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -569,11 +569,23 @@ export async function fetchLatestFromNpm(): Promise { const data = (await response.json()) as { version?: string }; - if (!data.version) { - throw new UpgradeError("network_error", "No version found in npm registry"); - } + return validateStableVersion(data.version, "npm registry"); +} - return data.version; +function validateStableVersion( + version: string | undefined, + source: string +): string { + if (!version) { + throw new UpgradeError("network_error", `No version found in ${source}`); + } + if (semverValid(version) === null || semverPrerelease(version) !== null) { + throw new UpgradeError( + "network_error", + `${source} returned an invalid stable version` + ); + } + return version; } /** @@ -823,6 +835,8 @@ export async function versionExists( return standaloneVersionExists(version, source); } + validateStableVersion(version, "Requested package version"); + const response = await fetchWithUpgradeError( `${NPM_REGISTRY_URL}/${version}`, { method: "HEAD" }, diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index c082a4ca39..3494c02a84 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -40,6 +40,7 @@ import { getReleaseChannel, setReleaseChannel, } from "../../../src/lib/db/release-channel.js"; +import { setVersionCheckInfo } from "../../../src/lib/db/version-check.js"; import { TEST_TMP_DIR, useTestConfigDir } from "../../helpers.js"; /** Store original fetch for restoration */ @@ -401,6 +402,55 @@ describe("sentry cli upgrade", () => { requests.every((request) => !request.includes("per_page=100")) ).toBe(true); }); + + test("uses the cached target only after a transport failure", async () => { + setVersionCheckInfo("88.88.88"); + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + const { context, getOutput, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl"], + context + ); + + expect(getOutput()).toContain("Using cached target: 88.88.88"); + }); + + test.each([ + ["HTTP 403", async () => new Response("Forbidden", { status: 403 })], + [ + "malformed HTTP 200", + async () => Response.json([{ tag_name: "mcp@1.0.0" }]), + ], + ])("never uses the cached target after %s", async (_name, response) => { + const requests: string[] = []; + setVersionCheckInfo("88.88.88"); + mockFetch(async (url) => { + requests.push(String(url)); + return response(); + }); + const { context, errors, getOutput, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl"], + context + ); + + expect(getOutput()).not.toContain("Using cached target"); + expect(errors).not.toEqual([]); + expect(requests).toHaveLength(1); + expect(requests[0]).toContain("getsentry/toolkit"); + }); }); describe("stable target", () => { @@ -1275,8 +1325,9 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp test("validates an npm stable pin through npm while tracking nightly", async () => { const requests: string[] = []; mockFetch(async (url) => { - requests.push(String(url)); - return String(url).includes("api.github.com") + const requestUrl = new URL(String(url)); + requests.push(requestUrl.href); + return requestUrl.origin === "https://api.github.com" ? new Response(JSON.stringify([]), { status: 200 }) : new Response(null, { status: 200 }); }); diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index f2805f31ac..9f1cda8967 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -460,7 +460,7 @@ describe("downloadNightlyBlob", () => { mockFetch(async () => { requestCount += 1; controller.abort(reason); - throw controller.signal.reason; + throw new DOMException("aborted", "AbortError"); }); await expect( @@ -471,19 +471,20 @@ describe("downloadNightlyBlob", () => { test("preserves external cancellation during the redirect request", async () => { const controller = new AbortController(); + const reason = { kind: "cancelled" }; const headers: Headers[] = []; mockFetch(async (_url, init) => { headers.push(new Headers(init?.headers)); if (headers.length === 1) { return Response.redirect("https://blob.storage.azure.com/file", 307); } - controller.abort(); + controller.abort(reason); throw new DOMException("aborted", "AbortError"); }); await expect( downloadNightlyBlob("token", "sha256:abc", controller.signal) - ).rejects.toMatchObject({ name: "AbortError" }); + ).rejects.toBe(reason); expect(headers).toHaveLength(2); expect(headers[1]?.has("authorization")).toBe(false); }); diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 9991222d3a..ba05ec56bb 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -488,6 +488,25 @@ describe("fetchLatestFromNpm", () => { "No version found in npm registry" ); }); + + test.each([ + "not-semver", + "1.2.3-dev.123", + "1.2.3-beta.1", + "1.2.3-rc.1", + ])("rejects non-stable npm latest version %s", async (version) => { + mockFetch( + async () => + new Response(JSON.stringify({ version }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(fetchLatestFromNpm()).rejects.toThrow( + "npm registry returned an invalid stable version" + ); + }); }); // fetchLatestNightlyVersion tests are in the dedicated describe block @@ -700,6 +719,20 @@ describe("fetchLatestVersion", () => { }); describe("versionExists", () => { + test.each([ + "npm", + "pnpm", + "bun", + "yarn", + ] as const)("rejects a prerelease pinned through %s before network access", async (method) => { + mockFetch(async () => { + throw new Error("fetch should not be called"); + }); + + await expect(versionExists(method, "1.2.3-beta.1")).rejects.toThrow( + "Requested package version returned an invalid stable version" + ); + }); test("probes prefixed Toolkit tags and retains the selected source", async () => { const requests: string[] = []; mockFetch(async (url) => { From 7d62ffa8317f9b98be0dc4afec0a927c1cabb0b7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 04:29:35 +0000 Subject: [PATCH 16/19] fix(cli): preserve paginated request cancellation --- packages/cli/src/lib/binary.ts | 3 +++ packages/cli/test/lib/upgrade.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index 994626cfd5..8a96e93615 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -501,6 +501,9 @@ export async function fetchWithUpgradeError( try { return await customFetch(url, init); } catch (error) { + if (init.signal?.aborted) { + throw init.signal.reason; + } // Re-throw AbortError as-is so callers can handle it specifically if (error instanceof Error && error.name === "AbortError") { throw error; diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index ba05ec56bb..6b76dfe7f4 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -243,6 +243,27 @@ describe("fetchLatestFromGitHub", () => { ]); }); + test("preserves an arbitrary abort reason during pagination", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + let requests = 0; + mockFetch(async () => { + requests += 1; + if (requests === 1) { + return new Response(JSON.stringify([{ tag_name: "mcp@9.0.0" }]), { + headers: { + Link: '; rel="next"', + }, + }); + } + controller.abort(reason); + throw reason; + }); + + await expect(fetchLatestFromGitHub(controller.signal)).rejects.toBe(reason); + expect(requests).toBe(2); + }); + test("selects the highest CLI SemVer across Toolkit release pages", async () => { let requests = 0; mockFetch(async () => { From cea7b2afba91f11f1cdbb1785ea97ba61159520c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 04:35:48 +0000 Subject: [PATCH 17/19] fix(cli): validate release response shapes --- packages/cli/src/lib/ghcr.ts | 19 +------- packages/cli/src/lib/upgrade.ts | 69 +++++++++++++++++---------- packages/cli/test/lib/ghcr.test.ts | 4 +- packages/cli/test/lib/upgrade.test.ts | 31 ++++++++++++ 4 files changed, 80 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index 3cba357f87..fb441e0571 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -72,26 +72,11 @@ function buildSignal( : timeoutSignal; } -/** - * Returns true when the given error was triggered by the external - * (caller-provided) abort signal rather than by our timeout. - */ -function isExternalAbort( - error: unknown, - externalSignal?: AbortSignal -): boolean { - return Boolean( - externalSignal?.aborted && - (error === externalSignal.reason || - (error instanceof Error && error.name === "AbortError")) - ); -} - function rethrowExternalAbort( - error: unknown, + _error: unknown, externalSignal?: AbortSignal ): void { - if (isExternalAbort(error, externalSignal)) { + if (externalSignal?.aborted) { throw externalSignal?.reason; } } diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 06bd856462..b6235a6b67 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -116,13 +116,21 @@ export type ResolvedUpgradeVersion = { }; function extractReleaseVersions( - data: - | { tag_name?: string; draft?: boolean; prerelease?: boolean } - | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>, + data: unknown, source: UpgradeSource ): string[] { + if (source.tagPrefix ? !Array.isArray(data) : Array.isArray(data)) { + throw new UpgradeError( + "network_error", + "GitHub returned invalid release metadata" + ); + } const releases = Array.isArray(data) ? data : [data]; return releases + .filter( + (release): release is Record => + typeof release === "object" && release !== null + ) .filter((release) => !(release.draft || release.prerelease)) .map((release) => release.tag_name) .filter( @@ -705,6 +713,34 @@ function validateNightlyManifestVersion( } } +async function validatePinnedGitHubRelease( + response: Response, + version: string, + source: UpgradeSource +): Promise { + let release: unknown; + try { + release = await response.json(); + } catch (error) { + throw new UpgradeError( + "network_error", + `GitHub returned invalid metadata for version ${version}: ${error instanceof Error ? error.message : String(error)}` + ); + } + const expectedTag = `${source.tagPrefix}${version}`; + if ( + typeof release !== "object" || + release === null || + !("tag_name" in release) || + release.tag_name !== expectedTag + ) { + throw new UpgradeError( + "network_error", + `GitHub returned invalid metadata for version ${version}` + ); + } +} + /** Resolve and validate a pinned standalone version against ordered sources. */ export async function resolveExistingUpgradeVersion( version: string @@ -722,27 +758,11 @@ export async function resolveExistingUpgradeVersion( const selected = await resolveUpgradeSource({ getProbeUrl: (source) => getGitHubReleaseByTagUrl(version, source), }); - let release: unknown; - try { - release = await selected.response.json(); - } catch (error) { - throw new UpgradeError( - "network_error", - `GitHub returned invalid metadata for version ${version}: ${error instanceof Error ? error.message : String(error)}` - ); - } - const expectedTag = `${selected.source.tagPrefix}${version}`; - if ( - typeof release !== "object" || - release === null || - !("tag_name" in release) || - release.tag_name !== expectedTag - ) { - throw new UpgradeError( - "network_error", - `GitHub returned invalid metadata for version ${version}` - ); - } + await validatePinnedGitHubRelease( + selected.response, + version, + selected.source + ); return { version, source: selected.source }; } catch (error) { if (error instanceof UpgradeSourceNotFoundError) { @@ -800,6 +820,7 @@ async function standaloneVersionExists( "GitHub" ); if (response.ok) { + await validatePinnedGitHubRelease(response, version, source); return true; } if (response.status === 404) { diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index 9f1cda8967..2558ac53b7 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -460,7 +460,7 @@ describe("downloadNightlyBlob", () => { mockFetch(async () => { requestCount += 1; controller.abort(reason); - throw new DOMException("aborted", "AbortError"); + throw new TypeError("invalid_argument"); }); await expect( @@ -479,7 +479,7 @@ describe("downloadNightlyBlob", () => { return Response.redirect("https://blob.storage.azure.com/file", 307); } controller.abort(reason); - throw new DOMException("aborted", "AbortError"); + throw new TypeError("invalid_argument"); }); await expect( diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 6b76dfe7f4..c44b8fe038 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -356,6 +356,29 @@ describe("fetchLatestFromGitHub", () => { ]); }); + test("rejects an object from the Toolkit release-list endpoint", async () => { + mockFetch(async () => Response.json({ tag_name: "cli@9.9.9" })); + + await expect(fetchLatestFromGitHub()).rejects.toThrow( + "GitHub returned invalid release metadata" + ); + }); + + test("rejects an array from the legacy latest-release endpoint", async () => { + let requests = 0; + mockFetch(async () => { + requests += 1; + return requests === 1 + ? new Response(null, { status: 404 }) + : Response.json([{ tag_name: "9.9.9" }]); + }); + + await expect(fetchLatestFromGitHub()).rejects.toThrow( + "GitHub returned invalid release metadata" + ); + expect(requests).toBe(2); + }); + test("does not use an MCP release as the latest CLI release", async () => { const requests: string[] = []; mockFetch(async (url) => { @@ -867,6 +890,14 @@ describe("versionExists", () => { ]); }); + test("rejects empty successful metadata from an explicit source", async () => { + mockFetch(async () => new Response(null, { status: 200 })); + + await expect( + versionExists("curl", "1.0.0", UPGRADE_SOURCES[0]) + ).rejects.toThrow("GitHub returned invalid metadata for version 1.0.0"); + }); + test.each([ 401, 403, 429, 500, ])("does not classify explicit source HTTP %i as a missing version", async (status) => { From a84012184c79c2566c7466aa6beda5f767199218 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 05:07:21 +0000 Subject: [PATCH 18/19] fix(cli): validate upgrade response bodies --- packages/cli/src/lib/binary.ts | 21 ++++ packages/cli/src/lib/ghcr.ts | 102 ++++++++++++++++-- packages/cli/src/lib/upgrade.ts | 29 ++--- .../cli/test/commands/cli/upgrade.test.ts | 51 +++++++-- packages/cli/test/lib/binary.test.ts | 43 ++++++++ packages/cli/test/lib/ghcr.test.ts | 63 ++++++++++- packages/cli/test/lib/upgrade.test.ts | 8 +- 7 files changed, 284 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index 8a96e93615..e64f5059e5 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -518,6 +518,27 @@ export async function fetchWithUpgradeError( } } +/** Parse an upgrade response while preserving cancellation and transport failures. */ +export async function parseUpgradeJson( + response: Response, + signal: AbortSignal | undefined, + invalidMessage: string +): Promise { + try { + return await response.json(); + } catch (error) { + if (signal?.aborted) { + throw signal.reason; + } + if (error instanceof SyntaxError) { + throw new UpgradeError("network_error", invalidMessage); + } + throw new UpgradeTransportError( + `${invalidMessage}: ${stringifyUnknown(error)}` + ); + } +} + /** * Replace the binary at the install path, handling platform differences. * diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index fb441e0571..c7b8c37b27 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -18,7 +18,11 @@ */ import { valid as semverValid } from "semver"; -import { PRIMARY_UPGRADE_SOURCE, type UpgradeSource } from "./binary.js"; +import { + PRIMARY_UPGRADE_SOURCE, + parseUpgradeJson, + type UpgradeSource, +} from "./binary.js"; import { getUserAgent } from "./constants.js"; import { customFetch } from "./custom-ca.js"; import { UpgradeError, UpgradeTransportError } from "./errors.js"; @@ -199,6 +203,47 @@ export type OciManifest = { annotations?: Record; }; +const SHA256_DIGEST_REGEX = /^sha256:[0-9a-f]{64}$/; + +function isStringRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + Object.values(value).every((item) => typeof item === "string") + ); +} + +function isOciLayer(value: unknown): value is OciLayer { + if (typeof value !== "object" || value === null) { + return false; + } + const layer = value as Partial; + return ( + typeof layer.digest === "string" && + SHA256_DIGEST_REGEX.test(layer.digest) && + typeof layer.mediaType === "string" && + Number.isSafeInteger(layer.size) && + (layer.size ?? -1) >= 0 && + (layer.annotations === undefined || isStringRecord(layer.annotations)) + ); +} + +function isOciManifest(value: unknown): value is OciManifest { + if (typeof value !== "object" || value === null) { + return false; + } + const manifest = value as Partial; + return ( + manifest.schemaVersion === 2 && + Array.isArray(manifest.layers) && + manifest.layers.every(isOciLayer) && + (manifest.mediaType === undefined || + typeof manifest.mediaType === "string") && + (manifest.config === undefined || isOciLayer(manifest.config)) && + (manifest.annotations === undefined || isStringRecord(manifest.annotations)) + ); +} + /** * Fetch a short-lived anonymous bearer token for read-only access to the * public `ghcr.io/getsentry/cli` package. @@ -231,8 +276,18 @@ export async function getAnonymousToken( ); } - const data = (await response.json()) as { token?: string }; - if (!data.token) { + const data = await parseUpgradeJson( + response, + externalSignal, + "GHCR token exchange returned invalid metadata" + ); + if ( + typeof data !== "object" || + data === null || + !("token" in data) || + typeof data.token !== "string" || + data.token.length === 0 + ) { throw new UpgradeError( "network_error", "GHCR token exchange returned no token" @@ -274,7 +329,18 @@ export async function fetchManifest( throw new GhcrManifestHttpError(tag, response.status); } - return (await response.json()) as OciManifest; + const data = await parseUpgradeJson( + response, + signal, + `Manifest for tag "${tag}" returned invalid metadata` + ); + if (!isOciManifest(data)) { + throw new UpgradeError( + "network_error", + `Manifest for tag "${tag}" returned invalid metadata` + ); + } + return data; } /** @@ -489,8 +555,32 @@ async function fetchTagPage( ); } - const data = (await response.json()) as { tags?: string[] }; - return data.tags ?? []; + const data = await parseUpgradeJson( + response, + signal, + "GHCR tag list returned invalid metadata" + ); + if (typeof data !== "object" || data === null) { + throw new UpgradeError( + "network_error", + "GHCR tag list returned invalid metadata" + ); + } + if (!("tags" in data)) { + return []; + } + if ( + !( + Array.isArray(data.tags) && + data.tags.every((tag) => typeof tag === "string") + ) + ) { + throw new UpgradeError( + "network_error", + "GHCR tag list returned invalid metadata" + ); + } + return data.tags; } /** diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index b6235a6b67..d928d2c59f 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -40,6 +40,7 @@ import { isNightlyVersion, KNOWN_CURL_DIRS, PRIMARY_UPGRADE_SOURCE, + parseUpgradeJson, releaseLock, resolveUpgradeSource, UPGRADE_SOURCES, @@ -506,9 +507,11 @@ export async function fetchLatestFromGitHubWithSource( const visitedPages = new Set([getGitHubLatestReleaseUrl(resolved.source)]); const versions: string[] = []; while (true) { - const data = (await response.json()) as - | { tag_name?: string } - | Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>; + const data = await parseUpgradeJson( + response, + signal, + "GitHub returned invalid release metadata" + ); versions.push(...extractReleaseVersions(data, resolved.source)); const nextPage = getNextGitHubReleasePage(response, resolved.source); if (!nextPage) { @@ -575,7 +578,11 @@ export async function fetchLatestFromNpm(): Promise { ); } - const data = (await response.json()) as { version?: string }; + const data = (await parseUpgradeJson( + response, + undefined, + "npm registry returned invalid metadata" + )) as { version?: string }; return validateStableVersion(data.version, "npm registry"); } @@ -718,15 +725,11 @@ async function validatePinnedGitHubRelease( version: string, source: UpgradeSource ): Promise { - let release: unknown; - try { - release = await response.json(); - } catch (error) { - throw new UpgradeError( - "network_error", - `GitHub returned invalid metadata for version ${version}: ${error instanceof Error ? error.message : String(error)}` - ); - } + const release = await parseUpgradeJson( + response, + undefined, + `GitHub returned invalid metadata for version ${version}` + ); const expectedTag = `${source.tagPrefix}${version}`; if ( typeof release !== "object" || diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 3494c02a84..445697a05d 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -264,12 +264,19 @@ function mockNightlyVersion(version: string): void { }); } if (urlStr.includes("/manifests/nightly")) { - return new Response(JSON.stringify({ annotations: { version } }), { - status: 200, - headers: { - "content-type": "application/vnd.oci.image.manifest.v1+json", - }, - }); + return new Response( + JSON.stringify({ + schemaVersion: 2, + layers: [], + annotations: { version }, + }), + { + status: 200, + headers: { + "content-type": "application/vnd.oci.image.manifest.v1+json", + }, + } + ); } return new Response("Not Found", { status: 404 }); }); @@ -422,6 +429,29 @@ describe("sentry cli upgrade", () => { expect(getOutput()).toContain("Using cached target: 88.88.88"); }); + test("uses the cached target after response body transport failure", async () => { + setVersionCheckInfo("88.88.88"); + mockFetch(async () => { + const response = Response.json([]); + response.json = async () => { + throw new TypeError("terminated"); + }; + return response; + }); + const { context, getOutput, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl"], + context + ); + + expect(getOutput()).toContain("Using cached target: 88.88.88"); + }); + test.each([ ["HTTP 403", async () => new Response("Forbidden", { status: 403 })], [ @@ -1200,10 +1230,13 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp } return new Response( JSON.stringify({ + schemaVersion: 2, annotations: { version: "0.99.0-dev.1234567890" }, layers: [ { - digest: "sha256:abc456", + digest: `sha256:${"a".repeat(64)}`, + mediaType: "application/gzip", + size: gzipped.byteLength, annotations: { "org.opencontainers.image.title": filename, }, @@ -1218,7 +1251,7 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp } ); } - if (urlStr.includes("/blobs/sha256:abc456")) { + if (urlStr.includes(`/blobs/sha256:${"a".repeat(64)}`)) { return Response.redirect("https://blob.example.com/nightly.gz", 307); } if (urlStr.includes("blob.example.com")) { @@ -1270,6 +1303,8 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.sp if (request.includes("/manifests/nightly-0.99.0-dev.1234567890")) { return new Response( JSON.stringify({ + schemaVersion: 2, + layers: [], annotations: { version: "0.99.0-dev.1234567890" }, }), { status: 200 } diff --git a/packages/cli/test/lib/binary.test.ts b/packages/cli/test/lib/binary.test.ts index be462848c4..29ead38289 100644 --- a/packages/cli/test/lib/binary.test.ts +++ b/packages/cli/test/lib/binary.test.ts @@ -31,6 +31,7 @@ import { installBinary, isDowngrade, isMusl, + parseUpgradeJson, releaseLock, replaceBinarySync, resolveUpgradeSource, @@ -500,6 +501,48 @@ describe("fetchWithUpgradeError", () => { }); }); +describe("parseUpgradeJson", () => { + test("preserves cancellation during body consumption", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + const response = Response.json({}); + response.json = async () => { + controller.abort(reason); + throw new DOMException("aborted", "AbortError"); + }; + + await expect( + parseUpgradeJson(response, controller.signal, "invalid metadata") + ).rejects.toBe(reason); + }); + + test("classifies body termination as transport failure", async () => { + const response = Response.json({}); + response.json = async () => { + throw new TypeError("terminated"); + }; + + await expect( + parseUpgradeJson(response, undefined, "invalid metadata") + ).rejects.toMatchObject({ + name: "UpgradeTransportError", + reason: "network_error", + }); + }); + + test("classifies completed malformed JSON as metadata failure", async () => { + const response = new Response("not json"); + + await expect( + parseUpgradeJson(response, undefined, "invalid metadata") + ).rejects.toMatchObject({ + name: "UpgradeError", + reason: "network_error", + message: "invalid metadata", + }); + }); +}); + describe("replaceBinarySync", () => { let testDir: string; diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index 2558ac53b7..20b11f51d2 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -39,13 +39,13 @@ function makeManifest(overrides: Partial = {}): OciManifest { schemaVersion: 2, mediaType: "application/vnd.oci.image.manifest.v1+json", config: { - digest: "sha256:config", + digest: `sha256:${"0".repeat(64)}`, mediaType: "application/vnd.oci.empty.v1+json", size: 2, }, layers: [ { - digest: "sha256:abc123", + digest: `sha256:${"a".repeat(64)}`, mediaType: "application/octet-stream", size: 1000, annotations: { @@ -53,7 +53,7 @@ function makeManifest(overrides: Partial = {}): OciManifest { }, }, { - digest: "sha256:def456", + digest: `sha256:${"d".repeat(64)}`, mediaType: "application/octet-stream", size: 1200, annotations: { @@ -176,6 +176,31 @@ describe("getAnonymousToken", () => { "GHCR token exchange returned no token" ); }); + + test("rejects a non-string token", async () => { + mockFetch(async () => Response.json({ token: {} })); + + await expect(getAnonymousToken()).rejects.toThrow( + "GHCR token exchange returned no token" + ); + }); + + test("preserves cancellation during token body consumption", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + mockFetch(async () => { + const response = Response.json({ token: "unused" }); + response.json = async () => { + controller.abort(reason); + throw new DOMException("aborted", "AbortError"); + }; + return response; + }); + + await expect(getAnonymousToken(undefined, controller.signal)).rejects.toBe( + reason + ); + }); }); describe("fetchNightlyManifest", () => { @@ -273,13 +298,13 @@ describe("findLayerByFilename", () => { test("finds layer by filename annotation", () => { const manifest = makeManifest(); const layer = findLayerByFilename(manifest, "sentry-linux-x64.gz"); - expect(layer.digest).toBe("sha256:abc123"); + expect(layer.digest).toBe(`sha256:${"a".repeat(64)}`); }); test("finds darwin layer", () => { const manifest = makeManifest(); const layer = findLayerByFilename(manifest, "sentry-darwin-arm64.gz"); - expect(layer.digest).toBe("sha256:def456"); + expect(layer.digest).toBe(`sha256:${"d".repeat(64)}`); }); test("throws UpgradeError when filename not found", () => { @@ -493,6 +518,34 @@ describe("downloadNightlyBlob", () => { // fetchManifest (generic tag variant) describe("fetchManifest", () => { + test.each([ + null, + [], + {}, + { schemaVersion: 2 }, + { schemaVersion: 2, layers: {} }, + ])("rejects invalid OCI manifest %#", async (manifest) => { + mockFetch(async () => Response.json(manifest)); + + await expect(fetchManifest("token", "nightly")).rejects.toThrow( + 'Manifest for tag "nightly" returned invalid metadata' + ); + }); + + test("classifies manifest body termination as transport failure", async () => { + mockFetch(async () => { + const response = Response.json(makeManifest()); + response.json = async () => { + throw new TypeError("terminated"); + }; + return response; + }); + + await expect(fetchManifest("token", "nightly")).rejects.toMatchObject({ + name: "UpgradeTransportError", + reason: "network_error", + }); + }); test("fetches manifest for an arbitrary tag", async () => { const manifest = makeManifest(); diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index c44b8fe038..8e990024ee 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -711,6 +711,8 @@ describe("fetchLatestVersion", () => { if (urlStr.includes("/manifests/nightly")) { return new Response( JSON.stringify({ + schemaVersion: 2, + layers: [], annotations: { version: "0.0.0-dev.1740393600" }, }), { status: 200 } @@ -736,6 +738,8 @@ describe("fetchLatestVersion", () => { if (urlStr.includes("/manifests/nightly")) { return new Response( JSON.stringify({ + schemaVersion: 2, + layers: [], annotations: { version: "0.0.0-dev.1740393600" }, }), { status: 200 } @@ -1075,6 +1079,8 @@ describe("versionExists", () => { if (request.includes("/manifests/nightly-0.14.0-dev.123")) { return new Response( JSON.stringify({ + schemaVersion: 2, + layers: [], annotations: { version: "0.14.0-dev.124" }, }), { status: 200 } @@ -2197,7 +2203,7 @@ describe("executeUpgrade with curl method (nightly)", () => { schemaVersion: 2, layers: [ { - digest: "sha256:blobdigest", + digest: `sha256:${"b".repeat(64)}`, mediaType: "application/octet-stream", size: gzipped.byteLength, annotations: { "org.opencontainers.image.title": title }, From 9525f72c70fabbb23ec21d13cf86add58f6287cc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 10 Sep 2026 10:43:42 +0000 Subject: [PATCH 19/19] fix(cli): harden upgrade metadata validation --- packages/cli/src/lib/ghcr.ts | 15 ++- packages/cli/src/lib/upgrade.ts | 43 +++++-- .../cli/test/commands/cli/upgrade.test.ts | 101 ++++++++++++++++- packages/cli/test/lib/ghcr.test.ts | 33 ++++++ packages/cli/test/lib/upgrade.test.ts | 106 +++++++++++++++--- 5 files changed, 270 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index c7b8c37b27..5bca7c0615 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -209,6 +209,7 @@ function isStringRecord(value: unknown): value is Record { return ( typeof value === "object" && value !== null && + !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string") ); } @@ -286,7 +287,8 @@ export async function getAnonymousToken( data === null || !("token" in data) || typeof data.token !== "string" || - data.token.length === 0 + data.token.length === 0 || + data.token.trim() !== data.token ) { throw new UpgradeError( "network_error", @@ -601,6 +603,7 @@ export async function listTags( source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { const allTags: string[] = []; + const visitedCursors = new Set(); let lastTag: string | undefined; for (;;) { @@ -619,7 +622,15 @@ export async function listTags( break; } - lastTag = tags.at(-1); + const nextTag = tags.at(-1); + if (!nextTag || visitedCursors.has(nextTag)) { + throw new UpgradeError( + "network_error", + "GHCR tag pagination returned a repeated cursor" + ); + } + visitedCursors.add(nextTag); + lastTag = nextTag; } return allTags; diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index d928d2c59f..b29d67ff2d 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -139,10 +139,10 @@ function extractReleaseVersions( typeof tag === "string" && tag.startsWith(source.tagPrefix) ) .map((tag) => tag.slice(source.tagPrefix.length)) - .map((tag) => tag.replace(VERSION_PREFIX_REGEX, "")) - .filter( - (tag) => semverValid(tag) !== null && semverPrerelease(tag) === null + .map((tag) => + source.tagPrefix ? tag : tag.replace(VERSION_PREFIX_REGEX, "") ) + .filter((tag) => semverValid(tag) === tag && semverPrerelease(tag) === null) .sort((a, b) => compareVersions(b, a)); } @@ -578,11 +578,23 @@ export async function fetchLatestFromNpm(): Promise { ); } - const data = (await parseUpgradeJson( + const data = await parseUpgradeJson( response, undefined, "npm registry returned invalid metadata" - )) as { version?: string }; + ); + if ( + typeof data !== "object" || + data === null || + Array.isArray(data) || + !("version" in data) || + typeof data.version !== "string" + ) { + throw new UpgradeError( + "network_error", + "npm registry returned invalid metadata" + ); + } return validateStableVersion(data.version, "npm registry"); } @@ -594,7 +606,7 @@ function validateStableVersion( if (!version) { throw new UpgradeError("network_error", `No version found in ${source}`); } - if (semverValid(version) === null || semverPrerelease(version) !== null) { + if (semverValid(version) !== version || semverPrerelease(version) !== null) { throw new UpgradeError( "network_error", `${source} returned an invalid stable version` @@ -735,7 +747,9 @@ async function validatePinnedGitHubRelease( typeof release !== "object" || release === null || !("tag_name" in release) || - release.tag_name !== expectedTag + release.tag_name !== expectedTag || + ("draft" in release && release.draft === true) || + ("prerelease" in release && release.prerelease === true) ) { throw new UpgradeError( "network_error", @@ -758,6 +772,7 @@ export async function resolveExistingUpgradeVersion( validateNightlyManifestVersion(resolved.manifest, version); return { version, source: resolved.source }; } + validateStableVersion(version, "Requested standalone version"); const selected = await resolveUpgradeSource({ getProbeUrl: (source) => getGitHubReleaseByTagUrl(version, source), }); @@ -813,6 +828,9 @@ async function standaloneVersionExists( version: string, source?: UpgradeSource ): Promise { + if (!isNightlyVersion(version)) { + validateStableVersion(version, "Requested standalone version"); + } if (source) { if (isNightlyVersion(version)) { return nightlyVersionExists(version, source); @@ -866,7 +884,16 @@ export async function versionExists( { method: "HEAD" }, "npm registry" ); - return response.ok; + if (response.ok) { + return true; + } + if (response.status === 404) { + return false; + } + throw new UpgradeError( + "network_error", + `Failed to fetch from npm: ${response.status}` + ); } // Upgrade Execution diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 445697a05d..b7fead58fb 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -1019,6 +1019,82 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy expect(setupCall?.args).toContain("--ensure-auth-scopes"); }); + test.each([ + "npm", + "pnpm", + "bun", + "yarn", + ] as const)("classifies a missing pinned %s version without running the package manager", async (method) => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(null, { status: 404 }); + }); + const { context, errors, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + await run(app, ["cli", "upgrade", "--method", method, "1.2.3"], context); + + expect(errors.join("\n")).toContain("Version 1.2.3 not found"); + expect(requests).toEqual(["https://registry.npmjs.org/sentry/1.2.3"]); + expect(spawnedArgs).toEqual([]); + }); + + test.each([ + "npm", + "pnpm", + "bun", + "yarn", + ] as const)("preserves non-404 HTTP failures for a pinned %s version", async (method) => { + for (const status of [401, 403, 429, 500]) { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return new Response(null, { status }); + }); + const { context, errors, restore } = createMockContext({ + homeDir: testDir, + }); + + await run(app, ["cli", "upgrade", "--method", method, "1.2.3"], context); + + restore(); + expect(errors.join("\n")).toContain( + `Failed to fetch from npm: ${status}` + ); + expect(errors.join("\n")).not.toContain("Version 1.2.3 not found"); + expect(requests).toEqual(["https://registry.npmjs.org/sentry/1.2.3"]); + expect(spawnedArgs).toEqual([]); + } + }); + + test.each([ + "npm", + "pnpm", + "bun", + "yarn", + ] as const)("rejects malformed latest metadata for %s without running the package manager", async (method) => { + const requests: string[] = []; + mockFetch(async (url) => { + requests.push(String(url)); + return Response.json(null); + }); + const { context, errors, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + await run(app, ["cli", "upgrade", "--method", method], context); + + expect(errors.join("\n")).toContain( + "npm registry returned invalid metadata" + ); + expect(requests).toEqual(["https://registry.npmjs.org/sentry/latest"]); + expect(spawnedArgs).toEqual([]); + }); + test("runs the new Homebrew binary and keeps JSON upgrades non-interactive", async () => { mockGitHubVersion("99.99.99"); const binaryPath = join(testDir, "sentry"); @@ -1070,6 +1146,7 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy const capturedUrls: string[] = []; const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); const gzipped = gzipSync(fakeContent); + const digest = `sha256:${"a".repeat(64)}`; // GHCR flow: token exchange → manifest → blob redirect → blob download mockFetch(async (url) => { @@ -1093,10 +1170,13 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy } return new Response( JSON.stringify({ + schemaVersion: 2, annotations: { version: "0.99.0-dev.1234567890" }, layers: [ { - digest: "sha256:abc123", + digest, + mediaType: "application/gzip", + size: gzipped.byteLength, annotations: { "org.opencontainers.image.title": filename, }, @@ -1111,7 +1191,7 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy } ); } - if (urlStr.includes("/blobs/sha256:abc123")) { + if (urlStr.includes(`/v2/getsentry/toolkit/blobs/${digest}`)) { // Redirect to blob storage (GHCR blob endpoint returns 307) return Response.redirect("https://blob.example.com/file.gz", 307); } @@ -1122,7 +1202,9 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy }); // "nightly" positional switches channel to nightly - const { context, restore } = createMockContext({ homeDir: testDir }); + const { context, getOutput, restore } = createMockContext({ + homeDir: testDir, + }); restoreStderr = restore; await run(app, ["cli", "upgrade", "--method", "curl", "nightly"], context); @@ -1132,6 +1214,19 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy expect(capturedUrls.some((u) => u.includes("/manifests/nightly"))).toBe( true ); + expect( + capturedUrls.some((url) => + url.includes(`/v2/getsentry/toolkit/blobs/${digest}`) + ) + ).toBe(true); + expect(capturedUrls.some((url) => url.includes("/v2/getsentry/cli/"))).toBe( + false + ); + expect(spawnedArgs.some((entry) => entry.args.includes("setup"))).toBe( + true + ); + expect(getOutput()).toContain("Upgraded to"); + expect(getOutput()).toContain("0.99.0-dev.1234567890"); }); test("--force proceeds to download the resolved target", async () => { diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts index 20b11f51d2..a55612b5af 100644 --- a/packages/cli/test/lib/ghcr.test.ts +++ b/packages/cli/test/lib/ghcr.test.ts @@ -185,6 +185,14 @@ describe("getAnonymousToken", () => { ); }); + test.each([" ", "\ttoken"])("rejects malformed token %j", async (token) => { + mockFetch(async () => Response.json({ token })); + + await expect(getAnonymousToken()).rejects.toThrow( + "GHCR token exchange returned no token" + ); + }); + test("preserves cancellation during token body consumption", async () => { const controller = new AbortController(); const reason = { kind: "cancelled" }; @@ -524,6 +532,18 @@ describe("fetchManifest", () => { {}, { schemaVersion: 2 }, { schemaVersion: 2, layers: {} }, + { schemaVersion: 2, layers: [], annotations: ["value"] }, + { + schemaVersion: 2, + layers: [ + { + digest: `sha256:${"a".repeat(64)}`, + mediaType: "application/octet-stream", + size: 1, + annotations: ["value"], + }, + ], + }, ])("rejects invalid OCI manifest %#", async (manifest) => { mockFetch(async () => Response.json(manifest)); @@ -594,6 +614,19 @@ describe("fetchManifest", () => { // listTags describe("listTags", () => { + test("rejects a repeated pagination cursor", async () => { + const tags = Array.from({ length: 100 }, (_, index) => `tag-${index}`); + let requests = 0; + mockFetch(async () => { + requests += 1; + return Response.json({ tags }); + }); + + await expect(listTags("token")).rejects.toThrow( + "GHCR tag pagination returned a repeated cursor" + ); + expect(requests).toBe(2); + }); test("returns all tags when no prefix filter", async () => { mockFetch(async (url) => { expect(String(url)).toContain(`/v2/${GHCR_REPO}/tags/list`); diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 8e990024ee..d10f21dd6f 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -396,7 +396,7 @@ describe("fetchLatestFromGitHub", () => { ]); }); - test("returns version from GitHub API", async () => { + test("rejects a v-prefixed Toolkit product version", async () => { mockFetch( async () => new Response(JSON.stringify([{ tag_name: "cli@v1.2.3" }]), { @@ -405,18 +405,19 @@ describe("fetchLatestFromGitHub", () => { }) ); - const version = await fetchLatestFromGitHub(); - expect(version).toBe("1.2.3"); + await expect(fetchLatestFromGitHub()).rejects.toThrow( + "No version found in GitHub release" + ); }); - test("strips v prefix from version", async () => { - mockFetch( - async () => - new Response(JSON.stringify([{ tag_name: "cli@v0.5.0" }]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - ); + test("strips v prefix from a legacy version", async () => { + let requests = 0; + mockFetch(async () => { + requests += 1; + return requests === 1 + ? new Response(null, { status: 404 }) + : Response.json({ tag_name: "v0.5.0" }); + }); const version = await fetchLatestFromGitHub(); expect(version).toBe("0.5.0"); @@ -529,12 +530,13 @@ describe("fetchLatestFromNpm", () => { ); await expect(fetchLatestFromNpm()).rejects.toThrow( - "No version found in npm registry" + "npm registry returned invalid metadata" ); }); test.each([ "not-semver", + "v1.2.3", "1.2.3-dev.123", "1.2.3-beta.1", "1.2.3-rc.1", @@ -551,6 +553,22 @@ describe("fetchLatestFromNpm", () => { "npm registry returned an invalid stable version" ); }); + + test.each([ + null, + [], + 42, + "version", + { version: 42 }, + { version: "" }, + ])("rejects malformed npm metadata %#", async (data) => { + mockFetch(async () => Response.json(data)); + + await expect(fetchLatestFromNpm()).rejects.toMatchObject({ + name: "UpgradeError", + reason: "network_error", + }); + }); }); // fetchLatestNightlyVersion tests are in the dedicated describe block @@ -610,7 +628,7 @@ describe("fetchLatestVersion", () => { test("uses GitHub for curl method", async () => { mockFetch( async () => - new Response(JSON.stringify([{ tag_name: "cli@v2.0.0" }]), { + new Response(JSON.stringify([{ tag_name: "cli@2.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -675,7 +693,7 @@ describe("fetchLatestVersion", () => { test("uses GitHub for brew method", async () => { mockFetch( async () => - new Response(JSON.stringify([{ tag_name: "cli@v2.0.0" }]), { + new Response(JSON.stringify([{ tag_name: "cli@2.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -755,7 +773,7 @@ describe("fetchLatestVersion", () => { test("defaults to stable channel (uses GitHub) when channel omitted", async () => { mockFetch( async () => - new Response(JSON.stringify([{ tag_name: "cli@v3.0.0" }]), { + new Response(JSON.stringify([{ tag_name: "cli@3.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -767,6 +785,64 @@ describe("fetchLatestVersion", () => { }); describe("versionExists", () => { + test.each([ + 401, 403, 429, 500, + ])("does not classify npm HTTP %i as a missing version", async (status) => { + mockFetch(async () => new Response(null, { status })); + + await expect(versionExists("npm", "1.0.0")).rejects.toMatchObject({ + reason: "network_error", + }); + }); + + test.each([ + "not-semver", + "v1.2.3", + "1.2.3-beta.1", + "mcp@1.0.0", + ])("rejects invalid standalone stable version %s before network access", async (version) => { + let requests = 0; + mockFetch(async () => { + requests += 1; + return new Response(null, { status: 200 }); + }); + + await expect(resolveExistingUpgradeVersion(version)).rejects.toMatchObject({ + reason: "network_error", + }); + expect(requests).toBe(0); + }); + + test.each([ + "not-semver", + "v1.2.3", + "1.2.3-beta.1", + "mcp@1.0.0", + ])("rejects explicit-source standalone version %s before network access", async (version) => { + let requests = 0; + mockFetch(async () => { + requests += 1; + return new Response(null, { status: 200 }); + }); + + await expect( + versionExists("curl", version, UPGRADE_SOURCES[0]) + ).rejects.toMatchObject({ reason: "network_error" }); + expect(requests).toBe(0); + }); + + test.each([ + "draft", + "prerelease", + ])("rejects a pinned stable release marked %s", async (flag) => { + mockFetch(async () => + Response.json({ tag_name: "cli@1.2.3", [flag]: true }) + ); + + await expect(resolveExistingUpgradeVersion("1.2.3")).rejects.toMatchObject({ + reason: "network_error", + }); + }); test.each([ "npm", "pnpm",