From f8603e990b3b1db51216b70a54223ad65cb6a9e4 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:03:08 -0400 Subject: [PATCH] fix(cli): prevent automatic updates from switching to less stable channels (#26132) --- packages/cli/src/ui/utils/updateCheck.test.ts | 83 +++++++++++++++++++ packages/cli/src/ui/utils/updateCheck.ts | 23 ++++- .../cli/src/utils/handleAutoUpdate.test.ts | 23 ++++- packages/cli/src/utils/handleAutoUpdate.ts | 25 +++++- packages/core/src/utils/channel.ts | 31 +++++-- 5 files changed, 173 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index 17b6c62121..76c0014314 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -15,6 +15,25 @@ const debugLogger = vi.hoisted(() => ({ vi.mock('@google/gemini-cli-core', () => ({ getPackageJson, debugLogger, + ReleaseChannel: { + NIGHTLY: 'nightly', + PREVIEW: 'preview', + STABLE: 'stable', + }, + getChannelFromVersion: (version: string) => { + if (!version || version.includes('nightly')) { + return 'nightly'; + } + if (version.includes('preview')) { + return 'preview'; + } + return 'stable'; + }, + RELEASE_CHANNEL_STABILITY: { + nightly: 0, + preview: 1, + stable: 2, + }, })); const latestVersion = vi.hoisted(() => vi.fn()); @@ -152,4 +171,68 @@ describe('checkForUpdates', () => { expect(result?.update.latest).toBe('1.2.3-nightly.2'); }); }); + + describe('channel stability', () => { + it('should NOT offer nightly update to a stable user even if tagged as latest', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + // latest points to a nightly that is semver-greater + latestVersion.mockResolvedValue('1.1.0-nightly.1'); + + const result = await checkForUpdates(mockSettings); + expect(result).toBeNull(); + }); + + it('should NOT offer preview update to a stable user even if tagged as latest', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + // latest points to a preview that is semver-greater + latestVersion.mockResolvedValue('1.1.0-preview.1'); + + const result = await checkForUpdates(mockSettings); + expect(result).toBeNull(); + }); + + it('should offer stable update to a stable user', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + latestVersion.mockResolvedValue('1.1.0'); + + const result = await checkForUpdates(mockSettings); + expect(result?.update.latest).toBe('1.1.0'); + }); + + it('should offer stable update to a nightly user', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0-nightly.1', + }); + latestVersion.mockImplementation(async (name, options) => { + if (options?.version === 'nightly') { + return '1.0.0-nightly.1'; // No nightly update + } + return '1.1.0'; // Stable update available + }); + + const result = await checkForUpdates(mockSettings); + expect(result?.update.latest).toBe('1.1.0'); + }); + + it('should offer stable update to a preview user', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0-preview.1', + }); + latestVersion.mockResolvedValue('1.1.0'); + + const result = await checkForUpdates(mockSettings); + expect(result?.update.latest).toBe('1.1.0'); + }); + }); }); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 9f80beee08..bac07be07e 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -6,7 +6,12 @@ import latestVersion from 'latest-version'; import semver from 'semver'; -import { getPackageJson, debugLogger } from '@google/gemini-cli-core'; +import { + getPackageJson, + debugLogger, + getChannelFromVersion, + RELEASE_CHANNEL_STABILITY, +} from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -65,6 +70,7 @@ export async function checkForUpdates( } const { name, version: currentVersion } = packageJson; + const currentChannel = getChannelFromVersion(currentVersion); const isNightly = currentVersion.includes('nightly'); if (isNightly) { @@ -90,8 +96,21 @@ export async function checkForUpdates( } } else { const latestUpdate = await latestVersion(name); + if (!latestUpdate) { + return null; + } - if (latestUpdate && semver.gt(latestUpdate, currentVersion)) { + const targetChannel = getChannelFromVersion(latestUpdate); + + // Only offer updates that are as stable or more stable than the current version + if ( + RELEASE_CHANNEL_STABILITY[targetChannel] < + RELEASE_CHANNEL_STABILITY[currentChannel] + ) { + return null; + } + + if (semver.gt(latestUpdate, currentVersion)) { const message = `Gemini CLI update available! ${currentVersion} → ${latestUpdate}`; const type = semver.diff(latestUpdate, currentVersion) || undefined; return { diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 198e19c5b0..ef18864d81 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -334,7 +334,8 @@ describe('handleAutoUpdate', () => { ...mockUpdateInfo, update: { ...mockUpdateInfo.update, - latest: '2.0.0-nightly', + current: '1.0.0-nightly.0', + latest: '2.0.0-nightly.1', }, }; mockGetInstallationInfo.mockReturnValue({ @@ -356,6 +357,26 @@ describe('handleAutoUpdate', () => { ); }); + it('should NOT update if target is less stable than current (defense-in-depth)', async () => { + mockUpdateInfo = { + ...mockUpdateInfo, + update: { + ...mockUpdateInfo.update, + current: '1.0.0', + latest: '1.1.0-nightly.1', + }, + }; + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + it('should emit "update-success" when the update process succeeds', async () => { await new Promise((resolve) => { mockGetInstallationInfo.mockReturnValue({ diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index cdd3f6ed18..cd39cd926d 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -11,7 +11,11 @@ import { updateEventEmitter } from './updateEventEmitter.js'; import { MessageType, type HistoryItem } from '../ui/types.js'; import { spawnWrapper } from './spawnWrapper.js'; import type { spawn } from 'node:child_process'; -import { debugLogger } from '@google/gemini-cli-core'; +import { + debugLogger, + getChannelFromVersion, + RELEASE_CHANNEL_STABILITY, +} from '@google/gemini-cli-core'; let _updateInProgress = false; @@ -122,6 +126,25 @@ export function handleAutoUpdate( return; } + const currentVersion = info.update.current; + if (!currentVersion) { + debugLogger.warn( + 'Update check: current version is missing. Skipping automatic update for safety.', + ); + return; + } + + const currentChannel = getChannelFromVersion(currentVersion); + const targetChannel = getChannelFromVersion(info.update.latest); + + // Defense-in-depth: prevent updates to a less stable channel + if ( + RELEASE_CHANNEL_STABILITY[targetChannel] < + RELEASE_CHANNEL_STABILITY[currentChannel] + ) { + return; + } + const isNightly = info.update.latest.includes('nightly'); const updateCommand = installationInfo.updateCommand.replace( diff --git a/packages/core/src/utils/channel.ts b/packages/core/src/utils/channel.ts index 09b654b761..222ccd0635 100644 --- a/packages/core/src/utils/channel.ts +++ b/packages/core/src/utils/channel.ts @@ -12,6 +12,15 @@ export enum ReleaseChannel { STABLE = 'stable', } +/** + * Stability ranking for release channels. Higher number means more stable. + */ +export const RELEASE_CHANNEL_STABILITY: Record = { + [ReleaseChannel.NIGHTLY]: 0, + [ReleaseChannel.PREVIEW]: 1, + [ReleaseChannel.STABLE]: 2, +}; + const cache = new Map(); /** @@ -22,6 +31,19 @@ export function _clearCache() { cache.clear(); } +/** + * Determines the release channel for a given version string. + */ +export function getChannelFromVersion(version: string): ReleaseChannel { + if (!version || version.includes('nightly')) { + return ReleaseChannel.NIGHTLY; + } + if (version.includes('preview')) { + return ReleaseChannel.PREVIEW; + } + return ReleaseChannel.STABLE; +} + export async function getReleaseChannel(cwd: string): Promise { if (cache.has(cwd)) { return cache.get(cwd)!; @@ -30,14 +52,7 @@ export async function getReleaseChannel(cwd: string): Promise { const packageJson = await getPackageJson(cwd); const version = packageJson?.version ?? ''; - let channel: ReleaseChannel; - if (version.includes('nightly') || version === '') { - channel = ReleaseChannel.NIGHTLY; - } else if (version.includes('preview')) { - channel = ReleaseChannel.PREVIEW; - } else { - channel = ReleaseChannel.STABLE; - } + const channel = getChannelFromVersion(version); cache.set(cwd, channel); return channel; }