Skip to content
Draft
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
224 changes: 224 additions & 0 deletions packages/app/src/cli/commands/app/release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import Release from './release.js'
import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js'
import {OrganizationSource} from '../../models/organization.js'
import {describe, expect, test, vi, beforeEach} from 'vitest'
import {renderWarning} from '@shopify/cli-kit/node/ui'

vi.mock('../../services/release.js')
vi.mock('../../services/app-context.js')
vi.mock('@shopify/cli-kit/node/metadata', async (importOriginal) => {
const actual = await importOriginal<typeof import('@shopify/cli-kit/node/metadata')>()
return {...actual, addPublicMetadata: vi.fn()}
})
vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => {
const actual = await importOriginal<typeof import('@shopify/cli-kit/node/ui')>()
return {...actual, renderWarning: vi.fn()}
})
vi.mock('@shopify/cli-kit/node/agent', async (importOriginal) => {
const actual = await importOriginal<typeof import('@shopify/cli-kit/node/agent')>()
return {...actual, getCurrentAgentSession: vi.fn()}
})

describe('app release --force deprecation warning', () => {
beforeEach(async () => {
const {linkedAppContext} = await import('../../services/app-context.js')
const {release} = await import('../../services/release.js')
vi.mocked(linkedAppContext).mockResolvedValue({
app: testAppLinked(),
remoteApp: testOrganizationApp(),
developerPlatformClient: testDeveloperPlatformClient(),
organization: {
id: '1',
businessName: 'test',
source: OrganizationSource.Partners,
},
specifications: [],
project: {} as any,
activeConfig: {} as any,
})
vi.mocked(release).mockResolvedValue(undefined)
})

test('shows deprecation warning when --force is passed', async () => {
await Release.run(['--version', 'v1.0.0', '--force'])

expect(renderWarning).toHaveBeenCalledWith(
expect.objectContaining({
headline: expect.arrayContaining(['The']),
body: expect.arrayContaining(['Use']),
}),
)
const call = vi.mocked(renderWarning).mock.calls[0]![0]
expect(JSON.stringify(call)).toContain('--force')
expect(JSON.stringify(call)).toContain('next major release')
})

test('shows deprecation warning when SHOPIFY_FLAG_FORCE env var is set', async () => {
vi.stubEnv('SHOPIFY_FLAG_FORCE', '1')

await Release.run(['--version', 'v1.0.0'])

expect(renderWarning).toHaveBeenCalled()
const call = vi.mocked(renderWarning).mock.calls[0]![0]
expect(JSON.stringify(call)).toContain('--force')

vi.unstubAllEnvs()
})

test('does not show deprecation warning when only --allow-updates is passed', async () => {
await Release.run(['--version', 'v1.0.0', '--allow-updates'])

expect(renderWarning).not.toHaveBeenCalled()
})

test('does not show deprecation warning when --allow-updates and --allow-deletes are passed', async () => {
await Release.run(['--version', 'v1.0.0', '--allow-updates', '--allow-deletes'])

expect(renderWarning).not.toHaveBeenCalled()
})

test('does not show deprecation warning when only --allow-deletes is passed', async () => {
await Release.run(['--version', 'v1.0.0', '--allow-deletes'])

expect(renderWarning).not.toHaveBeenCalled()
})
})

describe('app release agent session behavior', () => {
beforeEach(async () => {
const {linkedAppContext} = await import('../../services/app-context.js')
const {release} = await import('../../services/release.js')
const {getCurrentAgentSession} = await import('@shopify/cli-kit/node/agent')
vi.mocked(linkedAppContext).mockResolvedValue({
app: testAppLinked(),
remoteApp: testOrganizationApp(),
developerPlatformClient: testDeveloperPlatformClient(),
organization: {
id: '1',
businessName: 'test',
source: OrganizationSource.Partners,
},
specifications: [],
project: {} as any,
activeConfig: {} as any,
})
vi.mocked(release).mockResolvedValue(undefined)
vi.mocked(getCurrentAgentSession).mockReturnValue(undefined)
})

test('applies --allow-updates when agent session with defaultNonInteractive=true exists and no explicit flags', async () => {
const {getCurrentAgentSession} = await import('@shopify/cli-kit/node/agent')
vi.mocked(getCurrentAgentSession).mockReturnValue({
sessionId: 'test-session',
startedAt: new Date().toISOString(),
agentName: 'test-agent',
agentVersion: '1.0.0',
agentProvider: 'test-provider',
metricsMode: 'on',
defaultNonInteractive: true,
})

await Release.run(['--version', 'v1.0.0'])

const {release} = await import('../../services/release.js')
expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: undefined,
}),
)
})

test('explicit --allow-updates flag wins over agent session', async () => {
const {getCurrentAgentSession} = await import('@shopify/cli-kit/node/agent')
vi.mocked(getCurrentAgentSession).mockReturnValue({
sessionId: 'test-session',
startedAt: new Date().toISOString(),
agentName: 'test-agent',
agentVersion: '1.0.0',
agentProvider: 'test-provider',
metricsMode: 'on',
defaultNonInteractive: true,
})

await Release.run(['--version', 'v1.0.0', '--allow-updates'])

const {release} = await import('../../services/release.js')
expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: undefined,
}),
)
})

test('explicit --allow-deletes does not trigger --allow-updates from agent session', async () => {
const {getCurrentAgentSession} = await import('@shopify/cli-kit/node/agent')
vi.mocked(getCurrentAgentSession).mockReturnValue({
sessionId: 'test-session',
startedAt: new Date().toISOString(),
agentName: 'test-agent',
agentVersion: '1.0.0',
agentProvider: 'test-provider',
metricsMode: 'on',
defaultNonInteractive: true,
})

await Release.run(['--version', 'v1.0.0', '--allow-deletes'])

const {release} = await import('../../services/release.js')
expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: false,
allowDeletes: true,
}),
)
})

test('no behavior change when agent session exists but defaultNonInteractive=false', async () => {
const {getCurrentAgentSession} = await import('@shopify/cli-kit/node/agent')
vi.mocked(getCurrentAgentSession).mockReturnValue({
sessionId: 'test-session',
startedAt: new Date().toISOString(),
agentName: 'test-agent',
agentVersion: '1.0.0',
agentProvider: 'test-provider',
metricsMode: 'on',
defaultNonInteractive: false,
})

await Release.run(['--version', 'v1.0.0', '--allow-updates'])

const {release} = await import('../../services/release.js')
expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: undefined,
}),
)
})

test('explicit --force flag works with agent session', async () => {
const {getCurrentAgentSession} = await import('@shopify/cli-kit/node/agent')
vi.mocked(getCurrentAgentSession).mockReturnValue({
sessionId: 'test-session',
startedAt: new Date().toISOString(),
agentName: 'test-agent',
agentVersion: '1.0.0',
agentProvider: 'test-provider',
metricsMode: 'on',
defaultNonInteractive: true,
})

await Release.run(['--version', 'v1.0.0', '--force'])

const {release} = await import('../../services/release.js')
expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: true,
force: true,
}),
)
})
})
43 changes: 34 additions & 9 deletions packages/app/src/cli/commands/app/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {linkedAppContext} from '../../services/app-context.js'
import {Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {addPublicMetadata} from '@shopify/cli-kit/node/metadata'
import {renderWarning} from '@shopify/cli-kit/node/ui'
import {getCurrentAgentSession} from '@shopify/cli-kit/node/agent'

export default class Release extends AppLinkedCommand {
static summary = 'Release an app version.'
Expand All @@ -18,6 +20,13 @@ export default class Release extends AppLinkedCommand {
static flags = {
...globalFlags,
...appFlags,
force: Flags.boolean({
hidden: false,
description:
'[Deprecated] Release without asking for confirmation. Equivalent to --allow-updates --allow-deletes. Use --allow-updates for CI/CD environments instead.',
env: 'SHOPIFY_FLAG_FORCE',
char: 'f',
}),
'allow-updates': Flags.boolean({
hidden: false,
description:
Expand All @@ -42,19 +51,31 @@ export default class Release extends AppLinkedCommand {
const {flags} = await this.parse(Release)
const clientId = flags['client-id']

if (flags.force) {
renderWarning({
headline: ['The', {command: '--force'}, 'flag is deprecated and will be removed in the next major release.'],
body: [
'Use',
{command: '--allow-updates'},
'for CI/CD environments, or',
{command: '--allow-updates --allow-deletes'},
'if you also want to allow removals.',
],
})
}

await addPublicMetadata(() => ({
cmd_app_reset_used: flags.reset,
}))

const allowUpdates = flags['allow-updates']
const allowDeletes = flags['allow-deletes']
// `force` (skip confirmation prompt) is implied when both --allow-updates
// and --allow-deletes are set.
const force = Boolean(allowUpdates && allowDeletes)

// We require --allow-updates or --allow-deletes for non-TTY.
// We require --force or --allow-updates or --allow-deletes for non-TTY.
// Agent sessions with defaultNonInteractive=true implicitly provide --allow-updates.
const requiredNonTTYFlags: string[] = []
if (!allowUpdates && !allowDeletes) {
const agentSession = getCurrentAgentSession()
const isAgentNonInteractive = agentSession?.defaultNonInteractive === true
const hasExplicitReleaseFlags = flags.force || flags['allow-updates'] || flags['allow-deletes']
const hasReleaseConsent = hasExplicitReleaseFlags || isAgentNonInteractive
if (!hasReleaseConsent) {
requiredNonTTYFlags.push('allow-updates')
}
this.failMissingNonTTYFlags(flags, requiredNonTTYFlags)
Expand All @@ -66,11 +87,15 @@ export default class Release extends AppLinkedCommand {
userProvidedConfigName: flags.config,
})

const allowUpdates =
flags.force || flags['allow-updates'] || (isAgentNonInteractive && !hasExplicitReleaseFlags)
const allowDeletes = flags.force || flags['allow-deletes']

await release({
app,
remoteApp,
developerPlatformClient,
force,
force: flags.force,
allowUpdates,
allowDeletes,
version: flags.version,
Expand Down
18 changes: 17 additions & 1 deletion packages/cli-kit/src/private/node/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {getLastSeenAuthMethod} from './session.js'
import {getAutoUpgradeEnabled} from './conf-store.js'
import {getCurrentAgentSession, packAgentInfo, packAgentIds} from '../../public/node/agent.js'
import {hashString} from '../../public/node/crypto.js'
import {getPackageManager, packageManagerFromUserAgent} from '../../public/node/node-package-manager.js'
import BaseCommand from '../../public/node/base-command.js'
Expand Down Expand Up @@ -114,7 +115,22 @@ function getShopifyEnvironmentVariables() {
// Monorail fields, e.g. SHOPIFY_CLI_AGENT, SHOPIFY_CLI_AGENT_VERSION,
// SHOPIFY_CLI_AGENT_RUN_ID, SHOPIFY_CLI_AGENT_SESSION_ID, and
// SHOPIFY_CLI_AGENT_PROVIDER.
return Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('SHOPIFY_')))
const envVars = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('SHOPIFY_')))

// Resolve the persisted session once and let the packers preserve the precedence rule
// that explicit process env attribution wins over persisted session state.
const agentSession = getCurrentAgentSession()
const agentInfo = packAgentInfo(agentSession)
const agentIds = packAgentIds(agentSession)

if (agentInfo && !envVars.SHOPIFY_CLI_AGENT_INFO) {
envVars.SHOPIFY_CLI_AGENT_INFO = agentInfo
}
if (agentIds && !envVars.SHOPIFY_CLI_AGENT_IDS) {
envVars.SHOPIFY_CLI_AGENT_IDS = agentIds
}

return envVars
}

function getPluginNames(config: Interfaces.Config) {
Expand Down
Loading
Loading