From 28023c547764e5155cff4321af35b01b08d403b7 Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Wed, 27 Aug 2025 20:35:48 +0300 Subject: [PATCH 1/4] fix: return uploaded zip url in json format after deploy and uplaod is done --- src/commands/deploy/deploy.ts | 13 +- src/utils/deploy/upload-source-zip.ts | 13 +- .../commands/deploy/deploy.test.ts | 31 ++++ .../utils/deploy/upload-source-zip.test.ts | 133 ++++++++++++++++++ 4 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 089549b460c..49607164ed0 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -533,9 +533,11 @@ const runDeploy = async ({ logsUrl: string functionLogsUrl: string edgeFunctionLogsUrl: string + sourceZipUrl?: string }> => { let results let deployId + let uploadSourceZipResult try { if (deployToProduction) { @@ -550,7 +552,7 @@ const runDeploy = async ({ // Handle source zip upload if requested and URL provided if (options.uploadSourceZip && results.source_zip_upload_url && results.source_zip_filename) { - await uploadSourceZip({ + uploadSourceZipResult = await uploadSourceZip({ sourceDir: site.root, uploadUrl: results.source_zip_upload_url, filename: results.source_zip_filename, @@ -644,6 +646,7 @@ const runDeploy = async ({ logsUrl, functionLogsUrl, edgeFunctionLogsUrl, + sourceZipUrl: uploadSourceZipResult?.sourceZipUrl, } } @@ -735,15 +738,18 @@ interface JsonData { function_logs: string edge_function_logs: string url?: string + source_zip_url?: string } const printResults = ({ deployToProduction, + uploadSourceZip, json, results, runBuildCommand, }: { deployToProduction: boolean + uploadSourceZip: boolean json: boolean results: Awaited> runBuildCommand: boolean @@ -773,6 +779,10 @@ const printResults = ({ jsonData.url = results.siteUrl } + if (uploadSourceZip) { + jsonData.source_zip_url = results.sourceZipUrl + } + logJson(jsonData) exit(0) } else { @@ -1086,6 +1096,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) json: options.json, results, deployToProduction, + uploadSourceZip: !!options.uploadSourceZip, }) if (options.open) { diff --git a/src/utils/deploy/upload-source-zip.ts b/src/utils/deploy/upload-source-zip.ts index ca8b5fe4afb..995ba04ee5f 100644 --- a/src/utils/deploy/upload-source-zip.ts +++ b/src/utils/deploy/upload-source-zip.ts @@ -82,7 +82,7 @@ const uploadZipToS3 = async ( zipPath: string, uploadUrl: string, statusCb: (status: DeployEvent) => void, -): Promise => { +): Promise<{ url: string }> => { const zipBuffer = await readFile(zipPath) const sizeMB = (zipBuffer.length / 1024 / 1024).toFixed(2) @@ -104,6 +104,8 @@ const uploadZipToS3 = async ( if (!response.ok) { throw new Error(`Failed to upload zip: ${response.statusText}`) } + // todo provide proper url + return { url: '' } } export const uploadSourceZip = async ({ @@ -111,7 +113,7 @@ export const uploadSourceZip = async ({ uploadUrl, filename, statusCb = () => {}, -}: UploadSourceZipOptions): Promise => { +}: UploadSourceZipOptions): Promise<{ sourceZipUrl: string }> => { let zipPath: PathLike | undefined try { @@ -129,9 +131,12 @@ export const uploadSourceZip = async ({ throw error } + let sourceZipUrl: string + // Upload to S3 try { - await uploadZipToS3(zipPath, uploadUrl, statusCb) + const { url } = await uploadZipToS3(zipPath, uploadUrl, statusCb) + sourceZipUrl = url } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) statusCb({ @@ -150,6 +155,8 @@ export const uploadSourceZip = async ({ }) log(`✔ Source code uploaded`) + + return { sourceZipUrl } } finally { // Clean up temporary zip file if (zipPath) { diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index f3ddcd489e2..b53e684a044 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -1183,4 +1183,35 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co } }) }) + + test('should include source_zip_url in JSON output when --upload-source-zip flag is used', async (t) => { + await withSiteBuilder(t, async (builder) => { + const content = '

Source zip test

' + builder.withContentFile({ + path: 'public/index.html', + content, + }) + + await builder.build() + + try { + const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--upload-source-zip'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then((output: string) => JSON.parse(output)) + + await validateDeploy({ deploy, siteName: SITE_NAME, content }) + expect(deploy).toHaveProperty('source_zip_url') + expect(typeof deploy.source_zip_url).toBe('string') + expect(deploy.source_zip_url).toMatch(/^https:\/\//) + } catch (error) { + // If the feature is not yet supported by the API, skip the test + if (error instanceof Error && (error.message.includes('include_upload_url') || error.message.includes('source_zip'))) { + t.skip() + } else { + throw error + } + } + }) + }) }) diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index 273d1528c9f..8b8e2266e72 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -235,4 +235,137 @@ describe('uploadSourceZip', () => { }), ) }) + + test('handles zip creation failure correctly', async () => { + // Ensure OS platform mock returns non-Windows + const mockOs = await import('os') + vi.mocked(mockOs.platform).mockReturnValue('darwin') + + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') + + const mockChildProcess = await import('child_process') + const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') + const mockTempFile = await import('../../../../src/utils/temporary-file.js') + + // Mock execFile to simulate failure + vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (callback) { + callback(new Error('zip command failed'), '', 'zip: error creating archive') + } + return {} as import('child_process').ChildProcess + }) + + vi.mocked(mockCommandHelpers.warn).mockImplementation(() => {}) + vi.mocked(mockTempFile.temporaryDirectory).mockReturnValue('/tmp/test-temp-dir') + + const mockStatusCb = vi.fn() + + await expect( + uploadSourceZip({ + sourceDir: '/test/source', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + statusCb: mockStatusCb, + }), + ).rejects.toThrow('zip command failed') + + expect(mockStatusCb).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'source-zip-upload', + phase: 'error', + msg: 'Failed to create source zip: zip command failed', + }), + ) + + expect(mockCommandHelpers.warn).toHaveBeenCalledWith('Failed to create source zip: zip command failed') + }) + + test('cleans up zip file even when upload fails', async () => { + // Ensure OS platform mock returns non-Windows + const mockOs = await import('os') + vi.mocked(mockOs.platform).mockReturnValue('darwin') + + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') + + const mockFetch = await import('node-fetch') + const mockChildProcess = await import('child_process') + const mockFs = await import('fs/promises') + const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') + const mockTempFile = await import('../../../../src/utils/temporary-file.js') + + // Mock successful zip creation but failed upload + vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (callback) { + callback(null, '', '') + } + return {} as import('child_process').ChildProcess + }) + + vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) + vi.mocked(mockFetch.default).mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as unknown as import('node-fetch').Response) + + vi.mocked(mockCommandHelpers.warn).mockImplementation(() => {}) + vi.mocked(mockTempFile.temporaryDirectory).mockReturnValue('/tmp/test-temp-dir') + vi.mocked(mockFs.unlink).mockResolvedValue(undefined) + + const mockStatusCb = vi.fn() + + await expect( + uploadSourceZip({ + sourceDir: '/test/source', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + statusCb: mockStatusCb, + }), + ).rejects.toThrow('Failed to upload zip: Internal Server Error') + + // Should still attempt cleanup + expect(mockFs.unlink).toHaveBeenCalledWith(expect.stringMatching(/test-source\.zip$/)) + }) + + test('handles no status callback gracefully', async () => { + // Ensure OS platform mock returns non-Windows + const mockOs = await import('os') + vi.mocked(mockOs.platform).mockReturnValue('darwin') + + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') + + const mockFetch = await import('node-fetch') + const mockChildProcess = await import('child_process') + const mockFs = await import('fs/promises') + const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') + const mockTempFile = await import('../../../../src/utils/temporary-file.js') + + vi.mocked(mockFetch.default).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn().mockResolvedValue({ url: 'https://test-source-zip-url.com' }), + } as unknown as import('node-fetch').Response) + + vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (callback) { + callback(null, '', '') + } + return {} as import('child_process').ChildProcess + }) + + vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) + vi.mocked(mockCommandHelpers.log).mockImplementation(() => {}) + vi.mocked(mockTempFile.temporaryDirectory).mockReturnValue('/tmp/test-temp-dir') + + // Should not throw when no status callback provided + const result = await uploadSourceZip({ + sourceDir: '/test/source', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + // No statusCb provided - should use default empty function + }) + + expect(result).toHaveProperty('sourceZipUrl') + }) }) From 5a5a13c7be3fc83054b8ab233f20e691c2537204 Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Sun, 31 Aug 2025 16:17:00 +0300 Subject: [PATCH 2/4] fix: naming --- src/commands/deploy/deploy.ts | 8 ++++---- src/utils/deploy/upload-source-zip.ts | 18 ++++++------------ .../integration/commands/deploy/deploy.test.ts | 8 ++++---- .../utils/deploy/upload-source-zip.test.ts | 2 +- 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 49607164ed0..91b0eb8ac3d 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -533,7 +533,7 @@ const runDeploy = async ({ logsUrl: string functionLogsUrl: string edgeFunctionLogsUrl: string - sourceZipUrl?: string + sourceZipFileName?: string }> => { let results let deployId @@ -646,7 +646,7 @@ const runDeploy = async ({ logsUrl, functionLogsUrl, edgeFunctionLogsUrl, - sourceZipUrl: uploadSourceZipResult?.sourceZipUrl, + sourceZipFileName: uploadSourceZipResult?.sourceZipFileName, } } @@ -738,7 +738,7 @@ interface JsonData { function_logs: string edge_function_logs: string url?: string - source_zip_url?: string + source_zip_filename?: string } const printResults = ({ @@ -780,7 +780,7 @@ const printResults = ({ } if (uploadSourceZip) { - jsonData.source_zip_url = results.sourceZipUrl + jsonData.source_zip_filename = results.sourceZipFileName } logJson(jsonData) diff --git a/src/utils/deploy/upload-source-zip.ts b/src/utils/deploy/upload-source-zip.ts index 995ba04ee5f..814598f8499 100644 --- a/src/utils/deploy/upload-source-zip.ts +++ b/src/utils/deploy/upload-source-zip.ts @@ -78,11 +78,7 @@ const createSourceZip = async ({ return zipPath } -const uploadZipToS3 = async ( - zipPath: string, - uploadUrl: string, - statusCb: (status: DeployEvent) => void, -): Promise<{ url: string }> => { +const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (status: DeployEvent) => void) => { const zipBuffer = await readFile(zipPath) const sizeMB = (zipBuffer.length / 1024 / 1024).toFixed(2) @@ -104,8 +100,6 @@ const uploadZipToS3 = async ( if (!response.ok) { throw new Error(`Failed to upload zip: ${response.statusText}`) } - // todo provide proper url - return { url: '' } } export const uploadSourceZip = async ({ @@ -113,7 +107,7 @@ export const uploadSourceZip = async ({ uploadUrl, filename, statusCb = () => {}, -}: UploadSourceZipOptions): Promise<{ sourceZipUrl: string }> => { +}: UploadSourceZipOptions): Promise<{ sourceZipFileName: string }> => { let zipPath: PathLike | undefined try { @@ -131,12 +125,12 @@ export const uploadSourceZip = async ({ throw error } - let sourceZipUrl: string + let sourceZipFileName: string // Upload to S3 try { - const { url } = await uploadZipToS3(zipPath, uploadUrl, statusCb) - sourceZipUrl = url + await uploadZipToS3(zipPath, uploadUrl, statusCb) + sourceZipFileName = filename } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) statusCb({ @@ -156,7 +150,7 @@ export const uploadSourceZip = async ({ log(`✔ Source code uploaded`) - return { sourceZipUrl } + return { sourceZipFileName } } finally { // Clean up temporary zip file if (zipPath) { diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index b53e684a044..01febb4c6f4 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -1184,7 +1184,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) }) - test('should include source_zip_url in JSON output when --upload-source-zip flag is used', async (t) => { + test('should include source_zip_filename in JSON output when --upload-source-zip flag is used', async (t) => { await withSiteBuilder(t, async (builder) => { const content = '

Source zip test

' builder.withContentFile({ @@ -1201,9 +1201,9 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content }) - expect(deploy).toHaveProperty('source_zip_url') - expect(typeof deploy.source_zip_url).toBe('string') - expect(deploy.source_zip_url).toMatch(/^https:\/\//) + expect(deploy).toHaveProperty('source_zip_filename') + expect(typeof deploy.source_zip_filename).toBe('string') + expect(deploy.source_zip_filename).toMatch(/^https:\/\//) } catch (error) { // If the feature is not yet supported by the API, skip the test if (error instanceof Error && (error.message.includes('include_upload_url') || error.message.includes('source_zip'))) { diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index 8b8e2266e72..9c0840e002f 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -366,6 +366,6 @@ describe('uploadSourceZip', () => { // No statusCb provided - should use default empty function }) - expect(result).toHaveProperty('sourceZipUrl') + expect(result).toHaveProperty('sourceZipFileName') }) }) From bf3dc095188bb525b04e937b0686d8773066540c Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Sun, 31 Aug 2025 16:19:34 +0300 Subject: [PATCH 3/4] lint --- tests/integration/commands/deploy/deploy.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index 01febb4c6f4..f031ff90d23 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -1206,7 +1206,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co expect(deploy.source_zip_filename).toMatch(/^https:\/\//) } catch (error) { // If the feature is not yet supported by the API, skip the test - if (error instanceof Error && (error.message.includes('include_upload_url') || error.message.includes('source_zip'))) { + if ( + error instanceof Error && + (error.message.includes('include_upload_url') || error.message.includes('source_zip')) + ) { t.skip() } else { throw error From 2bd3e409d0be9f91f105f97915e4b7585afefb81 Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Sun, 31 Aug 2025 16:31:46 +0300 Subject: [PATCH 4/4] fix: tests --- tests/integration/commands/deploy/deploy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index f031ff90d23..13f6e0e41e2 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -1203,7 +1203,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await validateDeploy({ deploy, siteName: SITE_NAME, content }) expect(deploy).toHaveProperty('source_zip_filename') expect(typeof deploy.source_zip_filename).toBe('string') - expect(deploy.source_zip_filename).toMatch(/^https:\/\//) + expect(deploy.source_zip_filename).toMatch(/\.zip$/) } catch (error) { // If the feature is not yet supported by the API, skip the test if (