diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 8d8282368..b3039d86b 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -22,9 +22,11 @@ import type { SentryContext } from "../../context.js"; import { determineInstallDir, isDowngrade, + isNightlyVersion, LEGACY_INSTALL_SUBDIR, releaseLock, samePath, + type UpgradeSource, } from "../../lib/binary.js"; import { buildCommand } from "../../lib/command.js"; import { CLI_VERSION } from "../../lib/constants.js"; @@ -34,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"; @@ -54,6 +56,8 @@ import { NIGHTLY_TAG, type OfflineMode, parseInstallationMethod, + resolveExistingUpgradeVersion, + resolveLatestUpgradeVersion, VERSION_PREFIX_REGEX, versionExists, } from "../../lib/upgrade.js"; @@ -172,8 +176,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,15 +212,17 @@ 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 // 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 { @@ -233,7 +244,6 @@ async function resolveTargetWithFallback(opts: { function validateMethod( method: InstallationMethod, versionArg: string | undefined, - channel: ReleaseChannel, offline: boolean ): void { if (method === "unknown") { @@ -241,7 +251,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." @@ -257,6 +270,10 @@ function validateMethod( } } +function getArtifactChannel(target: string): ReleaseChannel { + return isNightlyVersion(target) ? "nightly" : "stable"; +} + type ResolveTargetOptions = { method: InstallationMethod; channel: ReleaseChannel; @@ -273,8 +290,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,30 +323,58 @@ async function resolveTargetVersion( opts: ResolveTargetOptions ): Promise { const { method, channel, versionArg, channelChanged, flags } = opts; - const latest = await fetchLatestVersion(method, channel); - const target = versionArg?.replace(VERSION_PREFIX_REGEX, "") ?? latest; + const standalone = + channel === "nightly" || method === "curl" || method === "brew"; + const pinnedTarget = + versionArg && !CHANNEL_VERSIONS.has(versionArg) + ? versionArg.replace(VERSION_PREFIX_REGEX, "") + : undefined; + let source: UpgradeSource | undefined; + + if (pinnedTarget) { + const lookupMethod = isNightlyVersion(pinnedTarget) ? "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}`); + 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, @@ -317,21 +382,7 @@ 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; - const exists = await versionExists(lookupMethod, target); - if (!exists) { - throw new UpgradeError( - "version_not_found", - `Version ${target} not found` - ); - } - } - - return { kind: "target", target }; + return { kind: "target", target: resolvedTarget, source }; } /** @@ -617,6 +668,7 @@ async function executeStandardUpgrade(opts: { offline?: OfflineMode; json?: boolean; noAgentSkills: boolean; + source?: UpgradeSource; }): Promise { const { method, @@ -629,6 +681,7 @@ async function executeStandardUpgrade(opts: { offline, json, noAgentSkills, + source, } = opts; // Use the rolling "nightly" tag only when upgrading to latest nightly @@ -639,7 +692,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 +766,11 @@ async function migrateToStandaloneForNightly(opts: { versionArg: string | undefined; noAgentSkills: boolean; json?: boolean; + source?: UpgradeSource; + channel: ReleaseChannel; }): Promise { - const { method, target, versionArg, noAgentSkills, json } = 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..."); @@ -724,7 +780,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) { @@ -746,7 +802,7 @@ async function migrateToStandaloneForNightly(opts: { await runSetupOnNewBinary({ binaryPath: downloadResult.tempBinaryPath, method: "curl", - channel: "nightly", + channel, install: true, installDir, ensureAuthScopes: !json, @@ -796,7 +852,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 }; } @@ -822,12 +878,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 +893,7 @@ function startChangelogFetch( channel, fromVersion: currentVersion, toVersion: targetVersion, + source, }) .then((result) => result ?? undefined) .catch(() => undefined as undefined); @@ -955,25 +1014,27 @@ export const upgradeCommand = buildCommand({ result.action === "checked" && result.currentVersion !== result.targetVersion ) { - result.changelog = await startChangelogFetch( - channel, - CLI_VERSION, - result.targetVersion, - false - ); + result.changelog = await startChangelogFetch({ + channel: getArtifactChannel(result.targetVersion), + 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( - channel, - CLI_VERSION, - target, - offline - ); + const changelogPromise = startChangelogFetch({ + channel: getArtifactChannel(target), + currentVersion: CLI_VERSION, + targetVersion: target, + offline, + source, + }); // --check with offline fallback: resolveTargetWithFallback returns // kind: "target" for offline check, so guard against actual upgrade. @@ -1008,7 +1069,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({ @@ -1017,6 +1078,8 @@ export const upgradeCommand = buildCommand({ versionArg, noAgentSkills: flags["no-agent-skills"], json: flags.json, + source, + channel, }); } else { await executeStandardUpgrade({ @@ -1030,6 +1093,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 75582612a..e64f5059e 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 */ @@ -102,6 +106,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 +235,130 @@ 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)}`; } -/** GitHub API base URL for releases */ -export const GITHUB_RELEASES_URL = - "https://api.github.com/repos/getsentry/cli/releases"; +/** 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; +}; + +/** 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. */ + 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 (options.signal?.aborted) { + throw options.signal.reason; + } + if (error instanceof Error && error.name === "AbortError") { + throw error; + } + if (error instanceof Error && isTlsCertError(error)) { + throw new UpgradeTransportError(buildTlsErrorDetail(error)); + } + throw new UpgradeTransportError( + `Failed to connect to GitHub: ${stringifyUnknown(error)}` + ); + } +} + +/** + * 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 UpgradeSourceNotFoundError(); +} /** * Detect whether a version string identifies a nightly build. @@ -228,7 +376,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. * @@ -353,21 +501,44 @@ 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; } 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}` ); } } +/** 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/delta-upgrade.ts b/packages/cli/src/lib/delta-upgrade.ts index ec709b8c8..204875dce 100644 --- a/packages/cli/src/lib/delta-upgrade.ts +++ b/packages/cli/src/lib/delta-upgrade.ts @@ -30,18 +30,20 @@ import { type SourceStrategy, type StableChainInfo, } from "binpatch"; +import { prerelease as semverPrerelease, valid as semverValid } from "semver"; 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 +70,63 @@ 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"); +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"); + } + return PRIMARY_UPGRADE_SOURCE; +} + const instrument: InstrumentHook = (name, fn) => withTracing(name, "http.client", fn); @@ -119,20 +173,48 @@ function getPatchCache(): PatchCache { return instrumentCache(makeCache(join(getConfigDir(), "patch-cache"))); } -function stableSource(): SourceStrategy { +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 && 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 = normalizeStableReleases(data, source); + return new Response(JSON.stringify(releases), response); + }; + return githubReleaseSource({ - releasesUrl: GITHUB_RELEASES_URL, + releasesUrl, binaryName: getPlatformBinaryName(), userAgent: `sentry-cli/${CLI_VERSION}`, - fetch: customFetch, + fetch: sourceFetch, instrument, }); } -function nightlySource(): SourceStrategy { +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", - repo: GHCR_REPO, + repo: source.ghcrRepo, binaryName: getPlatformBinaryName(), targetTag: (version) => `nightly-${version}`, compareVersions, @@ -153,28 +235,32 @@ export function canAttemptDelta(targetVersion: string): boolean { } export async function fetchRecentReleases( - signal?: AbortSignal -): Promise { + 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 []; + 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 as GitHubRelease[]; + return normalizeStableReleases(data, source); } catch (error) { log.debug("Failed to fetch recent releases from GitHub", error); - return []; + return normalizeStableReleases([], source); } } @@ -270,9 +356,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 +373,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 +582,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 +601,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 +620,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 +636,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 +710,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/errors.ts b/packages/cli/src/lib/errors.ts index f81b8c70c..b21505cd1 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 43d6deb7e..5bca7c061 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -17,9 +17,15 @@ * without the auth header. */ +import { valid as semverValid } from "semver"; +import { + PRIMARY_UPGRADE_SOURCE, + parseUpgradeJson, + 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; @@ -27,6 +33,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; @@ -67,12 +76,13 @@ 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: Error, externalSignal?: AbortSignal): boolean { - return Boolean(externalSignal?.aborted && error.name === "AbortError"); +function rethrowExternalAbort( + _error: unknown, + externalSignal?: AbortSignal +): void { + if (externalSignal?.aborted) { + throw externalSignal?.reason; + } } type RetryOptions = { @@ -112,11 +122,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)) { - break; + 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; @@ -124,14 +134,13 @@ async function fetchWithRetry( } } - throw new UpgradeError( - "network_error", + throw new UpgradeTransportError( `${context}: ${lastError?.message ?? "unknown error"}` ); } -/** 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"; @@ -142,6 +151,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. * @@ -179,6 +203,48 @@ 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 && + !Array.isArray(value) && + 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. @@ -189,13 +255,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) { @@ -205,8 +277,19 @@ export async function getAnonymousToken(signal?: AbortSignal): Promise { ); } - 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 || + data.token.trim() !== data.token + ) { throw new UpgradeError( "network_error", "GHCR token exchange returned no token" @@ -227,9 +310,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, { @@ -244,13 +328,21 @@ export async function fetchManifest( ); if (!response.ok) { + throw new GhcrManifestHttpError(tag, response.status); + } + + const data = await parseUpgradeJson( + response, + signal, + `Manifest for tag "${tag}" returned invalid metadata` + ); + if (!isOciManifest(data)) { throw new UpgradeError( "network_error", - `Failed to fetch manifest for tag "${tag}": HTTP ${response.status}` + `Manifest for tag "${tag}" returned invalid metadata` ); } - - return (await response.json()) as OciManifest; + return data; } /** @@ -263,9 +355,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); } /** @@ -285,6 +379,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; } @@ -332,9 +432,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. @@ -349,6 +450,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", @@ -390,6 +492,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", @@ -427,9 +530,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)}`; } @@ -453,8 +557,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; } /** @@ -471,13 +599,15 @@ async function fetchTagPage( export async function listTags( token: string, prefix?: string, - signal?: AbortSignal + signal?: AbortSignal, + source: UpgradeSource = PRIMARY_UPGRADE_SOURCE ): Promise { const allTags: string[] = []; + const visitedCursors = new Set(); 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; } @@ -492,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; @@ -513,8 +651,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 ddf617535..903ca51f6 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -15,11 +15,18 @@ 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"; +import { + type GitHubRelease, + isNormalizedForSource, + type NormalizedGitHubReleases, + normalizeStableReleases, +} from "./delta-upgrade.js"; import { logger } from "./logger.js"; const log = logger.withTag("release-notes"); @@ -413,27 +420,34 @@ 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; +}; + +function normalizeChangelogReleases( + releases: GitHubRelease[], + source: UpgradeSource +): GitHubRelease[] { + if (isNormalizedForSource(releases, source)) { + return releases; + } + return []; +} + +/** 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 } = options; + const inRange = releases.filter((release) => { + const version = release.tag_name.replace(VERSION_PREFIX_RE, ""); return ( compareVersions(version, fromVersion) === 1 && compareVersions(version, toVersion) <= 0 @@ -453,6 +467,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 +591,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) { @@ -582,7 +618,7 @@ async function fetchReleasesForChangelog(): Promise { log.debug("GitHub releases response is not an array", typeof data); return []; } - return data as GitHubRelease[]; + return normalizeStableReleases(data as GitHubRelease[], source); } /** @@ -593,23 +629,24 @@ 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 + ? normalizeChangelogReleases(prefetchedReleases, source) + : await fetchReleasesForChangelog(source); if (releases.length === 0) { return null; } - return buildChangelogSummary(releases, fromVersion, toVersion, maxItems); + return buildChangelogSummaryForSource(releases, fromVersion, toVersion, { + maxItems, + source, + }); } /** @@ -636,12 +673,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 +698,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 { @@ -704,7 +743,9 @@ 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; }; /** @@ -721,17 +762,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 945adc48d..b29d67ff2 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -21,35 +21,47 @@ 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 { prerelease as semverPrerelease, valid as semverValid } from "semver"; import { acquireLock, cleanupOldBinary, + compareVersions, determineInstallDir, fetchWithUpgradeError, - GITHUB_RELEASES_URL, getBinaryDownloadUrl, getBinaryFilename, getBinaryPaths, getGitHubHeaders, + getGitHubLatestReleaseUrl, + getGitHubReleaseByTagUrl, + getGitHubRepositoryUrl, getPlatformBinaryName, type InstallationMethod, isNightlyVersion, KNOWN_CURL_DIRS, + PRIMARY_UPGRADE_SOURCE, + parseUpgradeJson, releaseLock, + 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"; 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, fetchManifest, fetchNightlyManifest, findLayerByFilename, + GhcrManifestHttpError, getAnonymousToken, getNightlyVersion, + type OciManifest, } from "./ghcr.js"; import { logger } from "./logger.js"; import { clearPatchCache } from "./patch-cache.js"; @@ -87,6 +99,92 @@ 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"/; + +/** 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. */ + readonly version: string; + /** Source selected for every later lookup and download in this operation. */ + readonly source: UpgradeSource; +}; + +function extractReleaseVersions( + 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( + (tag): tag is string => + typeof tag === "string" && tag.startsWith(source.tagPrefix) + ) + .map((tag) => tag.slice(source.tagPrefix.length)) + .map((tag) => + source.tagPrefix ? tag : tag.replace(VERSION_PREFIX_REGEX, "") + ) + .filter((tag) => semverValid(tag) === tag && semverPrerelease(tag) === null) + .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; + } + 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" || + !(isSelectedSourcePath || isCanonicalRepositoryPath) || + page === null || + !PAGE_NUMBER_REGEX.test(page) + ) { + throw new UpgradeError( + "network_error", + "GitHub returned an invalid release pagination URL" + ); + } + const nextPage = new URL(getGitHubLatestReleaseUrl(source)); + nextPage.searchParams.set("page", page); + return nextPage.href; +} + // Curl Binary Helpers /** @@ -396,32 +494,68 @@ 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}` +export async function fetchLatestFromGitHubWithSource( + signal?: AbortSignal, + sources: readonly UpgradeSource[] = UPGRADE_SOURCES +): Promise { + const resolved = await resolveUpgradeSource({ + getProbeUrl: getGitHubLatestReleaseUrl, + signal, + sources, + }); + let response = resolved.response; + const visitedPages = new Set([getGitHubLatestReleaseUrl(resolved.source)]); + const versions: string[] = []; + while (true) { + const data = await parseUpgradeJson( + response, + signal, + "GitHub returned invalid release metadata" ); - } - - const data = (await response.json()) as { tag_name?: string }; - - if (!data.tag_name) { - throw new UpgradeError( - "network_error", - "No version found in GitHub release" + versions.push(...extractReleaseVersions(data, resolved.source)); + const nextPage = getNextGitHubReleasePage(response, resolved.source); + if (!nextPage) { + 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( + "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 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; } /** @@ -444,13 +578,41 @@ 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"); + const data = await parseUpgradeJson( + response, + undefined, + "npm registry returned invalid metadata" + ); + 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 data.version; + return validateStableVersion(data.version, "npm registry"); +} + +function validateStableVersion( + version: string | undefined, + source: string +): string { + if (!version) { + throw new UpgradeError("network_error", `No version found in ${source}`); + } + if (semverValid(version) !== version || semverPrerelease(version) !== null) { + throw new UpgradeError( + "network_error", + `${source} returned an invalid stable version` + ); + } + return version; } /** @@ -464,23 +626,63 @@ 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(); + throw signal.reason; } + 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 (error instanceof UpgradeSourceNotFoundError) { + 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 GhcrManifestHttpError && error.status === 404) { + continue; + } + throw error; + } } + throw new UpgradeSourceNotFoundError(); +} - const manifest = await fetchNightlyManifest(token); - return getNightlyVersion(manifest); +/** 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,35 +709,153 @@ 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); +} + +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}` + ); + } +} + +async function validatePinnedGitHubRelease( + response: Response, + version: string, + source: UpgradeSource +): Promise { + const release = await parseUpgradeJson( + response, + undefined, + `GitHub returned invalid metadata for version ${version}` + ); + const expectedTag = `${source.tagPrefix}${version}`; + if ( + typeof release !== "object" || + release === null || + !("tag_name" in release) || + release.tag_name !== expectedTag || + ("draft" in release && release.draft === true) || + ("prerelease" in release && release.prerelease === true) + ) { + 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 +): Promise { + try { + if (isNightlyVersion(version)) { + const resolved = await resolveNightlyManifest( + `nightly-${version}`, + undefined, + UPGRADE_SOURCES + ); + validateNightlyManifestVersion(resolved.manifest, version); + return { version, source: resolved.source }; + } + validateStableVersion(version, "Requested standalone version"); + const selected = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl(version, source), + }); + await validatePinnedGitHubRelease( + selected.response, + version, + selected.source + ); + return { version, source: selected.source }; + } catch (error) { + if (error instanceof UpgradeSourceNotFoundError) { + return null; + } + throw error; + } +} + /** * Check if a versioned nightly tag exists in GHCR. * * 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 * @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}`); + const manifest = await fetchManifest( + token, + `nightly-${version}`, + undefined, + source + ); + validateNightlyManifestVersion(manifest, version); 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 GhcrManifestHttpError && error.status === 404) { return false; } throw error; } } +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); + } + const response = await fetchWithUpgradeError( + getGitHubReleaseByTagUrl(version, source), + { headers: getGitHubHeaders() }, + "GitHub" + ); + if (response.ok) { + await validatePinnedGitHubRelease(response, version, source); + 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; +} + /** * Check if a specific version exists in the appropriate registry. * @@ -550,28 +870,30 @@ 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 (isNightlyVersion(version) || method === "curl" || method === "brew") { + return standaloneVersionExists(version, source); } - if (method === "curl" || method === "brew") { - const response = await fetchWithUpgradeError( - `${GITHUB_RELEASES_URL}/tags/${version}`, - { method: "HEAD", headers: getGitHubHeaders() }, - "GitHub" - ); - return response.ok; - } + validateStableVersion(version, "Requested package version"); const response = await fetchWithUpgradeError( `${NPM_REGISTRY_URL}/${version}`, { 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 @@ -728,15 +1050,24 @@ 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); + if (version) { + validateNightlyManifestVersion(manifest, version); + } 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 +1092,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 +1231,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 +1258,8 @@ export async function downloadBinaryToTemp( version, tempPath, !!offline, - setMessage + setMessage, + source ); let patchBytes: number | undefined; if (deltaResult) { @@ -940,7 +1275,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 +1323,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 +1348,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 +1475,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 c69ff0466..34393b1c7 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.test.ts b/packages/cli/test/commands/cli.test.ts index cd1514eb0..4eec11e9d 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@1.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch; @@ -135,18 +135,18 @@ 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"); }); 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(); @@ -187,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: "v0.0.0-dev" }), { + new Response(JSON.stringify([{ tag_name: "cli@1.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch; @@ -202,25 +206,14 @@ 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 () => { - // First call: fetch latest (returns 99.0.0) - // Second call: check if version exists (returns 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" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - // Version exists check - return 404 return new Response("Not Found", { status: 404 }); }) as typeof fetch; @@ -232,5 +225,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); }); }); diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index ca9688e46..b7fead58f 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 */ @@ -172,6 +173,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 +214,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 +254,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, @@ -258,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 }); }); @@ -290,8 +303,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, @@ -306,8 +319,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 () => { @@ -330,7 +343,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, @@ -349,22 +362,145 @@ 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/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" + ); + expect( + 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("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 })], + [ + "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("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"); }); }); @@ -403,6 +539,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 +582,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" }, }); @@ -433,23 +605,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."); }); }); @@ -522,7 +694,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, @@ -540,7 +712,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({ @@ -560,7 +732,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, @@ -580,7 +752,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; @@ -614,8 +786,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, @@ -630,8 +802,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 () => { @@ -746,8 +918,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" }, }); @@ -847,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"); @@ -872,8 +1120,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" }, }); @@ -898,11 +1146,15 @@ 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) => { 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, @@ -918,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, }, @@ -936,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); } @@ -947,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); @@ -957,10 +1214,23 @@ 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 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, @@ -973,9 +1243,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"); }); }); @@ -1030,13 +1300,16 @@ 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); // 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, @@ -1052,10 +1325,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, }, @@ -1070,7 +1346,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")) { @@ -1079,15 +1355,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( @@ -1100,6 +1377,111 @@ 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({ + schemaVersion: 2, + layers: [], + 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) => { + 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 }); + }); + 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).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) => 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 6fcfb3b1a..29ead3828 100644 --- a/packages/cli/test/lib/binary.test.ts +++ b/packages/cli/test/lib/binary.test.ts @@ -25,14 +25,19 @@ import { getBinaryDownloadUrl, getBinaryFilename, getBinaryPaths, + getGitHubReleaseByTagUrl, getLegacyInstallDirs, getPlatformBinaryName, installBinary, isDowngrade, isMusl, + parseUpgradeJson, releaseLock, replaceBinarySync, + resolveUpgradeSource, samePath, + UPGRADE_SOURCES, + UpgradeSourceNotFoundError, } from "../../src/lib/binary.js"; import { UpgradeError } from "../../src/lib/errors.js"; @@ -40,9 +45,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 +66,128 @@ 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[] = []; + + 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", + "https://api.github.com/repos/getsentry/cli/releases/tags/0.45.0", + ]); + }); +}); + describe("getBinaryFilename", () => { test("returns sentry on non-Windows", () => { if (process.platform !== "win32") { @@ -328,6 +455,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"); @@ -359,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/delta-upgrade.mocked.test.ts b/packages/cli/test/lib/delta-upgrade.mocked.test.ts index cd0a2fddc..f6186ab59 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/delta-upgrade.test.ts b/packages/cli/test/lib/delta-upgrade.test.ts index 3752d4ae6..68d1cfbdf 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) @@ -819,13 +828,14 @@ 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) => { 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 +846,39 @@ 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) => { + 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 })); @@ -921,13 +964,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")}`, @@ -939,7 +983,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]])); @@ -954,6 +998,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]); @@ -962,7 +1022,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")}`, @@ -974,7 +1034,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")}`, @@ -986,7 +1046,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( @@ -1011,7 +1071,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()); @@ -1029,7 +1089,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")}`, @@ -1041,7 +1101,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 @@ -1057,7 +1117,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`, @@ -1181,6 +1241,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 +1924,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 e5c81d532..a55612b5a 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, @@ -15,6 +16,7 @@ import { findLayerByFilename, GHCR_REPO, GHCR_TAG, + GhcrManifestHttpError, getAnonymousToken, getNightlyVersion, listTags, @@ -37,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: { @@ -51,7 +53,7 @@ function makeManifest(overrides: Partial = {}): OciManifest { }, }, { - digest: "sha256:def456", + digest: `sha256:${"d".repeat(64)}`, mediaType: "application/octet-stream", size: 1200, annotations: { @@ -91,6 +93,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 })); @@ -111,6 +126,42 @@ 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("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 () => @@ -125,6 +176,39 @@ 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.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" }; + 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", () => { @@ -153,6 +237,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 })); @@ -192,19 +288,31 @@ 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", () => { 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", () => { @@ -362,11 +470,102 @@ 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 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 new TypeError("invalid_argument"); + }); + + 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 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(reason); + throw new TypeError("invalid_argument"); + }); + + await expect( + downloadNightlyBlob("token", "sha256:abc", controller.signal) + ).rejects.toBe(reason); + expect(headers).toHaveLength(2); + expect(headers[1]?.has("authorization")).toBe(false); + }); }); // fetchManifest (generic tag variant) describe("fetchManifest", () => { + test.each([ + null, + [], + {}, + { 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)); + + 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(); @@ -387,12 +586,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 () => { @@ -412,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/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts index 78963b471..ffccf4901 100644 --- a/packages/cli/test/lib/release-notes.test.ts +++ b/packages/cli/test/lib/release-notes.test.ts @@ -9,16 +9,22 @@ */ import { marked } from "marked"; -import { describe, expect, test } from "vitest"; -import type { GitHubRelease } from "../../src/lib/delta-upgrade.js"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; +import { + fetchRecentReleases, + type GitHubRelease, +} from "../../src/lib/delta-upgrade.js"; import { buildChangelogSummary, type ChangeCategory, countListItems, extractNightlyTimestamp, extractSections, + fetchChangelog, parseCommitMessages, } from "../../src/lib/release-notes.js"; +import { mockFetch } from "../helpers.js"; // ─────────────────────────── Fixtures ────────────────────────────────────── @@ -293,3 +299,214 @@ 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( + "0.21.0", + "### Bug Fixes 🐛\n\n- Unprefixed Toolkit 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(changelog?.sections[0]?.markdown).not.toContain( + "Unprefixed Toolkit 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("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("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)); + 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" + ), + ] as never, + }); + + expect(changelog).toBeNull(); + 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"), + ] as never, + }); + + 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"), + ] as never, + }); + + expect(changelog).toBeNull(); + }); + + 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 7c6f63a4b..d10f21dd6 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, @@ -140,6 +141,7 @@ const { fetchLatestVersion, getCurlInstallPaths, parseInstallationMethod, + resolveExistingUpgradeVersion, startCleanupOldBinary, versionExists, } = await import("../../src/lib/upgrade.js"); @@ -188,37 +190,234 @@ describe("parseInstallationMethod", () => { }); describe("fetchLatestFromGitHub", () => { - test("returns version from GitHub API", async () => { - mockFetch( - async () => - new Response( - JSON.stringify({ - tag_name: "v1.2.3", - }), + 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@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" }, + ]), + { status: 200 } + ); + }); + + await expect(fetchLatestFromGitHub()).resolves.toBe("1.3.0"); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100", + ]); + }); + + 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: { "Content-Type": "application/json" }, + 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("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 () => { + 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) => { + 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); + }); - const version = await fetchLatestFromGitHub(); - expect(version).toBe("1.2.3"); + 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) => { + 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 cyclic release pagination" + ); + expect(requests).toHaveLength(2); + }); + + 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("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) => { + 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("strips v prefix from version", async () => { + test("rejects a v-prefixed Toolkit product 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@v1.2.3" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(fetchLatestFromGitHub()).rejects.toThrow( + "No version found in GitHub release" ); + }); + + 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"); @@ -227,15 +426,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 +446,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 +464,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" }, }) @@ -336,9 +530,45 @@ 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", + ])("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" + ); + }); + + 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 @@ -398,7 +628,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@2.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -463,7 +693,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@2.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -490,12 +720,17 @@ 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 }); } if (urlStr.includes("/manifests/nightly")) { return new Response( JSON.stringify({ + schemaVersion: 2, + layers: [], annotations: { version: "0.0.0-dev.1740393600" }, }), { status: 200 } @@ -512,12 +747,17 @@ 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 }); } if (urlStr.includes("/manifests/nightly")) { return new Response( JSON.stringify({ + schemaVersion: 2, + layers: [], annotations: { version: "0.0.0-dev.1740393600" }, }), { status: 200 } @@ -533,7 +773,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@3.0.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -545,9 +785,215 @@ describe("fetchLatestVersion", () => { }); describe("versionExists", () => { - test("checks GitHub for curl method - version exists", async () => { + 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", + "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) => { + 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 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.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", + "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) => { + 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("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) => { + 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(JSON.stringify({ tag_name: "cli@1.0.0" }), { status: 200 }) + ); + const exists = await versionExists("curl", "1.0.0"); expect(exists).toBe(true); }); @@ -588,7 +1034,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); @@ -631,9 +1080,16 @@ 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") { + return new Response(null, { status: 200 }); + } if (u.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } @@ -650,6 +1106,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 }); } @@ -664,9 +1123,16 @@ 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") { + return new Response(null, { status: 200 }); + } if (u.includes("ghcr.io/token")) { return new Response(JSON.stringify({ token: "tok" }), { status: 200 }); } @@ -680,6 +1146,32 @@ 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({ + schemaVersion: 2, + layers: [], + 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"); @@ -689,6 +1181,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); @@ -702,6 +1207,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", () => { @@ -974,10 +1497,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 +2035,116 @@ 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) => { + 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("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; 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 +2175,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 +2190,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 }); } @@ -1646,7 +2279,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 }, @@ -1673,6 +2306,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", () => { diff --git a/packages/cli/test/lib/version-check.test.ts b/packages/cli/test/lib/version-check.test.ts index 20172cd83..dd047a5cb 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", () => {