Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,9 +533,11 @@ const runDeploy = async ({
logsUrl: string
functionLogsUrl: string
edgeFunctionLogsUrl: string
sourceZipFileName?: string
}> => {
let results
let deployId
let uploadSourceZipResult

try {
if (deployToProduction) {
Expand All @@ -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,
Expand Down Expand Up @@ -644,6 +646,7 @@ const runDeploy = async ({
logsUrl,
functionLogsUrl,
edgeFunctionLogsUrl,
sourceZipFileName: uploadSourceZipResult?.sourceZipFileName,
}
}

Expand Down Expand Up @@ -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<ReturnType<typeof prepAndRunDeploy>>
runBuildCommand: boolean
Expand Down Expand Up @@ -773,6 +779,10 @@ const printResults = ({
jsonData.url = results.siteUrl
}

if (uploadSourceZip) {
jsonData.source_zip_filename = results.sourceZipFileName
}

logJson(jsonData)
exit(0)
} else {
Expand Down Expand Up @@ -1086,6 +1096,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand)
json: options.json,
results,
deployToProduction,
uploadSourceZip: !!options.uploadSourceZip,
})

if (options.open) {
Expand Down
13 changes: 7 additions & 6 deletions src/utils/deploy/upload-source-zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,7 @@ const createSourceZip = async ({
return zipPath
}

const uploadZipToS3 = async (
zipPath: string,
uploadUrl: string,
statusCb: (status: DeployEvent) => void,
): Promise<void> => {
const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (status: DeployEvent) => void) => {
const zipBuffer = await readFile(zipPath)
const sizeMB = (zipBuffer.length / 1024 / 1024).toFixed(2)

Expand Down Expand Up @@ -111,7 +107,7 @@ export const uploadSourceZip = async ({
uploadUrl,
filename,
statusCb = () => {},
}: UploadSourceZipOptions): Promise<void> => {
}: UploadSourceZipOptions): Promise<{ sourceZipFileName: string }> => {
let zipPath: PathLike | undefined

try {
Expand All @@ -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({
Expand All @@ -150,6 +149,8 @@ export const uploadSourceZip = async ({
})

log(`✔ Source code uploaded`)

return { sourceZipFileName }
} finally {
// Clean up temporary zip file
if (zipPath) {
Expand Down
34 changes: 34 additions & 0 deletions tests/integration/commands/deploy/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<h1>Source zip test</h1>'
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
}
}
})
})
})
133 changes: 133 additions & 0 deletions tests/unit/utils/deploy/upload-source-zip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Loading