CLI deploy hashing verification Head: 82488d9463e7e71a1456d930abef6ccb79905db4 Base: 904515c1457f17d1ba8c588fce90735b069ce241 Test file SHA-256: 21cd6129a57fa7dd4a3c665340cb304a43b09899cdead02bc7aa9d03a2360e1c Scope: nine new tests; no production source or workflow changes. The three protected contracts are exact file-byte digests and manifest/upload mappings; discovery filters and deploy-artifact namespaces; discovery/open failures that reject instead of returning partial manifests. Final local checks (all exit 0): $ CI=true npm run test:unit ✓ tests/unit/lib/functions/netlify-function.test.ts (1 test) 0ms ✓ tests/unit/utils/exit-codes.test.ts (2 tests) 0ms Test Files 78 passed (78) Tests 656 passed (656) Start at 04:18:39 Duration 26.35s (transform 943ms, setup 0ms, collect 2.64s, tests 23.13s, environment 0ms, prepare 175ms) $ npm run typecheck > netlify-cli@27.10.0 typecheck > node ./node_modules/typescript-native/bin/tsc $ npm run lint > netlify-cli@27.10.0 lint > eslint --cache $ npm run format:check > netlify-cli@27.10.0 format:check > oxfmt --check Checking formatting... All matched files use the correct format. Finished in 854ms on 649 files using 4 threads. $ npm exec --yes --package=node@22.13.0 -- node node_modules/vitest/vitest.mjs run tests/unit/utils/deploy/ ✓ tests/unit/utils/deploy/deploy-source.test.ts (5 tests) 1ms ✓ tests/unit/utils/deploy/util.test.ts (2 tests) 1ms Test Files 8 passed (8) Tests 40 passed (40) Start at 04:14:49 Duration 3.22s (transform 313ms, setup 0ms, collect 1.77s, tests 1.20s, environment 0ms, prepare 78ms) $ npm exec --yes --package=node@24.15.0 -- node node_modules/vitest/vitest.mjs run tests/unit/utils/deploy/ ✓ tests/unit/utils/deploy/deploy-source.test.ts (5 tests) 1ms ✓ tests/unit/utils/deploy/util.test.ts (2 tests) 0ms Test Files 8 passed (8) Tests 40 passed (40) Start at 04:14:49 Duration 2.89s (transform 324ms, setup 0ms, collect 1.50s, tests 1.18s, environment 0ms, prepare 72ms) Mutation proof: each mutation passes the nine pre-existing hash-files/hash-fns tests and fails the new suite. Source is restored after every experiment. Commands: npm exec vitest -- run tests/unit/utils/deploy/hash-files.test.ts tests/unit/utils/deploy/hash-fns.test.ts; npm exec vitest -- run tests/unit/utils/deploy/hash-files-pipeline.test.ts truncate-bytes in src/utils/deploy/hasher-segments.ts before: createReadStream(filePath) after: createReadStream(filePath, { end: 1048575 }) Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 2 failed) 199ms × hashes complete file bytes with concurrency 1 93ms → expected { …(3) } to deeply equal { …(3) } × hashes complete file bytes with concurrency 4 86ms → expected { …(3) } to deeply equal { …(3) } ✓ keeps every upload path for identical content and rehashes changed files on the next run 5ms ✓ prunes excluded files and directories before hashing 3ms ✓ preserves deploy artifact namespaces when several roots have the same relative filename 3ms ✓ returns an empty manifest when no files survive the filter 1ms ✓ rejects a missing input directory instead of returning a partial manifest 3ms ✓ propagates a file read failure and destroys the hashing pipeline 2ms ✓ rejects when a discovered file disappears before it can be read 2ms Test Files 1 failed (1) Tests 2 failed | 7 passed (9) Start at 04:17:59 Duration 406ms (transform 58ms, setup 0ms, collect 76ms, tests 199ms, environment 0ms, prepare 41ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > hashes complete file bytes with concurrency 1 FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > hashes complete file bytes with concurrency 4 AssertionError: expected { …(3) } to deeply equal { …(3) } - Expected + Received { "empty.txt": "da39a3ee5e6b4b0d3255bfef95601890afd80709", - "large.bin": "0bae8bd6084a15d3762fcea0c4fd78af6ff1e7a2", + "large.bin": "b345c889cb54740a10a3c907bab05de679c491a9", "nested/café.txt": "3e77d862483378a232e1e42dbf40faf2b9dc5325", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:62:19 60| }) 61| 62| expect(files).toEqual(expectedFiles) | ^ 63| expect(Object.keys(filesShaMap).sort()).toEqual(Object.values(expe… 64| for (const [name, hash] of Object.entries(expectedFiles)) { ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ decode-binary-as-utf8 in src/utils/deploy/hasher-segments.ts before: createReadStream(filePath) after: createReadStream(filePath, { encoding: 'utf8' }) Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 2 failed) 229ms × hashes complete file bytes with concurrency 1 109ms → expected { …(3) } to deeply equal { …(3) } × hashes complete file bytes with concurrency 4 101ms → expected { …(3) } to deeply equal { …(3) } ✓ keeps every upload path for identical content and rehashes changed files on the next run 5ms ✓ prunes excluded files and directories before hashing 3ms ✓ preserves deploy artifact namespaces when several roots have the same relative filename 3ms ✓ returns an empty manifest when no files survive the filter 1ms ✓ rejects a missing input directory instead of returning a partial manifest 2ms ✓ propagates a file read failure and destroys the hashing pipeline 2ms ✓ rejects when a discovered file disappears before it can be read 2ms Test Files 1 failed (1) Tests 2 failed | 7 passed (9) Start at 04:18:02 Duration 435ms (transform 60ms, setup 0ms, collect 77ms, tests 229ms, environment 0ms, prepare 40ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > hashes complete file bytes with concurrency 1 FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > hashes complete file bytes with concurrency 4 AssertionError: expected { …(3) } to deeply equal { …(3) } - Expected + Received { "empty.txt": "da39a3ee5e6b4b0d3255bfef95601890afd80709", - "large.bin": "0bae8bd6084a15d3762fcea0c4fd78af6ff1e7a2", + "large.bin": "c1626b552bfe7aef7bd9c07c7b97d759a8cedebf", "nested/café.txt": "3e77d862483378a232e1e42dbf40faf2b9dc5325", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:62:19 60| }) 61| 62| expect(files).toEqual(expectedFiles) | ^ 63| expect(Object.keys(filesShaMap).sort()).toEqual(Object.values(expe… 64| for (const [name, hash] of Object.entries(expectedFiles)) { ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ wrong-default-digest in src/utils/deploy/hash-files.ts before: hashAlgorithm = 'sha1' after: hashAlgorithm = 'sha256' Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 5 failed) 220ms × hashes complete file bytes with concurrency 1 107ms → expected { …(3) } to deeply equal { …(3) } × hashes complete file bytes with concurrency 4 93ms → expected { …(3) } to deeply equal { …(3) } × keeps every upload path for identical content and rehashes changed files on the next run 3ms → expected { …(2) } to deeply equal { …(2) } × prunes excluded files and directories before hashing 3ms → expected { Object (public/index.html) } to deeply equal { Object (public/index.html) } × preserves deploy artifact namespaces when several roots have the same relative filename 4ms → expected { …(4) } to deeply equal { …(4) } ✓ returns an empty manifest when no files survive the filter 1ms ✓ rejects a missing input directory instead of returning a partial manifest 3ms ✓ propagates a file read failure and destroys the hashing pipeline 3ms ✓ rejects when a discovered file disappears before it can be read 2ms Test Files 1 failed (1) Tests 5 failed | 4 passed (9) Start at 04:18:05 Duration 444ms (transform 63ms, setup 0ms, collect 81ms, tests 220ms, environment 0ms, prepare 43ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > hashes complete file bytes with concurrency 1 FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > hashes complete file bytes with concurrency 4 AssertionError: expected { …(3) } to deeply equal { …(3) } - Expected + Received { - "empty.txt": "da39a3ee5e6b4b0d3255bfef95601890afd80709", - "large.bin": "0bae8bd6084a15d3762fcea0c4fd78af6ff1e7a2", - "nested/café.txt": "3e77d862483378a232e1e42dbf40faf2b9dc5325", + "empty.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "large.bin": "758b2963cfb0e3723c9d071c27c9f13f3d55188903d0cfb6bb031cf96452ce12", + "nested/café.txt": "9ae1b261526f4c308dce6b7162f72894ca4f0cabf37a5e18a482130b41197565", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:62:19 60| }) 61| 62| expect(files).toEqual(expectedFiles) | ^ 63| expect(Object.keys(filesShaMap).sort()).toEqual(Object.values(expe… 64| for (const [name, hash] of Object.entries(expectedFiles)) { ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > keeps every upload path for identical content and rehashes changed files on the next run AssertionError: expected { …(2) } to deeply equal { …(2) } - Expected + Received { - "nested/two.txt": "ff3390557335ba88d37755e41514beb03bc499ec", - "one.txt": "ff3390557335ba88d37755e41514beb03bc499ec", + "nested/two.txt": "0967115f2813a3541eaef77de9d9d5773f1c0c04314b0bbfe4ff3b3b1c55b5d5", + "one.txt": "0967115f2813a3541eaef77de9d9d5773f1c0c04314b0bbfe4ff3b3b1c55b5d5", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:83:23 81| const first = await hashFiles(options) 82| 83| expect(first.files).toEqual({ 'one.txt': hash, 'nested/two.txt': has… | ^ 84| expect(Object.keys(first.filesShaMap)).toEqual([hash]) 85| const uploads = getUploadList([hash], first.filesShaMap) as File[] ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > prunes excluded files and directories before hashing AssertionError: expected { Object (public/index.html) } to deeply equal { Object (public/index.html) } - Expected + Received { - "public/index.html": "767013ce0ee0f6d7a07587912eba3104cfaabc15", + "public/index.html": "3660315a9af3df255d8f19ab077e4797822b41488a0e2a04bc6af71213c23274", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:123:24 121| const result = await hashFiles({ directories: [directory], filter, c… 122| 123| expect(result.files).toEqual({ 'public/index.html': createHash('sha1… | ^ 124| expect(Object.values(result.filesShaMap).flat()).toEqual([ 125| expect.objectContaining({ normalizedPath: 'public/index.html' }), ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > preserves deploy artifact namespaces when several roots have the same relative filename AssertionError: expected { …(4) } to deeply equal { …(4) } - Expected + Received { - ".netlify/deploy-config/manifest.json": "22a0e059ba07ad8e7c378d8c42017daf3f9f5f96", - ".netlify/internal/db/migrations/manifest.json": "f6feaf78b38066e2b0f9b75e7dd0ad75b4c4ad28", - ".netlify/internal/edge-functions/manifest.json": "7a34e638972451a0cc463c269c4de24df6c66fd7", - "manifest.json": "61c9b2b17db77a27841bbeeabff923448b0f6388", + ".netlify/deploy-config/manifest.json": "05878a6300e537a8e106c82b1080f3ab319d77bec1123b0e642191da2483f8e3", + ".netlify/internal/db/migrations/manifest.json": "a63fc25a2ee0b52f173705baf14266938cf34f95b73da513ae791ad93cd98b18", + ".netlify/internal/edge-functions/manifest.json": "b34d869db10dbb6cdc61134ce69b163ed469635089122f62a782a10781a331f8", + "manifest.json": "efa1f375d76194fa51a3556a97e641e61685f914d446979da50a551a4333ffd7", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:153:17 151| }) 152| 153| expect(files).toEqual( | ^ 154| Object.fromEntries( 155| expectedPaths.map((name, index) => [name, createHash('sha1').upd… ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯ drop-duplicate-content-path in src/utils/deploy/hasher-segments.ts before: shaMap[fileObj.hash].push(fileObj) after: shaMap[fileObj.hash] = [fileObj] Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 1 failed) 213ms ✓ hashes complete file bytes with concurrency 1 100ms ✓ hashes complete file bytes with concurrency 4 92ms × keeps every upload path for identical content and rehashes changed files on the next run 7ms → expected [ 'nested/two.txt' ] to deeply equal [ 'nested/two.txt', 'one.txt' ] ✓ prunes excluded files and directories before hashing 3ms ✓ preserves deploy artifact namespaces when several roots have the same relative filename 3ms ✓ returns an empty manifest when no files survive the filter 1ms ✓ rejects a missing input directory instead of returning a partial manifest 2ms ✓ propagates a file read failure and destroys the hashing pipeline 2ms ✓ rejects when a discovered file disappears before it can be read 2ms Test Files 1 failed (1) Tests 1 failed | 8 passed (9) Start at 04:18:08 Duration 431ms (transform 61ms, setup 0ms, collect 78ms, tests 213ms, environment 0ms, prepare 44ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > keeps every upload path for identical content and rehashes changed files on the next run AssertionError: expected [ 'nested/two.txt' ] to deeply equal [ 'nested/two.txt', 'one.txt' ] - Expected + Received [ "nested/two.txt", - "one.txt", ] ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:86:70 84| expect(Object.keys(first.filesShaMap)).toEqual([hash]) 85| const uploads = getUploadList([hash], first.filesShaMap) as File[] 86| expect(uploads.map(({ normalizedPath }) => normalizedPath).sort()).t… | ^ 87| expect(uploads.map(({ filepath }) => filepath).sort()).toEqual( 88| [join(directory, 'nested/two.txt'), join(directory, 'one.txt')].so… ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ ignore-filter in src/utils/deploy/hash-files.ts before: walker(directories, { filter }) after: walker(directories, { filter: () => true }) Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 2 failed) 219ms ✓ hashes complete file bytes with concurrency 1 97ms ✓ hashes complete file bytes with concurrency 4 97ms ✓ keeps every upload path for identical content and rehashes changed files on the next run 4ms × prunes excluded files and directories before hashing 8ms → expected { …(3) } to deeply equal { Object (public/index.html) } ✓ preserves deploy artifact namespaces when several roots have the same relative filename 3ms × returns an empty manifest when no files survive the filter 3ms → expected { …(2) } to deeply equal { files: {}, filesShaMap: {} } ✓ rejects a missing input directory instead of returning a partial manifest 3ms ✓ propagates a file read failure and destroys the hashing pipeline 2ms ✓ rejects when a discovered file disappears before it can be read 2ms Test Files 1 failed (1) Tests 2 failed | 7 passed (9) Start at 04:18:10 Duration 426ms (transform 59ms, setup 0ms, collect 77ms, tests 219ms, environment 0ms, prepare 40ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > prunes excluded files and directories before hashing AssertionError: expected { …(3) } to deeply equal { Object (public/index.html) } - Expected + Received { + "private/nested/secret.txt": "8d70e3f6a1a658f6188c8935f159c66df571e1a0", + "public/ignored.txt": "b21ecd9188a48b7a8554f39dfc869b4b0dfcabdb", "public/index.html": "767013ce0ee0f6d7a07587912eba3104cfaabc15", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:123:24 121| const result = await hashFiles({ directories: [directory], filter, c… 122| 123| expect(result.files).toEqual({ 'public/index.html': createHash('sha1… | ^ 124| expect(Object.values(result.filesShaMap).flat()).toEqual([ 125| expect.objectContaining({ normalizedPath: 'public/index.html' }), ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > returns an empty manifest when no files survive the filter AssertionError: expected { …(2) } to deeply equal { files: {}, filesShaMap: {} } - Expected + Received { - "files": {}, - "filesShaMap": {}, + "files": { + "ignored.txt": "7471fce8530d7bd0b0f7ad1269e277308456da4b", + }, + "filesShaMap": { + "7471fce8530d7bd0b0f7ad1269e277308456da4b": [ + { + "assetType": "file", + "basename": "ignored.txt", + "filepath": "/tmp/cli-hash-files-R7MYug/ignored.txt", + "hash": "7471fce8530d7bd0b0f7ad1269e277308456da4b", + "normalizedPath": "ignored.txt", + "relname": "ignored.txt", + "root": "/tmp/cli-hash-files-R7MYug", + "stat": Stats { + "atimeMs": 1790569091291.9937, + "birthtimeMs": 0, + "blksize": 4096, + "blocks": 8, + "ctimeMs": 1790569091291.9937, + "dev": 26, + "gid": 2500, + "ino": 82580, + "mode": 33188, + "mtimeMs": 1790569091291.9937, + "nlink": 1, + "rdev": 0, + "size": 8, + "uid": 2500, + }, + "type": "file", + }, + ], + }, } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:170:18 168| const result = await hashFiles({ directories: [directory], filter: (… 169| 170| expect(result).toEqual({ files: {}, filesShaMap: {} }) | ^ 171| expect(statusCb).not.toHaveBeenCalled() 172| }) ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ skip-deploy-normalizer in src/utils/deploy/hasher-segments.ts before: normalizeFunction !== undefined ? normalizeFunction(normalizedFile) : normalizedFile after: normalizedFile Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 1 failed) 219ms ✓ hashes complete file bytes with concurrency 1 100ms ✓ hashes complete file bytes with concurrency 4 95ms ✓ keeps every upload path for identical content and rehashes changed files on the next run 5ms ✓ prunes excluded files and directories before hashing 3ms × preserves deploy artifact namespaces when several roots have the same relative filename 8ms → expected { Object (manifest.json) } to deeply equal { …(4) } ✓ returns an empty manifest when no files survive the filter 1ms ✓ rejects a missing input directory instead of returning a partial manifest 2ms ✓ propagates a file read failure and destroys the hashing pipeline 2ms ✓ rejects when a discovered file disappears before it can be read 2ms Test Files 1 failed (1) Tests 1 failed | 8 passed (9) Start at 04:18:13 Duration 431ms (transform 60ms, setup 0ms, collect 77ms, tests 219ms, environment 0ms, prepare 42ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > preserves deploy artifact namespaces when several roots have the same relative filename AssertionError: expected { Object (manifest.json) } to deeply equal { …(4) } - Expected + Received { - ".netlify/deploy-config/manifest.json": "22a0e059ba07ad8e7c378d8c42017daf3f9f5f96", - ".netlify/internal/db/migrations/manifest.json": "f6feaf78b38066e2b0f9b75e7dd0ad75b4c4ad28", - ".netlify/internal/edge-functions/manifest.json": "7a34e638972451a0cc463c269c4de24df6c66fd7", - "manifest.json": "61c9b2b17db77a27841bbeeabff923448b0f6388", + "manifest.json": "f6feaf78b38066e2b0f9b75e7dd0ad75b4c4ad28", } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:153:17 151| }) 152| 153| expect(files).toEqual( | ^ 154| Object.fromEntries( 155| expectedPaths.map((name, index) => [name, createHash('sha1').upd… ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ swallow-discovery-error in src/utils/deploy/hash-files.ts before: await pipeline([fileStream, fileFilter, hasher, fileNormalizer, manifestCollector]) after: await pipeline([fileStream, fileFilter, hasher, fileNormalizer, manifestCollector]).catch(() => {}) Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 2 failed) 221ms ✓ hashes complete file bytes with concurrency 1 100ms ✓ hashes complete file bytes with concurrency 4 95ms ✓ keeps every upload path for identical content and rehashes changed files on the next run 5ms ✓ prunes excluded files and directories before hashing 3ms ✓ preserves deploy artifact namespaces when several roots have the same relative filename 3ms ✓ returns an empty manifest when no files survive the filter 1ms × rejects a missing input directory instead of returning a partial manifest 9ms → promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting ✓ propagates a file read failure and destroys the hashing pipeline 3ms × rejects when a discovered file disappears before it can be read 2ms → promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting Test Files 1 failed (1) Tests 2 failed | 7 passed (9) Start at 04:18:16 Duration 438ms (transform 60ms, setup 0ms, collect 83ms, tests 221ms, environment 0ms, prepare 41ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > rejects a missing input directory instead of returning a partial manifest AssertionError: promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting - Expected + Received - Error { - "message": "rejected promise", + { + "files": {}, + "filesShaMap": {}, } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:180:3 178| await expect( 179| hashFiles({ directories: [directory, missing], filter: () => true,… 180| ).rejects.toMatchObject({ code: 'ENOENT', path: missing }) | ^ 181| }) 182| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > rejects when a discovered file disappears before it can be read AssertionError: promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting - Expected + Received - Error { - "message": "rejected promise", + { + "files": {}, + "filesShaMap": {}, } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:222:3 220| await expect( 221| hashFiles({ directories: [directory], filter: () => true, concurre… 222| ).rejects.toMatchObject({ code: 'ENOENT', syscall: 'open', path: mis… | ^ 223| }) 224| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ swallow-file-read-error in src/utils/deploy/hasher-segments.ts before: cb(error as Error) after: cb() Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 2 failed) 225ms ✓ hashes complete file bytes with concurrency 1 102ms ✓ hashes complete file bytes with concurrency 4 96ms ✓ keeps every upload path for identical content and rehashes changed files on the next run 5ms ✓ prunes excluded files and directories before hashing 3ms ✓ preserves deploy artifact namespaces when several roots have the same relative filename 3ms ✓ returns an empty manifest when no files survive the filter 1ms ✓ rejects a missing input directory instead of returning a partial manifest 3ms × propagates a file read failure and destroys the hashing pipeline 7ms → promise resolved "undefined" instead of rejecting × rejects when a discovered file disappears before it can be read 4ms → promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting Test Files 1 failed (1) Tests 2 failed | 7 passed (9) Start at 04:18:19 Duration 442ms (transform 63ms, setup 0ms, collect 80ms, tests 225ms, environment 0ms, prepare 44ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > propagates a file read failure and destroys the hashing pipeline AssertionError: promise resolved "undefined" instead of rejecting - Expected: Error { "message": "rejected promise", } + Received: undefined ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:199:46 197| }) 198| 199| await expect(pipeline(source, hasher, sink)).rejects.toMatchObject({… | ^ 200| expect(received).toEqual([expect.objectContaining({ filepath: join(d… 201| expect(source.destroyed).toBe(true) ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > rejects when a discovered file disappears before it can be read AssertionError: promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting - Expected + Received - Error { - "message": "rejected promise", + { + "files": {}, + "filesShaMap": {}, } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:222:3 220| await expect( 221| hashFiles({ directories: [directory], filter: () => true, concurre… 222| ).rejects.toMatchObject({ code: 'ENOENT', syscall: 'open', path: mis… | ^ 223| }) 224| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ swallow-only-open-errors in src/utils/deploy/hash-files.ts before: await pipeline([fileStream, fileFilter, hasher, fileNormalizer, manifestCollector]) after: await pipeline([fileStream, fileFilter, hasher, fileNormalizer, manifestCollector]).catch((error) => { if (error.syscall !== 'open') throw error }) Existing exit 0; new exit 1. RUN v3.2.7 /tmp/netliloop-work/cli ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts (9 tests | 1 failed) 225ms ✓ hashes complete file bytes with concurrency 1 104ms ✓ hashes complete file bytes with concurrency 4 96ms ✓ keeps every upload path for identical content and rehashes changed files on the next run 4ms ✓ prunes excluded files and directories before hashing 3ms ✓ preserves deploy artifact namespaces when several roots have the same relative filename 3ms ✓ returns an empty manifest when no files survive the filter 1ms ✓ rejects a missing input directory instead of returning a partial manifest 3ms ✓ propagates a file read failure and destroys the hashing pipeline 2ms × rejects when a discovered file disappears before it can be read 8ms → promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting Test Files 1 failed (1) Tests 1 failed | 8 passed (9) Start at 04:18:22 Duration 442ms (transform 63ms, setup 0ms, collect 81ms, tests 225ms, environment 0ms, prepare 42ms) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/unit/utils/deploy/hash-files-pipeline.test.ts > rejects when a discovered file disappears before it can be read AssertionError: promise resolved "{ files: {}, filesShaMap: {} }" instead of rejecting - Expected + Received - Error { - "message": "rejected promise", + { + "files": {}, + "filesShaMap": {}, } ❯ tests/unit/utils/deploy/hash-files-pipeline.test.ts:222:3 220| await expect( 221| hashFiles({ directories: [directory], filter: () => true, concurre… 222| ).rejects.toMatchObject({ code: 'ENOENT', syscall: 'open', path: mis… | ^ 223| }) 224| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Independent verification: real deploySite -> discovery/hashing -> manifest -> uploadFiles -> installed @netlify/api -> loopback TCP. Only the remote Netlify service is replaced by an HTTP fixture. Inert framework metadata is supplied; no functions or executable edge bundles are deployed. Filesystem, streams, client, hashes, timers and HTTP transport are real. The committed disappearing-file test wraps createReadStream only to unlink a real discovered fixture immediately before its real open. Its stream and ENOENT error are real. Independent chmod(000) tests use no filesystem mocks. Portable receipt (script supplied separately): $ npm exec tsx -- /tmp/verify-deploy-hashing.mts PASS deploySite -> hashFiles -> getUploadList -> uploadFiles -> real NetlifyAPI -> TCP fixture: 10 exact uploads satisfy all 10 manifest paths, duplicate content paths, 3 artifact namespaces, Unicode, empty file, 2097157-byte binary, exclusion pruning; 12 HTTP requests; config nonempty PASS second deploy rehashes modified file, retains duplicate sibling, uploads exact changed bytes, and leaves first manifest intact; 12 HTTP requests PASS simultaneous hashFiles runs isolate manifests and physical paths across distinct roots with the same relative filename; 4 exact HTTP uploads PASS accepted empty directory produces empty maps and no HTTP uploads PASS SHA-256 hash pipeline retains upload metadata and streams exact original bytes; 2 HTTP uploads PASS real missing deployment directory rejects ENOENT before any manifest or upload HTTP request PASS real chmod(000) file read failure rejects EACCES before any manifest or upload HTTP request; no fs or pipeline mocks SUMMARY 30 HTTP requests received by the loopback deploy API fixture $ npm exec tsx -- /tmp/verify-deploy-hashing.mts --empty-config PASS deploySite -> hashFiles -> getUploadList -> uploadFiles -> real NetlifyAPI -> TCP fixture: 9 exact uploads satisfy all 10 manifest paths, duplicate content paths, 3 artifact namespaces, Unicode, empty file, 2097157-byte binary, exclusion pruning; 11 HTTP requests; config empty (content-addressed deduplication) PASS second deploy rehashes modified file, retains duplicate sibling, uploads exact changed bytes, and leaves first manifest intact; 11 HTTP requests PASS simultaneous hashFiles runs isolate manifests and physical paths across distinct roots with the same relative filename; 4 exact HTTP uploads PASS accepted empty directory produces empty maps and no HTTP uploads PASS SHA-256 hash pipeline retains upload metadata and streams exact original bytes; 2 HTTP uploads PASS real missing deployment directory rejects ENOENT before any manifest or upload HTTP request PASS real chmod(000) file read failure rejects EACCES before any manifest or upload HTTP request; no fs or pipeline mocks SUMMARY 28 HTTP requests received by the loopback deploy API fixture Negative control: npm exec tsx -- /tmp/verify-deploy-hashing.mts --negative-control; exit 1 as required. Actual diagnostic: node:internal/modules/run_main:107 triggerUncaughtException( ^ AssertionError [ERR_ASSERTION]: exact bytes received for one.txt + actual - expected + Buffer(38) [Uint8Array] [ - Buffer(15) [Uint8Array] [ 100, + 101, - 117, - 112, 108, 105, + 98, + 101, + 114, - 99, 97, 116, 101, + 108, + 121, 32, + 99, + 111, + 114, + 114, + 117, + 112, - 98, - 121, 116, + 32, + 102, + 105, + 120, + 116, + 117, + 114, 101, + 32, + 114, + 101, + 99, + 101, + 112, + 116, + 105, + 111, + 110 - 115 ] at verifyUploads (/tmp/netliloop-evidence/verify-deploy-hashing.mts:70:12) at (/tmp/netliloop-evidence/verify-deploy-hashing.mts:129:3) { generatedMessage: false, code: 'ERR_ASSERTION', actual: Buffer(38) [Uint8Array] [ 100, 101, 108, 105, 98, 101, 114, 97, 116, 101, 108, 121, 32, 99, 111, 114, 114, 117, 112, 116, 32, 102, 105, 120, 116, 117, 114, 101, 32, 114, 101, 99, 101, 112, 116, 105, 111, 110 ], expected: Buffer(15) [Uint8Array] [ 100, 117, 112, 108, 105, 99, 97, 116, 101, 32, 98, 121, 116, 101, 115 ], operator: 'deepStrictEqual', diff: 'simple' } Node.js v24.21.0 The fixture now accepts any uploaded representative of duplicate content and validates each declared digest against bytes received. Physical upload counts are observations only. Every manifest path is checked by content hash. Comparing physical upload count to path count is invalid: empty netlify.toml and empty.txt share one required blob. The corrected fixture passes both current and pristine source. Backend source confirmation: https://github.com/netlify/bitballoon/blob/478332afc5c9009989579628aaaf29bd340df97e/app/models/in_memory_deploy_file_tracker.rb (required_shas is a Set); netlify-server/filetracking/mongo_client.go UpdateFileTracking uses deploy ID + SHA with UpdateMany. Limitations: the live Netlify API, backend stores, and CDN delivery are unverified; the loopback service supplies those responses. This change only adds tests, so no deployment is required to exercise its implementation. The independent chmod failure scenario is Linux/non-root only. OS coverage for the committed portable tests runs in CI.