From db74b2a76cfd8d45704835de158489084cd6c892 Mon Sep 17 00:00:00 2001 From: Nathan Houle Date: Thu, 10 Apr 2025 15:56:06 -0700 Subject: [PATCH] feat!: run build before deploy This small change updates the `deploy` command to run the configured `build` command before deployment. Users who prefer the previous behavior can opt out by specifying `--no-build` when running `netlify deploy`. --- docs/commands/deploy.md | 5 +- src/commands/deploy/index.ts | 27 ++- .../commands/deploy/deploy.test.ts | 204 ++++++++++++------ 3 files changed, 170 insertions(+), 66 deletions(-) diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 611190e3b7e..4872e690652 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -86,13 +86,13 @@ netlify deploy - `alias` (*string*) - Specifies the alias for deployment, the string at the beginning of the deploy subdomain. Useful for creating predictable deployment URLs. Avoid setting an alias string to the same value as a deployed branch. `alias` doesn’t create a branch deploy and can’t be used in conjunction with the branch subdomain feature. Maximum 37 characters. - `branch` (*string*) - Serves the same functionality as --alias. Deprecated and will be removed in future versions -- `build` (*boolean*) - Run build command before deploying - `context` (*string*) - Specify a deploy context for environment variables read during the build (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: dev) - `dir` (*string*) - Specify a folder to deploy - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions folder to deploy - `json` (*boolean*) - Output deployment data as JSON - `message` (*string*) - A short message to include in the deploy log +- `no-build` (*boolean*) - Do not run build command before deploying. Only use this if you have no need for a build or your site has already been built. - `prod-if-unlocked` (*boolean*) - Deploy to production if unlocked, create a draft otherwise - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in @@ -108,13 +108,14 @@ netlify deploy ```bash netlify deploy netlify deploy --site my-first-site +netlify deploy --no-build # Deploy without running a build first netlify deploy --prod netlify deploy --prod --open netlify deploy --prod-if-unlocked netlify deploy --message "A message with an $ENV_VAR" netlify deploy --auth $NETLIFY_AUTH_TOKEN netlify deploy --trigger -netlify deploy --build --context deploy-preview +netlify deploy --context deploy-preview ``` diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 1b32e73ffc3..68d0b80e587 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -3,7 +3,7 @@ import { env } from 'process' import { Option } from 'commander' import BaseCommand from '../base-command.js' -import { logAndThrowError, warn } from '../../utils/command-helpers.js' +import { chalk, logAndThrowError, warn } from '../../utils/command-helpers.js' import type { DeployOptionValues } from './option_values.js' export const createDeployCommand = (program: BaseCommand) => @@ -109,7 +109,19 @@ Support for package.json's main field, and intrinsic index.js entrypoints are co 'build', ), ) - .option('--build', 'Run build command before deploying', false) + .addOption( + new Option('--build', 'Do not use - this is now the default. Will be removed in future versions.') + .default(true) + .hideHelp(true), + ) + /** + * Note that this has special meaning to commander. It negates the above `build` option. + * @see https://github.com/tj/commander.js/tree/83c3f4e391754d2f80b179acc4bccc2d4d0c863d?tab=readme-ov-file#other-option-types-negatable-boolean-and-booleanvalue + */ + .option( + '--no-build', + 'Do not run build command before deploying. Only use this if you have no need for a build or your site has already been built.', + ) .option( '--context ', 'Specify a deploy context for environment variables read during the build (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: dev)', @@ -122,15 +134,24 @@ Support for package.json's main field, and intrinsic index.js entrypoints are co .addExamples([ 'netlify deploy', 'netlify deploy --site my-first-site', + 'netlify deploy --no-build # Deploy without running a build first', 'netlify deploy --prod', 'netlify deploy --prod --open', 'netlify deploy --prod-if-unlocked', 'netlify deploy --message "A message with an $ENV_VAR"', 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', - 'netlify deploy --build --context deploy-preview', + 'netlify deploy --context deploy-preview', ]) .action(async (options: DeployOptionValues, command: BaseCommand) => { + if (options.build && command.getOptionValueSource('build') === 'cli') { + warn( + `${chalk.cyanBright( + '--build', + )} is now the default and can safely be omitted. This will fail in a future version.`, + ) + } + if (options.branch) { warn('--branch flag has been renamed to --alias and will be removed in future versions') } diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index 489072896fc..f52070c4c83 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -107,10 +107,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() - const deploy = await callCli(['deploy', '--json', '--dir', 'public'], { + const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content }) }) @@ -132,9 +132,9 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() - const deploy = await callCli(['deploy', '--json', '--site', SITE_NAME], { + const deploy = await callCli(['deploy', '--json', '--no-build', '--site', SITE_NAME], { cwd: builder.directory, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content }) }) @@ -156,10 +156,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() - const deploy = await callCli(['deploy', '--json'], { + const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content }) }) @@ -192,7 +192,9 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co } await callCli(['build'], options) - const deploy = await callCli(['deploy', '--json'], options).then((output) => JSON.parse(output as string)) + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => + JSON.parse(output), + ) // give edge functions manifest a couple ticks to propagate await pause(500) @@ -236,8 +238,8 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co } await callCli(['build', '--cwd', pathPrefix], options) - const deploy = await callCli(['deploy', '--json', '--cwd', pathPrefix], options).then((output) => - JSON.parse(output as string), + const deploy = await callCli(['deploy', '--json', '--no-build', '--cwd', pathPrefix], options).then( + (output: string) => JSON.parse(output), ) // give edge functions manifest a couple ticks to propagate @@ -252,7 +254,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) }) - test('should run build command before deploy when build flag is passed', async (t) => { + test('runs build command before deploy by default', async (t) => { await withSiteBuilder(t, async (builder) => { const content = '

⊂◉‿◉つ

' builder @@ -279,10 +281,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() - const output = (await callCli(['deploy', '--build'], { + const output: string = await callCli(['deploy'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - })) as string + }) t.expect(output).toContain('Netlify Build completed in') const [, deployId] = output.match(/DEPLOY_ID: (\w+)/) ?? [] @@ -293,6 +295,111 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) }) + test('warns and proceeds if extraneous `--build` is explicitly passed', async (t) => { + await withSiteBuilder(t, async (builder) => { + const content = '

⊂◉‿◉つ

' + builder + .withContentFile({ + path: 'public/index.html', + content, + }) + .withNetlifyToml({ + config: { + build: { publish: 'public' }, + plugins: [{ package: './plugins/log-env' }], + }, + }) + .withBuildPlugin({ + name: 'log-env', + plugin: { + async onSuccess() { + const { DEPLOY_ID, DEPLOY_URL } = require('process').env + console.log(`DEPLOY_ID: ${DEPLOY_ID}`) + console.log(`DEPLOY_URL: ${DEPLOY_URL}`) + }, + }, + }) + + await builder.build() + + const output: string = await callCli(['deploy', '--build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }) + + t.expect(output).toMatch( + /--build.+is now the default and can safely be omitted. This will fail in a future version./, + ) + + t.expect(output).toContain('Netlify Build completed in') + 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}--`) + }) + }) + + test('should return valid json when --json is passed', async (t) => { + await withSiteBuilder(t, async (builder) => { + const content = '

⊂◉‿◉つ

' + builder + .withContentFile({ + path: 'public/index.html', + content, + }) + .withNetlifyToml({ + config: { + build: { publish: 'public' }, + }, + }) + + await builder.build() + + const output: string = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }) + + expect(() => JSON.parse(output)).not.toThrowError() + }) + }) + + test('does not run build command and build plugins before deploy when --no-build flag is passed', async (t) => { + await withSiteBuilder(t, async (builder) => { + const content = '

⊂◉‿◉つ

' + builder + .withContentFile({ + path: 'public/index.html', + content, + }) + .withNetlifyToml({ + config: { + build: { publish: 'public' }, + plugins: [{ package: './plugins/log-hello' }], + }, + }) + .withBuildPlugin({ + name: 'log-hello', + plugin: { + async onSuccess() { + console.log('Hello from a build plugin') + }, + }, + }) + + await builder.build() + + const output: string = await callCli(['deploy', '--no-build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }) + + t.expect(output).not.toContain('Netlify Build completed in') + t.expect(output).not.toContain('Hello from a build plugin') + }) + }) + test('should print deploy-scoped URLs for build logs, function logs, and edge function logs', async (t) => { await withSiteBuilder(t, async (builder) => { const content = '

Why Next.js is perfect, an essay

' @@ -302,10 +409,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) await builder.build() - const deploy = await callCli(['deploy', '--json', '--dir', 'public'], { + const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content }) expect(deploy).toHaveProperty('logs', `https://app.netlify.com/sites/${SITE_NAME}/deploys/${deploy.deploy_id}`) @@ -329,10 +436,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) await builder.build() - const deploy = await callCli(['deploy', '--json', '--dir', 'public', '--prod'], { + const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--prod'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content }) expect(deploy).toHaveProperty('logs', `https://app.netlify.com/sites/${SITE_NAME}/deploys/${deploy.deploy_id}`) @@ -344,31 +451,6 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) }) - test('should return valid json when both --build and --json are passed', async (t) => { - await withSiteBuilder(t, async (builder) => { - const content = '

⊂◉‿◉つ

' - builder - .withContentFile({ - path: 'public/index.html', - content, - }) - .withNetlifyToml({ - config: { - build: { publish: 'public' }, - }, - }) - - await builder.build() - - const output = await callCli(['deploy', '--build', '--json'], { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }) - - JSON.parse(output as string) - }) - }) - test('should deploy hidden public folder but ignore hidden/__MACOSX files', { retry: 3 }, async (t) => { await withSiteBuilder(t, async (builder) => { builder @@ -398,10 +480,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() - const deploy = await callCli(['deploy', '--json'], { + const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) await validateContent({ @@ -443,10 +525,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() - const deploy = await callCli(['deploy', '--json'], { + const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) await validateContent({ @@ -478,10 +560,10 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() - const deploy = await callCli(['deploy', '--json'], { + const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output) => JSON.parse(output as string)) + }).then((output: string) => JSON.parse(output)) await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) await validateContent({ @@ -497,7 +579,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await builder.build() try { - await callCli(['deploy', '--dir', '.'], { + await callCli(['deploy', '--no-build', '--dir', '.'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, }) @@ -507,7 +589,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co }) }) - test('should refresh configuration when --build is passed', async (t) => { + test('refreshes configuration when building before deployment', async (t) => { await withSiteBuilder(t, async (builder) => { await builder .withContentFile({ @@ -528,7 +610,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co const { mkdir, writeFile } = require('node:fs/promises') as typeof import('node:fs/promises') const generatedFunctionsDir = 'new_functions' - // @ts-expect-error TS(2322) FIXME: Type 'string' is not assignable to type 'Functions... Remove this comment to see the full error message + // @ts-expect-error netlifyConfig.functions.directory = generatedFunctionsDir await mkdir(generatedFunctionsDir) @@ -542,7 +624,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .build() const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--build', '--json'], + ['deploy', '--json'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -652,7 +734,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .build() const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--build', '--json'], + ['deploy', '--json'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -700,7 +782,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .build() const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--build', '--json'], + ['deploy', '--json'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -758,7 +840,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .build() const deploy = (await callCli( - ['deploy', '--json', '--build'], + ['deploy', '--json'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -843,7 +925,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .build() const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], + ['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -902,7 +984,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .build() const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--skip-functions-cache'], + ['deploy', '--json', '--no-build', '--skip-functions-cache'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -963,7 +1045,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co .build() const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], + ['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -1022,7 +1104,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await execa.command('npm install', { cwd: builder.directory }) const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], + ['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -1037,13 +1119,13 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co setupFixtureTests('next-app-without-config', () => { test( - 'should run deploy with --build without any netlify specific configuration', + 'build without error without any netlify specific configuration', { timeout: 300_000, }, async ({ fixture }) => { const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--build', '--json'], + ['deploy', '--json'], { cwd: fixture.directory, env: { NETLIFY_SITE_ID: context.siteId }, @@ -1064,7 +1146,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co await withSiteBuilder(t, async (builder) => { await builder.build() try { - await callCli(['deploy', '--prod-if-unlocked', '--prod'], { + await callCli(['deploy', '--no-build', '--prod-if-unlocked', '--prod'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, })