From 7d8423aded316509c3cbbaaf07521a6dd9f7db74 Mon Sep 17 00:00:00 2001 From: Victor Chu Date: Thu, 23 Apr 2026 15:49:39 -0700 Subject: [PATCH 1/5] Normalize paths in bundle-ui-step same-path guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same-path guard at bundle-ui-step.ts compared `localOutputDir` and `bundleOutputDir` as raw strings. In practice these two values are computed along different code paths (`dirname(buildUIExtension())` vs `dirname(extension.outputPath)` or `joinPath(..., bundleFolder)`) and can resolve to the same filesystem directory while differing as strings — e.g. when one path has a `.` segment, a trailing slash, or otherwise non-canonical shape. When that happens, the guard slips through and fs-extra rejects the copy with "Source and destination must not be the same". Normalize both sides via `resolvePath` before comparing so the guard catches any string variant that maps to the same directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-bundle-ui-step-path-normalization.md | 5 +++++ packages/app/src/cli/services/build/steps/bundle-ui-step.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-bundle-ui-step-path-normalization.md diff --git a/.changeset/fix-bundle-ui-step-path-normalization.md b/.changeset/fix-bundle-ui-step-path-normalization.md new file mode 100644 index 0000000000..532dab38a5 --- /dev/null +++ b/.changeset/fix-bundle-ui-step-path-normalization.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fix `shopify app build` intermittently failing with "Source and destination must not be the same" on UI extensions when the local esbuild output directory and the bundle output directory resolve to the same path but differ as strings (e.g. due to `.` segments, trailing slashes, or path joining quirks). The same-path guard now normalizes both paths via `resolvePath` before comparison. diff --git a/packages/app/src/cli/services/build/steps/bundle-ui-step.ts b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts index ace25649bb..dd65b01ce7 100644 --- a/packages/app/src/cli/services/build/steps/bundle-ui-step.ts +++ b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts @@ -2,7 +2,7 @@ import {createOrUpdateManifestFile} from './include-assets/generate-manifest.js' import {buildUIExtension} from '../extension.js' import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js' import {copyFile} from '@shopify/cli-kit/node/fs' -import {dirname, joinPath} from '@shopify/cli-kit/node/path' +import {dirname, joinPath, resolvePath} from '@shopify/cli-kit/node/path' import type {BundleUIStep, BuildContext} from '../client-steps.js' interface ExtensionPointWithBuildManifest { @@ -26,7 +26,7 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont const bundleOutputDir = step.config?.bundleFolder ? joinPath(dirname(context.extension.outputPath), step.config.bundleFolder) : dirname(context.extension.outputPath) - if (localOutputDir !== bundleOutputDir) { + if (resolvePath(localOutputDir) !== resolvePath(bundleOutputDir)) { await copyFile(localOutputDir, bundleOutputDir) } From d6770b9dd780aaf2c6491474e37ab18a5b9009c6 Mon Sep 17 00:00:00 2001 From: Victor Chu Date: Thu, 23 Apr 2026 15:57:34 -0700 Subject: [PATCH 2/5] Add regression test for non-canonical path shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the same-path guard coverage with a case where localOutputDir and bundleOutputDir are string-distinct but resolve to the same directory (`/test/extension/dist` vs `/test/./extension/dist`). This is the actual shape that triggered the original fs-extra "Source and destination must not be the same" failure in the field — the existing identical-strings test did not catch it because both the old and new guard handle the trivial case. Verified that this test fails against the pre-fix guard (`copyFile` is called with distinct strings) and passes once the guard normalizes both sides with `resolvePath`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/cli/services/build/steps/bundle-ui-step.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts b/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts index c87aadb12a..5f73580949 100644 --- a/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts +++ b/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts @@ -46,4 +46,13 @@ describe('executeBundleUIStep', () => { // Then expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/dist', '/bundle/handle') }) + + test('skips the copy when local and bundle output directories resolve to the same path but differ as strings', async () => { + mockContext.extension.outputPath = '/test/./extension/dist/handle.js' + vi.mocked(buildExtension.buildUIExtension).mockResolvedValue('/test/extension/dist/handle.js') + + await executeBundleUIStep(step, mockContext) + + expect(fs.copyFile).not.toHaveBeenCalled() + }) }) From cb3bf6785262fb3d8fecc4eb88fa54e89f7b0913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 28 Apr 2026 10:25:16 +0200 Subject: [PATCH 3/5] Skip manifest generation when bundle dir matches local output The same-path guard previously only skipped the copy and still let manifest generation run. With the comment now explicitly stating both behaviors, lock that in with a test that verifies createOrUpdateManifestFile is not called when localOutputDir === bundleOutputDir, even with generatesAssetsManifest: true. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../build/steps/bundle-ui-step.test.ts | 23 +++++++++++++++++++ .../services/build/steps/bundle-ui-step.ts | 8 ++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts b/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts index 5f73580949..1a7a7a0cdd 100644 --- a/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts +++ b/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts @@ -1,4 +1,5 @@ import {executeBundleUIStep} from './bundle-ui-step.js' +import * as generateManifest from './include-assets/generate-manifest.js' import * as buildExtension from '../extension.js' import {BundleUIStep, BuildContext} from '../client-steps.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' @@ -7,6 +8,7 @@ import * as fs from '@shopify/cli-kit/node/fs' vi.mock('@shopify/cli-kit/node/fs') vi.mock('../extension.js') +vi.mock('./include-assets/generate-manifest.js') describe('executeBundleUIStep', () => { let mockContext: BuildContext @@ -55,4 +57,25 @@ describe('executeBundleUIStep', () => { expect(fs.copyFile).not.toHaveBeenCalled() }) + + test('skips manifest generation when local and bundle output directories resolve to the same path', async () => { + const stepWithManifest: BundleUIStep = { + id: 'bundle-ui', + name: 'Bundle UI Extension', + type: 'bundle_ui', + config: {generatesAssetsManifest: true}, + } + mockContext.extension.outputPath = '/test/./extension/dist/handle.js' + mockContext.extension.configuration = { + extension_points: [ + {target: 'admin.product-details.action.render', build_manifest: {assets: {main: {filepath: 'main.js'}}}}, + ], + } as ExtensionInstance['configuration'] + vi.mocked(buildExtension.buildUIExtension).mockResolvedValue('/test/extension/dist/handle.js') + + await executeBundleUIStep(stepWithManifest, mockContext) + + expect(fs.copyFile).not.toHaveBeenCalled() + expect(generateManifest.createOrUpdateManifestFile).not.toHaveBeenCalled() + }) }) diff --git a/packages/app/src/cli/services/build/steps/bundle-ui-step.ts b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts index dd65b01ce7..580550b053 100644 --- a/packages/app/src/cli/services/build/steps/bundle-ui-step.ts +++ b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts @@ -26,9 +26,11 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont const bundleOutputDir = step.config?.bundleFolder ? joinPath(dirname(context.extension.outputPath), step.config.bundleFolder) : dirname(context.extension.outputPath) - if (resolvePath(localOutputDir) !== resolvePath(bundleOutputDir)) { - await copyFile(localOutputDir, bundleOutputDir) - } + + // If the final output path is the same as the local one: don't copy the results and don't generate manifests. + if (resolvePath(localOutputDir) === resolvePath(bundleOutputDir)) return + + await copyFile(localOutputDir, bundleOutputDir) if (!step.config?.generatesAssetsManifest) return From 96853530313cab0f222f50e6c8e1645d2f1d57b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 28 Apr 2026 14:47:05 +0200 Subject: [PATCH 4/5] Add changeset for skip-manifest backport --- .changeset/skip-manifest-when-bundle-dir-matches-local.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/skip-manifest-when-bundle-dir-matches-local.md diff --git a/.changeset/skip-manifest-when-bundle-dir-matches-local.md b/.changeset/skip-manifest-when-bundle-dir-matches-local.md new file mode 100644 index 0000000000..f8cd42187b --- /dev/null +++ b/.changeset/skip-manifest-when-bundle-dir-matches-local.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Skip asset manifest generation in the UI extension bundle step when the local esbuild output directory and the bundle output directory resolve to the same path. Previously the same-path guard only skipped the file copy and still ran `createOrUpdateManifestFile`, which could produce or update a manifest in the local output unintentionally. From f85562117f0d0dacfa86f24662b3de043f17e79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 28 Apr 2026 14:50:21 +0200 Subject: [PATCH 5/5] Consolidate changesets --- .changeset/fix-bundle-ui-step-path-normalization.md | 5 ----- .changeset/fix-bundle-ui-step.md | 5 +++++ .changeset/skip-manifest-when-bundle-dir-matches-local.md | 5 ----- 3 files changed, 5 insertions(+), 10 deletions(-) delete mode 100644 .changeset/fix-bundle-ui-step-path-normalization.md create mode 100644 .changeset/fix-bundle-ui-step.md delete mode 100644 .changeset/skip-manifest-when-bundle-dir-matches-local.md diff --git a/.changeset/fix-bundle-ui-step-path-normalization.md b/.changeset/fix-bundle-ui-step-path-normalization.md deleted file mode 100644 index 532dab38a5..0000000000 --- a/.changeset/fix-bundle-ui-step-path-normalization.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': patch ---- - -Fix `shopify app build` intermittently failing with "Source and destination must not be the same" on UI extensions when the local esbuild output directory and the bundle output directory resolve to the same path but differ as strings (e.g. due to `.` segments, trailing slashes, or path joining quirks). The same-path guard now normalizes both paths via `resolvePath` before comparison. diff --git a/.changeset/fix-bundle-ui-step.md b/.changeset/fix-bundle-ui-step.md new file mode 100644 index 0000000000..19434812a9 --- /dev/null +++ b/.changeset/fix-bundle-ui-step.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fix `shopify app build` for some UI extensions diff --git a/.changeset/skip-manifest-when-bundle-dir-matches-local.md b/.changeset/skip-manifest-when-bundle-dir-matches-local.md deleted file mode 100644 index f8cd42187b..0000000000 --- a/.changeset/skip-manifest-when-bundle-dir-matches-local.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': patch ---- - -Skip asset manifest generation in the UI extension bundle step when the local esbuild output directory and the bundle output directory resolve to the same path. Previously the same-path guard only skipped the file copy and still ran `createOrUpdateManifestFile`, which could produce or update a manifest in the local output unintentionally.