Package: @netlify/zip-it-and-ship-it (packages/zip-it-and-ship-it)
Note: this was originally found against the standalone netlify/zip-it-and-ship-it repo, which is now archived and points here. The code is unchanged after the move, so this is filed against the monorepo.
Affected: verified in @netlify/zip-it-and-ship-it 9.40.2 and 10.1.1, and still present on netlify/build@main (currently 14.7.0). Seen via @netlify/build on Netlify deploys with node_bundler = "esbuild".
Summary: Netlify content-addresses function uploads, so an unchanged commit should produce identical bundle hashes and skip upload. With the esbuild bundler it doesn't: the bundled file content is identical between builds, but the order entries are written into the zip varies, so the zip sha256 changes every build and all functions re-upload on every deploy.
Impact: Every deploy (including no-op redeploys) re-uploads the entire function set — dominating deploy time/bandwidth on large sites. Measured on a ~239-function Next.js site: an identical redeploy uploaded all ~239 functions; forcing deterministic entry order dropped it to 1 (only ___netlify-server-handler, which legitimately changes via Next's BUILD_ID).
Root cause: packages/zip-it-and-ship-it/src/runtimes/node/bundlers/esbuild/index.ts returns srcFiles: [...supportingSrcFiles, ...bundlePaths.keys()] without sorting. supportingSrcFiles comes from getSrcFiles() (esbuild/src_files.ts), whose getSrcFilesForDependencies() builds the list via new Set(dependencies.flat()) over concurrent (Promise.all) filesystem traversal — so the order isn't stable across runs/environments. The zip writer (utils/zip.ts, createZipArchive) appends entries in srcFiles order; it even comments that the add loop is kept synchronous "so that the archive's checksum is deterministic" — but it still relies on the incoming srcFiles already being in a deterministic order.
The zisi and nft bundlers both .sort(), with comments explaining why:
zisi/src_files.ts: // We sort so that the archive's checksum is deterministic.
nft/index.ts: // Sorting the array to make the checksum deterministic.
The esbuild bundler omits this sort.
Reproduction: Call zipFunction twice on the same fixture with { config: { '*': { nodeBundler: 'esbuild', externalNodeModules: ['<dep>'] } }, archiveFormat: 'zip' }. The bundled file content is identical, but the order in which entries are written into the zip is whatever the dependency traversal produced — which is not stable across build environments (different machine, cache, Node/esbuild version), so the zip sha256 differs. nodeBundler: 'nft' is stable because it sorts.
(Note: two consecutive bundles in a single warm process can happen to be byte-identical because the filesystem traversal order is incidentally stable in-process; the non-determinism shows up across build environments. A regression test therefore needs to exercise order-independence explicitly rather than just bundling twice in one process.)
Fix: sort the file list in the esbuild bundler (one line), matching zisi/nft. PR incoming.
Package:
@netlify/zip-it-and-ship-it(packages/zip-it-and-ship-it)Affected: verified in
@netlify/zip-it-and-ship-it9.40.2 and 10.1.1, and still present onnetlify/build@main(currently 14.7.0). Seen via@netlify/buildon Netlify deploys withnode_bundler = "esbuild".Summary: Netlify content-addresses function uploads, so an unchanged commit should produce identical bundle hashes and skip upload. With the esbuild bundler it doesn't: the bundled file content is identical between builds, but the order entries are written into the zip varies, so the zip sha256 changes every build and all functions re-upload on every deploy.
Impact: Every deploy (including no-op redeploys) re-uploads the entire function set — dominating deploy time/bandwidth on large sites. Measured on a ~239-function Next.js site: an identical redeploy uploaded all ~239 functions; forcing deterministic entry order dropped it to 1 (only
___netlify-server-handler, which legitimately changes via Next'sBUILD_ID).Root cause:
packages/zip-it-and-ship-it/src/runtimes/node/bundlers/esbuild/index.tsreturnssrcFiles: [...supportingSrcFiles, ...bundlePaths.keys()]without sorting.supportingSrcFilescomes fromgetSrcFiles()(esbuild/src_files.ts), whosegetSrcFilesForDependencies()builds the list vianew Set(dependencies.flat())over concurrent (Promise.all) filesystem traversal — so the order isn't stable across runs/environments. The zip writer (utils/zip.ts,createZipArchive) appends entries insrcFilesorder; it even comments that the add loop is kept synchronous "so that the archive's checksum is deterministic" — but it still relies on the incomingsrcFilesalready being in a deterministic order.The
zisiandnftbundlers both.sort(), with comments explaining why:zisi/src_files.ts:// We sort so that the archive's checksum is deterministic.nft/index.ts:// Sorting the array to make the checksum deterministic.The esbuild bundler omits this sort.
Reproduction: Call
zipFunctiontwice on the same fixture with{ config: { '*': { nodeBundler: 'esbuild', externalNodeModules: ['<dep>'] } }, archiveFormat: 'zip' }. The bundled file content is identical, but the order in which entries are written into the zip is whatever the dependency traversal produced — which is not stable across build environments (different machine, cache, Node/esbuild version), so the zip sha256 differs.nodeBundler: 'nft'is stable because it sorts.(Note: two consecutive bundles in a single warm process can happen to be byte-identical because the filesystem traversal order is incidentally stable in-process; the non-determinism shows up across build environments. A regression test therefore needs to exercise order-independence explicitly rather than just bundling twice in one process.)
Fix: sort the file list in the esbuild bundler (one line), matching
zisi/nft. PR incoming.