Skip to content

Commit cd94c0a

Browse files
authored
feat: upload zip after deploy (#7573)
1 parent a873f90 commit cd94c0a

File tree

5 files changed

+424
-2
lines changed

5 files changed

+424
-2
lines changed

src/commands/deploy/deploy.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from '../../utils/command-helpers.js'
4141
import { DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js'
4242
import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js'
43+
import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js'
4344
import { getEnvelopeEnv } from '../../utils/env/index.js'
4445
import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js'
4546
import openBrowser from '../../utils/open-browser.js'
@@ -542,9 +543,21 @@ const runDeploy = async ({
542543
}
543544

544545
const draft = !deployToProduction && !alias
545-
results = await api.createSiteDeploy({ siteId, title, body: { draft, branch: alias } })
546+
const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip }
547+
548+
results = await api.createSiteDeploy({ siteId, title, body: createDeployBody })
546549
deployId = results.id
547550

551+
// Handle source zip upload if requested and URL provided
552+
if (options.uploadSourceZip && results.source_zip_upload_url && results.source_zip_filename) {
553+
await uploadSourceZip({
554+
sourceDir: site.root,
555+
uploadUrl: results.source_zip_upload_url,
556+
filename: results.source_zip_filename,
557+
statusCb: silent ? () => {} : deployProgressCb(),
558+
})
559+
}
560+
548561
const internalFunctionsFolder = await getInternalFunctionsDir({ base: site.root, packagePath, ensureExists: true })
549562

550563
await command.netlify.frameworksAPIPaths.functions.ensureExists()

src/commands/deploy/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { env } from 'process'
1+
import { env, platform } from 'process'
22

33
import { Option } from 'commander'
44
import terminalLink from 'terminal-link'
@@ -73,6 +73,7 @@ For detailed configuration options, see the Netlify documentation.`,
7373
'Ignore any functions created as part of a previous `build` or `deploy` commands, forcing them to be bundled again as part of the deployment',
7474
false,
7575
)
76+
.addOption(new Option('--upload-source-zip', 'Upload source code as a zip file').default(false).hideHelp(true))
7677
.option(
7778
'--create-site [name]',
7879
'Create a new site and deploy to it. Optionally specify a name, otherwise a random name will be generated. Requires --team flag if you have multiple teams.',
@@ -114,6 +115,12 @@ For more information about Netlify deploys, see ${terminalLink(docsUrl, docsUrl,
114115
return logAndThrowError('--team flag can only be used with --create-site flag')
115116
}
116117

118+
// Handle Windows + source zip upload
119+
if (options.uploadSourceZip && platform === 'win32') {
120+
warn('Source zip upload is not supported on Windows. Disabling --upload-source-zip option.')
121+
options.uploadSourceZip = false
122+
}
123+
117124
const { deploy } = await import('./deploy.js')
118125
await deploy(options, command)
119126
})

src/commands/deploy/option_values.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ export type DeployOptionValues = BaseOptionValues & {
2020
team?: string
2121
timeout?: number
2222
trigger?: boolean
23+
uploadSourceZip?: boolean
2324
}

src/utils/deploy/upload-source-zip.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { execFile } from 'child_process'
2+
import { readFile } from 'fs/promises'
3+
import { join } from 'path'
4+
import { promisify } from 'util'
5+
import type { PathLike } from 'fs'
6+
import { platform } from 'os'
7+
8+
import fetch from 'node-fetch'
9+
10+
import { log, warn } from '../command-helpers.js'
11+
import { temporaryDirectory } from '../temporary-file.js'
12+
import type { DeployEvent } from './status-cb.js'
13+
14+
const execFileAsync = promisify(execFile)
15+
16+
interface UploadSourceZipOptions {
17+
sourceDir: string
18+
uploadUrl: string
19+
filename: string
20+
statusCb?: (status: DeployEvent) => void
21+
}
22+
23+
const DEFAULT_IGNORE_PATTERNS = [
24+
'node_modules',
25+
'.git',
26+
'.netlify',
27+
'.next',
28+
'dist',
29+
'build',
30+
'.nuxt',
31+
'.output',
32+
'.vercel',
33+
'__pycache__',
34+
'.venv',
35+
'.env',
36+
'.DS_Store',
37+
'Thumbs.db',
38+
'*.log',
39+
'.nyc_output',
40+
'coverage',
41+
'.cache',
42+
'.tmp',
43+
'.temp',
44+
]
45+
46+
const createSourceZip = async ({
47+
sourceDir,
48+
filename,
49+
statusCb,
50+
}: {
51+
sourceDir: string
52+
filename: string
53+
statusCb: (status: DeployEvent) => void
54+
}) => {
55+
// Check for Windows - this feature is not supported on Windows
56+
if (platform() === 'win32') {
57+
throw new Error('Source zip upload is not supported on Windows')
58+
}
59+
60+
const tmpDir = temporaryDirectory()
61+
const zipPath = join(tmpDir, filename)
62+
63+
statusCb({
64+
type: 'source-zip-upload',
65+
msg: `Creating source zip...`,
66+
phase: 'start',
67+
})
68+
69+
// Create exclusion list for zip command
70+
const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern])
71+
72+
// Use system zip command to create the archive
73+
await execFileAsync('zip', ['-r', zipPath, '.', ...excludeArgs], {
74+
cwd: sourceDir,
75+
maxBuffer: 1024 * 1024 * 100, // 100MB buffer
76+
})
77+
78+
return zipPath
79+
}
80+
81+
const uploadZipToS3 = async (
82+
zipPath: string,
83+
uploadUrl: string,
84+
statusCb: (status: DeployEvent) => void,
85+
): Promise<void> => {
86+
const zipBuffer = await readFile(zipPath)
87+
const sizeMB = (zipBuffer.length / 1024 / 1024).toFixed(2)
88+
89+
statusCb({
90+
type: 'source-zip-upload',
91+
msg: `Uploading source zip (${sizeMB} MB)...`,
92+
phase: 'progress',
93+
})
94+
95+
const response = await fetch(uploadUrl, {
96+
method: 'PUT',
97+
body: zipBuffer,
98+
headers: {
99+
'Content-Type': 'application/zip',
100+
'Content-Length': zipBuffer.length.toString(),
101+
},
102+
})
103+
104+
if (!response.ok) {
105+
throw new Error(`Failed to upload zip: ${response.statusText}`)
106+
}
107+
}
108+
109+
export const uploadSourceZip = async ({
110+
sourceDir,
111+
uploadUrl,
112+
filename,
113+
statusCb = () => {},
114+
}: UploadSourceZipOptions): Promise<void> => {
115+
let zipPath: PathLike | undefined
116+
117+
try {
118+
// Create zip from source directory
119+
try {
120+
zipPath = await createSourceZip({ sourceDir, filename, statusCb })
121+
} catch (error) {
122+
const errorMsg = error instanceof Error ? error.message : String(error)
123+
statusCb({
124+
type: 'source-zip-upload',
125+
msg: `Failed to create source zip: ${errorMsg}`,
126+
phase: 'error',
127+
})
128+
warn(`Failed to create source zip: ${errorMsg}`)
129+
throw error
130+
}
131+
132+
// Upload to S3
133+
try {
134+
await uploadZipToS3(zipPath, uploadUrl, statusCb)
135+
} catch (error) {
136+
const errorMsg = error instanceof Error ? error.message : String(error)
137+
statusCb({
138+
type: 'source-zip-upload',
139+
msg: `Failed to upload source zip: ${errorMsg}`,
140+
phase: 'error',
141+
})
142+
warn(`Failed to upload source zip: ${errorMsg}`)
143+
throw error
144+
}
145+
146+
statusCb({
147+
type: 'source-zip-upload',
148+
msg: `Source zip uploaded successfully`,
149+
phase: 'stop',
150+
})
151+
152+
log(`✔ Source code uploaded`)
153+
} finally {
154+
// Clean up temporary zip file
155+
if (zipPath) {
156+
try {
157+
await import('fs/promises').then((fs) => fs.unlink(zipPath as unknown as PathLike))
158+
} catch {
159+
// Ignore cleanup errors
160+
}
161+
}
162+
}
163+
}

0 commit comments

Comments
 (0)