mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 06:25:16 -07:00
fix(cli): prevent automatic updates from switching to less stable channels (#26132)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, number> = {
|
||||
[ReleaseChannel.NIGHTLY]: 0,
|
||||
[ReleaseChannel.PREVIEW]: 1,
|
||||
[ReleaseChannel.STABLE]: 2,
|
||||
};
|
||||
|
||||
const cache = new Map<string, ReleaseChannel>();
|
||||
|
||||
/**
|
||||
@@ -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<ReleaseChannel> {
|
||||
if (cache.has(cwd)) {
|
||||
return cache.get(cwd)!;
|
||||
@@ -30,14 +52,7 @@ export async function getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user