diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 5c2b27e190d..597ae23db12 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -373,41 +373,46 @@ const prepareProductionDeploy = async ({ api, siteData, options, command }) => { } } -// @ts-expect-error TS(7006) FIXME: Parameter 'actual' implicitly has an 'any' type. -const hasErrorMessage = (actual, expected) => { +const hasErrorMessage = (actual: unknown, expected: string): boolean => { if (typeof actual === 'string') { return actual.includes(expected) } return false } -// @ts-expect-error TS(7031) FIXME: Binding element 'error_' implicitly has an 'any' t... Remove this comment to see the full error message -const reportDeployError = ({ error_, failAndExit }) => { +interface DeployError extends Error { + json?: { message?: string } + status?: unknown +} +const reportDeployError = ({ + error, + failAndExit, +}: { + error: DeployError + failAndExit: (err: unknown) => never +}): never => { switch (true) { - case error_.name === 'JSONHTTPError': { - const message = error_?.json?.message ?? '' + case error.name === 'JSONHTTPError': { + const message = error.json?.message ?? '' if (hasErrorMessage(message, 'Background Functions not allowed by team plan')) { return failAndExit(`\n${BACKGROUND_FUNCTIONS_WARNING}`) } - warn(`JSONHTTPError: ${message} ${error_.status}`) - warn(`\n${JSON.stringify(error_, null, ' ')}\n`) - failAndExit(error_) - return + warn(`JSONHTTPError: ${message} ${error.status}`) + warn(`\n${JSON.stringify(error, null, ' ')}\n`) + return failAndExit(error) } - case error_.name === 'TextHTTPError': { - warn(`TextHTTPError: ${error_.status}`) - warn(`\n${error_}\n`) - failAndExit(error_) - return + case error.name === 'TextHTTPError': { + warn(`TextHTTPError: ${error.status}`) + warn(`\n${error}\n`) + return failAndExit(error) } - case hasErrorMessage(error_.message, 'Invalid filename'): { - warn(error_.message) - failAndExit(error_) - return + case hasErrorMessage(error.message, 'Invalid filename'): { + warn(error.message) + return failAndExit(error) } default: { - warn(`\n${JSON.stringify(error_, null, ' ')}\n`) - failAndExit(error_) + warn(`\n${JSON.stringify(error, null, ' ')}\n`) + return failAndExit(error) } } } @@ -531,9 +536,11 @@ const runDeploy = async ({ skipFunctionsCache, // @ts-expect-error TS(7031) FIXME: Binding element 'title' implicitly has an 'any' ty... Remove this comment to see the full error message title, + deployId: existingDeployId, }: { functionsFolder?: string command: BaseCommand + deployId?: string }): Promise<{ siteId: string siteName: string @@ -546,28 +553,35 @@ const runDeploy = async ({ sourceZipFileName?: string }> => { let results - let deployId + let deployId = existingDeployId let uploadSourceZipResult try { - if (deployToProduction) { - await prepareProductionDeploy({ siteData, api, options, command }) - } - - const draft = options.draft || (!deployToProduction && !alias) - const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip } - - results = await api.createSiteDeploy({ siteId, title, body: createDeployBody }) - deployId = results.id + // 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) { + if (deployToProduction) { + await prepareProductionDeploy({ siteData, api, options, command }) + } - // Handle source zip upload if requested and URL provided - if (options.uploadSourceZip && results.source_zip_upload_url && results.source_zip_filename) { - uploadSourceZipResult = await uploadSourceZip({ - sourceDir: site.root, - uploadUrl: results.source_zip_upload_url, - filename: results.source_zip_filename, - statusCb: silent ? () => {} : deployProgressCb(), - }) + const draft = options.draft || (!deployToProduction && !alias) + const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip } + + 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, + uploadUrl: createDeployResponse.source_zip_upload_url, + filename: createDeployResponse.source_zip_filename, + statusCb: silent ? () => {} : deployProgressCb(), + }) + } } const internalFunctionsFolder = await getInternalFunctionsDir({ base: site.root, packagePath, ensureExists: true }) @@ -628,11 +642,12 @@ const runDeploy = async ({ skipFunctionsCache, siteRoot: site.root, }) - } catch (error_) { + } catch (error) { if (deployId) { await cancelDeploy({ api, deployId }) } - reportDeployError({ error_, failAndExit: logAndThrowError }) + + return reportDeployError({ error: error as DeployError, failAndExit: logAndThrowError }) } const siteUrl = results.deploy.ssl_url || results.deploy.url @@ -690,7 +705,7 @@ const handleBuild = async ({ }) const { configMutations, exitCode, newConfig, logs } = await runBuild(resolvedOptions) // Without this, the deploy command fails silently - if (options.json && exitCode !== 0) { + if (exitCode !== 0) { let message = '' if (options.verbose && logs?.stdout.length) { @@ -703,9 +718,6 @@ const handleBuild = async ({ logAndThrowError(`Error while running build${message}`) } - if (exitCode !== 0) { - exit(exitCode) - } return { newConfig, configMutations } } @@ -849,10 +861,12 @@ const prepAndRunDeploy = async ({ siteData, siteId, workingDir, + deployId, }: { options: DeployOptionValues command: BaseCommand workingDir: string + deployId?: string // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FIXME(serhalp) [key: string]: any }) => { @@ -933,6 +947,7 @@ const prepAndRunDeploy = async ({ siteId, skipFunctionsCache: options.skipFunctionsCache, title: options.message, + deployId, }) return results @@ -1080,29 +1095,75 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) let results = {} as Awaited> if (options.build) { - const settings = await detectFrameworkSettings(command, 'build') - await handleBuild({ - packagePath: command.workspacePackage, - cachedConfig: command.netlify.cachedConfig, - defaultConfig: getDefaultConfig(settings), - currentDir: command.workingDir, - options, - deployHandler: async ({ netlifyConfig }: { netlifyConfig: NetlifyConfig }) => { - results = await prepAndRunDeploy({ - command, - options, - workingDir, - api, - site, - config: netlifyConfig, - siteData, - siteId, - deployToProduction, - }) + if (deployToProduction) { + await prepareProductionDeploy({ siteData, api, options, command }) + } - return { newEnvChanges: { DEPLOY_ID: results.deployId, DEPLOY_URL: results.deployUrl } } - }, - }) + const draft = options.draft || (!deployToProduction && !alias) + const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip } + + // TODO: Type this properly in `@netlify/api`. + const deployMetadata = (await api.createSiteDeploy({ + siteId, + title: options.message, + body: createDeployBody, + })) as Awaited> & { + source_zip_upload_url?: string + source_zip_filename?: string + } + const deployId = deployMetadata.id || '' + const deployUrl = deployMetadata.deploy_ssl_url || deployMetadata.deploy_url || '' + + command.netlify.cachedConfig.env.DEPLOY_ID = { sources: ['internal'], value: deployId } + command.netlify.cachedConfig.env.DEPLOY_URL = { sources: ['internal'], value: deployUrl } + + process.env.DEPLOY_ID = deployId + process.env.DEPLOY_URL = deployUrl + + if ( + options.uploadSourceZip && + deployMetadata.source_zip_upload_url && + deployMetadata.source_zip_filename && + site.root + ) { + await uploadSourceZip({ + sourceDir: site.root, + uploadUrl: deployMetadata.source_zip_upload_url, + filename: deployMetadata.source_zip_filename, + statusCb: options.json || options.silent ? () => {} : deployProgressCb(), + }) + } + try { + const settings = await detectFrameworkSettings(command, 'build') + await handleBuild({ + packagePath: command.workspacePackage, + cachedConfig: command.netlify.cachedConfig, + defaultConfig: getDefaultConfig(settings), + currentDir: command.workingDir, + options, + deployHandler: async ({ netlifyConfig }: { netlifyConfig: NetlifyConfig }) => { + results = await prepAndRunDeploy({ + command, + options, + workingDir, + api, + site, + config: netlifyConfig, + siteData, + siteId, + deployToProduction, + deployId, + }) + + return {} + }, + }) + } catch (error) { + // The build has failed, so let's cancel the deploy we created. + await cancelDeploy({ api, deployId }) + + throw error + } } else { results = await prepAndRunDeploy({ command, diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index f6b0b8754ad..b454a74f9c3 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -371,11 +371,12 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co test('runs build command before deploy by default', async (t) => { await withSiteBuilder(t, async (builder) => { - const content = '

⊂◉‿◉つ

' + const rootContent = '

⊂◉‿◉つ

' + builder .withContentFile({ path: 'public/index.html', - content, + content: rootContent, }) .withNetlifyToml({ config: { @@ -386,6 +387,11 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .withBuildPlugin({ name: 'log-env', plugin: { + async onPreBuild() { + const { DEPLOY_ID, DEPLOY_URL } = require('process').env + console.log(`DEPLOY_ID_PREBUILD: ${DEPLOY_ID}`) + console.log(`DEPLOY_URL_PREBUILD: ${DEPLOY_URL}`) + }, async onSuccess() { const { DEPLOY_ID, DEPLOY_URL } = require('process').env console.log(`DEPLOY_ID: ${DEPLOY_ID}`) @@ -393,6 +399,20 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }, }, }) + .withEdgeFunction({ + handler: async () => new Response('Hello from edge function'), + name: 'edge', + config: { + path: '/edge-function', + }, + }) + .withFunction({ + config: { path: '/function' }, + path: 'hello.mjs', + pathPrefix: 'netlify/functions', + handler: async () => new Response('Hello from function'), + runtimeAPIVersion: 2, + }) await builder.build() @@ -402,11 +422,20 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) t.expect(output).toContain('Netlify Build completed in') + const [, deployIdPreBuild] = output.match(/DEPLOY_ID_PREBUILD: (\w+)/) ?? [] + const [, deployURLPreBuild] = output.match(/DEPLOY_URL_PREBUILD: (.+)/) ?? [] const [, deployId] = output.match(/DEPLOY_ID: (\w+)/) ?? [] const [, deployURL] = output.match(/DEPLOY_URL: (.+)/) ?? [] - t.expect(deployId).not.toEqual('0') - t.expect(deployURL).toContain(`https://${deployId}--`) + t.expect(deployIdPreBuild).toBeTruthy() + t.expect(deployIdPreBuild).not.toEqual('0') + t.expect(deployURLPreBuild).toContain(`https://${deployIdPreBuild}--`) + t.expect(deployId).toEqual(deployIdPreBuild) + t.expect(deployURL).toEqual(deployURLPreBuild) + + await validateContent({ siteUrl: deployURL, path: '', content: rootContent }) + await validateContent({ siteUrl: deployURL, path: '/edge-function', content: 'Hello from edge function' }) + await validateContent({ siteUrl: deployURL, path: '/function', content: 'Hello from function' }) }) })