diff --git a/.changeset/json-app-release.md b/.changeset/json-app-release.md new file mode 100644 index 00000000000..8fff9b67391 --- /dev/null +++ b/.changeset/json-app-release.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to app release. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 2191cb75c30..bcc6a26812a 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: 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 '?: 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 ': 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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: 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 '?: 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 ': string\n}" } }, "appsecuritycheck": { diff --git a/packages/app/src/cli/commands/app/release.test.ts b/packages/app/src/cli/commands/app/release.test.ts new file mode 100644 index 00000000000..f13bcebd9f6 --- /dev/null +++ b/packages/app/src/cli/commands/app/release.test.ts @@ -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>) + 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('../../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>) + 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) +} diff --git a/packages/app/src/cli/commands/app/release.ts b/packages/app/src/cli/commands/app/release.ts index 556872645da..ae14290efdb 100644 --- a/packages/app/src/cli/commands/app/release.ts +++ b/packages/app/src/cli/commands/app/release.ts @@ -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' @@ -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: @@ -64,7 +71,7 @@ export default class Release extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - await release({ + const result = await release({ app, remoteApp, developerPlatformClient, @@ -73,7 +80,7 @@ export default class Release extends AppLinkedCommand { allowDeletes, version: flags.version, }) - + renderAppReleaseResult(result, remoteApp, flags.json ? 'json' : 'text') return {app} } } diff --git a/packages/app/src/cli/services/release.test.ts b/packages/app/src/cli/services/release.test.ts index fb00a65db6d..f4485dab52f 100644 --- a/packages/app/src/cli/services/release.test.ts +++ b/packages/app/src/cli/services/release.test.ts @@ -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') @@ -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 () => { @@ -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) @@ -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() }) }) @@ -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, diff --git a/packages/app/src/cli/services/release.ts b/packages/app/src/cli/services/release.ts index 9a5d7f38e23..02995ff57cf 100644 --- a/packages/app/src/cli/services/release.ts +++ b/packages/app/src/cli/services/release.ts @@ -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 */ @@ -33,7 +33,7 @@ interface ReleaseOptions { version: string } -export async function release(options: ReleaseOptions) { +export async function release(options: ReleaseOptions): Promise { const {developerPlatformClient, app, remoteApp} = options const {extensionIdentifiersBreakdown, versionDetails} = await extensionsIdentifiersReleaseBreakdown( @@ -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 } @@ -80,21 +80,8 @@ export async function release(options: ReleaseOptions) { appRelease: {appRelease: release}, } = await renderTasks(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} } diff --git a/packages/app/src/cli/services/release/result.test.ts b/packages/app/src/cli/services/release/result.test.ts new file mode 100644 index 00000000000..6fefb102077 --- /dev/null +++ b/packages/app/src/cli/services/release/result.test.ts @@ -0,0 +1,59 @@ +import {renderAppReleaseResult} from './result.js' +import {testOrganizationApp} from '../../models/app/app.test-data.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {unstyled} from '@shopify/cli-kit/node/output' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {expect, test} from 'vitest' +import type {ReleaseResult} from './types.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 result: ReleaseResult = {status: 'success', version} + +test('encodes unavailable version fields as null', async () => { + await withCapturedStandardStreams(async ({stdout}) => { + renderAppReleaseResult( + {...result, version: {...version, versionTag: undefined, message: ''}}, + testOrganizationApp(), + 'json', + ) + expect(JSON.parse(stdout())).toMatchObject({release: {version: {name: null, message: null}}}) + }) +}) + +test('text keeps the released-version banner on stderr', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + renderAppReleaseResult(result, testOrganizationApp(), 'text') + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain('Version released to users.') + expect(unstyled(stderr())).toContain('Release message') + expect(unstyled(stderr())).toContain('v1') + }) +}) + +test('text retains the failed release banner and exit behavior', async () => { + const originalExitCode = process.exitCode + const userErrors = [{message: 'First error'}, {message: 'Second error'}].map((error) => ({ + ...error, + category: 'validation', + details: [], + })) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + renderAppReleaseResult({status: 'failed', version, userErrors}, testOrganizationApp(), 'text') + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain("Version couldn't be released.") + expect(unstyled(stderr())).toContain('First error, Second error') + expect(unstyled(stderr())).toContain('Release message') + expect(process.exitCode).toBe(originalExitCode) + }) +}) + +test('text retains silent cancellation', () => { + expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'text')).toThrow(AbortSilentError) +}) diff --git a/packages/app/src/cli/services/release/result.ts b/packages/app/src/cli/services/release/result.ts new file mode 100644 index 00000000000..115912c6f31 --- /dev/null +++ b/packages/app/src/cli/services/release/result.ts @@ -0,0 +1,56 @@ +import {appReleaseJsonOutputSchema, type AppReleaseResult, type ReleaseResult} from './types.js' +import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderError, renderSuccess, type TokenItem} from '@shopify/cli-kit/node/ui' +import type {OrganizationApp} from '../../models/organization.js' + +export function renderAppReleaseResult( + result: ReleaseResult, + remoteApp: OrganizationApp, + format: 'json' | 'text', +): void { + if (format === 'json') { + if (result.status === 'failed') { + const error = new AbortError("Version couldn't be released.") + error.details = {userErrors: result.userErrors} + throw error + } + outputResult(appReleaseJsonOutputSchema.encode(appReleaseResult(result, remoteApp))) + if (result.status === 'cancelled') throw Object.assign(new AbortSilentError(), {oclif: {exit: 0}}) + return + } + + if (result.status === 'cancelled') throw new AbortSilentError() + const linkAndMessage: TokenItem = [ + {link: {label: result.version.versionTag ?? undefined, url: result.version.location}}, + result.version.message ? `\n${result.version.message}` : '', + ] + if (result.status === 'failed') { + const errorMessages = result.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}) + } +} + +function appReleaseResult( + result: Exclude, + remoteApp: OrganizationApp, +): AppReleaseResult { + if (result.status === 'cancelled') return {status: 'cancelled'} + return { + status: 'success', + app: {name: remoteApp.title, clientId: remoteApp.apiKey}, + release: { + version: { + gid: result.version.uuid, + name: result.version.versionTag === '' ? null : (result.version.versionTag ?? null), + message: result.version.message === '' ? null : (result.version.message ?? null), + url: result.version.location, + }, + }, + } +} diff --git a/packages/app/src/cli/services/release/types.test.ts b/packages/app/src/cli/services/release/types.test.ts new file mode 100644 index 00000000000..3fed3934dd5 --- /dev/null +++ b/packages/app/src/cli/services/release/types.test.ts @@ -0,0 +1,19 @@ +import {appReleaseJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +const app = {name: 'Example', clientId: 'client-id'} +const version = {gid: 'gid://shopify/Version/123', name: 'v1', message: null, url: 'https://example.com/version/123'} +const result = {status: 'success' as const, app, release: {version}} + +test.each([ + ['top-level field', {...result, internalId: 'private'}], + ['app field', {...result, app: {...app, internalId: 'private'}}], + ['release field', {...result, release: {version, internalId: 'private'}}], + ['version field', {...result, release: {version: {...version, internalId: 'private'}}}], + ['cancelled field', {status: 'cancelled' as const, app}], + ['client ID', {...result, app: {...app, clientId: ''}}], + ['version GID', {...result, release: {version: {...version, gid: '123'}}}], + ['version URL', {...result, release: {version: {...version, url: 'invalid'}}}], +])('rejects an invalid %s', (_name, value) => { + expect(() => appReleaseJsonOutputSchema.encode(value)).toThrow() +}) diff --git a/packages/app/src/cli/services/release/types.ts b/packages/app/src/cli/services/release/types.ts new file mode 100644 index 00000000000..b3581a6b7eb --- /dev/null +++ b/packages/app/src/cli/services/release/types.ts @@ -0,0 +1,40 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' +import type {AppVersionWithContext, UserError} from '../../utilities/developer-platform-client.js' + +const appSchema = zod + .object({ + name: zod.string(), + clientId: zod.string().min(1).describe('The client ID of the app receiving the release.'), + }) + .strict() + +const versionSchema = zod + .object({ + gid: zod + .string() + .regex(/^gid:\/\/shopify\/Version\/\d+$/) + .describe('The Shopify app version GID.'), + name: zod.string().min(1).nullable().describe('The version tag, or null when no tag is available.'), + message: zod.string().min(1).nullable().describe('The version message, or null when no message is available.'), + url: zod.string().url().describe('The Developer Dashboard URL for this version.'), + }) + .strict() + +const releaseSchema = zod.object({version: versionSchema}).strict() + +export const appReleaseJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppReleaseResult', + schema: zod.discriminatedUnion('status', [ + zod.object({status: zod.literal('success'), app: appSchema, release: releaseSchema}).strict(), + zod.object({status: zod.literal('cancelled')}).strict(), + ]), + definitions: {App: appSchema, AppRelease: releaseSchema, AppReleaseVersion: versionSchema}, +}) + +export type AppReleaseResult = InferJsonOutputSchema + +export type ReleaseResult = + | {status: 'success'; version: AppVersionWithContext} + | {status: 'failed'; version: AppVersionWithContext; userErrors: UserError[]} + | {status: 'cancelled'} diff --git a/packages/app/src/cli/services/release/version-diff.test.ts b/packages/app/src/cli/services/release/version-diff.test.ts index 76608aa6354..0186b037e46 100644 --- a/packages/app/src/cli/services/release/version-diff.test.ts +++ b/packages/app/src/cli/services/release/version-diff.test.ts @@ -3,33 +3,25 @@ import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app import {AppVersionWithContext} from '../../utilities/developer-platform-client.js' import {AppVersionsDiffSchema} from '../../api/graphql/app_versions_diff.js' import {describe, expect, test} from 'vitest' -import {AbortSilentError} from '@shopify/cli-kit/node/error' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {AbortError} from '@shopify/cli-kit/node/error' describe('versionDiffByVersion', () => { - test('throws an abort silent error and display an error message when the version is not found', async () => { + test('reports the failed lookup as an abort without presentation', async () => { // Given const outputMock = mockAndCaptureOutput() + const cause = new AbortError('HTTP 404: Cannot find a valid organization') const developerPlatformClient = testDeveloperPlatformClient({ appVersionByTag: () => { - throw new Error('not found') + throw cause }, }) // When/Then - await expect(versionDiffByVersion(testOrganizationApp(), 'version', developerPlatformClient)).rejects.toThrow( - AbortSilentError, - ) - expect(outputMock.error()).toMatchInlineSnapshot(` - "╭─ error ──────────────────────────────────────────────────────────────────────╮ - │ │ - │ Version couldn't be released. │ - │ │ - │ Version version could not be found. │ - │ │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - " - `) + const result = versionDiffByVersion(testOrganizationApp(), 'version', developerPlatformClient) + await expect(result).rejects.toThrow(AbortError) + await expect(result).rejects.toThrow('Version version could not be found.') + expect(outputMock.error()).toBe('') }) test('returns versionDiff and versionDetails when the version is found', async () => { diff --git a/packages/app/src/cli/services/release/version-diff.ts b/packages/app/src/cli/services/release/version-diff.ts index 317da90ba64..73754c90902 100644 --- a/packages/app/src/cli/services/release/version-diff.ts +++ b/packages/app/src/cli/services/release/version-diff.ts @@ -1,8 +1,7 @@ import {AppVersionsDiffSchema} from '../../api/graphql/app_versions_diff.js' import {AppVersionWithContext, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {MinimalOrganizationApp} from '../../models/organization.js' -import {renderError} from '@shopify/cli-kit/node/ui' -import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {AbortError} from '@shopify/cli-kit/node/error' export async function versionDiffByVersion( app: MinimalOrganizationApp, @@ -29,13 +28,8 @@ async function versionDetailsByTag( developerPlatformClient: DeveloperPlatformClient, ) { try { - const appVersion = await developerPlatformClient.appVersionByTag(app, versionTag) - return appVersion - } catch (err) { - renderError({ - headline: "Version couldn't be released.", - body: ['Version', {userInput: versionTag}, 'could not be found.'], - }) - throw new AbortSilentError() + return await developerPlatformClient.appVersionByTag(app, versionTag) + } catch { + throw new AbortError(`Version ${versionTag} could not be found.`) } } diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index fe1c84726e5..6c46a2abcb1 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -1397,6 +1397,41 @@ describe('deploy', () => { }) }) +describe('release', () => { + test.each([null, {version: {id: 'gid://shopify/Version/123', metadata: {}}}])( + 'retains user errors with release response %j', + async (release) => { + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + const userError = {field: ['version'], message: 'Release failed.', category: 'validation', on: []} + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({appReleaseCreate: {release, userErrors: [userError]}}) + + await expect( + client.release({ + app: testOrganizationApp(), + version: {versionId: 'gid://shopify/Version/123', appVersionId: 123}, + }), + ).resolves.toEqual({appRelease: {userErrors: [{...userError, details: []}]}}) + }, + ) + + test.each([{userErrors: []}, {userErrors: undefined}])( + 'rejects a missing release without user errors ($userErrors)', + async ({userErrors}) => { + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({appReleaseCreate: {release: null, userErrors}}) + + await expect( + client.release({ + app: testOrganizationApp(), + version: {versionId: 'gid://shopify/Version/123', appVersionId: 123}, + }), + ).rejects.toThrow('The Developer Dashboard did not confirm the app version release.') + }, + ) +}) + describe('appVersions', () => { test('preserves a missing app in the API response instead of dereferencing it', async () => { // Given diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 1ae6c08e1b7..641a136a423 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -840,7 +840,7 @@ export class AppManagementClient implements DeveloperPlatformClient { variables: releaseVariables, }) - if (releaseResult.appReleaseCreate.release) { + if (releaseResult.appReleaseCreate.release && !releaseResult.appReleaseCreate.userErrors?.length) { return { appRelease: { appVersion: { @@ -855,6 +855,9 @@ export class AppManagementClient implements DeveloperPlatformClient { }, } } else { + if (!releaseResult.appReleaseCreate.userErrors?.length) { + throw new AbortError('The Developer Dashboard did not confirm the app version release.') + } return { appRelease: { userErrors: diff --git a/packages/cli/README.md b/packages/cli/README.md index 102b71e50b7..71e170345cb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3613,6 +3613,10 @@ FLAGS The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --allow-deletes 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. @@ -3663,6 +3667,131 @@ DESCRIPTION Release an app version. Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppReleaseResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "app": { + "$ref": "#/definitions/App" + }, + "release": { + "$ref": "#/definitions/AppRelease" + } + }, + "required": [ + "status", + "app", + "release" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ], + "title": "AppReleaseResult", + "definitions": { + "App": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "clientId": { + "type": "string", + "minLength": 1, + "description": "The client ID of the app receiving the release." + } + }, + "required": [ + "name", + "clientId" + ], + "additionalProperties": false + }, + "AppRelease": { + "type": "object", + "properties": { + "version": { + "$ref": "#/definitions/AppReleaseVersion" + } + }, + "required": [ + "version" + ], + "additionalProperties": false + }, + "AppReleaseVersion": { + "type": "object", + "properties": { + "gid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/Version\\/\\d+$", + "description": "The Shopify app version GID." + }, + "name": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "The version tag, or null when no tag is available." + }, + "message": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "The version message, or null when no message is available." + }, + "url": { + "type": "string", + "format": "uri", + "description": "The Developer Dashboard URL for this version." + } + }, + "required": [ + "gid", + "name", + "message", + "url" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app security check` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index f3e1faecad1..d03244304ab 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3901,7 +3901,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag.", + "description": "Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppReleaseResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/App\"\n },\n \"release\": {\n \"$ref\": \"#/definitions/AppRelease\"\n }\n },\n \"required\": [\n \"status\",\n \"app\",\n \"release\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"AppReleaseResult\",\n \"definitions\": {\n \"App\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The client ID of the app receiving the release.\"\n }\n },\n \"required\": [\n \"name\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n },\n \"AppRelease\": {\n \"type\": \"object\",\n \"properties\": {\n \"version\": {\n \"$ref\": \"#/definitions/AppReleaseVersion\"\n }\n },\n \"required\": [\n \"version\"\n ],\n \"additionalProperties\": false\n },\n \"AppReleaseVersion\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Version\\\\/\\\\d+$\",\n \"description\": \"The Shopify app version GID.\"\n },\n \"name\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"minLength\": 1\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The version tag, or null when no tag is available.\"\n },\n \"message\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"minLength\": 1\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The version message, or null when no message is available.\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"The Developer Dashboard URL for this version.\"\n }\n },\n \"required\": [\n \"gid\",\n \"name\",\n \"message\",\n \"url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag.", "flags": { "allow-deletes": { @@ -3950,6 +3950,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index bd4c9b82a8b..c91e1d4d762 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -20,7 +20,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/import/custom-data-definitions.ts', 'packages/app/src/cli/commands/app/import/dashboard-extensions.ts', 'packages/app/src/cli/commands/app/init.ts', - 'packages/app/src/cli/commands/app/release.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts',