Skip to content

Commit 1cc36f9

Browse files
feat: create deploy before building (#7768)
* feat: create deploy before building * refactor: remove error * refactor: re-add error * chore: update test * refactor: add types to `reportDeployError` --------- Co-authored-by: Philippe Serhal <[email protected]>
1 parent d8e2c59 commit 1cc36f9

File tree

2 files changed

+161
-71
lines changed

2 files changed

+161
-71
lines changed

src/commands/deploy/deploy.ts

Lines changed: 128 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -373,41 +373,46 @@ const prepareProductionDeploy = async ({ api, siteData, options, command }) => {
373373
}
374374
}
375375

376-
// @ts-expect-error TS(7006) FIXME: Parameter 'actual' implicitly has an 'any' type.
377-
const hasErrorMessage = (actual, expected) => {
376+
const hasErrorMessage = (actual: unknown, expected: string): boolean => {
378377
if (typeof actual === 'string') {
379378
return actual.includes(expected)
380379
}
381380
return false
382381
}
383382

384-
// @ts-expect-error TS(7031) FIXME: Binding element 'error_' implicitly has an 'any' t... Remove this comment to see the full error message
385-
const reportDeployError = ({ error_, failAndExit }) => {
383+
interface DeployError extends Error {
384+
json?: { message?: string }
385+
status?: unknown
386+
}
387+
const reportDeployError = ({
388+
error,
389+
failAndExit,
390+
}: {
391+
error: DeployError
392+
failAndExit: (err: unknown) => never
393+
}): never => {
386394
switch (true) {
387-
case error_.name === 'JSONHTTPError': {
388-
const message = error_?.json?.message ?? ''
395+
case error.name === 'JSONHTTPError': {
396+
const message = error.json?.message ?? ''
389397
if (hasErrorMessage(message, 'Background Functions not allowed by team plan')) {
390398
return failAndExit(`\n${BACKGROUND_FUNCTIONS_WARNING}`)
391399
}
392-
warn(`JSONHTTPError: ${message} ${error_.status}`)
393-
warn(`\n${JSON.stringify(error_, null, ' ')}\n`)
394-
failAndExit(error_)
395-
return
400+
warn(`JSONHTTPError: ${message} ${error.status}`)
401+
warn(`\n${JSON.stringify(error, null, ' ')}\n`)
402+
return failAndExit(error)
396403
}
397-
case error_.name === 'TextHTTPError': {
398-
warn(`TextHTTPError: ${error_.status}`)
399-
warn(`\n${error_}\n`)
400-
failAndExit(error_)
401-
return
404+
case error.name === 'TextHTTPError': {
405+
warn(`TextHTTPError: ${error.status}`)
406+
warn(`\n${error}\n`)
407+
return failAndExit(error)
402408
}
403-
case hasErrorMessage(error_.message, 'Invalid filename'): {
404-
warn(error_.message)
405-
failAndExit(error_)
406-
return
409+
case hasErrorMessage(error.message, 'Invalid filename'): {
410+
warn(error.message)
411+
return failAndExit(error)
407412
}
408413
default: {
409-
warn(`\n${JSON.stringify(error_, null, ' ')}\n`)
410-
failAndExit(error_)
414+
warn(`\n${JSON.stringify(error, null, ' ')}\n`)
415+
return failAndExit(error)
411416
}
412417
}
413418
}
@@ -531,9 +536,11 @@ const runDeploy = async ({
531536
skipFunctionsCache,
532537
// @ts-expect-error TS(7031) FIXME: Binding element 'title' implicitly has an 'any' ty... Remove this comment to see the full error message
533538
title,
539+
deployId: existingDeployId,
534540
}: {
535541
functionsFolder?: string
536542
command: BaseCommand
543+
deployId?: string
537544
}): Promise<{
538545
siteId: string
539546
siteName: string
@@ -546,28 +553,35 @@ const runDeploy = async ({
546553
sourceZipFileName?: string
547554
}> => {
548555
let results
549-
let deployId
556+
let deployId = existingDeployId
550557
let uploadSourceZipResult
551558

552559
try {
553-
if (deployToProduction) {
554-
await prepareProductionDeploy({ siteData, api, options, command })
555-
}
556-
557-
const draft = options.draft || (!deployToProduction && !alias)
558-
const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip }
559-
560-
results = await api.createSiteDeploy({ siteId, title, body: createDeployBody })
561-
deployId = results.id
560+
// We won't have a deploy ID if we run the command with `--no-build`.
561+
// In this case, we must create the deploy.
562+
if (!deployId) {
563+
if (deployToProduction) {
564+
await prepareProductionDeploy({ siteData, api, options, command })
565+
}
562566

563-
// Handle source zip upload if requested and URL provided
564-
if (options.uploadSourceZip && results.source_zip_upload_url && results.source_zip_filename) {
565-
uploadSourceZipResult = await uploadSourceZip({
566-
sourceDir: site.root,
567-
uploadUrl: results.source_zip_upload_url,
568-
filename: results.source_zip_filename,
569-
statusCb: silent ? () => {} : deployProgressCb(),
570-
})
567+
const draft = options.draft || (!deployToProduction && !alias)
568+
const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip }
569+
570+
const createDeployResponse = await api.createSiteDeploy({ siteId, title, body: createDeployBody })
571+
deployId = createDeployResponse.id as string
572+
573+
if (
574+
options.uploadSourceZip &&
575+
createDeployResponse.source_zip_upload_url &&
576+
createDeployResponse.source_zip_filename
577+
) {
578+
uploadSourceZipResult = await uploadSourceZip({
579+
sourceDir: site.root,
580+
uploadUrl: createDeployResponse.source_zip_upload_url,
581+
filename: createDeployResponse.source_zip_filename,
582+
statusCb: silent ? () => {} : deployProgressCb(),
583+
})
584+
}
571585
}
572586

573587
const internalFunctionsFolder = await getInternalFunctionsDir({ base: site.root, packagePath, ensureExists: true })
@@ -628,11 +642,12 @@ const runDeploy = async ({
628642
skipFunctionsCache,
629643
siteRoot: site.root,
630644
})
631-
} catch (error_) {
645+
} catch (error) {
632646
if (deployId) {
633647
await cancelDeploy({ api, deployId })
634648
}
635-
reportDeployError({ error_, failAndExit: logAndThrowError })
649+
650+
return reportDeployError({ error: error as DeployError, failAndExit: logAndThrowError })
636651
}
637652

638653
const siteUrl = results.deploy.ssl_url || results.deploy.url
@@ -690,7 +705,7 @@ const handleBuild = async ({
690705
})
691706
const { configMutations, exitCode, newConfig, logs } = await runBuild(resolvedOptions)
692707
// Without this, the deploy command fails silently
693-
if (options.json && exitCode !== 0) {
708+
if (exitCode !== 0) {
694709
let message = ''
695710

696711
if (options.verbose && logs?.stdout.length) {
@@ -703,9 +718,6 @@ const handleBuild = async ({
703718

704719
logAndThrowError(`Error while running build${message}`)
705720
}
706-
if (exitCode !== 0) {
707-
exit(exitCode)
708-
}
709721
return { newConfig, configMutations }
710722
}
711723

@@ -849,10 +861,12 @@ const prepAndRunDeploy = async ({
849861
siteData,
850862
siteId,
851863
workingDir,
864+
deployId,
852865
}: {
853866
options: DeployOptionValues
854867
command: BaseCommand
855868
workingDir: string
869+
deployId?: string
856870
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FIXME(serhalp)
857871
[key: string]: any
858872
}) => {
@@ -933,6 +947,7 @@ const prepAndRunDeploy = async ({
933947
siteId,
934948
skipFunctionsCache: options.skipFunctionsCache,
935949
title: options.message,
950+
deployId,
936951
})
937952

938953
return results
@@ -1080,29 +1095,75 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand)
10801095
let results = {} as Awaited<ReturnType<typeof prepAndRunDeploy>>
10811096

10821097
if (options.build) {
1083-
const settings = await detectFrameworkSettings(command, 'build')
1084-
await handleBuild({
1085-
packagePath: command.workspacePackage,
1086-
cachedConfig: command.netlify.cachedConfig,
1087-
defaultConfig: getDefaultConfig(settings),
1088-
currentDir: command.workingDir,
1089-
options,
1090-
deployHandler: async ({ netlifyConfig }: { netlifyConfig: NetlifyConfig }) => {
1091-
results = await prepAndRunDeploy({
1092-
command,
1093-
options,
1094-
workingDir,
1095-
api,
1096-
site,
1097-
config: netlifyConfig,
1098-
siteData,
1099-
siteId,
1100-
deployToProduction,
1101-
})
1098+
if (deployToProduction) {
1099+
await prepareProductionDeploy({ siteData, api, options, command })
1100+
}
11021101

1103-
return { newEnvChanges: { DEPLOY_ID: results.deployId, DEPLOY_URL: results.deployUrl } }
1104-
},
1105-
})
1102+
const draft = options.draft || (!deployToProduction && !alias)
1103+
const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip }
1104+
1105+
// TODO: Type this properly in `@netlify/api`.
1106+
const deployMetadata = (await api.createSiteDeploy({
1107+
siteId,
1108+
title: options.message,
1109+
body: createDeployBody,
1110+
})) as Awaited<ReturnType<typeof api.createSiteDeploy>> & {
1111+
source_zip_upload_url?: string
1112+
source_zip_filename?: string
1113+
}
1114+
const deployId = deployMetadata.id || ''
1115+
const deployUrl = deployMetadata.deploy_ssl_url || deployMetadata.deploy_url || ''
1116+
1117+
command.netlify.cachedConfig.env.DEPLOY_ID = { sources: ['internal'], value: deployId }
1118+
command.netlify.cachedConfig.env.DEPLOY_URL = { sources: ['internal'], value: deployUrl }
1119+
1120+
process.env.DEPLOY_ID = deployId
1121+
process.env.DEPLOY_URL = deployUrl
1122+
1123+
if (
1124+
options.uploadSourceZip &&
1125+
deployMetadata.source_zip_upload_url &&
1126+
deployMetadata.source_zip_filename &&
1127+
site.root
1128+
) {
1129+
await uploadSourceZip({
1130+
sourceDir: site.root,
1131+
uploadUrl: deployMetadata.source_zip_upload_url,
1132+
filename: deployMetadata.source_zip_filename,
1133+
statusCb: options.json || options.silent ? () => {} : deployProgressCb(),
1134+
})
1135+
}
1136+
try {
1137+
const settings = await detectFrameworkSettings(command, 'build')
1138+
await handleBuild({
1139+
packagePath: command.workspacePackage,
1140+
cachedConfig: command.netlify.cachedConfig,
1141+
defaultConfig: getDefaultConfig(settings),
1142+
currentDir: command.workingDir,
1143+
options,
1144+
deployHandler: async ({ netlifyConfig }: { netlifyConfig: NetlifyConfig }) => {
1145+
results = await prepAndRunDeploy({
1146+
command,
1147+
options,
1148+
workingDir,
1149+
api,
1150+
site,
1151+
config: netlifyConfig,
1152+
siteData,
1153+
siteId,
1154+
deployToProduction,
1155+
deployId,
1156+
})
1157+
1158+
return {}
1159+
},
1160+
})
1161+
} catch (error) {
1162+
// The build has failed, so let's cancel the deploy we created.
1163+
await cancelDeploy({ api, deployId })
1164+
1165+
throw error
1166+
}
11061167
} else {
11071168
results = await prepAndRunDeploy({
11081169
command,

tests/integration/commands/deploy/deploy.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,11 +371,12 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
371371

372372
test('runs build command before deploy by default', async (t) => {
373373
await withSiteBuilder(t, async (builder) => {
374-
const content = '<h1>⊂◉‿◉つ</h1>'
374+
const rootContent = '<h1>⊂◉‿◉つ</h1>'
375+
375376
builder
376377
.withContentFile({
377378
path: 'public/index.html',
378-
content,
379+
content: rootContent,
379380
})
380381
.withNetlifyToml({
381382
config: {
@@ -386,13 +387,32 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
386387
.withBuildPlugin({
387388
name: 'log-env',
388389
plugin: {
390+
async onPreBuild() {
391+
const { DEPLOY_ID, DEPLOY_URL } = require('process').env
392+
console.log(`DEPLOY_ID_PREBUILD: ${DEPLOY_ID}`)
393+
console.log(`DEPLOY_URL_PREBUILD: ${DEPLOY_URL}`)
394+
},
389395
async onSuccess() {
390396
const { DEPLOY_ID, DEPLOY_URL } = require('process').env
391397
console.log(`DEPLOY_ID: ${DEPLOY_ID}`)
392398
console.log(`DEPLOY_URL: ${DEPLOY_URL}`)
393399
},
394400
},
395401
})
402+
.withEdgeFunction({
403+
handler: async () => new Response('Hello from edge function'),
404+
name: 'edge',
405+
config: {
406+
path: '/edge-function',
407+
},
408+
})
409+
.withFunction({
410+
config: { path: '/function' },
411+
path: 'hello.mjs',
412+
pathPrefix: 'netlify/functions',
413+
handler: async () => new Response('Hello from function'),
414+
runtimeAPIVersion: 2,
415+
})
396416

397417
await builder.build()
398418

@@ -402,11 +422,20 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
402422
})
403423

404424
t.expect(output).toContain('Netlify Build completed in')
425+
const [, deployIdPreBuild] = output.match(/DEPLOY_ID_PREBUILD: (\w+)/) ?? []
426+
const [, deployURLPreBuild] = output.match(/DEPLOY_URL_PREBUILD: (.+)/) ?? []
405427
const [, deployId] = output.match(/DEPLOY_ID: (\w+)/) ?? []
406428
const [, deployURL] = output.match(/DEPLOY_URL: (.+)/) ?? []
407429

408-
t.expect(deployId).not.toEqual('0')
409-
t.expect(deployURL).toContain(`https://${deployId}--`)
430+
t.expect(deployIdPreBuild).toBeTruthy()
431+
t.expect(deployIdPreBuild).not.toEqual('0')
432+
t.expect(deployURLPreBuild).toContain(`https://${deployIdPreBuild}--`)
433+
t.expect(deployId).toEqual(deployIdPreBuild)
434+
t.expect(deployURL).toEqual(deployURLPreBuild)
435+
436+
await validateContent({ siteUrl: deployURL, path: '', content: rootContent })
437+
await validateContent({ siteUrl: deployURL, path: '/edge-function', content: 'Hello from edge function' })
438+
await validateContent({ siteUrl: deployURL, path: '/function', content: 'Hello from function' })
410439
})
411440
})
412441

0 commit comments

Comments
 (0)