Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auto-upgrade-major-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': patch
'@shopify/cli': patch
---

Show a daily new-major upgrade notice while automatic updates continue within the current major.
35 changes: 35 additions & 0 deletions packages/cli-kit/src/public/node/hooks/postrun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import {
hasBlockingAutoUpgradeNotification,
runCLIUpgrade,
versionToAutoUpgrade,
warnIfUpgradeAvailable,
} from '../upgrade.js'
import {isMajorVersionChange, availableHomebrewCLIVersion} from '../version.js'
import {inferPackageManagerForGlobalCLI, getHomebrewCLIFormula} from '../is-global.js'
import {addPublicMetadata} from '../metadata.js'
import {reportAnalyticsEvent} from '../analytics.js'
import {CLI_KIT_VERSION} from '../../common/version.js'
import {runAtMinimumInterval} from '../../../private/node/conf-store.js'
import {SemVer} from 'semver'
import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest'

Expand All @@ -21,6 +23,7 @@ vi.mock('../upgrade.js', async (importOriginal) => {
runCLIUpgrade: vi.fn(),
getOutputUpdateCLIReminder: vi.fn(),
versionToAutoUpgrade: vi.fn(),
warnIfUpgradeAvailable: vi.fn().mockResolvedValue(undefined),
hasBlockingAutoUpgradeNotification: vi.fn().mockResolvedValue(false),
}
})
Expand Down Expand Up @@ -99,6 +102,38 @@ describe('postrun hook', () => {
})

describe('autoUpgradeIfNeeded', () => {
test('checks release notices even when no same-major update is available', async () => {
vi.mocked(versionToAutoUpgrade).mockResolvedValue(undefined)

await autoUpgradeIfNeeded()

expect(warnIfUpgradeAvailable).toHaveBeenCalledOnce()
expect(runCLIUpgrade).not.toHaveBeenCalled()
})

test('checks release notices before installing a same-major update', async () => {
vi.mocked(versionToAutoUpgrade).mockResolvedValue('3.91.0')
vi.mocked(runCLIUpgrade).mockResolvedValue(undefined)

await autoUpgradeIfNeeded()

expect(warnIfUpgradeAvailable).toHaveBeenCalledOnce()
expect(runCLIUpgrade).toHaveBeenCalledWith({autoupgrade: true, version: '3.91.0'})
expect(vi.mocked(warnIfUpgradeAvailable).mock.invocationCallOrder[0]!).toBeLessThan(
vi.mocked(runCLIUpgrade).mock.invocationCallOrder[0]!,
)
})

test('checks release notices even when the daily automatic upgrade attempt is skipped', async () => {
vi.mocked(versionToAutoUpgrade).mockResolvedValue('3.91.0')
vi.mocked(runAtMinimumInterval).mockResolvedValue(false)

await autoUpgradeIfNeeded()

expect(warnIfUpgradeAvailable).toHaveBeenCalledOnce()
expect(runCLIUpgrade).not.toHaveBeenCalled()
})

test('runs the upgrade when versionToAutoUpgrade returns a version', async () => {
// Given
vi.mocked(versionToAutoUpgrade).mockResolvedValue('3.91.0')
Expand Down
13 changes: 6 additions & 7 deletions packages/cli-kit/src/public/node/hooks/postrun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,17 @@ export const hook: Hook.Postrun = async ({config, Command}) => {
}

/**
* Auto-upgrades the CLI after a command completes, if a newer version is available.
* The entire flow is rate-limited to once per day unless forced via SHOPIFY_CLI_FORCE_AUTO_UPGRADE.
* Checks release notices and auto-upgrades the CLI after a command completes.
* Notices have their own daily limit. Upgrade attempts run once per day unless forced
* via SHOPIFY_CLI_FORCE_AUTO_UPGRADE.
*
* @returns Resolves when the upgrade attempt (or fallback warning) is complete.
*/
export async function autoUpgradeIfNeeded(): Promise<void> {
const {versionToAutoUpgrade, warnIfUpgradeAvailable} = await import('../upgrade.js')
await warnIfUpgradeAvailable()
const newerVersion = await versionToAutoUpgrade()
if (!newerVersion) {
await warnIfUpgradeAvailable()
return
}
if (!newerVersion) return

const forced = process.env.SHOPIFY_CLI_FORCE_AUTO_UPGRADE === '1'

Expand All @@ -103,7 +102,7 @@ export async function autoUpgradeIfNeeded(): Promise<void> {
await performAutoUpgrade(newerVersion)
} else {
const {runAtMinimumInterval} = await import('../../../private/node/conf-store.js')
// Rate-limit the entire upgrade flow to once per day to avoid repeated attempts and major-version warnings.
// Rate-limit upgrade attempts independently of release notices.
await runAtMinimumInterval('auto-upgrade', {days: 1}, async () => {
await performAutoUpgrade(newerVersion)
})
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-kit/src/public/node/hooks/prerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ function findAlias(aliases: string[]) {

/**
* Triggers a background check for a newer CLI version (non-blocking).
* The result is cached for manual-upgrade hints. Automatic upgrades use a separate
* lookup restricted to the current major version.
* Caches the result for release notices and manual-upgrade hints.
* Automatic upgrades use a separate lookup restricted to the current major version.
*/
export async function checkForNewVersionInBackground(): Promise<void> {
const [{CLI_KIT_VERSION}, {isPreReleaseVersion}, {checkForNewVersion}] = await Promise.all([
Expand Down
108 changes: 107 additions & 1 deletion packages/cli-kit/src/public/node/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import {
runCLIUpgrade,
upgradeCLI,
versionToAutoUpgrade,
warnIfUpgradeAvailable,
} from './upgrade.js'
import {Notification, fetchNotifications} from './notifications-system.js'
import {globalCLIVersion, isPreReleaseVersion, availableHomebrewCLIVersion} from './version.js'
import {mockAndCaptureOutput} from './testing/output.js'
import {inTemporaryDirectory, writeFile} from './fs.js'
import {joinPath} from './path.js'
import {getAutoUpgradeEnabled} from '../../private/node/conf-store.js'
import {LocalStorage} from './local-storage.js'
import {getAutoUpgradeEnabled, runAtMinimumInterval, type ConfSchema} from '../../private/node/conf-store.js'
import {CLI_KIT_VERSION} from '../common/version.js'
import {SemVer} from 'semver'
import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest'
Expand Down Expand Up @@ -500,6 +502,110 @@ describe('versionToAutoUpgrade', () => {
})
})

describe('warnIfUpgradeAvailable', () => {
beforeEach(() => {
mockAndCaptureOutput().clear()
vi.mocked(isUnitTest).mockReturnValue(true)
vi.mocked(isCI).mockReturnValue(false)
vi.mocked(getAutoUpgradeEnabled).mockReturnValue(true)
vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('npm')
vi.mocked(runAtMinimumInterval).mockImplementation(async (_key, _timeout, task) => {
await task()
return true
})
})

afterEach(() => {
vi.useRealTimers()
mockAndCaptureOutput().clear()
})

test.each([true, false])('shows a major notice with auto-upgrade enabled: %s', async (enabled) => {
const nextMajor = new SemVer(CLI_KIT_VERSION).inc('major').version
vi.mocked(getAutoUpgradeEnabled).mockReturnValue(enabled)
vi.mocked(checkForCachedNewVersion).mockReturnValue(nextMajor)

await warnIfUpgradeAvailable()

const warning = mockAndCaptureOutput().warn()
expect(warning).toContain(`Version ${nextMajor} available!`)
expect(warning).toContain(`npm install -g @shopify/cli@${nextMajor}`)
expect(warning).toContain('This is a major version')
expect(warning).toContain(`https://github.com/Shopify/cli/releases/tag/${nextMajor}`)
expect(checkForNewVersion).not.toHaveBeenCalled()
})

test.each([true, false])('shows a same-major notice only when auto-upgrade is disabled: %s', async (enabled) => {
const nextPatch = new SemVer(CLI_KIT_VERSION).inc('patch').version
vi.mocked(getAutoUpgradeEnabled).mockReturnValue(enabled)
vi.mocked(checkForCachedNewVersion).mockReturnValue(nextPatch)

await warnIfUpgradeAvailable()

const warning = mockAndCaptureOutput().warn()
if (enabled) {
expect(warning).toBe('')
expect(runAtMinimumInterval).not.toHaveBeenCalled()
} else {
expect(warning).toContain(`npm install -g @shopify/cli@${nextPatch}`)
expect(warning).not.toContain('This is a major version')
}
})

test.each(['CI', 'prerelease'])('skips notices in %s', async (environment) => {
vi.mocked(checkForCachedNewVersion).mockReturnValue(new SemVer(CLI_KIT_VERSION).inc('major').version)
vi.mocked(isCI).mockReturnValue(environment === 'CI')
vi.mocked(isPreReleaseVersion).mockReturnValue(environment === 'prerelease')

await warnIfUpgradeAvailable()

expect(mockAndCaptureOutput().warn()).toBe('')
expect(runAtMinimumInterval).not.toHaveBeenCalled()
})

test('does not consume the daily notice interval while the cache is empty', async () => {
vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined)

await warnIfUpgradeAvailable()

expect(mockAndCaptureOutput().warn()).toBe('')
expect(runAtMinimumInterval).not.toHaveBeenCalled()

const nextMajor = new SemVer(CLI_KIT_VERSION).inc('major').version
vi.mocked(checkForCachedNewVersion).mockReturnValue(nextMajor)
await warnIfUpgradeAvailable()
expect(mockAndCaptureOutput().warn()).toContain(nextMajor)
})

test('shows the major notice once per day independently of automatic upgrade attempts', async () => {
await inTemporaryDirectory(async (directory) => {
const storage = new LocalStorage<ConfSchema>({cwd: directory})
const {runAtMinimumInterval: actualRunAtMinimumInterval} = await vi.importActual<
typeof import('../../private/node/conf-store.js')
>('../../private/node/conf-store.js')
vi.mocked(runAtMinimumInterval).mockImplementation((key, timeout, task) =>
actualRunAtMinimumInterval(key, timeout, task, storage),
)
vi.useFakeTimers({toFake: ['Date']})
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
const nextMajor = new SemVer(CLI_KIT_VERSION).inc('major').version
vi.mocked(checkForCachedNewVersion).mockReturnValue(nextMajor)
await actualRunAtMinimumInterval('auto-upgrade', {days: 1}, async () => {}, storage)

await warnIfUpgradeAvailable()
expect(mockAndCaptureOutput().warn()).toContain(nextMajor)
mockAndCaptureOutput().clear()

await warnIfUpgradeAvailable()
expect(mockAndCaptureOutput().warn()).toBe('')

vi.setSystemTime(Date.now() + 24 * 3600 * 1000)
await warnIfUpgradeAvailable()
expect(mockAndCaptureOutput().warn()).toContain(nextMajor)
})
})
})

describe('hasBlockingAutoUpgradeNotification', () => {
function notification(overrides: Partial<Notification> = {}): Notification {
return {
Expand Down
14 changes: 8 additions & 6 deletions packages/cli-kit/src/public/node/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {presentUpgradeResult} from './upgrade/result.js'
import {execUpgradeCommand, upgradeOutputStreams} from './upgrade/output.js'
import {cwd, moduleDirectory, sniffForPath} from './path.js'
import {isCI} from './system.js'
import {globalCLIVersion, isPreReleaseVersion, availableHomebrewCLIVersion} from './version.js'
import {globalCLIVersion, isPreReleaseVersion, isMajorVersionChange, availableHomebrewCLIVersion} from './version.js'
import {AbortError} from './error.js'
import {getAutoUpgradeEnabled, setAutoUpgradeEnabled, runAtMinimumInterval} from '../../private/node/conf-store.js'
import {CLI_KIT_VERSION} from '../common/version.js'
Expand Down Expand Up @@ -236,18 +236,20 @@ export async function hasBlockingAutoUpgradeNotification(): Promise<boolean> {
}

/**
* Shows a daily upgrade-available warning for users who have not enabled auto-upgrade.
* Skipped in CI and for pre-release versions. When auto-upgrade is enabled this is a no-op
* because the postrun hook will handle the upgrade directly.
* Shows a daily notice for a new major, independently of automatic same-major upgrades.
* When auto-upgrade is disabled, also shows notices for minor and patch releases.
* Skipped in CI and for pre-release versions.
*/
export async function warnIfUpgradeAvailable(): Promise<void> {
if (isCI() || isPreReleaseVersion(CLI_KIT_VERSION) || getAutoUpgradeEnabled()) return
if (isCI() || isPreReleaseVersion(CLI_KIT_VERSION)) return

const newerVersion = checkForCachedNewVersion('@shopify/cli', CLI_KIT_VERSION)
if (!newerVersion) return
const isMajor = isMajorVersionChange(CLI_KIT_VERSION, newerVersion)
if (getAutoUpgradeEnabled() && !isMajor) return

await runAtMinimumInterval('warn-on-available-upgrade', {days: 1}, async () => {
outputWarn(getOutputUpdateCLIReminder(newerVersion))
outputWarn(getOutputUpdateCLIReminder(newerVersion, isMajor))
})
}

Expand Down
Loading