From e94c94eb50d8a4f2f0a197b837165d5d73b95a34 Mon Sep 17 00:00:00 2001 From: Victor Chu Date: Thu, 23 Apr 2026 15:49:39 -0700 Subject: [PATCH 1/2] 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 00000000000..532dab38a51 --- /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 5eae48ab71d..b77eb1fa1e3 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 { @@ -27,7 +27,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 35deef9b9f7e252eed0278bcc50f3aa67f9c0aa9 Mon Sep 17 00:00:00 2001 From: Victor Chu Date: Thu, 23 Apr 2026 15:57:34 -0700 Subject: [PATCH 2/2] 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 18aa9742142..1f3d905db63 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 @@ -57,4 +57,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() + }) })