From ae4d442b07515d40aed59fe06c8403324a2bc38e Mon Sep 17 00:00:00 2001 From: Mitch Lillie Date: Mon, 6 Apr 2026 15:45:35 -0700 Subject: [PATCH] Run web build commands during deploy for hosted apps When a web.toml defines a `build` command, deploy now runs it concurrently alongside extension bundling. Previously users had to manually run `shopify app build` before `shopify app deploy` to ensure web assets were up to date. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .changeset/web-build-on-deploy.md | 5 + .../src/cli/services/deploy/bundle.test.ts | 151 +++++++++++++++++- .../app/src/cli/services/deploy/bundle.ts | 21 ++- 3 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 .changeset/web-build-on-deploy.md diff --git a/.changeset/web-build-on-deploy.md b/.changeset/web-build-on-deploy.md new file mode 100644 index 00000000000..76a6ff0bb54 --- /dev/null +++ b/.changeset/web-build-on-deploy.md @@ -0,0 +1,5 @@ +--- +"@shopify/app": patch +--- + +Run web build commands during `shopify app deploy` for hosted apps. Previously, the `build` command defined in `web.toml` was only executed by `shopify app build` — deploy skipped it entirely, requiring a manual build step first. diff --git a/packages/app/src/cli/services/deploy/bundle.test.ts b/packages/app/src/cli/services/deploy/bundle.test.ts index cce9f433456..e30d3e091fc 100644 --- a/packages/app/src/cli/services/deploy/bundle.test.ts +++ b/packages/app/src/cli/services/deploy/bundle.test.ts @@ -1,13 +1,15 @@ import {bundleAndBuildExtensions} from './bundle.js' import {testApp, testFunctionExtension, testThemeExtensions, testUIExtension} from '../../models/app/app.test-data.js' -import {AppInterface, AppManifest} from '../../models/app/app.js' +import {AppInterface, AppManifest, WebType} from '../../models/app/app.js' import * as bundle from '../bundle.js' import * as functionBuild from '../function/build.js' +import * as webService from '../web.js' import {describe, expect, test, vi} from 'vitest' import * as file from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' vi.mock('../function/build.js') +vi.mock('../web.js') describe('bundleAndBuildExtensions', () => { let app: AppInterface @@ -254,6 +256,153 @@ describe('bundleAndBuildExtensions', () => { }) }) + test('runs web build command concurrently with extensions when build command is defined', async () => { + await file.inTemporaryDirectory(async (tmpDir: string) => { + // Given + const bundlePath = joinPath(tmpDir, 'bundle.zip') + const mockBuildWeb = vi.mocked(webService.default) + + const functionExtension = await testFunctionExtension() + const extensionBuildMock = vi.fn().mockImplementation(async (options, bundleDirectory) => { + file.writeFileSync(joinPath(bundleDirectory, 'index.wasm'), '') + }) + functionExtension.buildForBundle = extensionBuildMock + + const app = testApp({ + allExtensions: [functionExtension], + directory: tmpDir, + webs: [ + { + directory: '/tmp/web', + configuration: { + roles: [WebType.Backend], + commands: {dev: 'npm run dev', build: 'npm run build'}, + }, + }, + ], + }) + + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) + + // When + await bundleAndBuildExtensions({ + app, + appManifest, + identifiers, + bundlePath, + skipBuild: false, + isDevDashboardApp: false, + }) + + // Then + expect(mockBuildWeb).toHaveBeenCalledWith('build', expect.objectContaining({web: app.webs[0]})) + }) + }) + + test('skips web build for webs without a build command defined', async () => { + await file.inTemporaryDirectory(async (tmpDir: string) => { + // Given + const bundlePath = joinPath(tmpDir, 'bundle.zip') + const mockBuildWeb = vi.mocked(webService.default) + + const functionExtension = await testFunctionExtension() + const extensionBuildMock = vi.fn().mockImplementation(async (options, bundleDirectory) => { + file.writeFileSync(joinPath(bundleDirectory, 'index.wasm'), '') + }) + functionExtension.buildForBundle = extensionBuildMock + + const app = testApp({ + allExtensions: [functionExtension], + directory: tmpDir, + webs: [ + { + directory: '/tmp/web', + configuration: { + roles: [WebType.Backend], + commands: {dev: 'npm run dev'}, + }, + }, + ], + }) + + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) + + // When + await bundleAndBuildExtensions({ + app, + appManifest, + identifiers, + bundlePath, + skipBuild: false, + isDevDashboardApp: false, + }) + + // Then + expect(mockBuildWeb).not.toHaveBeenCalled() + }) + }) + + test('skips web build command when skipBuild is true', async () => { + await file.inTemporaryDirectory(async (tmpDir: string) => { + // Given + const bundlePath = joinPath(tmpDir, 'bundle.zip') + const mockBuildWeb = vi.mocked(webService.default) + + const functionExtension = await testFunctionExtension() + const extensionCopyMock = vi.fn().mockImplementation(async (options, bundleDirectory) => { + file.writeFileSync(joinPath(bundleDirectory, 'index.wasm'), '') + }) + functionExtension.copyIntoBundle = extensionCopyMock + + const app = testApp({ + allExtensions: [functionExtension], + directory: tmpDir, + webs: [ + { + directory: '/tmp/web', + configuration: { + roles: [WebType.Backend], + commands: {dev: 'npm run dev', build: 'npm run build'}, + }, + }, + ], + }) + + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) + + // When + await bundleAndBuildExtensions({ + app, + appManifest, + identifiers, + bundlePath, + skipBuild: true, + isDevDashboardApp: false, + }) + + // Then + expect(mockBuildWeb).not.toHaveBeenCalled() + }) + }) + test('handles multiple extension types together', async () => { await file.inTemporaryDirectory(async (tmpDir: string) => { // Given diff --git a/packages/app/src/cli/services/deploy/bundle.ts b/packages/app/src/cli/services/deploy/bundle.ts index cc18cddcf00..fbb454a0937 100644 --- a/packages/app/src/cli/services/deploy/bundle.ts +++ b/packages/app/src/cli/services/deploy/bundle.ts @@ -1,6 +1,7 @@ import {AppInterface, AppManifest} from '../../models/app/app.js' import {Identifiers} from '../../models/app/identifiers.js' import {installJavy} from '../function/build.js' +import buildWeb from '../web.js' import {compressBundle, writeManifestToBundle} from '../bundle.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {mkdir, rmdir} from '@shopify/cli-kit/node/fs' @@ -30,9 +31,21 @@ export async function bundleAndBuildExtensions(options: BundleOptions) { await installJavy(options.app) } + const webBuildProcesses = options.skipBuild + ? [] + : options.app.webs + .filter((web) => web.configuration.commands.build) + .map((web) => ({ + prefix: ['web', ...web.configuration.roles].join('-'), + action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { + await buildWeb('build', {web, stdout, stderr, signal}) + }, + })) + await renderConcurrent({ - processes: options.app.allExtensions.map((extension) => { - return { + processes: [ + ...webBuildProcesses, + ...options.app.allExtensions.map((extension) => ({ prefix: extension.localIdentifier, action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { // This outputId is the UID for AppManagement, and UUID for Partners @@ -55,8 +68,8 @@ export async function bundleAndBuildExtensions(options: BundleOptions) { ) } }, - } - }), + })), + ], showTimestamps: false, })