fix(cli): prevent automatic updates from switching to less stable channels (#26132)

This commit is contained in:
Adib234
2026-04-28 14:03:08 -04:00
committed by GitHub
parent 59b2dea0e5
commit f8603e990b
5 changed files with 173 additions and 12 deletions
@@ -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');
});
});
});
+21 -2
View File
@@ -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({
+24 -1
View File
@@ -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(