Skip to content
Open
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
63 changes: 46 additions & 17 deletions src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from '../../utils/command-helpers.js'
import { DEFAULT_CONCURRENT_HASH, DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js'
import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js'
import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js'
import { EmptySourceZipError, createSourceZip, uploadSourceZip } from '../../utils/deploy/upload-source-zip.js'
import { getEnvelopeEnv } from '../../utils/env/index.js'
import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js'
import { isEmpty } from '../../utils/object-utilities.js'
Expand Down Expand Up @@ -297,6 +297,33 @@ const SEC_TO_MILLISEC = 1e3
// 100 bytes
const SYNC_FILE_LIMIT = 1e2

// Exit code used when a source-zip deploy has nothing to upload (the source is
// empty after applying ignore patterns). Mirrors `zip`'s own "nothing to do"
// exit code (12) so upstream tooling can distinguish it from a real failure.
const EMPTY_SOURCE_ZIP_EXIT_CODE = 12

// Builds the source zip *before* the deploy is created, so an empty source
// exits (code 12) without ever creating a dangling deploy. Returns the zip path,
// or undefined when source-zip upload isn't requested.
const buildSourceZipOrExit = async (
enabled: boolean | undefined,
sourceDir: string | undefined,
statusCb: (status: DeployEvent) => void,
): Promise<string | undefined> => {
if (!enabled || !sourceDir) {
return undefined
}
try {
return await createSourceZip({ sourceDir, statusCb })
} catch (error) {
if (error instanceof EmptySourceZipError) {
warn(error.message)
return exit(EMPTY_SOURCE_ZIP_EXIT_CODE)
}
throw error
}
}

// Helper function to generate copy-pasteable deploy command
const generateDeployCommand = (
options: DeployOptionValues,
Expand Down Expand Up @@ -579,6 +606,12 @@ const runDeploy = async ({
// We won't have a deploy ID if we run the command with `--no-build`.
// In this case, we must create the deploy.
if (!deployId) {
const sourceZipPath = await buildSourceZipOrExit(
options.uploadSourceZip,
site.root,
silent ? () => {} : deployProgressCb(),
)

if (deployToProduction) {
await prepareProductionDeploy({ siteData, api, options, command })
}
Expand All @@ -594,17 +627,13 @@ const runDeploy = async ({
const createDeployResponse = await api.createSiteDeploy({ siteId, title, body: createDeployBody })
deployId = createDeployResponse.id as string

if (
options.uploadSourceZip &&
createDeployResponse.source_zip_upload_url &&
createDeployResponse.source_zip_filename
) {
uploadSourceZipResult = await uploadSourceZip({
sourceDir: site.root,
if (sourceZipPath && createDeployResponse.source_zip_upload_url && createDeployResponse.source_zip_filename) {
await uploadSourceZip({
zipPath: sourceZipPath,
uploadUrl: createDeployResponse.source_zip_upload_url,
filename: createDeployResponse.source_zip_filename,
statusCb: silent ? () => {} : deployProgressCb(),
})
uploadSourceZipResult = { sourceZipFileName: createDeployResponse.source_zip_filename }
}
}

Expand Down Expand Up @@ -1361,6 +1390,12 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand)
let results = {} as Awaited<ReturnType<typeof prepAndRunDeploy>>

if (options.build) {
const sourceZipPath = await buildSourceZipOrExit(
options.uploadSourceZip,
site.root,
options.json || options.silent ? () => {} : deployProgressCb(),
)

if (deployToProduction) {
await prepareProductionDeploy({ siteData, api, options, command })
}
Expand All @@ -1386,16 +1421,10 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand)
const skewProtectionToken = deployMetadata.skew_protection_token
let sourceZipFileName: string | undefined

if (
options.uploadSourceZip &&
deployMetadata.source_zip_upload_url &&
deployMetadata.source_zip_filename &&
site.root
) {
if (sourceZipPath && deployMetadata.source_zip_upload_url && deployMetadata.source_zip_filename) {
await uploadSourceZip({
sourceDir: site.root,
zipPath: sourceZipPath,
uploadUrl: deployMetadata.source_zip_upload_url,
filename: deployMetadata.source_zip_filename,
statusCb: options.json || options.silent ? () => {} : deployProgressCb(),
})
sourceZipFileName = deployMetadata.source_zip_filename
Expand Down
184 changes: 106 additions & 78 deletions src/utils/deploy/upload-source-zip.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { mkdir, readFile } from 'node:fs/promises'
import { readFile, rm } from 'node:fs/promises'
import { join, dirname } from 'node:path'
import type { PathLike } from 'node:fs'
import { platform } from 'node:os'

import execa, { ExecaError } from 'execa'
Expand All @@ -10,11 +9,18 @@ import { log, warn } from '../command-helpers.js'
import { temporaryDirectory } from '../temporary-file.js'
import type { DeployEvent } from './status-cb.js'

interface UploadSourceZipOptions {
sourceDir: string
uploadUrl: string
filename: string
statusCb?: (status: DeployEvent) => void
/**
* Thrown when the source zip would be empty — every file matched an ignore
* pattern (or the directory is empty), so `zip` exits with code 12 ("nothing to
* do"). This is not a failure: there is simply nothing to deploy. Callers decide
* how to surface it (the deploy command exits with a dedicated code so upstream
* tooling can detect it).
*/
export class EmptySourceZipError extends Error {
constructor() {
super('Source zip is empty: no files to deploy after applying ignore patterns')
this.name = 'EmptySourceZipError'
}
}

const DEFAULT_IGNORE_PATTERNS = [
Expand All @@ -40,55 +46,99 @@ const DEFAULT_IGNORE_PATTERNS = [
'.temp*',
]

const createSourceZip = async ({
// `zip` exits with code 12 ("nothing to do") when no files were added to the
// archive (everything matched an exclude, or the directory is empty).
const isEmptyZipError = (error: unknown): boolean => {
const err = error as { exitCode?: number; all?: string; stderr?: string } | null
if (!err) {
return false
}
return err.exitCode === 12 || /nothing to do/iu.test(err.all ?? err.stderr ?? '')
}

/**
* Creates the source zip in a temporary directory and returns its path. The
* caller uploads it via `uploadSourceZip`, which removes the temporary directory
* afterwards. On any failure (including an empty archive) the directory is
* removed here before throwing.
*
* Throws `EmptySourceZipError` when the archive would be empty, so the caller
* can avoid creating a deploy for a source that has nothing to upload.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export const createSourceZip = async ({
sourceDir,
filename,
statusCb,
statusCb = () => {},
}: {
sourceDir: string
filename: string
statusCb: (status: DeployEvent) => void
}) => {
// Check for Windows - this feature is not supported on Windows
if (platform() === 'win32') {
throw new Error('Source zip upload is not supported on Windows')
}
statusCb?: (status: DeployEvent) => void
}): Promise<string> => {
const zipPath = join(temporaryDirectory(), 'source.zip')

const tmpDir = temporaryDirectory()
const zipPath = join(tmpDir, filename)
try {
// Check for Windows - this feature is not supported on Windows
if (platform() === 'win32') {
throw new Error('Source zip upload is not supported on Windows')
}

// Ensure the directory for the zip file exists
// The filename from the API includes a subdirectory path (e.g., 'workspace-snapshots/source-xxx.zip')
// While temporaryDirectory() creates a new empty directory, the subdirectory within it doesn't exist
// so we need to create it before the zip command can write the file
await mkdir(dirname(zipPath), { recursive: true })
statusCb({
type: 'source-zip-upload',
msg: `Creating source zip...`,
phase: 'start',
})

statusCb({
type: 'source-zip-upload',
msg: `Creating source zip...`,
phase: 'start',
})
// Create exclusion list for zip command
const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern])

// Create exclusion list for zip command
const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern])
// Use system zip command to create the archive
try {
Comment on lines +78 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove comments that just restate the following line.

"Check for Windows - this feature is not supported on Windows", "Create exclusion list for zip command", and "Use system zip command to create the archive" merely narrate the code immediately below them and add no information the code doesn't already convey.

♻️ Proposed cleanup
-    // Check for Windows - this feature is not supported on Windows
     if (platform() === 'win32') {
       throw new Error('Source zip upload is not supported on Windows')
     }

     statusCb({
       type: 'source-zip-upload',
       msg: `Creating source zip...`,
       phase: 'start',
     })

-    // Create exclusion list for zip command
     const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern])

-    // Use system zip command to create the archive
     try {
       await execa('zip', ['-r', '-q', zipPath, '.', ...excludeArgs], {

As per coding guidelines, "Never write comments on what the code does; make the code clean and self-explanatory instead."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check for Windows - this feature is not supported on Windows
if (platform() === 'win32') {
throw new Error('Source zip upload is not supported on Windows')
}
// Ensure the directory for the zip file exists
// The filename from the API includes a subdirectory path (e.g., 'workspace-snapshots/source-xxx.zip')
// While temporaryDirectory() creates a new empty directory, the subdirectory within it doesn't exist
// so we need to create it before the zip command can write the file
await mkdir(dirname(zipPath), { recursive: true })
statusCb({
type: 'source-zip-upload',
msg: `Creating source zip...`,
phase: 'start',
})
statusCb({
type: 'source-zip-upload',
msg: `Creating source zip...`,
phase: 'start',
})
// Create exclusion list for zip command
const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern])
// Create exclusion list for zip command
const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern])
// Use system zip command to create the archive
try {
if (platform() === 'win32') {
throw new Error('Source zip upload is not supported on Windows')
}
statusCb({
type: 'source-zip-upload',
msg: `Creating source zip...`,
phase: 'start',
})
const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern])
try {
await execa('zip', ['-r', '-q', zipPath, '.', ...excludeArgs], {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/deploy/upload-source-zip.ts` around lines 78 - 93, Remove the
redundant narrative comments in uploadSourceZip that merely restate the next
statements; keep the logic in platform() check, DEFAULT_IGNORE_PATTERNS
handling, and the zip command try block unchanged, and rely on the code itself
for clarity.

Source: Coding guidelines

await execa('zip', ['-r', '-q', zipPath, '.', ...excludeArgs], {
all: true,
cwd: sourceDir,
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch (baseErr) {
// An empty archive is an expected outcome, not a failure — let the caller decide.
if (isEmptyZipError(baseErr)) {
throw new EmptySourceZipError()
}

// Use system zip command to create the archive
try {
await execa('zip', ['-r', '-q', zipPath, '.', ...excludeArgs], {
all: true,
cwd: sourceDir,
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch (_baseErr) {
let message = 'zip command failed'
if (_baseErr instanceof Error && 'command' in _baseErr) {
const baseErr = _baseErr as ExecaError
message = `${baseErr.shortMessage}\n\n${baseErr.all ?? ''}`
let message = 'zip command failed'
if (baseErr instanceof Error && 'command' in baseErr) {
const execaErr = baseErr as ExecaError
message = `${execaErr.shortMessage}\n\n${execaErr.all ?? ''}`
}
throw new Error(message, { cause: baseErr })
}

return zipPath
} catch (error) {
// Nothing usable was produced; drop the temp directory we created.
await removeSourceZipDir(zipPath)

// An empty source zip is not a reported failure.
if (error instanceof EmptySourceZipError) {
throw error
}
throw new Error(message, { cause: _baseErr })

const errorMsg = error instanceof Error ? error.message : String(error)
statusCb({
type: 'source-zip-upload',
msg: `Failed to create source zip: ${errorMsg}`,
phase: 'error',
})
warn(`Failed to create source zip: ${errorMsg}`)
throw error
}
}
Comment on lines +59 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Relevant files ==\n'
fd -a 'upload-source-zip.ts|deploy.ts' src

printf '\n== Outline: src/utils/deploy/upload-source-zip.ts ==\n'
ast-grep outline src/utils/deploy/upload-source-zip.ts --view expanded

printf '\n== Outline: src/utils/deploy/deploy.ts ==\n'
ast-grep outline src/utils/deploy/deploy.ts --view expanded

printf '\n== Search for createSourceZip / uploadSourceZip usage ==\n'
rg -n "createSourceZip|uploadSourceZip|source_zip_upload_url|source_zip_filename|removeSourceZipDir|temporaryDirectory" src

printf '\n== Read upload-source-zip.ts (selected ranges) ==\n'
sed -n '1,240p' src/utils/deploy/upload-source-zip.ts

printf '\n== Read deploy.ts (selected ranges around source zip handling) ==\n'
rg -n -A40 -B20 "source_zip_upload_url|source_zip_filename|createSourceZip|uploadSourceZip" src/utils/deploy/deploy.ts

Repository: netlify/cli

Length of output: 10488


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== deploy.ts source-zip section ==\n'
sed -n '1380,1465p' src/commands/deploy/deploy.ts

printf '\n== createSourceZip callsites ==\n'
rg -n -A8 -B8 "createSourceZip\(" src/commands/deploy/deploy.ts src

printf '\n== cleanup/finally around deploy flow ==\n'
rg -n -A20 -B20 "finally|removeSourceZipDir|uploadSourceZip\(" src/commands/deploy/deploy.ts

Repository: netlify/cli

Length of output: 8756


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Any cleanup of source zip path elsewhere ==\n'
rg -n "removeSourceZipDir|rm\\(dirname\\(zipPath\\)|sourceZipPath|sourceZipFileName|uploadSourceZipResult" src/commands/deploy src/utils/deploy

printf '\n== buildSourceZipOrExit implementation ==\n'
sed -n '300,330p' src/commands/deploy/deploy.ts

Repository: netlify/cli

Length of output: 3144


Clean up sourceZipPath when the upload branch is skipped src/commands/deploy/deploy.ts:1424

If createSourceZip succeeds but the deploy response never provides both source_zip_upload_url and source_zip_filename (or fails before that branch runs), the temp directory stays on disk because only uploadSourceZip removes it. Add a caller-side cleanup path for the no-upload case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/deploy/upload-source-zip.ts` around lines 59 - 132, The deploy flow
currently leaves the temporary source zip directory behind when
`createSourceZip` succeeds but the `uploadSourceZip` branch is skipped because
the response lacks both `source_zip_upload_url` and `source_zip_filename`, or an
error occurs before upload. Add a caller-side cleanup path in the deploy logic
that owns `sourceZipPath` so the temp directory is removed whenever no upload
happens, while keeping `uploadSourceZip` responsible for the successful upload
case.


return zipPath
// Removes the temporary directory that holds a source zip. Best-effort:
// cleanup failures are ignored.
const removeSourceZipDir = async (zipPath: string): Promise<void> => {
try {
await rm(dirname(zipPath), { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}

const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (status: DeployEvent) => void) => {
Expand All @@ -115,35 +165,22 @@ const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (stat
}
}

/**
* Uploads a source zip previously created by `createSourceZip` and removes its
* temporary directory afterwards.
*/
export const uploadSourceZip = async ({
sourceDir,
zipPath,
uploadUrl,
filename,
statusCb = () => {},
}: UploadSourceZipOptions): Promise<{ sourceZipFileName: string }> => {
let zipPath: PathLike | undefined

}: {
zipPath: string
uploadUrl: string
statusCb?: (status: DeployEvent) => void
}): Promise<void> => {
try {
// Create zip from source directory
try {
zipPath = await createSourceZip({ sourceDir, filename, statusCb })
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
statusCb({
type: 'source-zip-upload',
msg: `Failed to create source zip: ${errorMsg}`,
phase: 'error',
})
warn(`Failed to create source zip: ${errorMsg}`)
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 @@ -162,16 +199,7 @@ export const uploadSourceZip = async ({
})

log(`✔ Source code uploaded`)

return { sourceZipFileName }
} finally {
// Clean up temporary zip file
if (zipPath) {
try {
await (await import('node:fs/promises')).unlink(zipPath as unknown as PathLike)
} catch {
// Ignore cleanup errors
}
}
await removeSourceZipDir(zipPath)
}
}
Loading
Loading