From 4762dfaa780f077cca7376c92f8341d673dbc24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 13:50:55 +0200 Subject: [PATCH 1/6] Add typed JSON output to app release --- .changeset/json-app-release.md | 5 + .../generated/generated_docs_data_v2.json | 11 +- .../app/src/cli/commands/app/release.test.ts | 236 ++++++++++++++++++ packages/app/src/cli/commands/app/release.ts | 39 ++- packages/app/src/cli/services/release.test.ts | 45 ++-- packages/app/src/cli/services/release.ts | 25 +- .../src/cli/services/release/result.test.ts | 112 +++++++++ .../app/src/cli/services/release/result.ts | 72 ++++++ .../src/cli/services/release/types.test.ts | 36 +++ .../app/src/cli/services/release/types.ts | 40 +++ .../cli/services/release/version-diff.test.ts | 25 +- .../src/cli/services/release/version-diff.ts | 17 +- packages/cli/README.md | 129 ++++++++++ packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - 15 files changed, 717 insertions(+), 87 deletions(-) create mode 100644 .changeset/json-app-release.md create mode 100644 packages/app/src/cli/commands/app/release.test.ts create mode 100644 packages/app/src/cli/services/release/result.test.ts create mode 100644 packages/app/src/cli/services/release/result.ts create mode 100644 packages/app/src/cli/services/release/types.test.ts create mode 100644 packages/app/src/cli/services/release/types.ts 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 79b22e26edf..5a6f7951aaf 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -3371,9 +3371,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..31490ec6d9e --- /dev/null +++ b/packages/app/src/cli/commands/app/release.test.ts @@ -0,0 +1,236 @@ +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 {appReleaseJsonOutputSchema} from '../../services/release/types.js' +import {ReleaseVersionLookupError} from '../../services/release/version-diff.js' +import { + configExtensionsIdentifiersReleaseBreakdown, + extensionsIdentifiersReleaseBreakdown, +} from '../../services/context/breakdown-extensions.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import * as system from '@shopify/cli-kit/node/system' +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') +vi.mock('../../services/context/breakdown-extensions.js') +vi.mock('../../prompts/deploy-release.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: [], +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('app release command', () => { + beforeEach(() => { + vi.mocked(linkedAppContext).mockReset() + vi.mocked(linkedAppContext).mockResolvedValue({ + app: testAppLinked(), + remoteApp: testOrganizationApp(), + } as Awaited>) + vi.mocked(release).mockResolvedValue({status: 'success', version}) + }) + + test('exposes its schema and JSON flag in help', () => { + expect(Release.jsonOutputSchema).toBe(appReleaseJsonOutputSchema) + expect(Release.description).toContain('AppReleaseResult') + expect(Release.flags.json.char).toBe('j') + }) + + 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}) => { + const result = await runRelease(['--path', tmp, '--version', 'v1', '--json', '--no-input', '--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'}) + expect(result).toEqual({app: testAppLinked()}) + expect(release).toHaveBeenCalledWith(expect.objectContaining({version: 'v1', force: false, allowUpdates: true})) + }) + }) + }) + + test('writes cancelled after a declined confirmation', async () => { + vi.mocked(release).mockResolvedValue({status: 'cancelled'}) + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runRelease(['--path', tmp, '--version', 'v1', '--json', '--allow-updates']) + expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) + expect(stderr()).toBe('') + }) + }) + }) + + test('real release execution sends task progress to stderr and one result to stdout', async () => { + const {release: executeRelease} = + await vi.importActual('../../services/release.js') + vi.mocked(release).mockImplementationOnce(executeRelease) + vi.mocked(extensionsIdentifiersReleaseBreakdown).mockResolvedValue({ + extensionIdentifiersBreakdown: {onlyRemote: [], toCreate: [], toUpdate: [], unchanged: []}, + versionDetails: version, + }) + vi.mocked(configExtensionsIdentifiersReleaseBreakdown).mockReturnValue(undefined) + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + const developerPlatformClient = testDeveloperPlatformClient({release: async () => ({appRelease: {}})}) + const remoteApp = testOrganizationApp() + vi.mocked(linkedAppContext).mockResolvedValue({ + app: testAppLinked(), + remoteApp, + developerPlatformClient, + } as unknown as Awaited>) + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runRelease(['--path', tmp, '--version', 'v1', '--json', '--no-input', '--allow-updates']) + expect(JSON.parse(stdout())).toMatchObject({status: 'success', release: {version: {gid: version.uuid}}}) + expect( + stderr() + .trim() + .split('\n') + .map((event) => JSON.parse(event)), + ).toEqual([ + expect.objectContaining({type: 'progress', status: 'started', message: 'Releasing version'}), + expect.objectContaining({type: 'progress', status: 'completed', message: 'Releasing version'}), + ]) + expect(developerPlatformClient.release).toHaveBeenCalledWith({ + app: remoteApp, + version: {versionId: version.uuid, appVersionId: 123}, + }) + }) + }) + }) + + test('retains silent cancellation in text mode', async () => { + vi.mocked(release).mockResolvedValue({status: 'cancelled'}) + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runRelease(['--path', tmp, '--version', 'v1', '--allow-updates'])).rejects.toThrow() + expect(stdout()).toBe('') + }) + }) + }) + + test('a failed release uses the shared error document without a result', 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('a missing version retains the upstream fatal error', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(release).mockRejectedValue( + new ReleaseVersionLookupError('missing', new AbortError('Version not found for tag: missing')), + ) + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runRelease(['--path', tmp, '--version', 'missing', '--json', '--allow-updates'])).rejects.toThrow() + expect(JSON.parse(stdout())).toMatchObject({ + error: {type: 'abort', message: 'Version not found for tag: missing'}, + }) + expect(stderr()).toBe('') + }) + }) + }) + + test('does not report a silent failure as cancellation', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(release).mockRejectedValue(new AbortSilentError()) + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runRelease(['--path', tmp, '--version', 'v1', '--json', '--allow-updates'])).rejects.toThrow() + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort'}}) + }) + }) + }) + + test('JSON does not disable confirmation policy', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(false) + try { + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runRelease(['--path', tmp, '--version', 'v1', '--json'])).rejects.toThrow() + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort'}}) + expect(release).not.toHaveBeenCalled() + }) + }) + } finally { + vi.mocked(system.terminalSupportsPrompting).mockRestore() + } + }) + + test('JSON remains interactive when input is available', async () => { + vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(true) + try { + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout}) => { + await runRelease(['--path', tmp, '--version', 'v1', '--json']) + expect(JSON.parse(stdout())).toMatchObject({status: 'success'}) + expect(release).toHaveBeenCalledWith( + expect.objectContaining({allowUpdates: undefined, allowDeletes: undefined, force: false}), + ) + }) + }) + } finally { + vi.mocked(system.terminalSupportsPrompting).mockRestore() + } + }) + + test('no-input alone keeps text output', async () => { + await inTemporaryDirectory(async (tmp) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runRelease(['--path', tmp, '--version', 'v1', '--no-input', '--allow-updates']) + expect(stdout()).toBe('') + expect(stderr()).toContain('Version released to users.') + }) + }) + }) + + test('requires a version before loading app context', async () => { + await expect(runRelease(['--json', '--allow-updates'])).rejects.toThrow() + expect(linkedAppContext).not.toHaveBeenCalled() + }) + + test('rejects unsupported environment batches', async () => { + await expect(runRelease(['--version', 'v1', '--allow-updates', '--environment', 'production'])).rejects.toThrow() + expect(release).not.toHaveBeenCalled() + }) +}) + +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..7ff6c6afcde 100644 --- a/packages/app/src/cli/commands/app/release.ts +++ b/packages/app/src/cli/commands/app/release.ts @@ -1,10 +1,14 @@ import {appFlags} from '../../flags.js' import {release} from '../../services/release.js' +import {appReleaseJsonOutputSchema} from '../../services/release/types.js' +import {renderAppReleaseError, renderAppReleaseResult} from '../../services/release/result.js' +import {ReleaseVersionLookupError} from '../../services/release/version-diff.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 {AbortSilentError} from '@shopify/cli-kit/node/error' import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' export default class Release extends AppLinkedCommand { @@ -14,11 +18,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,16 +73,24 @@ export default class Release extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - await release({ - app, - remoteApp, - developerPlatformClient, - force, - allowUpdates, - allowDeletes, - version: flags.version, - }) - + let result + try { + result = await release({ + app, + remoteApp, + developerPlatformClient, + force, + allowUpdates, + allowDeletes, + version: flags.version, + }) + } catch (error) { + if (error instanceof ReleaseVersionLookupError || error instanceof AbortSilentError) { + renderAppReleaseError(error, flags.json ? 'json' : 'text') + } + throw error + } + 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..ddcecf50dfb 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 () => { @@ -74,28 +76,18 @@ describe('release', () => { 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 +114,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 +135,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..0d9e6ae54d8 --- /dev/null +++ b/packages/app/src/cli/services/release/result.test.ts @@ -0,0 +1,112 @@ +import {renderAppReleaseError, renderAppReleaseResult} from './result.js' +import {ReleaseVersionLookupError} from './version-diff.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 {AbortError, 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('projects only public data through the real encoder and stdout writer', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + renderAppReleaseResult(result, testOrganizationApp(), 'json') + 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(stderr()).toBe('') + expect(stdout()).not.toContain('apiSecret') + expect(stdout()).not.toContain('appModuleVersions') + }) +}) + +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('JSON failure throws an abort with native user errors and no result', async () => { + const userErrors = [{message: 'Release failed.', category: 'validation', details: [], field: ['version']}] + await withCapturedStandardStreams(async ({stdout, stderr}) => { + expect(() => + renderAppReleaseResult({status: 'failed', version, userErrors}, testOrganizationApp(), 'json'), + ).toThrow(AbortError) + expect(() => + renderAppReleaseResult({status: 'failed', version, userErrors}, testOrganizationApp(), 'json'), + ).toThrow(expect.objectContaining({details: {userErrors}})) + expect(stdout()).toBe('') + expect(stderr()).toBe('') + }) +}) + +test('declined confirmation is a JSON result and retains silent text cancellation', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'json') + expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) + expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'text')).toThrow(AbortSilentError) + expect(stderr()).toBe('') + }) +}) + +test('text preserves the missing-version banner, while JSON retains the original failure', async () => { + const cause = new AbortError('Version not found for tag: missing') + const error = new ReleaseVersionLookupError('missing', cause) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + expect(() => renderAppReleaseError(error, 'text')).toThrow(AbortSilentError) + expect(unstyled(stderr())).toContain('Version missing could not be found.') + expect(stdout()).toBe('') + expect(() => renderAppReleaseError(error, 'json')).toThrow(cause) + }) +}) + +test('does not turn unrelated failures into cancellation', () => { + const cause = new AbortSilentError() + expect(() => renderAppReleaseError(cause, 'json')).toThrow(AbortError) + expect(() => renderAppReleaseError(cause, 'text')).toThrow(cause) +}) 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..7918a520a58 --- /dev/null +++ b/packages/app/src/cli/services/release/result.ts @@ -0,0 +1,72 @@ +import {appReleaseJsonOutputSchema, type AppReleaseResult, type ReleaseResult} from './types.js' +import {ReleaseVersionLookupError} from './version-diff.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))) + 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}) + } +} + +export function renderAppReleaseError(error: unknown, format: 'json' | 'text'): never { + if (format === 'json') { + const cause = error instanceof ReleaseVersionLookupError ? error.cause : error + if (cause instanceof AbortSilentError) { + throw new AbortError('The app version could not be released. See the release diagnostics for details.') + } + throw cause + } + if (!(error instanceof ReleaseVersionLookupError)) throw error + renderError({ + headline: "Version couldn't be released.", + body: ['Version', {userInput: error.versionTag}, 'could not be found.'], + }) + throw new AbortSilentError() +} + +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..721b9dd37b7 --- /dev/null +++ b/packages/app/src/cli/services/release/types.test.ts @@ -0,0 +1,36 @@ +import {appReleaseJsonOutputSchema} from './types.js' +import {describe, expect, test} from 'vitest' + +const version = { + gid: 'gid://shopify/Version/123', + name: 'v1', + message: null, + url: 'https://dev.shopify.com/dashboard/1/apps/1/versions/123', +} +const result = {status: 'success' as const, app: {name: 'Example', clientId: 'client-id'}, release: {version}} + +describe('appReleaseJsonOutputSchema', () => { + test('encodes success and unavailable version fields', () => { + const value = {...result, release: {version: {...version, name: null}}} + expect(JSON.parse(appReleaseJsonOutputSchema.encode(value))).toEqual(value) + }) + + test('encodes cancellation without a release', () => { + expect(JSON.parse(appReleaseJsonOutputSchema.encode({status: 'cancelled'}))).toEqual({status: 'cancelled'}) + }) + + test.each([ + {...result, status: 'unknown'}, + {...result, app: {...result.app, apiSecret: 'secret'}}, + {...result, app: {...result.app, clientId: ''}}, + {...result, release: {...result.release, internalId: 'internal'}}, + {...result, release: {version: {...version, gid: '123'}}}, + {...result, release: {version: {...version, url: 'not-a-url'}}}, + {...result, release: {version: {...version, name: ''}}}, + {...result, release: {version: {...version, message: ''}}}, + {...result, release: {version: {...version, id: 123}}}, + {status: 'cancelled', release: {version}}, + ])('rejects invalid CLI-owned fields: %j', (value) => { + expect(() => appReleaseJsonOutputSchema.validate(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..8a0e312d316 100644 --- a/packages/app/src/cli/services/release/version-diff.test.ts +++ b/packages/app/src/cli/services/release/version-diff.test.ts @@ -1,35 +1,26 @@ -import {versionDiffByVersion} from './version-diff.js' +import {ReleaseVersionLookupError, versionDiffByVersion} from './version-diff.js' import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' 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' 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 without presentation and retains its cause', async () => { // Given const outputMock = mockAndCaptureOutput() + const cause = new Error('not found') 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(ReleaseVersionLookupError) + await expect(result).rejects.toMatchObject({versionTag: 'version', cause}) + 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..11846904c23 100644 --- a/packages/app/src/cli/services/release/version-diff.ts +++ b/packages/app/src/cli/services/release/version-diff.ts @@ -1,8 +1,15 @@ 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' + +export class ReleaseVersionLookupError extends Error { + constructor( + public readonly versionTag: string, + cause: unknown, + ) { + super(`Version ${versionTag} could not be found.`, {cause}) + } +} export async function versionDiffByVersion( app: MinimalOrganizationApp, @@ -32,10 +39,6 @@ async function versionDetailsByTag( 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() + throw new ReleaseVersionLookupError(versionTag, err) } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 95b19a8e8b7..c5d95920a50 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3529,6 +3529,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. @@ -3579,6 +3583,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 31e82b9ad49..750bcf957d4 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3883,7 +3883,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": { @@ -3932,6 +3932,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 0fcd20c5ff1..a138d768368 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -22,7 +22,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/subscription-migrations/cancel.ts', 'packages/app/src/cli/commands/app/subscription-migrations/list.ts', 'packages/app/src/cli/commands/app/subscription-migrations/schedule.ts', From a1fa51adbe2d919059ab52f4b4ac3c21158a7742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:02:15 +0200 Subject: [PATCH 2/6] Skip success hooks after cancelled JSON releases --- .../app/src/cli/commands/app/release.test.ts | 52 ++++++++++++++++++- .../src/cli/services/release/result.test.ts | 32 +++++++++++- .../app/src/cli/services/release/result.ts | 1 + 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/commands/app/release.test.ts b/packages/app/src/cli/commands/app/release.test.ts index 31490ec6d9e..f79cc42774f 100644 --- a/packages/app/src/cli/commands/app/release.test.ts +++ b/packages/app/src/cli/commands/app/release.test.ts @@ -77,7 +77,7 @@ describe('app release command', () => { vi.mocked(release).mockResolvedValue({status: 'cancelled'}) await inTemporaryDirectory(async (tmp) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runRelease(['--path', tmp, '--version', 'v1', '--json', '--allow-updates']) + await expect(runRelease(['--path', tmp, '--version', 'v1', '--json', '--allow-updates'])).rejects.toThrow() expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) expect(stderr()).toBe('') }) @@ -122,6 +122,56 @@ describe('app release command', () => { }) }) + test.each(['success', 'cancelled'] as const)( + 'runs success hooks only for a completed command: %s', + async (status) => { + class ControlledRelease extends Release { + async catch(error: Error): Promise { + throw error + } + + protected async init(): Promise {} + } + + vi.mocked(release).mockResolvedValue( + status === 'cancelled' ? {status: 'cancelled'} : {status: 'success', version}, + ) + await inTemporaryDirectory(async (directory) => { + const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../..')}) + const runHook = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) + const cachedCommand = { + id: 'app:release', + aliases: [], + hiddenAliases: [], + hidden: false, + args: {}, + flags: {}, + load: async () => ControlledRelease, + } + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const execution = config.runCommand( + 'app:release', + ['--path', directory, '--version', 'v1', '--json', '--allow-updates'], + cachedCommand, + ) + if (status === 'cancelled') { + await expect(execution).rejects.toMatchObject({oclif: {exit: 0}}) + expect(runHook).not.toHaveBeenCalledWith('postrun', expect.anything()) + } else { + await execution + expect(runHook).toHaveBeenCalledWith('postrun', expect.anything()) + } + expect(JSON.parse(stdout())).toMatchObject({status}) + expect(stderr()).toBe('') + }) + } finally { + runHook.mockRestore() + } + }) + }, + ) + test('retains silent cancellation in text mode', async () => { vi.mocked(release).mockResolvedValue({status: 'cancelled'}) await inTemporaryDirectory(async (tmp) => { diff --git a/packages/app/src/cli/services/release/result.test.ts b/packages/app/src/cli/services/release/result.test.ts index 0d9e6ae54d8..880f85c64be 100644 --- a/packages/app/src/cli/services/release/result.test.ts +++ b/packages/app/src/cli/services/release/result.test.ts @@ -4,7 +4,9 @@ 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 {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' -import {expect, test} from 'vitest' +import {errorHandler} from '@shopify/cli-kit/node/error-handler' +import {Errors} from '@oclif/core' +import {expect, test, vi} from 'vitest' import type {ReleaseResult} from './types.js' const version = { @@ -87,7 +89,9 @@ test('JSON failure throws an abort with native user errors and no result', async test('declined confirmation is a JSON result and retains silent text cancellation', async () => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'json') + expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'json')).toThrow( + expect.objectContaining({oclif: {exit: 0}}), + ) expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'text')).toThrow(AbortSilentError) expect(stderr()).toBe('') @@ -110,3 +114,27 @@ test('does not turn unrelated failures into cancellation', () => { expect(() => renderAppReleaseError(cause, 'json')).toThrow(AbortError) expect(() => renderAppReleaseError(cause, 'text')).toThrow(cause) }) + +test('cancelled JSON uses the standard silent handler and exits zero without another document', async () => { + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + let cancellation: unknown + try { + renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'json') + } catch (error) { + if (!(error instanceof AbortSilentError)) throw error + cancellation = error + } + expect(cancellation).toBeInstanceOf(AbortSilentError) + expect(cancellation).toMatchObject({oclif: {exit: 0}}) + await errorHandler(cancellation as Error) + await Errors.handle(cancellation as Error) + expect(exit).toHaveBeenCalledExactlyOnceWith(0) + expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) + expect(stderr()).toBe('') + }) + } finally { + exit.mockRestore() + } +}) diff --git a/packages/app/src/cli/services/release/result.ts b/packages/app/src/cli/services/release/result.ts index 7918a520a58..0c14261b80c 100644 --- a/packages/app/src/cli/services/release/result.ts +++ b/packages/app/src/cli/services/release/result.ts @@ -17,6 +17,7 @@ export function renderAppReleaseResult( throw error } outputResult(appReleaseJsonOutputSchema.encode(appReleaseResult(result, remoteApp))) + if (result.status === 'cancelled') throw Object.assign(new AbortSilentError(), {oclif: {exit: 0}}) return } From 7b7e8f2d9d9fdd4aac7a8f31fb95d8b9fd397b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:43:51 +0200 Subject: [PATCH 3/6] Focus release JSON tests on changed behavior --- .../app/src/cli/commands/app/release.test.ts | 239 +++--------------- .../src/cli/services/release/result.test.ts | 69 +---- .../src/cli/services/release/types.test.ts | 36 +-- 3 files changed, 42 insertions(+), 302 deletions(-) diff --git a/packages/app/src/cli/commands/app/release.test.ts b/packages/app/src/cli/commands/app/release.test.ts index f79cc42774f..bf5e2f8e3b9 100644 --- a/packages/app/src/cli/commands/app/release.test.ts +++ b/packages/app/src/cli/commands/app/release.test.ts @@ -1,28 +1,19 @@ 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 {appReleaseJsonOutputSchema} from '../../services/release/types.js' -import {ReleaseVersionLookupError} from '../../services/release/version-diff.js' -import { - configExtensionsIdentifiersReleaseBreakdown, - extensionsIdentifiersReleaseBreakdown, -} from '../../services/context/breakdown-extensions.js' -import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {testAppLinked, 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} from '@shopify/cli-kit/node/output' -import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' -import * as system from '@shopify/cli-kit/node/system' -import {Config} from '@oclif/core' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {errorHandler} from '@shopify/cli-kit/node/error-handler' +import {Config, Errors} 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') -vi.mock('../../services/context/breakdown-extensions.js') -vi.mock('../../prompts/deploy-release.js') const version = { id: 123, @@ -39,7 +30,6 @@ afterEach(() => { describe('app release command', () => { beforeEach(() => { - vi.mocked(linkedAppContext).mockReset() vi.mocked(linkedAppContext).mockResolvedValue({ app: testAppLinked(), remoteApp: testOrganizationApp(), @@ -47,12 +37,6 @@ describe('app release command', () => { vi.mocked(release).mockResolvedValue({status: 'success', version}) }) - test('exposes its schema and JSON flag in help', () => { - expect(Release.jsonOutputSchema).toBe(appReleaseJsonOutputSchema) - expect(Release.description).toContain('AppReleaseResult') - expect(Release.flags.json.char).toBe('j') - }) - test('writes one JSON result and sends diagnostics to stderr', async () => { vi.mocked(release).mockImplementationOnce(async () => { outputInfo('Releasing an app version') @@ -60,124 +44,13 @@ describe('app release command', () => { }) await inTemporaryDirectory(async (tmp) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - const result = await runRelease(['--path', tmp, '--version', 'v1', '--json', '--no-input', '--allow-updates']) + 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'}) - expect(result).toEqual({app: testAppLinked()}) - expect(release).toHaveBeenCalledWith(expect.objectContaining({version: 'v1', force: false, allowUpdates: true})) - }) - }) - }) - - test('writes cancelled after a declined confirmation', async () => { - vi.mocked(release).mockResolvedValue({status: 'cancelled'}) - 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())).toEqual({status: 'cancelled'}) - expect(stderr()).toBe('') - }) - }) - }) - - test('real release execution sends task progress to stderr and one result to stdout', async () => { - const {release: executeRelease} = - await vi.importActual('../../services/release.js') - vi.mocked(release).mockImplementationOnce(executeRelease) - vi.mocked(extensionsIdentifiersReleaseBreakdown).mockResolvedValue({ - extensionIdentifiersBreakdown: {onlyRemote: [], toCreate: [], toUpdate: [], unchanged: []}, - versionDetails: version, - }) - vi.mocked(configExtensionsIdentifiersReleaseBreakdown).mockReturnValue(undefined) - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - const developerPlatformClient = testDeveloperPlatformClient({release: async () => ({appRelease: {}})}) - const remoteApp = testOrganizationApp() - vi.mocked(linkedAppContext).mockResolvedValue({ - app: testAppLinked(), - remoteApp, - developerPlatformClient, - } as unknown as Awaited>) - await inTemporaryDirectory(async (tmp) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runRelease(['--path', tmp, '--version', 'v1', '--json', '--no-input', '--allow-updates']) - expect(JSON.parse(stdout())).toMatchObject({status: 'success', release: {version: {gid: version.uuid}}}) - expect( - stderr() - .trim() - .split('\n') - .map((event) => JSON.parse(event)), - ).toEqual([ - expect.objectContaining({type: 'progress', status: 'started', message: 'Releasing version'}), - expect.objectContaining({type: 'progress', status: 'completed', message: 'Releasing version'}), - ]) - expect(developerPlatformClient.release).toHaveBeenCalledWith({ - app: remoteApp, - version: {versionId: version.uuid, appVersionId: 123}, - }) - }) - }) - }) - - test.each(['success', 'cancelled'] as const)( - 'runs success hooks only for a completed command: %s', - async (status) => { - class ControlledRelease extends Release { - async catch(error: Error): Promise { - throw error - } - - protected async init(): Promise {} - } - - vi.mocked(release).mockResolvedValue( - status === 'cancelled' ? {status: 'cancelled'} : {status: 'success', version}, - ) - await inTemporaryDirectory(async (directory) => { - const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../..')}) - const runHook = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) - const cachedCommand = { - id: 'app:release', - aliases: [], - hiddenAliases: [], - hidden: false, - args: {}, - flags: {}, - load: async () => ControlledRelease, - } - try { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - const execution = config.runCommand( - 'app:release', - ['--path', directory, '--version', 'v1', '--json', '--allow-updates'], - cachedCommand, - ) - if (status === 'cancelled') { - await expect(execution).rejects.toMatchObject({oclif: {exit: 0}}) - expect(runHook).not.toHaveBeenCalledWith('postrun', expect.anything()) - } else { - await execution - expect(runHook).toHaveBeenCalledWith('postrun', expect.anything()) - } - expect(JSON.parse(stdout())).toMatchObject({status}) - expect(stderr()).toBe('') - }) - } finally { - runHook.mockRestore() - } - }) - }, - ) - - test('retains silent cancellation in text mode', async () => { - vi.mocked(release).mockResolvedValue({status: 'cancelled'}) - await inTemporaryDirectory(async (tmp) => { - await withCapturedStandardStreams(async ({stdout}) => { - await expect(runRelease(['--path', tmp, '--version', 'v1', '--allow-updates'])).rejects.toThrow() - expect(stdout()).toBe('') }) }) }) @@ -197,85 +70,45 @@ describe('app release command', () => { }) }) - test('a missing version retains the upstream fatal error', async () => { - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - vi.mocked(release).mockRejectedValue( - new ReleaseVersionLookupError('missing', new AbortError('Version not found for tag: missing')), - ) - await inTemporaryDirectory(async (tmp) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(runRelease(['--path', tmp, '--version', 'missing', '--json', '--allow-updates'])).rejects.toThrow() - expect(JSON.parse(stdout())).toMatchObject({ - error: {type: 'abort', message: 'Version not found for tag: missing'}, - }) - expect(stderr()).toBe('') - }) - }) - }) - - test('does not report a silent failure as cancellation', async () => { - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - vi.mocked(release).mockRejectedValue(new AbortSilentError()) - await inTemporaryDirectory(async (tmp) => { - await withCapturedStandardStreams(async ({stdout}) => { - await expect(runRelease(['--path', tmp, '--version', 'v1', '--json', '--allow-updates'])).rejects.toThrow() - expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort'}}) - }) - }) - }) + test('cancelled JSON exits zero without running success hooks', async () => { + class CancelledRelease extends Release { + async catch(error: Error): Promise { + await errorHandler(error) + await Errors.handle(error) + throw error + } - test('JSON does not disable confirmation policy', async () => { - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(false) - try { - await inTemporaryDirectory(async (tmp) => { - await withCapturedStandardStreams(async ({stdout}) => { - await expect(runRelease(['--path', tmp, '--version', 'v1', '--json'])).rejects.toThrow() - expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort'}}) - expect(release).not.toHaveBeenCalled() - }) - }) - } finally { - vi.mocked(system.terminalSupportsPrompting).mockRestore() + protected async init(): Promise {} } - }) - - test('JSON remains interactive when input is available', async () => { - vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(true) + vi.mocked(release).mockResolvedValue({status: 'cancelled'}) + const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../..')}) + const runHook = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) try { - await inTemporaryDirectory(async (tmp) => { - await withCapturedStandardStreams(async ({stdout}) => { - await runRelease(['--path', tmp, '--version', 'v1', '--json']) - expect(JSON.parse(stdout())).toMatchObject({status: 'success'}) - expect(release).toHaveBeenCalledWith( - expect.objectContaining({allowUpdates: undefined, allowDeletes: undefined, force: false}), - ) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + config.runCommand('app:release', ['--path', directory, '--version', 'v1', '--json', '--allow-updates'], { + id: 'app:release', + aliases: [], + hiddenAliases: [], + hidden: false, + args: {}, + flags: {}, + load: async () => CancelledRelease, + }), + ).rejects.toBeInstanceOf(AbortSilentError) + expect(exit).toHaveBeenCalledExactlyOnceWith(0) + expect(runHook).not.toHaveBeenCalledWith('postrun', expect.anything()) + expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) + expect(stderr()).toBe('') }) }) } finally { - vi.mocked(system.terminalSupportsPrompting).mockRestore() + runHook.mockRestore() + exit.mockRestore() } }) - - test('no-input alone keeps text output', async () => { - await inTemporaryDirectory(async (tmp) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runRelease(['--path', tmp, '--version', 'v1', '--no-input', '--allow-updates']) - expect(stdout()).toBe('') - expect(stderr()).toContain('Version released to users.') - }) - }) - }) - - test('requires a version before loading app context', async () => { - await expect(runRelease(['--json', '--allow-updates'])).rejects.toThrow() - expect(linkedAppContext).not.toHaveBeenCalled() - }) - - test('rejects unsupported environment batches', async () => { - await expect(runRelease(['--version', 'v1', '--allow-updates', '--environment', 'production'])).rejects.toThrow() - expect(release).not.toHaveBeenCalled() - }) }) async function runRelease(argv: string[]) { diff --git a/packages/app/src/cli/services/release/result.test.ts b/packages/app/src/cli/services/release/result.test.ts index 880f85c64be..d7309ee2141 100644 --- a/packages/app/src/cli/services/release/result.test.ts +++ b/packages/app/src/cli/services/release/result.test.ts @@ -4,9 +4,7 @@ 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 {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' -import {errorHandler} from '@shopify/cli-kit/node/error-handler' -import {Errors} from '@oclif/core' -import {expect, test, vi} from 'vitest' +import {expect, test} from 'vitest' import type {ReleaseResult} from './types.js' const version = { @@ -19,22 +17,6 @@ const version = { } const result: ReleaseResult = {status: 'success', version} -test('projects only public data through the real encoder and stdout writer', async () => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - renderAppReleaseResult(result, testOrganizationApp(), 'json') - 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(stderr()).toBe('') - expect(stdout()).not.toContain('apiSecret') - expect(stdout()).not.toContain('appModuleVersions') - }) -}) - test('encodes unavailable version fields as null', async () => { await withCapturedStandardStreams(async ({stdout}) => { renderAppReleaseResult( @@ -73,29 +55,8 @@ test('text retains the failed release banner and exit behavior', async () => { }) }) -test('JSON failure throws an abort with native user errors and no result', async () => { - const userErrors = [{message: 'Release failed.', category: 'validation', details: [], field: ['version']}] - await withCapturedStandardStreams(async ({stdout, stderr}) => { - expect(() => - renderAppReleaseResult({status: 'failed', version, userErrors}, testOrganizationApp(), 'json'), - ).toThrow(AbortError) - expect(() => - renderAppReleaseResult({status: 'failed', version, userErrors}, testOrganizationApp(), 'json'), - ).toThrow(expect.objectContaining({details: {userErrors}})) - expect(stdout()).toBe('') - expect(stderr()).toBe('') - }) -}) - -test('declined confirmation is a JSON result and retains silent text cancellation', async () => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'json')).toThrow( - expect.objectContaining({oclif: {exit: 0}}), - ) - expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) - expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'text')).toThrow(AbortSilentError) - expect(stderr()).toBe('') - }) +test('text retains silent cancellation', () => { + expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'text')).toThrow(AbortSilentError) }) test('text preserves the missing-version banner, while JSON retains the original failure', async () => { @@ -114,27 +75,3 @@ test('does not turn unrelated failures into cancellation', () => { expect(() => renderAppReleaseError(cause, 'json')).toThrow(AbortError) expect(() => renderAppReleaseError(cause, 'text')).toThrow(cause) }) - -test('cancelled JSON uses the standard silent handler and exits zero without another document', async () => { - const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) - try { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - let cancellation: unknown - try { - renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'json') - } catch (error) { - if (!(error instanceof AbortSilentError)) throw error - cancellation = error - } - expect(cancellation).toBeInstanceOf(AbortSilentError) - expect(cancellation).toMatchObject({oclif: {exit: 0}}) - await errorHandler(cancellation as Error) - await Errors.handle(cancellation as Error) - expect(exit).toHaveBeenCalledExactlyOnceWith(0) - expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) - expect(stderr()).toBe('') - }) - } finally { - exit.mockRestore() - } -}) diff --git a/packages/app/src/cli/services/release/types.test.ts b/packages/app/src/cli/services/release/types.test.ts index 721b9dd37b7..40c570cef41 100644 --- a/packages/app/src/cli/services/release/types.test.ts +++ b/packages/app/src/cli/services/release/types.test.ts @@ -1,36 +1,6 @@ import {appReleaseJsonOutputSchema} from './types.js' -import {describe, expect, test} from 'vitest' +import {expect, test} from 'vitest' -const version = { - gid: 'gid://shopify/Version/123', - name: 'v1', - message: null, - url: 'https://dev.shopify.com/dashboard/1/apps/1/versions/123', -} -const result = {status: 'success' as const, app: {name: 'Example', clientId: 'client-id'}, release: {version}} - -describe('appReleaseJsonOutputSchema', () => { - test('encodes success and unavailable version fields', () => { - const value = {...result, release: {version: {...version, name: null}}} - expect(JSON.parse(appReleaseJsonOutputSchema.encode(value))).toEqual(value) - }) - - test('encodes cancellation without a release', () => { - expect(JSON.parse(appReleaseJsonOutputSchema.encode({status: 'cancelled'}))).toEqual({status: 'cancelled'}) - }) - - test.each([ - {...result, status: 'unknown'}, - {...result, app: {...result.app, apiSecret: 'secret'}}, - {...result, app: {...result.app, clientId: ''}}, - {...result, release: {...result.release, internalId: 'internal'}}, - {...result, release: {version: {...version, gid: '123'}}}, - {...result, release: {version: {...version, url: 'not-a-url'}}}, - {...result, release: {version: {...version, name: ''}}}, - {...result, release: {version: {...version, message: ''}}}, - {...result, release: {version: {...version, id: 123}}}, - {status: 'cancelled', release: {version}}, - ])('rejects invalid CLI-owned fields: %j', (value) => { - expect(() => appReleaseJsonOutputSchema.validate(value)).toThrow() - }) +test('rejects unexpected fields in the public result', () => { + expect(() => appReleaseJsonOutputSchema.validate({status: 'cancelled', internalId: 'private'})).toThrow() }) From 8e4ebdc2027a926291016cb10495db803374ec0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 16:00:37 +0200 Subject: [PATCH 4/6] Declare release JSON schema as a static field --- bin/prettify-manifests.js | 7 ++++++- packages/app/src/cli/commands/app/release.ts | 4 +--- packages/eslint-plugin-cli/rules/command-json-output.js | 4 +++- .../eslint-plugin-cli/rules/command-json-output.test.js | 4 +--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 2363b227819..5ce134edb21 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,6 +13,11 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') + const manifest = JSON.parse(content) + for (const command of Object.values(manifest.commands)) { + // Runtime schemas belong to command classes, not cached metadata. + delete command.jsonOutputSchema + } + const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } diff --git a/packages/app/src/cli/commands/app/release.ts b/packages/app/src/cli/commands/app/release.ts index 7ff6c6afcde..9f981bb6c1d 100644 --- a/packages/app/src/cli/commands/app/release.ts +++ b/packages/app/src/cli/commands/app/release.ts @@ -18,9 +18,7 @@ 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 jsonOutputSchema = appReleaseJsonOutputSchema static description = this.descriptionForHelp() diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index a6493344283..4d41adab57a 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,7 +62,9 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), + isStaticMemberNamed(member, 'jsonOutputSchema') && + ((member.type === 'MethodDefinition' && member.kind === 'get') || + (member.type === 'PropertyDefinition' && Boolean(member.value))), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index 5eaa3d46144..b095246b579 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,9 +37,7 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static get jsonOutputSchema() { - return widgetDeleteJsonOutputSchema - } + static jsonOutputSchema = widgetDeleteJsonOutputSchema } `, }, From 126d935b0070b68b0aa666f0c2d259531546c243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 8 Oct 2026 18:03:14 +0200 Subject: [PATCH 5/6] Keep release JSON conversion focused and preserve release failures --- bin/prettify-manifests.js | 7 +-- .../app/src/cli/commands/app/release.test.ts | 48 +++++++------------ packages/app/src/cli/commands/app/release.ts | 4 +- packages/app/src/cli/services/release.test.ts | 4 +- .../src/cli/services/release/types.test.ts | 17 ++++++- .../app-management-client.test.ts | 35 ++++++++++++++ .../app-management-client.ts | 5 +- .../rules/command-json-output.js | 4 +- .../rules/command-json-output.test.js | 4 +- 9 files changed, 82 insertions(+), 46 deletions(-) diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 5ce134edb21..2363b227819 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,11 +13,6 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const manifest = JSON.parse(content) - for (const command of Object.values(manifest.commands)) { - // Runtime schemas belong to command classes, not cached metadata. - delete command.jsonOutputSchema - } - const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') + const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } diff --git a/packages/app/src/cli/commands/app/release.test.ts b/packages/app/src/cli/commands/app/release.test.ts index bf5e2f8e3b9..14fdff47f36 100644 --- a/packages/app/src/cli/commands/app/release.test.ts +++ b/packages/app/src/cli/commands/app/release.test.ts @@ -1,13 +1,13 @@ import Release from './release.js' import {release} from '../../services/release.js' import {linkedAppContext} from '../../services/app-context.js' +import {ReleaseVersionLookupError} from '../../services/release/version-diff.js' import {testAppLinked, 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} from '@shopify/cli-kit/node/output' -import {AbortSilentError} from '@shopify/cli-kit/node/error' -import {errorHandler} from '@shopify/cli-kit/node/error-handler' -import {Config, Errors} from '@oclif/core' +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' @@ -24,7 +24,9 @@ const version = { appModuleVersions: [], } +const originalExitCode = process.exitCode afterEach(() => { + process.exitCode = originalExitCode vi.unstubAllEnvs() }) @@ -55,57 +57,41 @@ describe('app release command', () => { }) }) - test('a failed release uses the shared error document without a result', async () => { + test.each(['release rejection', 'missing version'])('%s uses one shared error document', async (failure) => { vi.stubEnv('SHOPIFY_FLAG_JSON', '1') const userErrors = [{message: 'Release failed.', category: 'validation', details: [], field: ['version']}] - vi.mocked(release).mockResolvedValue({status: 'failed', version, userErrors}) + if (failure === 'missing version') { + vi.mocked(release).mockRejectedValue(new ReleaseVersionLookupError('v1', new AbortError('Version not found.'))) + } else { + 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}}, + error: + failure === 'missing version' + ? {type: 'abort', message: 'Version not found.'} + : {type: 'abort', message: "Version couldn't be released.", details: {userErrors}}, }) expect(stderr()).toBe('') }) }) }) - test('cancelled JSON exits zero without running success hooks', async () => { - class CancelledRelease extends Release { - async catch(error: Error): Promise { - await errorHandler(error) - await Errors.handle(error) - throw error - } - - protected async init(): Promise {} - } + test('cancelled JSON exits zero through the silent error path', async () => { vi.mocked(release).mockResolvedValue({status: 'cancelled'}) - const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../..')}) - const runHook = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) try { await inTemporaryDirectory(async (directory) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect( - config.runCommand('app:release', ['--path', directory, '--version', 'v1', '--json', '--allow-updates'], { - id: 'app:release', - aliases: [], - hiddenAliases: [], - hidden: false, - args: {}, - flags: {}, - load: async () => CancelledRelease, - }), - ).rejects.toBeInstanceOf(AbortSilentError) + await runRelease(['--path', directory, '--version', 'v1', '--json', '--allow-updates']) expect(exit).toHaveBeenCalledExactlyOnceWith(0) - expect(runHook).not.toHaveBeenCalledWith('postrun', expect.anything()) expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) expect(stderr()).toBe('') }) }) } finally { - runHook.mockRestore() exit.mockRestore() } }) diff --git a/packages/app/src/cli/commands/app/release.ts b/packages/app/src/cli/commands/app/release.ts index 9f981bb6c1d..7ff6c6afcde 100644 --- a/packages/app/src/cli/commands/app/release.ts +++ b/packages/app/src/cli/commands/app/release.ts @@ -18,7 +18,9 @@ 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 jsonOutputSchema = appReleaseJsonOutputSchema + static get jsonOutputSchema() { + return appReleaseJsonOutputSchema + } static description = this.descriptionForHelp() diff --git a/packages/app/src/cli/services/release.test.ts b/packages/app/src/cli/services/release.test.ts index ddcecf50dfb..f4485dab52f 100644 --- a/packages/app/src/cli/services/release.test.ts +++ b/packages/app/src/cli/services/release.test.ts @@ -69,7 +69,9 @@ describe('release', () => { return { appRelease: { - appRelease: {}, + appRelease: { + appVersion: {versionTag: '1.0.0', message: 'message', location: 'https://example.com'}, + }, }, } }) diff --git a/packages/app/src/cli/services/release/types.test.ts b/packages/app/src/cli/services/release/types.test.ts index 40c570cef41..3fed3934dd5 100644 --- a/packages/app/src/cli/services/release/types.test.ts +++ b/packages/app/src/cli/services/release/types.test.ts @@ -1,6 +1,19 @@ import {appReleaseJsonOutputSchema} from './types.js' import {expect, test} from 'vitest' -test('rejects unexpected fields in the public result', () => { - expect(() => appReleaseJsonOutputSchema.validate({status: 'cancelled', internalId: 'private'})).toThrow() +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/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/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index 4d41adab57a..a6493344283 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,9 +62,7 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - isStaticMemberNamed(member, 'jsonOutputSchema') && - ((member.type === 'MethodDefinition' && member.kind === 'get') || - (member.type === 'PropertyDefinition' && Boolean(member.value))), + member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index b095246b579..5eaa3d46144 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,7 +37,9 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static jsonOutputSchema = widgetDeleteJsonOutputSchema + static get jsonOutputSchema() { + return widgetDeleteJsonOutputSchema + } } `, }, From 8f5bc823f30dac2ef5da3a31049abe85cc744da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Fri, 9 Oct 2026 13:33:36 +0200 Subject: [PATCH 6/6] Simplify app release version lookup errors --- .../app/src/cli/commands/app/release.test.ts | 46 +++++++++++++------ packages/app/src/cli/commands/app/release.ts | 30 ++++-------- .../src/cli/services/release/result.test.ts | 22 +-------- .../app/src/cli/services/release/result.ts | 17 ------- .../cli/services/release/version-diff.test.ts | 11 +++-- .../src/cli/services/release/version-diff.ts | 17 ++----- 6 files changed, 55 insertions(+), 88 deletions(-) diff --git a/packages/app/src/cli/commands/app/release.test.ts b/packages/app/src/cli/commands/app/release.test.ts index 14fdff47f36..f13bcebd9f6 100644 --- a/packages/app/src/cli/commands/app/release.test.ts +++ b/packages/app/src/cli/commands/app/release.test.ts @@ -1,11 +1,10 @@ import Release from './release.js' import {release} from '../../services/release.js' import {linkedAppContext} from '../../services/app-context.js' -import {ReleaseVersionLookupError} from '../../services/release/version-diff.js' -import {testAppLinked, testOrganizationApp} from '../../models/app/app.test-data.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} from '@shopify/cli-kit/node/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' @@ -57,28 +56,49 @@ describe('app release command', () => { }) }) - test.each(['release rejection', 'missing version'])('%s uses one shared error document', async (failure) => { + 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']}] - if (failure === 'missing version') { - vi.mocked(release).mockRejectedValue(new ReleaseVersionLookupError('v1', new AbortError('Version not found.'))) - } else { - vi.mocked(release).mockResolvedValue({status: 'failed', version, userErrors}) - } + 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: - failure === 'missing version' - ? {type: 'abort', message: 'Version not found.'} - : {type: 'abort', message: "Version couldn't be released.", details: {userErrors}}, + 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) diff --git a/packages/app/src/cli/commands/app/release.ts b/packages/app/src/cli/commands/app/release.ts index 7ff6c6afcde..ae14290efdb 100644 --- a/packages/app/src/cli/commands/app/release.ts +++ b/packages/app/src/cli/commands/app/release.ts @@ -1,14 +1,12 @@ import {appFlags} from '../../flags.js' import {release} from '../../services/release.js' import {appReleaseJsonOutputSchema} from '../../services/release/types.js' -import {renderAppReleaseError, renderAppReleaseResult} from '../../services/release/result.js' -import {ReleaseVersionLookupError} from '../../services/release/version-diff.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, jsonFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import {AbortSilentError} from '@shopify/cli-kit/node/error' import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' export default class Release extends AppLinkedCommand { @@ -73,23 +71,15 @@ export default class Release extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - let result - try { - result = await release({ - app, - remoteApp, - developerPlatformClient, - force, - allowUpdates, - allowDeletes, - version: flags.version, - }) - } catch (error) { - if (error instanceof ReleaseVersionLookupError || error instanceof AbortSilentError) { - renderAppReleaseError(error, flags.json ? 'json' : 'text') - } - throw error - } + const result = await release({ + app, + remoteApp, + developerPlatformClient, + force, + allowUpdates, + allowDeletes, + version: flags.version, + }) renderAppReleaseResult(result, remoteApp, flags.json ? 'json' : 'text') return {app} } diff --git a/packages/app/src/cli/services/release/result.test.ts b/packages/app/src/cli/services/release/result.test.ts index d7309ee2141..6fefb102077 100644 --- a/packages/app/src/cli/services/release/result.test.ts +++ b/packages/app/src/cli/services/release/result.test.ts @@ -1,9 +1,8 @@ -import {renderAppReleaseError, renderAppReleaseResult} from './result.js' -import {ReleaseVersionLookupError} from './version-diff.js' +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 {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {AbortSilentError} from '@shopify/cli-kit/node/error' import {expect, test} from 'vitest' import type {ReleaseResult} from './types.js' @@ -58,20 +57,3 @@ test('text retains the failed release banner and exit behavior', async () => { test('text retains silent cancellation', () => { expect(() => renderAppReleaseResult({status: 'cancelled'}, testOrganizationApp(), 'text')).toThrow(AbortSilentError) }) - -test('text preserves the missing-version banner, while JSON retains the original failure', async () => { - const cause = new AbortError('Version not found for tag: missing') - const error = new ReleaseVersionLookupError('missing', cause) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - expect(() => renderAppReleaseError(error, 'text')).toThrow(AbortSilentError) - expect(unstyled(stderr())).toContain('Version missing could not be found.') - expect(stdout()).toBe('') - expect(() => renderAppReleaseError(error, 'json')).toThrow(cause) - }) -}) - -test('does not turn unrelated failures into cancellation', () => { - const cause = new AbortSilentError() - expect(() => renderAppReleaseError(cause, 'json')).toThrow(AbortError) - expect(() => renderAppReleaseError(cause, 'text')).toThrow(cause) -}) diff --git a/packages/app/src/cli/services/release/result.ts b/packages/app/src/cli/services/release/result.ts index 0c14261b80c..115912c6f31 100644 --- a/packages/app/src/cli/services/release/result.ts +++ b/packages/app/src/cli/services/release/result.ts @@ -1,5 +1,4 @@ import {appReleaseJsonOutputSchema, type AppReleaseResult, type ReleaseResult} from './types.js' -import {ReleaseVersionLookupError} from './version-diff.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' @@ -37,22 +36,6 @@ export function renderAppReleaseResult( } } -export function renderAppReleaseError(error: unknown, format: 'json' | 'text'): never { - if (format === 'json') { - const cause = error instanceof ReleaseVersionLookupError ? error.cause : error - if (cause instanceof AbortSilentError) { - throw new AbortError('The app version could not be released. See the release diagnostics for details.') - } - throw cause - } - if (!(error instanceof ReleaseVersionLookupError)) throw error - renderError({ - headline: "Version couldn't be released.", - body: ['Version', {userInput: error.versionTag}, 'could not be found.'], - }) - throw new AbortSilentError() -} - function appReleaseResult( result: Exclude, remoteApp: OrganizationApp, 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 8a0e312d316..0186b037e46 100644 --- a/packages/app/src/cli/services/release/version-diff.test.ts +++ b/packages/app/src/cli/services/release/version-diff.test.ts @@ -1,15 +1,16 @@ -import {ReleaseVersionLookupError, versionDiffByVersion} from './version-diff.js' +import {versionDiffByVersion} from './version-diff.js' import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' import {AppVersionWithContext} from '../../utilities/developer-platform-client.js' import {AppVersionsDiffSchema} from '../../api/graphql/app_versions_diff.js' import {describe, expect, test} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {AbortError} from '@shopify/cli-kit/node/error' describe('versionDiffByVersion', () => { - test('reports the failed lookup without presentation and retains its cause', async () => { + test('reports the failed lookup as an abort without presentation', async () => { // Given const outputMock = mockAndCaptureOutput() - const cause = new Error('not found') + const cause = new AbortError('HTTP 404: Cannot find a valid organization') const developerPlatformClient = testDeveloperPlatformClient({ appVersionByTag: () => { throw cause @@ -18,8 +19,8 @@ describe('versionDiffByVersion', () => { // When/Then const result = versionDiffByVersion(testOrganizationApp(), 'version', developerPlatformClient) - await expect(result).rejects.toThrow(ReleaseVersionLookupError) - await expect(result).rejects.toMatchObject({versionTag: 'version', cause}) + await expect(result).rejects.toThrow(AbortError) + await expect(result).rejects.toThrow('Version version could not be found.') expect(outputMock.error()).toBe('') }) diff --git a/packages/app/src/cli/services/release/version-diff.ts b/packages/app/src/cli/services/release/version-diff.ts index 11846904c23..73754c90902 100644 --- a/packages/app/src/cli/services/release/version-diff.ts +++ b/packages/app/src/cli/services/release/version-diff.ts @@ -1,15 +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' - -export class ReleaseVersionLookupError extends Error { - constructor( - public readonly versionTag: string, - cause: unknown, - ) { - super(`Version ${versionTag} could not be found.`, {cause}) - } -} +import {AbortError} from '@shopify/cli-kit/node/error' export async function versionDiffByVersion( app: MinimalOrganizationApp, @@ -36,9 +28,8 @@ async function versionDetailsByTag( developerPlatformClient: DeveloperPlatformClient, ) { try { - const appVersion = await developerPlatformClient.appVersionByTag(app, versionTag) - return appVersion - } catch (err) { - throw new ReleaseVersionLookupError(versionTag, err) + return await developerPlatformClient.appVersionByTag(app, versionTag) + } catch { + throw new AbortError(`Version ${versionTag} could not be found.`) } }