App deploy intermittently fails with ENOENT when multiple UI extensions share one extension directory

I have reproduced the issue on the latest CLI version.

Yes, I am on the latest version

I have searched existing posts and this report is not a duplicate.

Yes, this isn’t a duplicate

In which of these areas are you experiencing a problem?

Extension

Expected behavior

app deploy builds and bundles all extensions and releases a new version.

Actual behavior

Deploys intermittently fail (roughly 1 in 10 runs for us) during bundling:

╭─ error ──────────────────────────────────────────────────────────────────────╮
│                                                                              │
│  Build step "Bundle UI Extension" failed: ENOENT: no such file or            │
│  directory, lstat                                                            │
│  '<repo>/extensions/checkout-ui-extension/dist/<file>.js.map'
│                                                                              │
╰──────────────────────────────────────────────────────────────────────────────╯

Root cause: a race on the shared dist/ directory

For each ui_extension, deploy does three filesystem steps
(bundle-ui-step.ts and
extension-instance.ts):

  1. Build — esbuild writes dist/<handle>.js + dist/<handle>.js.map into the extension directory’s shared local dist/ (sourcemaps are on because ui_extension has the generates_source_maps feature).
  2. Copy into bundlebundle-ui-step.ts line 33: copyFile(localOutputDir, bundleOutputDir) recursively copies the entire shared dist/ into that extension’s bundle staging folder. (copyFile is fs-extra copy, which readdirs the source then lstats each entry.)
  3. Relocate sourcemapkeepBuiltSourcemapsLocally (extension-instance.ts:225–238): moveFile(bundleMap, localMap, {overwrite: true}) moves the extension’s own .js.map from the bundle folder back over the copy in the shared dist/. fs-extra move with overwrite deletes the destination, then renames — so the shared dist/<handle>.js.map briefly does not exist.

All handles in the directory run this concurrently. The race:

  • Extension A is in step 2, recursively copying the shared dist/. Its readdir sees <B>.js.map.
  • Extension B, already in step 3, deletes dist/<B>.js.map (the delete half of move-with-overwrite).
  • A’s copy lstats the now-missing file → ENOENT → the whole deploy aborts.

Depending on timing the same race also surfaces as ENOENT: no such file or directory, copyfile '...<handle>.js.map' -> '<bundle>/...'.

The more extensions share one directory, the larger the shared dist/ and the more concurrent copy/move pairs, so the failure probability grows with exactly the multi-target setup the single-TOML layout encourages.

Reproduction steps

The failure can be demonstrated with nothing but Node and fs-extra. Build a small script that mimics what app deploy does for every UI extension in a shared directory, then run many trials.

Setup: create a temp workspace containing a single shared dist/ directory (representing the extension directory’s output folder) and an empty per-handle bundle area. Pick around six fake handle names — matching the number of ui_extension blocks that share one directory in a real app.

Per handle, run these three steps in order — this mirrors the CLI’s deploy pipeline exactly:

  1. Build: write <handle>.js and <handle>.js.map into the shared dist/. Use realistic sizes (tens of KB for the JS, a few hundred KB for the map) so the later copy takes long enough to be interruptible.
  2. Copy into bundle: recursively copy the entire shared dist/ — not just that handle’s files — into the handle’s own bundle folder, using fs-extra’s copy. This is what the CLI’s “Bundle UI Extension” step does, and it’s the operation that eventually throws.
  3. Relocate the sourcemap: move the handle’s own .js.map from its bundle folder back to the shared dist/, using fs-extra’s move with overwrite: true. This mirrors the CLI’s keepBuiltSourcemapsLocally. The overwrite option is the crux: fs-extra implements it as delete-the-destination-then-rename, so the shared dist/<handle>.js.map briefly doesn’t exist.

Run all handles concurrently, with small staggered start times (a few milliseconds apart) so they’re in different steps at the same moment — just like real extension builds finishing at different times. That’s one simulated deploy.

Repeat for 100–200 simulated deploys, using a fresh workspace each time, and collect any rejections.

Verbose output

╭─ error ──────────────────────────────────────────────────────────────────────╮
│                                                                              │
│  Build step "Bundle UI Extension" failed: ENOENT: no such file or            │
│  directory, lstat                                                            │
│  '<repo>/extensions/checkout-ui-extension/dist/<file>.js.map'
│                                                                              │
╰──────────────────────────────────────────────────────────────────────────────╯

Operating system

Ubuntu (GitHub Actions ubuntu-latest); the bug is platform-independent

CLI version

4.5.2