diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 089549b460c..91b0eb8ac3d 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 + sourceZipFileName?: 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, + sourceZipFileName: uploadSourceZipResult?.sourceZipFileName, } } @@ -735,15 +738,18 @@ interface JsonData { function_logs: string edge_function_logs: string url?: string + source_zip_filename?: 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_filename = results.sourceZipFileName + } + 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..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 => { +const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (status: DeployEvent) => void) => { const zipBuffer = await readFile(zipPath) const sizeMB = (zipBuffer.length / 1024 / 1024).toFixed(2) @@ -111,7 +107,7 @@ export const uploadSourceZip = async ({ uploadUrl, filename, statusCb = () => {}, -}: UploadSourceZipOptions): Promise => { +}: UploadSourceZipOptions): Promise<{ sourceZipFileName: string }> => { let zipPath: PathLike | undefined try { @@ -129,9 +125,12 @@ export const uploadSourceZip = async ({ throw error } + let sourceZipFileName: string + // Upload to S3 try { await uploadZipToS3(zipPath, uploadUrl, statusCb) + sourceZipFileName = filename } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) statusCb({ @@ -150,6 +149,8 @@ export const uploadSourceZip = async ({ }) log(`✔ Source code uploaded`) + + 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 f3ddcd489e2..13f6e0e41e2 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -1183,4 +1183,38 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co } }) }) + + 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({ + 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_filename') + expect(typeof deploy.source_zip_filename).toBe('string') + expect(deploy.source_zip_filename).toMatch(/\.zip$/) + } 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..9c0840e002f 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('sourceZipFileName') + }) })