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
5 changes: 5 additions & 0 deletions .changeset/json-app-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add typed JSON output to app release.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -3389,9 +3389,18 @@
"description": "The name of the app configuration.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-release.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
}
],
"value": "export interface apprelease {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. Required in non-interactive environments unless --allow-updates is provided.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. Required in non-interactive environments unless --allow-deletes is provided.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The name of the app version to release.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version <value>': string\n}"
"value": "export interface apprelease {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. Required in non-interactive environments unless --allow-updates is provided.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. Required in non-interactive environments unless --allow-deletes is provided.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The name of the app version to release.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version <value>': string\n}"
}
},
"appsecuritycheck": {
Expand Down
125 changes: 125 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,125 @@
import Release from './release.js'
import {release} from '../../services/release.js'
import {linkedAppContext} from '../../services/app-context.js'
import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {outputInfo, unstyled} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
import {Config} from '@oclif/core'
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {fileURLToPath} from 'node:url'

vi.mock('../../services/release.js')
vi.mock('../../services/app-context.js')

const version = {
id: 123,
uuid: 'gid://shopify/Version/123',
versionTag: 'v1',
message: 'Release message',
location: 'https://dev.shopify.com/dashboard/1/apps/1/versions/123',
appModuleVersions: [],
}

const originalExitCode = process.exitCode
afterEach(() => {
process.exitCode = originalExitCode
vi.unstubAllEnvs()
})

describe('app release command', () => {
beforeEach(() => {
vi.mocked(linkedAppContext).mockResolvedValue({
app: testAppLinked(),
remoteApp: testOrganizationApp(),
} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(release).mockResolvedValue({status: 'success', version})
})

test('writes one JSON result and sends diagnostics to stderr', async () => {
vi.mocked(release).mockImplementationOnce(async () => {
outputInfo('Releasing an app version')
return {status: 'success', version}
})
await inTemporaryDirectory(async (tmp) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runRelease(['--path', tmp, '--version', 'v1', '--json', '--allow-updates'])
expect(JSON.parse(stdout())).toEqual({
status: 'success',
app: {name: 'app1', clientId: 'api-key'},
release: {version: {gid: version.uuid, name: 'v1', message: 'Release message', url: version.location}},
})
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Releasing an app version'})
})
})
})

test('release rejection uses one shared error document', async () => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
const userErrors = [{message: 'Release failed.', category: 'validation', details: [], field: ['version']}]
vi.mocked(release).mockResolvedValue({status: 'failed', version, userErrors})
await inTemporaryDirectory(async (tmp) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await expect(runRelease(['--path', tmp, '--version', 'v1', '--json', '--allow-updates'])).rejects.toThrow()
expect(JSON.parse(stdout())).toMatchObject({
error: {type: 'abort', message: "Version couldn't be released.", details: {userErrors}},
})
expect(stderr()).toBe('')
})
})
})

test.each(['json', 'text'])('missing version uses the shared %s error handler', async (format) => {
vi.stubEnv('SHOPIFY_FLAG_JSON', format === 'json' ? '1' : '0')
const {release: releaseService} =
await vi.importActual<typeof import('../../services/release.js')>('../../services/release.js')
vi.mocked(release).mockImplementationOnce(releaseService)
vi.mocked(linkedAppContext).mockResolvedValueOnce({
app: testAppLinked(),
remoteApp: testOrganizationApp(),
developerPlatformClient: testDeveloperPlatformClient({
appVersionByTag: vi.fn().mockRejectedValue(new AbortError('HTTP 404: Cannot find a valid organization')),
}),
} as unknown as Awaited<ReturnType<typeof linkedAppContext>>)
await inTemporaryDirectory(async (directory) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
const argv = ['--path', directory, '--version', 'missing', '--allow-updates']
if (format === 'json') argv.push('--json')
await expect(runRelease(argv)).rejects.toThrow()
if (format === 'json') {
expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: 'Version missing could not be found.'}})
expect(stderr()).toBe('')
} else {
expect(stdout()).toBe('')
expect(unstyled(stderr())).toContain('Version missing could not be found.')
}
})
})
})

test('cancelled JSON exits zero through the silent error path', async () => {
vi.mocked(release).mockResolvedValue({status: 'cancelled'})
const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never)
try {
await inTemporaryDirectory(async (directory) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runRelease(['--path', directory, '--version', 'v1', '--json', '--allow-updates'])
expect(exit).toHaveBeenCalledExactlyOnceWith(0)
expect(JSON.parse(stdout())).toEqual({status: 'cancelled'})
expect(stderr()).toBe('')
})
})
} finally {
exit.mockRestore()
}
})
})

async function runRelease(argv: string[]) {
const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../..')})
// The source package is bundled in the installed CLI, not a custom plugin.
config.plugins.delete('@shopify/app')
return Release.run(argv, config)
}
13 changes: 10 additions & 3 deletions packages/app/src/cli/commands/app/release.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {appFlags} from '../../flags.js'
import {release} from '../../services/release.js'
import {appReleaseJsonOutputSchema} from '../../services/release/types.js'
import {renderAppReleaseResult} from '../../services/release/result.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../services/app-context.js'
import {Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {addPublicMetadata} from '@shopify/cli-kit/node/metadata'
import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command'

Expand All @@ -14,11 +16,16 @@ export default class Release extends AppLinkedCommand {

static descriptionWithMarkdown = `Releases an existing app version. Pass the name of the version that you want to release using the \`--version\` flag.`

static get jsonOutputSchema() {
return appReleaseJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...appFlags,
...jsonFlag,
'allow-updates': Flags.boolean({
hidden: false,
description:
Expand Down Expand Up @@ -64,7 +71,7 @@ export default class Release extends AppLinkedCommand {
userProvidedConfigName: flags.config,
})

await release({
const result = await release({
app,
remoteApp,
developerPlatformClient,
Expand All @@ -73,7 +80,7 @@ export default class Release extends AppLinkedCommand {
allowDeletes,
version: flags.version,
})

renderAppReleaseResult(result, remoteApp, flags.json ? 'json' : 'text')
return {app}
}
}
49 changes: 18 additions & 31 deletions packages/app/src/cli/services/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {AppLinkedInterface} from '../models/app/app.js'
import {OrganizationApp} from '../models/organization.js'
import {beforeEach, describe, expect, vi, test} from 'vitest'
import {renderError, renderSuccess, renderTasks, Task} from '@shopify/cli-kit/node/ui'
import {AbortSilentError} from '@shopify/cli-kit/node/error'

vi.mock('./context.js')
vi.mock('../models/app/identifiers.js')
Expand Down Expand Up @@ -52,7 +51,10 @@ describe('release', () => {
vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(false)

// When/Then
await expect(testRelease(app, 'app-version')).rejects.toThrow(AbortSilentError)
const developerPlatformClient = testDeveloperPlatformClient()
await expect(testRelease(app, 'app-version', {developerPlatformClient})).resolves.toEqual({status: 'cancelled'})
expect(developerPlatformClient.release).not.toHaveBeenCalled()
expect(renderTasks).not.toHaveBeenCalled()
})

test('triggers mutations if the user confirms', async () => {
Expand All @@ -67,35 +69,27 @@ describe('release', () => {

return {
appRelease: {
appRelease: {},
appRelease: {
appVersion: {versionTag: '1.0.0', message: 'message', location: 'https://example.com'},
},
},
}
})
const developerPlatformClient = testDeveloperPlatformClient()

// When
await testRelease(app, 'app-version', {developerPlatformClient})
const result = await testRelease(app, 'app-version', {developerPlatformClient})

// Then
expect(developerPlatformClient.release).toHaveBeenCalledWith({
app: APP,
version: {appVersionId: 1, versionId: 'uuid'},
})
expect(renderSuccess).toHaveBeenCalledWith({
body: [
{
link: {
label: '1.0.0',
url: 'https://example.com',
},
},
'\nmessage',
],
headline: 'Version released to users.',
})
expect(result).toEqual({status: 'success', version: buildExtensionsBreakdown().versionDetails})
expect(renderSuccess).not.toHaveBeenCalled()
})

test('shows a custom error message with link and message if errors are returned', async () => {
test('returns failed release data if errors are returned', async () => {
// Given
const app = testAppLinked()
vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true)
Expand All @@ -122,22 +116,15 @@ describe('release', () => {
})

// When
await testRelease(app, 'app-version')
const result = await testRelease(app, 'app-version')

// Then
expect(renderError).toHaveBeenCalledWith({
body: [
{
link: {
label: '1.0.0',
url: 'https://example.com',
},
},
'\nmessage',
'\n\nsome kind of error 1, some kind of error 2',
],
headline: "Version couldn't be released.",
expect(result).toEqual({
status: 'failed',
version: buildExtensionsBreakdown().versionDetails,
userErrors: [{message: 'some kind of error 1'}, {message: 'some kind of error 2'}],
})
expect(renderError).not.toHaveBeenCalled()
})
})

Expand All @@ -150,7 +137,7 @@ async function testRelease(
vi.mocked(extensionsIdentifiersReleaseBreakdown).mockResolvedValue(buildExtensionsBreakdown())
vi.mocked(configExtensionsIdentifiersReleaseBreakdown).mockReturnValue(buildConfigExtensionsBreakdown())

await release({
return release({
app,
remoteApp: APP,
developerPlatformClient,
Expand Down
25 changes: 6 additions & 19 deletions packages/app/src/cli/services/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {AppReleaseSchema} from '../api/graphql/app_release.js'
import {deployOrReleaseConfirmationPrompt} from '../prompts/deploy-release.js'
import {OrganizationApp} from '../models/organization.js'
import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js'
import {renderError, renderSuccess, renderTasks, TokenItem} from '@shopify/cli-kit/node/ui'
import {AbortSilentError} from '@shopify/cli-kit/node/error'
import {renderTasks} from '@shopify/cli-kit/node/ui'
import type {ReleaseResult} from './release/types.js'

interface ReleaseOptions {
/** The app to be built and uploaded */
Expand All @@ -33,7 +33,7 @@ interface ReleaseOptions {
version: string
}

export async function release(options: ReleaseOptions) {
export async function release(options: ReleaseOptions): Promise<ReleaseResult> {
const {developerPlatformClient, app, remoteApp} = options

const {extensionIdentifiersBreakdown, versionDetails} = await extensionsIdentifiersReleaseBreakdown(
Expand All @@ -56,7 +56,7 @@ export async function release(options: ReleaseOptions) {
allowDeletes: options.force || options.allowDeletes,
})

if (!confirmed) throw new AbortSilentError()
if (!confirmed) return {status: 'cancelled'}
interface Context {
appRelease: AppReleaseSchema
}
Expand All @@ -80,21 +80,8 @@ export async function release(options: ReleaseOptions) {
appRelease: {appRelease: release},
} = await renderTasks<Context>(tasks)

const linkAndMessage: TokenItem = [
{link: {label: versionDetails.versionTag ?? undefined, url: versionDetails.location}},
versionDetails.message ? `\n${versionDetails.message}` : '',
]

if (release.userErrors && release.userErrors.length > 0) {
const errorMessages = release.userErrors?.map((error) => error.message).join(', ')
renderError({
headline: "Version couldn't be released.",
body: [...linkAndMessage, `${linkAndMessage.length > 0 ? '\n\n' : ''}${errorMessages}`],
})
} else {
renderSuccess({
headline: 'Version released to users.',
body: linkAndMessage,
})
return {status: 'failed', version: versionDetails, userErrors: release.userErrors}
}
return {status: 'success', version: versionDetails}
}
Loading
Loading