Skip to content

Commit e4b461c

Browse files
Merge pull request #7422 from Shopify/add-autoupgrade-notification-switch
Add notification-driven kill switch for auto-upgrade
2 parents abf8800 + c8123b6 commit e4b461c

4 files changed

Lines changed: 140 additions & 3 deletions

File tree

packages/cli-kit/src/public/node/hooks/postrun.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import {autoUpgradeIfNeeded} from './postrun.js'
22
import {mockAndCaptureOutput} from '../testing/output.js'
3-
import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from '../upgrade.js'
3+
import {
4+
getOutputUpdateCLIReminder,
5+
hasBlockingAutoUpgradeNotification,
6+
runCLIUpgrade,
7+
versionToAutoUpgrade,
8+
} from '../upgrade.js'
49
import {isMajorVersionChange} from '../version.js'
510
import {inferPackageManagerForGlobalCLI} from '../is-global.js'
611
import {addPublicMetadata} from '../metadata.js'
@@ -13,6 +18,7 @@ vi.mock('../upgrade.js', async (importOriginal) => {
1318
runCLIUpgrade: vi.fn(),
1419
getOutputUpdateCLIReminder: vi.fn(),
1520
versionToAutoUpgrade: vi.fn(),
21+
hasBlockingAutoUpgradeNotification: vi.fn().mockResolvedValue(false),
1622
}
1723
})
1824

@@ -55,6 +61,7 @@ vi.mock('../../../private/node/conf-store.js', async (importOriginal) => {
5561
afterEach(() => {
5662
mockAndCaptureOutput().clear()
5763
vi.mocked(addPublicMetadata).mockClear()
64+
vi.mocked(hasBlockingAutoUpgradeNotification).mockResolvedValue(false)
5865
})
5966

6067
describe('autoUpgradeIfNeeded', () => {
@@ -126,6 +133,8 @@ describe('autoUpgradeIfNeeded', () => {
126133
env_auto_upgrade_skipped_reason: 'major_version',
127134
}),
128135
)
136+
// Notifications should not be queried on the major-version skip path.
137+
expect(hasBlockingAutoUpgradeNotification).not.toHaveBeenCalled()
129138
})
130139

131140
test('records success=true on successful upgrade', async () => {
@@ -140,6 +149,29 @@ describe('autoUpgradeIfNeeded', () => {
140149
expect(calls).toContainEqual(expect.objectContaining({env_auto_upgrade_success: true}))
141150
})
142151

152+
test('silently skips the upgrade when a blocking autoupgrade notification is active', async () => {
153+
const outputMock = mockAndCaptureOutput()
154+
vi.mocked(versionToAutoUpgrade).mockReturnValue('3.100.0')
155+
vi.mocked(isMajorVersionChange).mockReturnValue(false)
156+
vi.mocked(hasBlockingAutoUpgradeNotification).mockResolvedValue(true)
157+
158+
await autoUpgradeIfNeeded()
159+
160+
expect(runCLIUpgrade).not.toHaveBeenCalled()
161+
expect(outputMock.warn()).toBe('')
162+
expect(outputMock.info()).toBe('')
163+
const calls = vi.mocked(addPublicMetadata).mock.calls.map((call) => call[0]())
164+
expect(calls).toContainEqual(expect.objectContaining({env_auto_upgrade_skipped_reason: 'blocked_by_notification'}))
165+
})
166+
167+
test('does not query notifications when there is no version to upgrade to', async () => {
168+
vi.mocked(versionToAutoUpgrade).mockReturnValue(undefined)
169+
170+
await autoUpgradeIfNeeded()
171+
172+
expect(hasBlockingAutoUpgradeNotification).not.toHaveBeenCalled()
173+
})
174+
143175
test('records success=false on failed upgrade', async () => {
144176
vi.mocked(versionToAutoUpgrade).mockReturnValue('3.100.0')
145177
vi.mocked(isMajorVersionChange).mockReturnValue(false)

packages/cli-kit/src/public/node/hooks/postrun.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ async function performAutoUpgrade(newerVersion: string): Promise<void> {
6666
{CLI_KIT_VERSION},
6767
{isMajorVersionChange},
6868
{outputWarn, outputDebug},
69-
{getOutputUpdateCLIReminder, runCLIUpgrade},
69+
{getOutputUpdateCLIReminder, runCLIUpgrade, hasBlockingAutoUpgradeNotification},
7070
metadata,
7171
] = await Promise.all([
7272
import('../../common/version.js'),
@@ -84,6 +84,17 @@ async function performAutoUpgrade(newerVersion: string): Promise<void> {
8484
return
8585
}
8686

87+
// Notification kill switch: an `error`-type notification on the `autoupgrade` surface
88+
// silently disables auto-upgrade. Checked last — after every other gate, including the
89+
// daily rate limit and the major-version check — so the network fetch only happens when
90+
// we're about to actually run the upgrade.
91+
if (await hasBlockingAutoUpgradeNotification()) {
92+
await metadata.addPublicMetadata(() => ({
93+
env_auto_upgrade_skipped_reason: 'blocked_by_notification',
94+
}))
95+
return
96+
}
97+
8798
try {
8899
await runCLIUpgrade()
89100
await metadata.addPublicMetadata(() => ({env_auto_upgrade_success: true}))

packages/cli-kit/src/public/node/upgrade.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,25 @@ import {isDevelopment} from './context/local.js'
22
import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI} from './is-global.js'
33
import {checkForCachedNewVersion, packageManagerFromUserAgent, PackageManager} from './node-package-manager.js'
44
import {exec, isCI} from './system.js'
5-
import {cliInstallCommand, getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from './upgrade.js'
5+
import {
6+
cliInstallCommand,
7+
getOutputUpdateCLIReminder,
8+
hasBlockingAutoUpgradeNotification,
9+
runCLIUpgrade,
10+
versionToAutoUpgrade,
11+
} from './upgrade.js'
12+
import {Notification, fetchNotifications} from './notifications-system.js'
613
import {isPreReleaseVersion} from './version.js'
714
import {getAutoUpgradeEnabled} from '../../private/node/conf-store.js'
815
import {vi, describe, test, expect, beforeEach} from 'vitest'
916

17+
vi.mock('./notifications-system.js', async (importOriginal) => {
18+
const actual: any = await importOriginal()
19+
return {
20+
...actual,
21+
fetchNotifications: vi.fn(),
22+
}
23+
})
1024
vi.mock('./context/local.js')
1125
vi.mock('./is-global.js')
1226
vi.mock('./node-package-manager.js')
@@ -253,3 +267,56 @@ describe('versionToAutoUpgrade', () => {
253267
vi.mocked(isPreReleaseVersion).mockReturnValue(false)
254268
})
255269
})
270+
271+
describe('hasBlockingAutoUpgradeNotification', () => {
272+
function notification(overrides: Partial<Notification> = {}): Notification {
273+
return {
274+
id: 'block-autoupgrade',
275+
message: 'Auto-upgrade temporarily disabled',
276+
type: 'error',
277+
frequency: 'always',
278+
ownerChannel: '#cli',
279+
surface: 'autoupgrade',
280+
...overrides,
281+
}
282+
}
283+
284+
test('returns true when an error notification on the autoupgrade surface is active', async () => {
285+
vi.mocked(fetchNotifications).mockResolvedValue({notifications: [notification()]})
286+
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(true)
287+
})
288+
289+
test('returns false for non-error notifications on the autoupgrade surface', async () => {
290+
vi.mocked(fetchNotifications).mockResolvedValue({
291+
notifications: [notification({type: 'warning'}), notification({type: 'info'})],
292+
})
293+
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(false)
294+
})
295+
296+
test('returns false for error notifications on other surfaces', async () => {
297+
vi.mocked(fetchNotifications).mockResolvedValue({notifications: [notification({surface: 'app'})]})
298+
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(false)
299+
})
300+
301+
test('returns false when the current CLI version is below minVersion', async () => {
302+
vi.mocked(fetchNotifications).mockResolvedValue({notifications: [notification({minVersion: '999.0.0'})]})
303+
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(false)
304+
})
305+
306+
test('returns false when the current CLI version is above maxVersion', async () => {
307+
vi.mocked(fetchNotifications).mockResolvedValue({notifications: [notification({maxVersion: '0.0.1'})]})
308+
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(false)
309+
})
310+
311+
test('returns false when the notification window is in the past', async () => {
312+
vi.mocked(fetchNotifications).mockResolvedValue({
313+
notifications: [notification({minDate: '2000-01-01', maxDate: '2000-01-02'})],
314+
})
315+
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(false)
316+
})
317+
318+
test('fails open and returns false when fetching notifications throws', async () => {
319+
vi.mocked(fetchNotifications).mockRejectedValue(new Error('network down'))
320+
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(false)
321+
})
322+
})

packages/cli-kit/src/public/node/upgrade.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {fetchNotifications, filterNotifications} from './notifications-system.js'
12
import {isDevelopment} from './context/local.js'
23
import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, getProjectDir} from './is-global.js'
34
import {
@@ -112,6 +113,32 @@ export function versionToAutoUpgrade(): string | undefined {
112113
return newerVersion
113114
}
114115

116+
/**
117+
* Checks the freshly fetched notifications feed for a kill-switch notification that
118+
* disables auto-upgrade. A blocking notification is one with `surface: "autoupgrade"`,
119+
* `type: "error"`, and matching version/date ranges for the current CLI.
120+
*
121+
* Fails open: any error fetching or parsing the feed results in `false`, so a broken
122+
* notifications endpoint never prevents users from auto-upgrading. Intentionally silent
123+
* (no logs) — this is invoked on the auto-upgrade hot path.
124+
*
125+
* @returns `true` when an active blocking notification is found, `false` otherwise.
126+
*/
127+
export async function hasBlockingAutoUpgradeNotification(): Promise<boolean> {
128+
try {
129+
const {notifications} = await fetchNotifications()
130+
// Reuse the standard notifications filtering for version/date/surface. The empty
131+
// commandId disables the command filter; ['autoupgrade'] scopes to our surface.
132+
// The frequency filter is a no-op here because we never render, so nothing gets
133+
// written to the lastShown cache for these notifications.
134+
const matching = filterNotifications(notifications, '', ['autoupgrade'])
135+
return matching.some((notification) => notification.type === 'error')
136+
// eslint-disable-next-line no-catch-all/no-catch-all
137+
} catch {
138+
return false
139+
}
140+
}
141+
115142
/**
116143
* Shows a daily upgrade-available warning for users who have not enabled auto-upgrade.
117144
* Skipped in CI and for pre-release versions. When auto-upgrade is enabled this is a no-op

0 commit comments

Comments
 (0)