Skip to content
Closed
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
201 changes: 201 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,201 @@
import Release from './release.js'
import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js'
import {OrganizationSource} from '../../models/organization.js'
import {linkedAppContext} from '../../services/app-context.js'
import {release} from '../../services/release.js'
import {describe, expect, test, vi, beforeEach} from 'vitest'
import * as agent from '@shopify/cli-kit/node/agent'
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/ui')

describe('app release --force deprecation warning', () => {
beforeEach(() => {
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(() => {
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.spyOn(agent, 'getCurrentAgentSession').mockReturnValue(undefined)
})

test('applies --allow-updates when agent session with defaultNonInteractive=true exists and no explicit flags', async () => {
vi.mocked(agent.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'])

expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: undefined,
}),
)
})

test('explicit --allow-updates flag wins over agent session', async () => {
vi.mocked(agent.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'])

expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: undefined,
}),
)
})

test('explicit --allow-deletes does not trigger --allow-updates from agent session', async () => {
vi.mocked(agent.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'])

expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: false,
allowDeletes: true,
}),
)
})

test('no behavior change when agent session exists but defaultNonInteractive=false', async () => {
vi.mocked(agent.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'])

expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: undefined,
}),
)
})

test('explicit --force flag works with agent session', async () => {
vi.mocked(agent.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'])

expect(release).toHaveBeenCalledWith(
expect.objectContaining({
allowUpdates: true,
allowDeletes: true,
force: true,
}),
)
})
})
42 changes: 33 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,14 @@ 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