From aa243128f06dcaba053108254eade65db34d0b5d Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Tue, 20 Oct 2026 10:39:36 -0400 Subject: [PATCH 1/8] feat(deploy): add `--remote` to build existing commit on Netlify Previously, `netlify deploy` for a project with a build step could only run the build step locally, where the command is run. This adds `netlify deploy --remote`, an option that asks Netlify to build and deploy the locally checked out commit instead of building locally, streams the build log, shows real-time build and deploy progress, and waits for the deploy to finish. As usual, `--prod` creates and publishes a production deploy. For now this only covers projects connected to a third-party git host, with a clean tree, and the checked out commit already on the remote. Anything else fails with what to do instead (commit and push, build locally, etc.). Flags that only make sense for local builds are rejected. --- docs/commands/deploy.md | 3 + src/commands/deploy/deploy.ts | 57 ++- src/commands/deploy/index.ts | 19 + src/commands/deploy/option_values.ts | 1 + src/commands/deploy/remote-deploy.ts | 176 +++++++++ src/commands/deploy/remote/follow.ts | 32 ++ src/commands/deploy/remote/git.ts | 88 +++++ src/commands/deploy/remote/source.ts | 136 +++++++ src/utils/deploy/deploy-source.ts | 4 +- src/utils/netlify-git.ts | 28 ++ src/utils/socketeer.ts | 10 +- .../__snapshots__/deploy-remote.test.ts.snap | 35 ++ .../commands/deploy/deploy-remote.test.ts | 350 ++++++++++++++++++ .../commands/deploy/remote/follow.test.ts | 71 ++++ tests/unit/commands/deploy/remote/git.test.ts | 48 +++ .../commands/deploy/remote/source.test.ts | 282 ++++++++++++++ tests/unit/utils/netlify-git.test.ts | 17 +- tests/utils/git.ts | 97 +++++ 18 files changed, 1441 insertions(+), 13 deletions(-) create mode 100644 src/commands/deploy/remote-deploy.ts create mode 100644 src/commands/deploy/remote/follow.ts create mode 100644 src/commands/deploy/remote/git.ts create mode 100644 src/commands/deploy/remote/source.ts create mode 100644 tests/integration/commands/deploy/__snapshots__/deploy-remote.test.ts.snap create mode 100644 tests/integration/commands/deploy/deploy-remote.test.ts create mode 100644 tests/unit/commands/deploy/remote/follow.test.ts create mode 100644 tests/unit/commands/deploy/remote/git.test.ts create mode 100644 tests/unit/commands/deploy/remote/source.test.ts create mode 100644 tests/utils/git.ts diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 504839b0484..830ebe2a35b 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -44,6 +44,7 @@ netlify deploy - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `prod` (*boolean*) - Deploy to production +- `remote` (*boolean*) - Build and deploy on Netlify from your project source, instead of building locally. Netlify builds your checked-out commit, which must already be pushed to the git provider your project is connected to. - `secret-env` (*string*) - Set a secret environment variable for this deploy only. Applies to deployed functions only. The value is masked in the Netlify UI and API. Can be specified multiple times. - `site` (*string*) - A project name or ID to deploy to - `site-name` (*string*) - Name for a new site. Implies --create-site if the site does not already exist. @@ -64,6 +65,8 @@ 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 --remote # Build a draft on Netlify from your checked-out commit +netlify deploy --remote --prod netlify deploy --context deploy-preview netlify deploy --env "NODE_ENV=production" --env "API_URL=https://api.example.com" netlify deploy --env "NODE_ENV=production" --secret-env "DATABASE_PASSWORD=$DB_PASSWORD" diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 1ad8b6f7d92..ee3fc484876 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -41,7 +41,7 @@ import { import { DEFAULT_CONCURRENT_HASH, DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js' import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js' import { getDeploySourceFields } from '../../utils/deploy/deploy-source.js' -import { getDeployUrls } from '../../utils/deploy/deploy-urls.js' +import { getDeployUrls, type DeployUrls } from '../../utils/deploy/deploy-urls.js' import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js' import { getEnvelopeEnv } from '../../utils/env/index.js' import { mergeDeployEnvVars } from '../../utils/env/deploy-env-vars.js' @@ -72,6 +72,7 @@ import { sitesCreate } from '../sites/sites-create.js' import type { NetlifyOptions, NetlifySite } from '../types.js' import type { SiteInfo } from '../../utils/types.js' import type { DeployOptionValues } from './option_values.js' +import { remoteDeploy } from './remote-deploy.js' import boxen from 'boxen' import terminalLink from 'terminal-link' import { anyEdgeFunctionsDirectoryExists } from '../../lib/edge-functions/get-directories.js' @@ -856,6 +857,15 @@ interface JsonData { edge_function_logs: string url?: string source_zip_filename?: string + commit_sha?: string +} + +interface DeployResults extends DeployUrls { + siteId: string + siteName: string + deployId: string + sourceZipFileName?: string + commitSha?: string } const printResults = ({ @@ -863,13 +873,13 @@ const printResults = ({ uploadSourceZip, json, results, - runBuildCommand, + prodCommand, }: { deployToProduction: boolean uploadSourceZip: boolean json: boolean - results: Awaited> - runBuildCommand: boolean + results: DeployResults + prodCommand: string }): void => { const msgData: Record = { 'Build logs': terminalLink(results.logsUrl, results.logsUrl, { fallback: false }), @@ -900,6 +910,10 @@ const printResults = ({ jsonData.source_zip_filename = results.sourceZipFileName } + if (results.commitSha) { + jsonData.commit_sha = results.commitSha + } + logJson(jsonData) exit(0) } else if (!isInteractive()) { @@ -916,7 +930,7 @@ const printResults = ({ if (!deployToProduction) { log() log('If everything looks good on your draft URL, deploy it to your main project URL with the --prod flag:') - log(`netlify deploy${runBuildCommand ? '' : ' --no-build'} --prod`) + log(prodCommand) log() } } else { @@ -944,7 +958,7 @@ const printResults = ({ if (!deployToProduction) { log() log('If everything looks good on your draft URL, deploy it to your main project URL with the --prod flag:') - log(chalk.cyanBright.bold(`netlify deploy${runBuildCommand ? '' : ' --no-build'} --prod`)) + log(chalk.cyanBright.bold(prodCommand)) log() } } @@ -1162,6 +1176,12 @@ const ensureSiteExists = async ( return siteInfo } + if (options.remote) { + return logAndThrowError( + '--remote needs an existing Netlify project connected to a git repository. Run netlify link to link this directory to one, or pass --site.', + ) + } + if (options.createSite) { return createSiteWithFlags(options, command, site) } @@ -1354,7 +1374,13 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) const { api, site, siteInfo } = command.netlify const alias = options.alias || options.branch - command.setAnalyticsPayload({ open: options.open, prod: options.prod, json: options.json, alias: Boolean(alias) }) + command.setAnalyticsPayload({ + open: options.open, + prod: options.prod, + json: options.json, + alias: Boolean(alias), + remote: Boolean(options.remote), + }) if (options.allowAnonymous) { const [token] = await getToken(options.auth) @@ -1402,9 +1428,20 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) const deployToProduction = !options.draft && (options.prod || (options.prodIfUnlocked && !(siteData.published_deploy?.locked ?? false))) - let results = {} as Awaited> + let results = {} as DeployResults + let prodCommand = `netlify deploy${options.build ? '' : ' --no-build'} --prod` - if (options.build) { + if (options.remote) { + results = await remoteDeploy({ + api, + workingDir, + options, + siteData, + deployToProduction, + prepareProductionDeploy: () => prepareProductionDeploy({ siteData, api, options, command }), + }) + prodCommand = 'netlify deploy --remote --prod' + } else if (options.build) { const deployMetadata = await createDeploy({ alias, api, command, deployToProduction, options, siteData, siteId }) const deployId = deployMetadata.id const skewProtectionToken = deployMetadata.skew_protection_token @@ -1476,7 +1513,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) }) } printResults({ - runBuildCommand: options.build, + prodCommand, json: options.json, results, deployToProduction, diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 549651ae245..9a324087751 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -9,6 +9,17 @@ import BaseCommand from '../base-command.js' import { chalk, logAndThrowError, warn } from '../../utils/command-helpers.js' import type { DeployOptionValues } from './option_values.js' +const BUILD_RELATED_OPTIONS = [ + 'build', + 'context', + 'dir', + 'env', + 'functions', + 'secretEnv', + 'skipFunctionsCache', + 'uploadSourceZip', +] + export const createDeployCommand = (program: BaseCommand) => program .command('deploy') @@ -59,6 +70,12 @@ For detailed configuration options, see the Netlify documentation.`, 'build', ), ) + .addOption( + new Option( + '--remote', + 'Build and deploy on Netlify from your project source, instead of building locally. Netlify builds your checked-out commit, which must already be pushed to the git provider your project is connected to.', + ).conflicts([...BUILD_RELATED_OPTIONS, 'alias', 'allowAnonymous', 'branch', 'createSite', 'siteName', 'trigger']), + ) .addOption( new Option('--build', 'Do not use - this is now the default. Will be removed in future versions.') .default(true) @@ -120,6 +137,8 @@ For detailed configuration options, see the Netlify documentation.`, 'netlify deploy --message "A message with an $ENV_VAR"', 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', + 'netlify deploy --remote # Build a draft on Netlify from your checked-out commit', + 'netlify deploy --remote --prod', 'netlify deploy --context deploy-preview', 'netlify deploy --env "NODE_ENV=production" --env "API_URL=https://api.example.com"', 'netlify deploy --env "NODE_ENV=production" --secret-env "DATABASE_PASSWORD=$DB_PASSWORD"', diff --git a/src/commands/deploy/option_values.ts b/src/commands/deploy/option_values.ts index 2f74d76999b..842f5aa101a 100644 --- a/src/commands/deploy/option_values.ts +++ b/src/commands/deploy/option_values.ts @@ -20,6 +20,7 @@ export type DeployOptionValues = BaseOptionValues & { open: boolean prod: boolean prodIfUnlocked: boolean + remote?: boolean secretEnv?: DeployEnvironmentVariable[] site?: string siteName?: string diff --git a/src/commands/deploy/remote-deploy.ts b/src/commands/deploy/remote-deploy.ts new file mode 100644 index 00000000000..544f49a6354 --- /dev/null +++ b/src/commands/deploy/remote-deploy.ts @@ -0,0 +1,176 @@ +import { confirm } from '@inquirer/prompts' +import type { NetlifyAPI } from '@netlify/api' +import { TimeoutError } from 'p-wait-for' +import terminalLink from 'terminal-link' + +import { startSpinner } from '../../lib/spinner.js' +import { chalk, exit, log, logAndThrowError, NETLIFYDEVLOG, warn } from '../../utils/command-helpers.js' +import { DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js' +import { getDeploySource } from '../../utils/deploy/deploy-source.js' +import { getDeployUrls, type DeployUrls } from '../../utils/deploy/deploy-urls.js' +import type { GitCommandObserver } from './remote/git.js' +import { + prepareSourceCommit, + RemoteBuildError, + resolveRemoteSource, + type RemoteSource, + type SourceCommit, +} from './remote/source.js' +import { followDeploy } from './remote/follow.js' +import { DeployStateError } from '../../utils/deploy/util.js' +import { isInteractive } from '../../utils/scripted-commands.js' +import type { SiteInfo } from '../../utils/types.js' +import type { DeployOptionValues } from './option_values.js' + +// FIXME(@netlify/api): the `createSiteBuild` response type makes `id` and `deploy_id` optional +type CreatedBuild = Awaited> & { id: string; deploy_id: string } + +export interface RemoteDeployResults extends DeployUrls { + siteId: string + siteName: string + deployId: string + commitSha: string +} + +const describeSource = (source: RemoteSource): string => `Checking your commit is on ${source.repoUrl}` + +type DebugLog = (message: string) => void + +const describeMode = (source: RemoteSource): string => `external (${source.repoUrl}), nothing is pushed` + +const describeUntracked = (files: string[], sha: string, repoUrl: string): string => { + const shown = files.slice(0, 5).join(', ') + const more = files.length > 5 ? ` and ${String(files.length - 5)} more` : '' + return `${String(files.length)} untracked ${files.length === 1 ? 'file' : 'files'} won't be in this deploy, since Netlify builds ${sha} from ${repoUrl}: ${shown}${more}` +} + +const logGitCommand = + (debugLog: DebugLog): GitCommandObserver => + ({ args, cwd, exitCode, stderr }) => { + debugLog(`git ${args.join(' ')} (in ${cwd}) exited ${String(exitCode)}`) + if (exitCode !== 0 && stderr) { + debugLog(stderr) + } + } + +export const remoteDeploy = async ({ + api, + workingDir, + options, + siteData, + deployToProduction, + prepareProductionDeploy, +}: { + api: NetlifyAPI + workingDir: string + options: DeployOptionValues + siteData: SiteInfo + deployToProduction: boolean + /** Called only once the source checks pass, so production isn't unlocked for a deploy that then fails. */ + prepareProductionDeploy: () => Promise +}): Promise => { + const quiet = Boolean(options.json || options.silent) + const siteId = siteData.id + const timeoutMs = options.timeout ? options.timeout * 1000 : DEFAULT_DEPLOY_TIMEOUT + const debugLog: DebugLog | undefined = options.debug + ? (message) => { + log(`${NETLIFYDEVLOG} ${chalk.dim(message)}`) + } + : undefined + const onCommand = debugLog && logGitCommand(debugLog) + + let source: RemoteSource + let commit: SourceCommit + // Debug output would interleave with the spinner's redraws. + const spinner = debugLog ? undefined : startSpinner({ text: 'Preparing your source for a remote build' }) + try { + const { provider, repo_url: repoUrl, repo_branch: productionBranch } = siteData.build_settings ?? {} + debugLog?.( + `Project repository: ${repoUrl ?? '(none)'} (${provider ?? 'no provider'}), production branch ${productionBranch ?? '(none)'}`, + ) + source = await resolveRemoteSource({ + siteInfo: siteData, + workingDir, + onCommand, + production: deployToProduction, + }) + debugLog?.(`Mode: ${describeMode(source)}`) + spinner?.update({ text: describeSource(source) }) + commit = await prepareSourceCommit(source) + } catch (error) { + spinner?.error() + if (error instanceof RemoteBuildError) { + return logAndThrowError(error.message) + } + throw error + } + const shortSha = commit.sha.slice(0, 7) + const foundText = `Found ${shortSha} on ${source.repoUrl}` + if (spinner) { + spinner.success({ text: foundText }) + } else { + log(`${chalk.green('✔')} ${foundText}`) + } + + if (source.untracked.length !== 0) { + warn(describeUntracked(source.untracked, shortSha, source.repoUrl)) + if (isInteractive() && !quiet && !(await confirm({ message: 'Deploy anyway?', default: true }))) { + exit(0) + } + } + + if (deployToProduction) { + await prepareProductionDeploy() + } + + debugLog?.( + `Requesting a ${deployToProduction ? 'production' : 'draft'} build of ${commit.sha} on branch ${commit.branch ?? '(none)'}`, + ) + let build: CreatedBuild + try { + build = (await api.createSiteBuild({ + siteId, + branch: commit.branch, + title: options.message, + // @ts-expect-error FIXME(@netlify/api): `createSiteBuild` doesn't declare `commit_sha`, `draft` or `deploy_source`; the client drops undeclared query params, so they go in the body + body: { commit_sha: commit.sha, draft: !deployToProduction, deploy_source: getDeploySource() }, + })) as CreatedBuild + } catch (error) { + const { json, message } = error as { json?: { message?: string; error?: string }; message: string } + return logAndThrowError(`Couldn't start a build of ${commit.sha}: ${json?.message ?? json?.error ?? message}`) + } + + debugLog?.(`Build ${build.id}, deploy ${build.deploy_id}`) + const { logsUrl } = getDeployUrls({ id: build.deploy_id, admin_url: siteData.admin_url }, { deployToProduction }) + // Not on the spinner, which writes to stderr, so non-interactive output says what's being built. + log(`${NETLIFYDEVLOG} Building ${shortSha} on Netlify (${terminalLink('↗ View deploy logs', logsUrl)})`) + log('') + + try { + const deploy = await followDeploy(api, { + siteId, + deployId: build.deploy_id, + timeoutMs, + onLogLine: quiet + ? undefined + : (line) => { + log(`${chalk.dim('│')} ${line.trimEnd()}`) + }, + }) + return { + siteId, + siteName: siteData.name, + deployId: build.deploy_id, + commitSha: commit.sha, + ...getDeployUrls(deploy, { deployToProduction }), + } + } catch (error) { + if (error instanceof DeployStateError) { + return logAndThrowError(`Deploy failed: ${error.message}\n\nDeploy logs: ${logsUrl}`) + } + const timeoutHint = error instanceof TimeoutError ? ' To wait longer, pass a larger --timeout (in seconds).' : '' + return logAndThrowError( + `${(error as Error).message}\n\nThe build may still be running on Netlify.${timeoutHint} Deploy logs: ${logsUrl}`, + ) + } +} diff --git a/src/commands/deploy/remote/follow.ts b/src/commands/deploy/remote/follow.ts new file mode 100644 index 00000000000..4119f4126d2 --- /dev/null +++ b/src/commands/deploy/remote/follow.ts @@ -0,0 +1,32 @@ +import type { NetlifyAPI } from '@netlify/api' + +import { streamDeploy } from '../../logs/sources/deploy.js' +import { waitForDeploy } from '../../../utils/deploy/util.js' + +// The log stream is best-effort; the deploy's state decides success. +export const followDeploy = async ( + api: NetlifyAPI, + { + siteId, + deployId, + timeoutMs, + onLogLine, + }: { siteId: string; deployId: string; timeoutMs: number; onLogLine?: (line: string) => void }, +) => { + const stopStreaming = onLogLine + ? streamDeploy( + siteId, + deployId, + api.accessToken, + (entry) => { + onLogLine(entry.message) + }, + () => undefined, + ) + : undefined + try { + return await waitForDeploy(api, deployId, siteId, timeoutMs) + } finally { + stopStreaming?.() + } +} diff --git a/src/commands/deploy/remote/git.ts b/src/commands/deploy/remote/git.ts new file mode 100644 index 00000000000..a304af97f73 --- /dev/null +++ b/src/commands/deploy/remote/git.ts @@ -0,0 +1,88 @@ +import { execFile } from 'node:child_process' + +export interface GitCommand { + args: string[] + cwd: string + exitCode: number + stderr: string +} + +export type GitCommandObserver = (command: GitCommand) => void + +export interface GitContext { + cwd: string + onCommand?: GitCommandObserver +} + +// Resolves on any exit code, which callers inspect; rejects only if git never ran (e.g. ENOENT). +const run = (ctx: GitContext, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> => + new Promise((resolve, reject) => { + // GIT_OPTIONAL_LOCKS keeps read-only commands like `git status` from refreshing the user's index. + const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' } + execFile('git', args, { cwd: ctx.cwd, env }, (error, stdout, stderr) => { + if (error && typeof error.code !== 'number') { + reject(new Error(`Couldn't run git: ${error.message}`, { cause: error })) + return + } + const result = { exitCode: typeof error?.code === 'number' ? error.code : 0, stdout, stderr: stderr.trimEnd() } + ctx.onCommand?.({ args, cwd: ctx.cwd, exitCode: result.exitCode, stderr: result.stderr }) + resolve(result) + }) + }) + +const git = async (ctx: GitContext, args: string[]): Promise => { + const { exitCode, stdout, stderr } = await run(ctx, args) + if (exitCode !== 0) { + throw new Error(`git ${args[0]} failed: ${stderr || stdout}`) + } + return stdout.trim() +} + +const tryGit = async (ctx: GitContext, args: string[]): Promise => { + const { exitCode, stdout } = await run(ctx, args) + return exitCode === 0 ? stdout.trim() : undefined +} + +interface Remote { + name: string + url: string +} + +export const getRemotes = async (ctx: GitContext): Promise => { + const names = ((await tryGit(ctx, ['remote'])) ?? '').split('\n').filter(Boolean) + return Promise.all(names.map(async (name) => ({ name, url: await git(ctx, ['remote', 'get-url', name]) }))) +} + +export const getHead = (ctx: GitContext): Promise => tryGit(ctx, ['rev-parse', '--verify', 'HEAD']) + +export const getGitVersion = async (ctx: GitContext): Promise => { + const output = await tryGit(ctx, ['version']).catch(() => undefined) + return output ? /\d+\.\d+(\.\d+)?/.exec(output)?.[0] : undefined +} + +// Full ref names, since short ones become ambiguous when a local branch is named like a remote one +// (e.g. `origin/main`). +const stripRefPrefix = (ref: string | undefined, prefix: string) => + ref?.startsWith(prefix) ? ref.slice(prefix.length) : undefined + +export const getCurrentBranch = async (ctx: GitContext): Promise => + stripRefPrefix(await tryGit(ctx, ['symbolic-ref', '--quiet', 'HEAD']), 'refs/heads/') + +export const getUpstream = async (ctx: GitContext): Promise => + stripRefPrefix(await tryGit(ctx, ['rev-parse', '--symbolic-full-name', '@{upstream}']), 'refs/remotes/') + +export const getWorkTreeStatus = async (ctx: GitContext): Promise<{ hasChanges: boolean; untracked: string[] }> => { + const lines = (await git(ctx, ['status', '--porcelain', '--untracked-files=normal'])).split('\n').filter(Boolean) + return { + hasChanges: lines.some((line) => !line.startsWith('?? ')), + untracked: lines.filter((line) => line.startsWith('?? ')).map((line) => line.slice(3)), + } +} + +const listRemoteBranches = async (ctx: GitContext, filter: string[]): Promise => { + const output = await tryGit(ctx, ['branch', '--remotes', ...filter, '--format=%(refname:lstrip=2)']) + return (output ?? '').split('\n').filter((name) => name !== '' && !name.endsWith('/HEAD')) +} + +export const getRemoteBranchesContaining = (ctx: GitContext, sha: string): Promise => + listRemoteBranches(ctx, ['--contains', sha]) diff --git a/src/commands/deploy/remote/source.ts b/src/commands/deploy/remote/source.ts new file mode 100644 index 00000000000..78a554129dc --- /dev/null +++ b/src/commands/deploy/remote/source.ts @@ -0,0 +1,136 @@ +import { isNetlifyGitUrl, isSameRepository } from '../../../utils/netlify-git.js' +import type { SiteInfo } from '../../../utils/types.js' +import { + getCurrentBranch, + getGitVersion, + getHead, + getRemotes, + getUpstream, + getRemoteBranchesContaining, + getWorkTreeStatus, + type GitCommandObserver, + type GitContext, +} from './git.js' + +// `external`: the git host already has the commit, so nothing is pushed. +export type RemoteSource = { + kind: 'external' + repoUrl: string + head: string + branch: string + /** Not part of the commit, so not part of the deploy either. */ + untracked: string[] +} + +export interface SourceCommit { + sha: string + branch?: string +} + +/** A problem the user can fix, as opposed to an unexpected failure. */ +export class RemoteBuildError extends Error { + constructor(message: string) { + super(message) + this.name = 'RemoteBuildError' + } +} + +const describeGitProblem = (version: string | undefined): string | undefined => { + if (!version) { + return "--remote uses git, which isn't installed or isn't on your PATH. Install git, then deploy again." + } + const [major, minor] = version.split('.').map(Number) + // 2.13 added `git branch --format`. + if (major < 2 || (major === 2 && minor < 13)) { + return `--remote needs git 2.13 or later, but you have ${version}. Update git, then deploy again.` + } + return undefined +} + +type CloneState = + | { pushed: true; head: string; branch?: string; untracked: string[] } + | { pushed: false; reason: string } + +const inspectClone = async (ctx: GitContext, repoUrl: string): Promise => { + const head = await getHead(ctx) + if (!head) { + return { pushed: false, reason: `This directory isn't a clone of ${repoUrl}.` } + } + // Another remote having the commit doesn't mean the provider can build it. + const providerRemotes = (await getRemotes(ctx)) + .filter(({ url }) => isSameRepository(url, repoUrl)) + .map(({ name }) => name) + if (providerRemotes.length === 0) { + return { pushed: false, reason: `This repository has no remote for ${repoUrl}.` } + } + const { hasChanges, untracked } = await getWorkTreeStatus(ctx) + if (hasChanges) { + return { pushed: false, reason: 'You have uncommitted changes.' } + } + const remoteOf = (ref: string) => providerRemotes.find((remote) => ref.startsWith(`${remote}/`)) + const pushedTo = (await getRemoteBranchesContaining(ctx, head)).filter((ref) => remoteOf(ref) !== undefined) + if (pushedTo.length === 0) { + return { pushed: false, reason: `Your checked-out commit isn't on ${repoUrl} as of your last fetch.` } + } + // Only a branch the user evidently pushed this commit to, never a guess among other branches that + // happen to contain it. + const currentBranch = await getCurrentBranch(ctx) + const candidates = [ + await getUpstream(ctx), + ...(currentBranch ? providerRemotes.map((remote) => `${remote}/${currentBranch}`) : []), + ] + const ref = candidates.find((candidate) => candidate !== undefined && pushedTo.includes(candidate)) + return { pushed: true, head, branch: ref?.slice((remoteOf(ref) ?? '').length + 1), untracked } +} + +const describeManualPush = (reason: string, repoUrl: string, productionBranch: string) => + `${reason} Netlify builds this project from ${repoUrl}, so commit your changes and push them to ${productionBranch} or another branch, then deploy again from your clone.` + +export const resolveRemoteSource = async ({ + siteInfo, + workingDir, + onCommand, + production = false, +}: { + siteInfo: SiteInfo + workingDir: string + onCommand?: GitCommandObserver + production?: boolean +}): Promise => { + const settings = siteInfo.build_settings + if (!settings?.repo_url) { + throw new RemoteBuildError( + 'This project has no repository for Netlify to build from, so it can only be deployed with a local build.', + ) + } + const productionBranch = settings.repo_branch + + const ctx = { cwd: workingDir, onCommand } + + if (settings.provider !== 'netlify-git' && !isNetlifyGitUrl(settings.repo_url)) { + const gitProblem = describeGitProblem(await getGitVersion(ctx)) + if (gitProblem) { + throw new RemoteBuildError(gitProblem) + } + const repoUrl = settings.repo_url + const clone = await inspectClone(ctx, repoUrl) + if (clone.pushed) { + // Any pushed commit can go to production: the build fetches it by SHA, not from the branch. + const branch = production ? productionBranch : clone.branch + if (!branch) { + throw new RemoteBuildError( + `Couldn't tell which branch to build ${clone.head.slice(0, 7)} as: it isn't on your checked-out branch's upstream or a branch of the same name on ${repoUrl}. Check out the branch you pushed it to, then deploy again.`, + ) + } + return { kind: 'external', repoUrl, head: clone.head, branch, untracked: clone.untracked } + } + throw new RemoteBuildError(describeManualPush(clone.reason, repoUrl, productionBranch)) + } + + throw new RemoteBuildError( + "Remote builds aren't supported yet for projects on Netlify Git. Deploy without --remote to build locally.", + ) +} + +export const prepareSourceCommit = (source: RemoteSource): Promise => + Promise.resolve({ sha: source.head, branch: source.branch }) diff --git a/src/utils/deploy/deploy-source.ts b/src/utils/deploy/deploy-source.ts index 81c490eb207..d7081c5ae99 100644 --- a/src/utils/deploy/deploy-source.ts +++ b/src/utils/deploy/deploy-source.ts @@ -4,12 +4,14 @@ export interface DeploySourceFields { agent_runner_session_id?: string } +export const getDeploySource = (): string => process.env.NETLIFY_DEPLOY_SOURCE || 'cli' + export const getDeploySourceFields = (): DeploySourceFields => { const agentRunnerId = process.env.NETLIFY_AGENT_RUNNER_ID const agentRunnerSessionId = process.env.NETLIFY_AGENT_RUNNER_SESSION_ID return { - deploy_source: process.env.NETLIFY_DEPLOY_SOURCE || 'cli', + deploy_source: getDeploySource(), ...(agentRunnerId ? { agent_runner_id: agentRunnerId } : {}), ...(agentRunnerSessionId ? { agent_runner_session_id: agentRunnerSessionId } : {}), } diff --git a/src/utils/netlify-git.ts b/src/utils/netlify-git.ts index 88d4e7778fe..1171650d128 100644 --- a/src/utils/netlify-git.ts +++ b/src/utils/netlify-git.ts @@ -10,3 +10,31 @@ export const isNetlifyGitUrl = (repoUrl: string): boolean => { return false } } + +// Reduces `https://host/owner/repo.git`, `ssh://git@host/owner/repo` and `git@host:owner/repo.git` to +// `host/owner/repo`, ignoring credentials, case and a trailing `.git`. Local paths have no host. +const getRepositoryKey = (repoUrl: string): string | undefined => { + const parse = (): { host: string; path: string } | undefined => { + if (repoUrl.startsWith('/')) { + return { host: '', path: repoUrl } + } + const scpLike = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(repoUrl) + if (scpLike) { + return { host: scpLike[1], path: scpLike[2] } + } + try { + const { hostname, pathname } = new URL(repoUrl) + return { host: hostname, path: pathname } + } catch { + return undefined + } + } + const parsed = parse() + const path = parsed?.path.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '') + return parsed && path ? `${parsed.host}/${path}`.toLowerCase() : undefined +} + +export const isSameRepository = (a: string, b: string): boolean => { + const keyA = getRepositoryKey(a) + return keyA !== undefined && keyA === getRepositoryKey(b) +} diff --git a/src/utils/socketeer.ts b/src/utils/socketeer.ts index 8ab1d8e7de8..12fbe71c03b 100644 --- a/src/utils/socketeer.ts +++ b/src/utils/socketeer.ts @@ -1 +1,9 @@ -export const SOCKETEER_URL = process.env.NETLIFY_SOCKETEER_URL ?? 'wss://socketeer.services.netlify.com' +const getSocketeerUrl = (): string => { + // An empty value means unset, as with NETLIFY_API_URL. + if (process.env.NETLIFY_SOCKETEER_URL) { + return process.env.NETLIFY_SOCKETEER_URL + } + return 'wss://socketeer.services.netlify.com' +} + +export const SOCKETEER_URL = getSocketeerUrl() diff --git a/tests/integration/commands/deploy/__snapshots__/deploy-remote.test.ts.snap b/tests/integration/commands/deploy/__snapshots__/deploy-remote.test.ts.snap new file mode 100644 index 00000000000..ecc32534240 --- /dev/null +++ b/tests/integration/commands/deploy/__snapshots__/deploy-remote.test.ts.snap @@ -0,0 +1,35 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`deploy --remote > prints the production URL when deploying to production 1`] = ` +"⬥ Building 7046334 on Netlify (↗ View deploy logs https://app.netlify.com/projects/test-site/deploys/deploy_id) + + +🚀 Deploy complete +──────────────────────────────────────────────────────────────── + +Production URL: +Unique deploy URL: + +Build logs: +Function logs: +Edge function logs: " +`; + +exports[`deploy --remote > streams the build log, then prints the draft URL and how to deploy it to production 1`] = ` +"⬥ Building 7046334 on Netlify (↗ View deploy logs https://app.netlify.com/projects/test-site/deploys/deploy_id) + +│ Installing dependencies +│ Build script success + +🚀 Deploy complete +──────────────────────────────────────────────────────────────── + +Draft URL: + +Build logs: +Function logs: +Edge function logs: + +If everything looks good on your draft URL, deploy it to your main project URL with the --prod flag: +netlify deploy --remote --prod" +`; diff --git a/tests/integration/commands/deploy/deploy-remote.test.ts b/tests/integration/commands/deploy/deploy-remote.test.ts new file mode 100644 index 00000000000..07ca84458dd --- /dev/null +++ b/tests/integration/commands/deploy/deploy-remote.test.ts @@ -0,0 +1,350 @@ +import { once } from 'node:events' +import { writeFile } from 'node:fs/promises' +import type { AddressInfo } from 'node:net' +import { join } from 'node:path' + +import type express from 'express' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { WebSocketServer } from 'ws' + +import { git, initPushedRepository, isolateGitConfig } from '../../../utils/git.js' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, type MockApi, type Route } from '../../utils/mock-api-vitest.js' +import { withSiteBuilder, type SiteBuilder } from '../../utils/site-builder.js' +import { normalize } from '../../utils/snapshots.js' +import { startDeployMockApi } from './deploy-api-routes.js' + +const siteInfo = { + id: 'site_id', + name: 'test-site', + account_slug: 'test-account', + admin_url: 'https://app.netlify.com/projects/test-site', + ssl_url: 'https://test-site.netlify.app', + url: 'https://test-site.netlify.app', + build_settings: { + provider: 'netlify-git', + repo_url: 'https://hgit.services-prod.nsvcs.net/abc123', + repo_branch: 'main', + }, +} + +const readyDeploy = { + id: 'deploy_id', + site_id: 'site_id', + name: 'test-site', + state: 'ready', + deploy_ssl_url: 'https://deploy-id--test-site.netlify.app', + admin_url: 'https://app.netlify.com/projects/test-site', + ssl_url: 'https://test-site.netlify.app', +} + +interface RemoteDeployOutput { + deploy_id: string + deploy_url: string + commit_sha: string + url?: string +} + +interface Harness { + mockApi: MockApi + logServer?: WebSocketServer + buildRequests: Record[] +} + +const startFakeDeployLog = async (lines: string[], onSent: () => void) => { + const server = new WebSocketServer({ port: 0 }) + await once(server, 'listening') + server.on('connection', (ws) => { + ws.once('message', () => { + for (const line of lines) { + ws.send(JSON.stringify({ message: line })) + } + onSent() + }) + }) + vi.stubEnv('NETLIFY_SOCKETEER_URL', `ws://localhost:${String((server.address() as AddressInfo).port)}`) + return server +} + +/** + * With `buildLog`, the deploy stays building until those lines have been streamed. With `failWith`, the + * deploy fails with that error. With `rejectBuildWith`, the API refuses to start the build. + */ +const setUp = async ( + builder: SiteBuilder, + site: Record = siteInfo, + { buildLog, failWith, rejectBuildWith }: { buildLog?: string[]; failWith?: string; rejectBuildWith?: string } = {}, +): Promise => { + await isolateGitConfig(join(builder.directory, '..')) + // Keeps the CLI off the real log stream; `startFakeDeployLog` points it at a local one instead. + vi.stubEnv('NETLIFY_SOCKETEER_URL', 'ws://127.0.0.1:9') + let deployState = failWith ? 'error' : buildLog ? 'building' : 'ready' + const logServer = + buildLog && + (await startFakeDeployLog(buildLog, () => { + deployState = 'ready' + })) + + const buildRequests: Record[] = [] + const routes: Route[] = [ + { path: 'sites/site_id', response: site }, + { path: 'sites', method: 'GET', response: [site] }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + { path: 'accounts/test-account/env', response: [] }, + { + path: 'sites/site_id/builds', + method: 'POST', + response: (req: express.Request, res: express.Response) => { + buildRequests.push({ ...(req.query as Record), ...(req.body as Record) }) + if (rejectBuildWith) { + res.status(403).json({ message: rejectBuildWith }) + return + } + res.json({ id: 'build_id', deploy_id: 'deploy_id' }) + }, + }, + { + path: 'sites/site_id/deploys/deploy_id', + method: 'GET', + response: (_req: express.Request, res: express.Response) => { + res.json({ ...readyDeploy, state: deployState, error_message: failWith }) + }, + }, + ] + const mockApi = await startDeployMockApi({ routes }) + + return { mockApi, logServer, buildRequests } +} + +const setUpForgeProject = async ( + builder: SiteBuilder, + options?: Parameters[2], + siteOverrides: Record = {}, +) => { + const forge = join(builder.directory, '..', 'forge.git') + await git(builder.directory, 'init', '--bare', '--quiet', forge) + const site = { + ...siteInfo, + build_settings: { provider: 'github', repo_url: forge, repo_branch: 'main' }, + ...siteOverrides, + } + const harness = await setUp(builder, site, options) + await initPushedRepository(builder.directory, forge) + return { harness, forge, head: await git(builder.directory, 'rev-parse', 'HEAD') } +} + +const deployRemote = async (builder: SiteBuilder, harness: Harness, args: string[] = []): Promise => + (await callCli(['deploy', '--remote', ...args], getCLIOptions({ apiUrl: harness.mockApi.apiUrl, builder }))) as string + +const deployRemoteJson = async (builder: SiteBuilder, harness: Harness, args: string[] = []) => + JSON.parse(await deployRemote(builder, harness, ['--json', ...args])) as RemoteDeployOutput + +describe('deploy --remote', () => { + let harness: Harness | undefined + + beforeEach(() => { + harness = undefined + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await harness?.mockApi.close() + harness?.logServer?.close() + }) + + test('builds the pushed commit of a project on a third-party git host', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + const project = await setUpForgeProject(builder) + harness = project.harness + + const output = await deployRemoteJson(builder, harness) + + expect(output.commit_sha).toBe(project.head) + expect(harness.buildRequests).toEqual([ + { commit_sha: project.head, branch: 'main', draft: true, deploy_source: 'cli' }, + ]) + }) + }) + + test('streams the build log, then prints the draft URL and how to deploy it to production', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + const project = await setUpForgeProject(builder, { + buildLog: ['Installing dependencies', 'Build script success'], + }) + harness = project.harness + + expect(normalize(await deployRemote(builder, harness))).toMatchSnapshot() + }) + }) + + test('prints the production URL when deploying to production', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + const project = await setUpForgeProject(builder) + harness = project.harness + + expect(normalize(await deployRemote(builder, harness, ['--prod']))).toMatchSnapshot() + }) + }) + + test('deploys an already-pushed commit from any branch to production without pushing', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Committed

' }) + await builder.build() + const project = await setUpForgeProject(builder) + harness = project.harness + await git(builder.directory, 'switch', '--quiet', '--create', 'feature') + await git(builder.directory, 'commit', '--quiet', '--allow-empty', '--message', 'Feature') + await git(builder.directory, 'push', '--quiet', '--set-upstream', 'origin', 'feature') + const head = await git(builder.directory, 'rev-parse', 'HEAD') + const forgeRefs = await git(project.forge, 'for-each-ref') + + const output = await deployRemoteJson(builder, harness, ['--prod']) + + expect(output.commit_sha).toBe(head) + expect(harness.buildRequests).toEqual([{ commit_sha: head, branch: 'main', draft: false, deploy_source: 'cli' }]) + expect(await git(project.forge, 'for-each-ref')).toBe(forgeRefs) + }) + }) + + test('fails with guidance when the project has uncommitted changes', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Committed

' }) + await builder.build() + const project = await setUpForgeProject(builder) + harness = project.harness + await writeFile(join(builder.directory, 'index.html'), '

Uncommitted

') + + await expect(deployRemoteJson(builder, harness)).rejects.toThrow( + /You have uncommitted changes\. .*commit your changes and push them/, + ) + expect(harness.buildRequests).toEqual([]) + }) + }) + + test('checks the source before asking to unlock locked production deploys', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Committed

' }) + await builder.build() + const project = await setUpForgeProject(builder, {}, { published_deploy: { id: 'published', locked: true } }) + harness = project.harness + await writeFile(join(builder.directory, 'index.html'), '

Uncommitted

') + + await expect(deployRemoteJson(builder, harness, ['--prod'])).rejects.toThrow(/You have uncommitted changes/) + }) + }) + + test('warns about untracked files, which are not part of the deploy, and deploys anyway', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + const project = await setUpForgeProject(builder) + harness = project.harness + await writeFile(join(builder.directory, 'scratch.txt'), 'notes') + + const output = await deployRemote(builder, harness) + + expect(output).toContain(`1 untracked file won't be in this deploy`) + expect(output).toContain('scratch.txt') + expect(harness.buildRequests).toHaveLength(1) + }) + }) + + test('fails with guidance when the checked-out commit is not pushed', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Committed

' }) + await builder.build() + const project = await setUpForgeProject(builder) + harness = project.harness + await git(builder.directory, 'commit', '--quiet', '--allow-empty', '--message', 'Unpushed') + + await expect(deployRemoteJson(builder, harness, ['--prod'])).rejects.toThrow( + /Your checked-out commit isn't on .+ as of your last fetch\. .*commit your changes and push them/, + ) + expect(harness.buildRequests).toEqual([]) + }) + }) + + test('fails with the deploy error and a link to the deploy logs when the deploy fails', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + const project = await setUpForgeProject(builder, { failWith: 'Build script returned non-zero exit code: 2' }) + harness = project.harness + + await expect(deployRemoteJson(builder, harness)).rejects.toThrow( + /Deploy failed: Build script returned non-zero exit code: 2\s+Deploy logs: https:\/\/app\.netlify\.com\/projects\/test-site\/deploys\/deploy_id/, + ) + }) + }) + + test("fails with the API's reason when it refuses to start the build", async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + const project = await setUpForgeProject(builder, { + rejectBuildWith: 'Builds for this site are currently stopped', + }) + harness = project.harness + + await expect(deployRemoteJson(builder, harness)).rejects.toThrow( + `Couldn't start a build of ${project.head}: Builds for this site are currently stopped`, + ) + }) + }) + + test('fails as not supported yet for a project on Netlify Git', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + harness = await setUp(builder) + + await expect(deployRemoteJson(builder, harness)).rejects.toThrow( + /aren't supported yet for projects on Netlify Git/, + ) + expect(harness.buildRequests).toEqual([]) + }) + }) + + test('fails without creating a project when no project is linked', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + harness = await setUp(builder) + + await expect( + callCli( + ['deploy', '--remote'], + getCLIOptions({ apiUrl: harness.mockApi.apiUrl, builder, env: { NETLIFY_SITE_ID: '' } }), + ), + ).rejects.toThrow(/--remote needs an existing Netlify project/) + expect( + harness.mockApi.requests.filter(({ method, path }) => method === 'POST' && path.endsWith('/sites')), + ).toEqual([]) + }) + }) + + test('fails with guidance when the project has no repository to build from', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + harness = await setUp(builder, { ...siteInfo, build_settings: { repo_url: '' } }) + + await expect(deployRemoteJson(builder, harness)).rejects.toThrow(/can only be deployed with a local build/) + expect(harness.buildRequests).toEqual([]) + }) + }) + + test.each([['--dir', 'public'], ['--no-build'], ['--alias', 'preview'], ['--trigger']])( + 'rejects %s, which only applies to local builds', + async (...flags) => { + await expect(callCli(['deploy', '--remote', ...flags])).rejects.toThrow(/cannot be used with option/) + }, + ) +}) diff --git a/tests/unit/commands/deploy/remote/follow.test.ts b/tests/unit/commands/deploy/remote/follow.test.ts new file mode 100644 index 00000000000..63417c1a46d --- /dev/null +++ b/tests/unit/commands/deploy/remote/follow.test.ts @@ -0,0 +1,71 @@ +import type { NetlifyAPI } from '@netlify/api' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { streamDeploy } from '../../../../../src/commands/logs/sources/deploy.js' +import { followDeploy } from '../../../../../src/commands/deploy/remote/follow.js' +import { DeployStateError } from '../../../../../src/utils/deploy/util.js' + +vi.mock('../../../../../src/commands/logs/sources/deploy.js', () => ({ + streamDeploy: vi.fn(), +})) + +const stopStreaming = vi.fn() + +const createApi = (...states: string[]) => { + const getSiteDeploy = vi.fn() + for (const state of states) { + getSiteDeploy.mockResolvedValueOnce({ id: 'deploy_id', state, error_message: 'Build failed' }) + } + return { accessToken: 'token', getSiteDeploy } as unknown as NetlifyAPI +} + +const follow = (api: NetlifyAPI, onLogLine?: (line: string) => void) => + followDeploy(api, { siteId: 'site_id', deployId: 'deploy_id', timeoutMs: 5_000, onLogLine }) + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.mocked(streamDeploy).mockReturnValue(stopStreaming) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('followDeploy', () => { + test('resolves with the deploy once it is ready, streaming its log meanwhile', async () => { + const onLogLine = vi.fn() + vi.mocked(streamDeploy).mockImplementation((_siteId, _deployId, _token, onEntry) => { + onEntry({ source: 'deploy', name: 'deploy', ts: 0, level: 'INFO', message: 'Installing dependencies' }) + return stopStreaming + }) + + const result = follow(createApi('building', 'ready'), onLogLine) + await vi.runAllTimersAsync() + + await expect(result).resolves.toMatchObject({ state: 'ready' }) + expect(onLogLine).toHaveBeenCalledWith('Installing dependencies') + expect(streamDeploy).toHaveBeenCalledWith( + 'site_id', + 'deploy_id', + 'token', + expect.any(Function), + expect.any(Function), + ) + expect(stopStreaming).toHaveBeenCalledOnce() + }) + + test('does not stream without a log handler', async () => { + await follow(createApi('ready')) + + expect(streamDeploy).not.toHaveBeenCalled() + }) + + test('stops streaming when the deploy fails', async () => { + const result = follow(createApi('building', 'error'), vi.fn()) + await Promise.all([expect(result).rejects.toThrow(DeployStateError), vi.runAllTimersAsync()]) + + await expect(result).rejects.toThrow('Build failed') + expect(stopStreaming).toHaveBeenCalledOnce() + }) +}) diff --git a/tests/unit/commands/deploy/remote/git.test.ts b/tests/unit/commands/deploy/remote/git.test.ts new file mode 100644 index 00000000000..d155fd5d469 --- /dev/null +++ b/tests/unit/commands/deploy/remote/git.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { + getHead, + getRemoteBranchesContaining, + getWorkTreeStatus, + type GitCommand, +} from '../../../../../src/commands/deploy/remote/git.js' +import { createGitSandbox, git, writeFiles, type GitSandbox } from '../../../../utils/git.js' + +let sandbox: GitSandbox + +beforeEach(async () => { + sandbox = await createGitSandbox() +}) + +afterEach(async () => { + await sandbox.cleanup() +}) + +describe('inspecting a clone', () => { + test('reports every git command to the observer', async () => { + const project = await sandbox.createDir('project', { 'index.html': 'v1' }) + const commands: GitCommand[] = [] + const ctx = { cwd: project, onCommand: (command: GitCommand) => commands.push(command) } + + expect(await getHead(ctx)).toBeUndefined() + await expect(getWorkTreeStatus(ctx)).rejects.toThrow() + + expect(commands.map(({ args }) => args[0])).toEqual(['rev-parse', 'status']) + const failedStatus = commands.at(-1) + expect(failedStatus).toMatchObject({ cwd: project, exitCode: 128 }) + expect(failedStatus?.stderr).toMatch(/not a git repository/) + }) + + test('getRemoteBranchesContaining lists remote-tracking branches with the commit', async () => { + const project = await sandbox.createClone('project', { 'index.html': 'v1' }) + const head = await git(project, 'rev-parse', 'HEAD') + + expect(await getRemoteBranchesContaining({ cwd: project }, head)).toEqual(['origin/main']) + + await writeFiles(project, { 'index.html': 'v2' }) + await git(project, 'commit', '--quiet', '--all', '--message', 'Unpushed') + const unpushed = await git(project, 'rev-parse', 'HEAD') + + expect(await getRemoteBranchesContaining({ cwd: project }, unpushed)).toEqual([]) + }) +}) diff --git a/tests/unit/commands/deploy/remote/source.test.ts b/tests/unit/commands/deploy/remote/source.test.ts new file mode 100644 index 00000000000..05f86cc970a --- /dev/null +++ b/tests/unit/commands/deploy/remote/source.test.ts @@ -0,0 +1,282 @@ +import { chmod } from 'node:fs/promises' +import { join } from 'node:path' + +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { + prepareSourceCommit, + RemoteBuildError, + resolveRemoteSource, +} from '../../../../../src/commands/deploy/remote/source.js' +import type { SiteInfo } from '../../../../../src/utils/types.js' +import { createGitSandbox, git, writeFiles, type GitSandbox } from '../../../../utils/git.js' + +let sandbox: GitSandbox | undefined + +const setup = async () => { + sandbox = await createGitSandbox() + return sandbox +} + +afterEach(async () => { + await sandbox?.cleanup() + sandbox = undefined +}) + +const getSiteInfo = (buildSettings: Partial> | undefined) => + ({ name: 'my-site', account_slug: 'my-team', build_settings: buildSettings }) as SiteInfo + +const netlifyGitSettings = { + provider: 'netlify-git', + repo_url: 'https://hgit.services-prod.nsvcs.net/abc123', + repo_branch: 'main', +} + +describe('resolveRemoteSource', () => { + test('rejects projects without a repository', async () => { + const { createDir } = await setup() + const dir = await createDir('project') + + await expect(resolveRemoteSource({ siteInfo: getSiteInfo(undefined), workingDir: dir })).rejects.toThrow( + RemoteBuildError, + ) + await expect(resolveRemoteSource({ siteInfo: getSiteInfo({ repo_url: '' }), workingDir: dir })).rejects.toThrow( + RemoteBuildError, + ) + }) + + test.each([ + ['provider', netlifyGitSettings], + [ + 'repository URL', + { provider: 'manual', repo_url: 'https://git.netlify.com/my-team/my-site.git', repo_branch: 'main' }, + ], + ])('rejects Netlify Git projects, identified by their %s, as not supported yet', async (_, buildSettings) => { + const { createClone } = await setup() + const dir = await createClone('project', { 'index.html': 'hi' }) + + const result = resolveRemoteSource({ siteInfo: getSiteInfo(buildSettings), workingDir: dir }) + + await expect(result).rejects.toThrow(RemoteBuildError) + await expect(result).rejects.toThrow(/aren't supported yet for projects on Netlify Git/) + }) +}) + +describe('resolveRemoteSource for a project on a third-party git host', () => { + const resolve = (dir: string, repoUrl: string, production?: boolean) => + resolveRemoteSource({ + siteInfo: getSiteInfo({ provider: 'github', repo_url: repoUrl, repo_branch: 'main' }), + workingDir: dir, + production, + }) + + test('builds the pushed HEAD of a clean clone from the provider', async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'switch', '--quiet', '--create', 'feature') + await writeFiles(dir, { 'index.html': 'v2' }) + await git(dir, 'commit', '--quiet', '--all', '--message', 'Feature') + await git(dir, 'push', '--quiet', '--set-upstream', 'origin', 'feature') + const head = await git(dir, 'rev-parse', 'HEAD') + + const source = await resolve(dir, remote) + + expect(source).toEqual({ + kind: 'external', + repoUrl: remote, + head, + branch: 'feature', + untracked: [], + }) + expect(await prepareSourceCommit(source)).toEqual({ sha: head, branch: 'feature' }) + }) + + test('builds the production branch from the provider for production', async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + const head = await git(dir, 'rev-parse', 'HEAD') + + expect(await resolve(dir, remote, true)).toMatchObject({ + kind: 'external', + head, + branch: 'main', + }) + }) + + test('builds a commit pushed only to another branch for production, attributed to the production branch', async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'switch', '--quiet', '--create', 'feature') + await writeFiles(dir, { 'index.html': 'v2' }) + await git(dir, 'commit', '--quiet', '--all', '--message', 'Feature') + await git(dir, 'push', '--quiet', '--set-upstream', 'origin', 'feature') + const head = await git(dir, 'rev-parse', 'HEAD') + + expect(await resolve(dir, remote, true)).toMatchObject({ + kind: 'external', + head, + branch: 'main', + }) + }) + + test("attributes the build to the upstream branch, even when it's named differently", async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'switch', '--quiet', '--create', 'feature') + await writeFiles(dir, { 'index.html': 'v2' }) + await git(dir, 'commit', '--quiet', '--all', '--message', 'Feature') + await git(dir, 'push', '--quiet', '--set-upstream', 'origin', 'feature:someone/feature') + + expect(await resolve(dir, remote)).toMatchObject({ branch: 'someone/feature' }) + }) + + test('attributes the build to the same-named branch when there is no upstream', async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'switch', '--quiet', '--create', 'feature') + await writeFiles(dir, { 'index.html': 'v2' }) + await git(dir, 'commit', '--quiet', '--all', '--message', 'Feature') + await git(dir, 'push', '--quiet', 'origin', 'feature') + + expect(await resolve(dir, remote)).toMatchObject({ branch: 'feature' }) + }) + + test("doesn't guess a branch for a draft when the commit is only on other branches", async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'switch', '--quiet', '--create', 'feature') + await writeFiles(dir, { 'index.html': 'v2' }) + await git(dir, 'commit', '--quiet', '--all', '--message', 'Feature') + await git(dir, 'push', '--quiet', 'origin', 'feature:other') + + await expect(resolve(dir, remote)).rejects.toThrow(/Couldn't tell which branch to build/) + expect(await resolve(dir, remote, true)).toMatchObject({ branch: 'main' }) + }) + + test('recognizes remote branches when a local branch is named like one', async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'branch', 'origin/main') + + expect(await resolve(dir, remote)).toMatchObject({ branch: 'main' }) + }) + + test('reports untracked files rather than treating them as uncommitted changes', async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await writeFiles(dir, { 'scratch.txt': 'notes' }) + + expect(await resolve(dir, remote)).toMatchObject({ branch: 'main', untracked: ['scratch.txt'] }) + }) + + test("prefers the checked-out branch's upstream when several branches have the commit", async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'push', '--quiet', 'origin', 'main:aaa') + + expect(await resolve(dir, remote)).toMatchObject({ branch: 'main' }) + }) + + test("doesn't guess a branch for a draft from a detached HEAD", async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + await git(dir, 'switch', '--quiet', '--detach') + + await expect(resolve(dir, remote)).rejects.toThrow(/Couldn't tell which branch to build/) + expect(await resolve(dir, remote, true)).toMatchObject({ branch: 'main' }) + }) + + test('recognizes the provider remote in another URL form', async () => { + const { createClone, remote } = await setup() + const dir = await createClone('project', { 'index.html': 'v1' }) + + expect(await resolve(dir, remote.replace(/\.git$/, ''))).toMatchObject({ kind: 'external' }) + }) + + test.each([ + [ + 'uncommitted changes', + async (sandbox: GitSandbox) => { + const dir = await sandbox.createClone('project', { 'index.html': 'v1' }) + await writeFiles(dir, { 'index.html': 'v2' }) + return dir + }, + /You have uncommitted changes/, + ], + [ + 'an unpushed commit', + async (sandbox: GitSandbox) => { + const dir = await sandbox.createClone('project', { 'index.html': 'v1' }) + await writeFiles(dir, { 'index.html': 'v2' }) + await git(dir, 'commit', '--quiet', '--all', '--message', 'Unpushed') + return dir + }, + /isn't on .+ as of your last fetch/, + ], + [ + 'outside a clone', + async (sandbox: GitSandbox) => sandbox.createDir('project', { 'index.html': 'v1' }), + /isn't a clone of /, + ], + [ + 'without a remote for the provider repository', + async (sandbox: GitSandbox) => { + const dir = await sandbox.createClone('project', { 'index.html': 'v1' }) + await git(dir, 'remote', 'remove', 'origin') + await writeFiles(dir, { 'index.html': 'v2' }) + return dir + }, + /has no remote for /, + ], + [ + 'with HEAD pushed only to another remote', + async (sandbox: GitSandbox) => { + const dir = await sandbox.createClone('project', { 'index.html': 'v1' }) + const elsewhere = join(sandbox.root, 'elsewhere.git') + await git(sandbox.root, 'init', '--bare', '--quiet', elsewhere) + await git(dir, 'remote', 'set-url', 'origin', elsewhere) + await git(dir, 'push', '--quiet', 'origin', 'main') + return dir + }, + /has no remote for /, + ], + ])('explains how to deploy %s', async (_, createProject, reason) => { + const created = await setup() + const dir = await createProject(created) + + const result = resolve(dir, created.remote) + + await expect(result).rejects.toThrow(RemoteBuildError) + await expect(result).rejects.toThrow(reason) + await expect(result).rejects.toThrow(/commit your changes and push them/) + }) +}) + +describe('resolveRemoteSource git check', () => { + const resolveGitHub = (dir: string) => + resolveRemoteSource({ + siteInfo: getSiteInfo({ provider: 'github', repo_url: 'https://github.com/team/site', repo_branch: 'main' }), + workingDir: dir, + }) + + test('fails with guidance when git is not installed', async () => { + const { createDir } = await setup() + const dir = await createDir('project') + vi.stubEnv('PATH', await createDir('empty-bin')) + + await expect(resolveGitHub(dir)).rejects.toThrow( + "--remote uses git, which isn't installed or isn't on your PATH. Install git, then deploy again.", + ) + }) + + test.skipIf(process.platform === 'win32')('fails with guidance when git is too old', async () => { + const { createDir } = await setup() + const bin = await createDir('bin', { git: '#!/bin/sh\necho "git version 2.12.5"\n' }) + await chmod(join(bin, 'git'), 0o755) + vi.stubEnv('PATH', bin) + + await expect(resolveGitHub(await createDir('project'))).rejects.toThrow( + '--remote needs git 2.13 or later, but you have 2.12.5. Update git, then deploy again.', + ) + }) +}) diff --git a/tests/unit/utils/netlify-git.test.ts b/tests/unit/utils/netlify-git.test.ts index 8262e6a64e8..c01f5f7a2d6 100644 --- a/tests/unit/utils/netlify-git.test.ts +++ b/tests/unit/utils/netlify-git.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest' -import { isNetlifyGitUrl } from '../../../src/utils/netlify-git.js' +import { isNetlifyGitUrl, isSameRepository } from '../../../src/utils/netlify-git.js' describe('isNetlifyGitUrl', () => { test.each([ @@ -14,3 +14,18 @@ describe('isNetlifyGitUrl', () => { expect(isNetlifyGitUrl(url)).toBe(expected) }) }) + +describe('isSameRepository', () => { + test.each([ + ['https://github.com/team/site.git', 'https://github.com/team/site', true], + ['git@github.com:team/site.git', 'https://github.com/Team/Site', true], + ['ssh://git@github.com/team/site.git', 'https://x-access-token:t@github.com/team/site/', true], + ['/srv/repos/site.git', 'file:///srv/repos/site', true], + ['https://github.com/team/site', 'https://github.com/team/other', false], + ['https://github.com/team/site', 'https://gitlab.com/team/site', false], + ['https://github.com/team/site', 'https://github.com/other/team/site', false], + ['not a url', 'not a url', false], + ])('%s and %s → %s', (a, b, expected) => { + expect(isSameRepository(a, b)).toBe(expected) + }) +}) diff --git a/tests/utils/git.ts b/tests/utils/git.ts new file mode 100644 index 00000000000..bc81676fd87 --- /dev/null +++ b/tests/utils/git.ts @@ -0,0 +1,97 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { promisify } from 'node:util' + +import { vi } from 'vitest' + +const execFileAsync = promisify(execFile) + +export const git = async (cwd: string, ...args: string[]): Promise => { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout.trim() +} + +export const writeFiles = async (dir: string, files: Record): Promise => { + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(dir, path)), { recursive: true }) + await writeFile(join(dir, path), content) + } +} + +/** + * Isolates git, in this process and any child process such as the CLI, from the developer's own + * configuration, so commits are reproducible and unsigned. Undo with `vi.unstubAllEnvs()`. + */ +export const isolateGitConfig = async (dir: string): Promise => { + const configPath = join(dir, 'gitconfig') + await writeFile( + configPath, + [ + '[init]', + '\tdefaultBranch = main', + '[commit]', + '\tgpgsign = false', + '[user]', + '\tname = Test User', + '\temail = test@example.com', + '', + ].join('\n'), + ) + vi.stubEnv('GIT_CONFIG_GLOBAL', configPath) + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') + // Fixed dates make commit SHAs deterministic, e.g. for snapshots. + vi.stubEnv('GIT_AUTHOR_DATE', '2026-01-01T00:00:00Z') + vi.stubEnv('GIT_COMMITTER_DATE', '2026-01-01T00:00:00Z') + for (const name of ['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'EMAIL']) { + vi.stubEnv(name, undefined) + } +} + +export const initPushedRepository = async (dir: string, remote: string): Promise => { + await git(dir, 'init', '--quiet') + await git(dir, 'add', '--all') + await git(dir, 'commit', '--quiet', '--message', 'Initial commit') + await git(dir, 'remote', 'add', 'origin', remote) + await git(dir, 'push', '--quiet', '--set-upstream', 'origin', 'main') +} + +export interface GitSandbox { + root: string + /** A bare repository standing in for the git provider. */ + remote: string + createDir: (name: string, files?: Record) => Promise + createClone: (name: string, files: Record) => Promise + cleanup: () => Promise +} + +export const createGitSandbox = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'netlify-remote-deploy-')) + await isolateGitConfig(root) + + const remote = join(root, 'remote.git') + await git(root, 'init', '--bare', '--quiet', remote) + + const createDir = async (name: string, files: Record = {}) => { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + await writeFiles(dir, files) + return dir + } + + return { + root, + remote, + createDir, + createClone: async (name, files) => { + const dir = await createDir(name, files) + await initPushedRepository(dir, remote) + return dir + }, + cleanup: async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + }, + } +} From 287ef302d91bb8e7b75280a1fd014ec535ded645 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 22 Oct 2026 14:47:49 -0400 Subject: [PATCH 2/8] test(deploy): verify behaviour when deploys locked --- tests/integration/commands/deploy/deploy-remote.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/integration/commands/deploy/deploy-remote.test.ts b/tests/integration/commands/deploy/deploy-remote.test.ts index 07ca84458dd..e5c976a7fbd 100644 --- a/tests/integration/commands/deploy/deploy-remote.test.ts +++ b/tests/integration/commands/deploy/deploy-remote.test.ts @@ -228,7 +228,7 @@ describe('deploy --remote', () => { }) }) - test('checks the source before asking to unlock locked production deploys', async (t) => { + test('checks the source, then the production lock, before starting a production build', async (t) => { await withSiteBuilder(t, async (builder) => { builder.withContentFile({ path: 'index.html', content: '

Committed

' }) await builder.build() @@ -237,6 +237,11 @@ describe('deploy --remote', () => { await writeFile(join(builder.directory, 'index.html'), '

Uncommitted

') await expect(deployRemoteJson(builder, harness, ['--prod'])).rejects.toThrow(/You have uncommitted changes/) + + await git(builder.directory, 'checkout', '--', 'index.html') + + await expect(deployRemoteJson(builder, harness, ['--prod'])).rejects.toThrow(/Deployments are "locked"/) + expect(harness.buildRequests).toEqual([]) }) }) From d483f95524a0de43d42833431617dc119ca77daa Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 22 Oct 2026 14:49:10 -0400 Subject: [PATCH 3/8] fix(deploy): print more specific, actionable errors --- src/commands/deploy/remote/source.ts | 29 ++++++++++++------- .../commands/deploy/deploy-remote.test.ts | 4 +-- .../commands/deploy/remote/source.test.ts | 11 ++++--- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/commands/deploy/remote/source.ts b/src/commands/deploy/remote/source.ts index 78a554129dc..154ef275fd7 100644 --- a/src/commands/deploy/remote/source.ts +++ b/src/commands/deploy/remote/source.ts @@ -51,26 +51,38 @@ type CloneState = | { pushed: true; head: string; branch?: string; untracked: string[] } | { pushed: false; reason: string } -const inspectClone = async (ctx: GitContext, repoUrl: string): Promise => { +const inspectClone = async (ctx: GitContext, repoUrl: string, productionBranch: string): Promise => { const head = await getHead(ctx) if (!head) { - return { pushed: false, reason: `This directory isn't a clone of ${repoUrl}.` } + return { + pushed: false, + reason: `This directory isn't a clone of ${repoUrl}, which Netlify builds this project from. Run this from a clone of it.`, + } } // Another remote having the commit doesn't mean the provider can build it. const providerRemotes = (await getRemotes(ctx)) .filter(({ url }) => isSameRepository(url, repoUrl)) .map(({ name }) => name) if (providerRemotes.length === 0) { - return { pushed: false, reason: `This repository has no remote for ${repoUrl}.` } + return { + pushed: false, + reason: `This repository has no remote for ${repoUrl}, which Netlify builds this project from. Run this from a clone of it, or add a remote for it.`, + } } const { hasChanges, untracked } = await getWorkTreeStatus(ctx) if (hasChanges) { - return { pushed: false, reason: 'You have uncommitted changes.' } + return { + pushed: false, + reason: `You have uncommitted changes. Netlify builds this project from ${repoUrl}, so commit them and push them to ${productionBranch} or another branch, then deploy again.`, + } } const remoteOf = (ref: string) => providerRemotes.find((remote) => ref.startsWith(`${remote}/`)) const pushedTo = (await getRemoteBranchesContaining(ctx, head)).filter((ref) => remoteOf(ref) !== undefined) if (pushedTo.length === 0) { - return { pushed: false, reason: `Your checked-out commit isn't on ${repoUrl} as of your last fetch.` } + return { + pushed: false, + reason: `Your checked-out commit isn't on ${repoUrl} as of your last fetch. Push it to ${productionBranch} or another branch (or fetch, if you already pushed it), then deploy again.`, + } } // Only a branch the user evidently pushed this commit to, never a guess among other branches that // happen to contain it. @@ -83,9 +95,6 @@ const inspectClone = async (ctx: GitContext, repoUrl: string): Promise - `${reason} Netlify builds this project from ${repoUrl}, so commit your changes and push them to ${productionBranch} or another branch, then deploy again from your clone.` - export const resolveRemoteSource = async ({ siteInfo, workingDir, @@ -113,7 +122,7 @@ export const resolveRemoteSource = async ({ throw new RemoteBuildError(gitProblem) } const repoUrl = settings.repo_url - const clone = await inspectClone(ctx, repoUrl) + const clone = await inspectClone(ctx, repoUrl, productionBranch) if (clone.pushed) { // Any pushed commit can go to production: the build fetches it by SHA, not from the branch. const branch = production ? productionBranch : clone.branch @@ -124,7 +133,7 @@ export const resolveRemoteSource = async ({ } return { kind: 'external', repoUrl, head: clone.head, branch, untracked: clone.untracked } } - throw new RemoteBuildError(describeManualPush(clone.reason, repoUrl, productionBranch)) + throw new RemoteBuildError(clone.reason) } throw new RemoteBuildError( diff --git a/tests/integration/commands/deploy/deploy-remote.test.ts b/tests/integration/commands/deploy/deploy-remote.test.ts index e5c976a7fbd..576dcce2126 100644 --- a/tests/integration/commands/deploy/deploy-remote.test.ts +++ b/tests/integration/commands/deploy/deploy-remote.test.ts @@ -222,7 +222,7 @@ describe('deploy --remote', () => { await writeFile(join(builder.directory, 'index.html'), '

Uncommitted

') await expect(deployRemoteJson(builder, harness)).rejects.toThrow( - /You have uncommitted changes\. .*commit your changes and push them/, + /You have uncommitted changes\. .*commit them and push them/, ) expect(harness.buildRequests).toEqual([]) }) @@ -270,7 +270,7 @@ describe('deploy --remote', () => { await git(builder.directory, 'commit', '--quiet', '--allow-empty', '--message', 'Unpushed') await expect(deployRemoteJson(builder, harness, ['--prod'])).rejects.toThrow( - /Your checked-out commit isn't on .+ as of your last fetch\. .*commit your changes and push them/, + /Your checked-out commit isn't on .+ as of your last fetch\. Push it to main/, ) expect(harness.buildRequests).toEqual([]) }) diff --git a/tests/unit/commands/deploy/remote/source.test.ts b/tests/unit/commands/deploy/remote/source.test.ts index 05f86cc970a..3c48cb065cc 100644 --- a/tests/unit/commands/deploy/remote/source.test.ts +++ b/tests/unit/commands/deploy/remote/source.test.ts @@ -201,7 +201,7 @@ describe('resolveRemoteSource for a project on a third-party git host', () => { await writeFiles(dir, { 'index.html': 'v2' }) return dir }, - /You have uncommitted changes/, + /You have uncommitted changes\. .*commit them and push them to main or another branch/, ], [ 'an unpushed commit', @@ -211,12 +211,12 @@ describe('resolveRemoteSource for a project on a third-party git host', () => { await git(dir, 'commit', '--quiet', '--all', '--message', 'Unpushed') return dir }, - /isn't on .+ as of your last fetch/, + /isn't on .+ as of your last fetch\. Push it to main or another branch/, ], [ 'outside a clone', async (sandbox: GitSandbox) => sandbox.createDir('project', { 'index.html': 'v1' }), - /isn't a clone of /, + /isn't a clone of .+\. Run this from a clone of it\./, ], [ 'without a remote for the provider repository', @@ -226,7 +226,7 @@ describe('resolveRemoteSource for a project on a third-party git host', () => { await writeFiles(dir, { 'index.html': 'v2' }) return dir }, - /has no remote for /, + /has no remote for .+\. Run this from a clone of it, or add a remote for it\./, ], [ 'with HEAD pushed only to another remote', @@ -238,7 +238,7 @@ describe('resolveRemoteSource for a project on a third-party git host', () => { await git(dir, 'push', '--quiet', 'origin', 'main') return dir }, - /has no remote for /, + /has no remote for .+\. Run this from a clone of it, or add a remote for it\./, ], ])('explains how to deploy %s', async (_, createProject, reason) => { const created = await setup() @@ -248,7 +248,6 @@ describe('resolveRemoteSource for a project on a third-party git host', () => { await expect(result).rejects.toThrow(RemoteBuildError) await expect(result).rejects.toThrow(reason) - await expect(result).rejects.toThrow(/commit your changes and push them/) }) }) From 51648e670f276024e33429bdea904f516191278c Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 22 Oct 2026 15:20:55 -0400 Subject: [PATCH 4/8] docs(deploy): improve some comments --- src/commands/deploy/remote-deploy.ts | 3 +-- src/commands/deploy/remote/source.ts | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/commands/deploy/remote-deploy.ts b/src/commands/deploy/remote-deploy.ts index 544f49a6354..ffbe81b39ca 100644 --- a/src/commands/deploy/remote-deploy.ts +++ b/src/commands/deploy/remote-deploy.ts @@ -66,7 +66,6 @@ export const remoteDeploy = async ({ options: DeployOptionValues siteData: SiteInfo deployToProduction: boolean - /** Called only once the source checks pass, so production isn't unlocked for a deploy that then fails. */ prepareProductionDeploy: () => Promise }): Promise => { const quiet = Boolean(options.json || options.silent) @@ -119,6 +118,7 @@ export const remoteDeploy = async ({ } } + // After the checks above, so we never unlock production and then bail. if (deployToProduction) { await prepareProductionDeploy() } @@ -142,7 +142,6 @@ export const remoteDeploy = async ({ debugLog?.(`Build ${build.id}, deploy ${build.deploy_id}`) const { logsUrl } = getDeployUrls({ id: build.deploy_id, admin_url: siteData.admin_url }, { deployToProduction }) - // Not on the spinner, which writes to stderr, so non-interactive output says what's being built. log(`${NETLIFYDEVLOG} Building ${shortSha} on Netlify (${terminalLink('↗ View deploy logs', logsUrl)})`) log('') diff --git a/src/commands/deploy/remote/source.ts b/src/commands/deploy/remote/source.ts index 154ef275fd7..43a7709439c 100644 --- a/src/commands/deploy/remote/source.ts +++ b/src/commands/deploy/remote/source.ts @@ -12,13 +12,11 @@ import { type GitContext, } from './git.js' -// `external`: the git host already has the commit, so nothing is pushed. export type RemoteSource = { kind: 'external' repoUrl: string head: string branch: string - /** Not part of the commit, so not part of the deploy either. */ untracked: string[] } From eca8818d85bf579a663db854a2f1d780eb366b44 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 22 Oct 2026 15:21:24 -0400 Subject: [PATCH 5/8] fix(deploy): improve timeout vs. non-timeout error --- src/commands/deploy/remote-deploy.ts | 8 ++++-- .../commands/deploy/deploy-remote.test.ts | 25 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/commands/deploy/remote-deploy.ts b/src/commands/deploy/remote-deploy.ts index ffbe81b39ca..04f2dc5e088 100644 --- a/src/commands/deploy/remote-deploy.ts +++ b/src/commands/deploy/remote-deploy.ts @@ -167,9 +167,13 @@ export const remoteDeploy = async ({ if (error instanceof DeployStateError) { return logAndThrowError(`Deploy failed: ${error.message}\n\nDeploy logs: ${logsUrl}`) } - const timeoutHint = error instanceof TimeoutError ? ' To wait longer, pass a larger --timeout (in seconds).' : '' + if (error instanceof TimeoutError) { + return logAndThrowError( + `Timed out after ${String(timeoutMs / 1000)} seconds waiting for the deploy to finish. It may still be running on Netlify; to wait longer, pass a larger --timeout.\n\nDeploy logs: ${logsUrl}`, + ) + } return logAndThrowError( - `${(error as Error).message}\n\nThe build may still be running on Netlify.${timeoutHint} Deploy logs: ${logsUrl}`, + `${(error as Error).message}\n\nThe build may still be running on Netlify. Deploy logs: ${logsUrl}`, ) } } diff --git a/tests/integration/commands/deploy/deploy-remote.test.ts b/tests/integration/commands/deploy/deploy-remote.test.ts index 576dcce2126..2564bbb4e70 100644 --- a/tests/integration/commands/deploy/deploy-remote.test.ts +++ b/tests/integration/commands/deploy/deploy-remote.test.ts @@ -69,17 +69,23 @@ const startFakeDeployLog = async (lines: string[], onSent: () => void) => { /** * With `buildLog`, the deploy stays building until those lines have been streamed. With `failWith`, the - * deploy fails with that error. With `rejectBuildWith`, the API refuses to start the build. + * deploy fails with that error. With `rejectBuildWith`, the API refuses to start the build. With `stuck`, + * the deploy never finishes. */ const setUp = async ( builder: SiteBuilder, site: Record = siteInfo, - { buildLog, failWith, rejectBuildWith }: { buildLog?: string[]; failWith?: string; rejectBuildWith?: string } = {}, + { + buildLog, + failWith, + rejectBuildWith, + stuck, + }: { buildLog?: string[]; failWith?: string; rejectBuildWith?: string; stuck?: boolean } = {}, ): Promise => { await isolateGitConfig(join(builder.directory, '..')) // Keeps the CLI off the real log stream; `startFakeDeployLog` points it at a local one instead. vi.stubEnv('NETLIFY_SOCKETEER_URL', 'ws://127.0.0.1:9') - let deployState = failWith ? 'error' : buildLog ? 'building' : 'ready' + let deployState = failWith ? 'error' : buildLog || stuck ? 'building' : 'ready' const logServer = buildLog && (await startFakeDeployLog(buildLog, () => { @@ -289,6 +295,19 @@ describe('deploy --remote', () => { }) }) + test('says how to wait longer when the deploy times out', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'index.html', content: '

Hi

' }) + await builder.build() + const project = await setUpForgeProject(builder, { stuck: true }) + harness = project.harness + + await expect(deployRemoteJson(builder, harness, ['--timeout', '2'])).rejects.toThrow( + /Timed out after 2 seconds waiting for the deploy to finish\. .*pass a larger --timeout/, + ) + }) + }) + test("fails with the API's reason when it refuses to start the build", async (t) => { await withSiteBuilder(t, async (builder) => { builder.withContentFile({ path: 'index.html', content: '

Hi

' }) From ce5221705434d924a30bd099e04573fefa579aad Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 22 Oct 2026 15:28:48 -0400 Subject: [PATCH 6/8] refactor(deploy): use semver pkg, avoid undefined, promisify --- src/commands/deploy/remote/git.ts | 63 +++++++++++-------- src/commands/deploy/remote/source.ts | 14 ++--- tests/unit/commands/deploy/remote/git.test.ts | 2 +- 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/commands/deploy/remote/git.ts b/src/commands/deploy/remote/git.ts index a304af97f73..35d0d8a1ee8 100644 --- a/src/commands/deploy/remote/git.ts +++ b/src/commands/deploy/remote/git.ts @@ -1,4 +1,9 @@ -import { execFile } from 'node:child_process' +import { execFile, type ExecFileException } from 'node:child_process' +import { promisify } from 'node:util' + +import semver from 'semver' + +const execFileAsync = promisify(execFile) export interface GitCommand { args: string[] @@ -14,33 +19,38 @@ export interface GitContext { onCommand?: GitCommandObserver } -// Resolves on any exit code, which callers inspect; rejects only if git never ran (e.g. ENOENT). -const run = (ctx: GitContext, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> => - new Promise((resolve, reject) => { +// `code` is the exit code once git ran, or a string like ENOENT when it couldn't start. +const isFailedCommand = (error: unknown): error is ExecFileException & { code: number } => + error instanceof Error && 'code' in error && typeof error.code === 'number' + +const run = async (ctx: GitContext, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + let result: { exitCode: number; stdout: string; stderr: string } + try { // GIT_OPTIONAL_LOCKS keeps read-only commands like `git status` from refreshing the user's index. const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' } - execFile('git', args, { cwd: ctx.cwd, env }, (error, stdout, stderr) => { - if (error && typeof error.code !== 'number') { - reject(new Error(`Couldn't run git: ${error.message}`, { cause: error })) - return - } - const result = { exitCode: typeof error?.code === 'number' ? error.code : 0, stdout, stderr: stderr.trimEnd() } - ctx.onCommand?.({ args, cwd: ctx.cwd, exitCode: result.exitCode, stderr: result.stderr }) - resolve(result) - }) - }) + const { stdout, stderr } = await execFileAsync('git', args, { cwd: ctx.cwd, env }) + result = { exitCode: 0, stdout, stderr } + } catch (error) { + if (!isFailedCommand(error)) { + throw new Error(`Couldn't run git: ${String(error)}`, { cause: error }) + } + result = { exitCode: error.code, stdout: error.stdout ?? '', stderr: error.stderr ?? '' } + } + ctx.onCommand?.({ args, cwd: ctx.cwd, exitCode: result.exitCode, stderr: result.stderr.trimEnd() }) + return result +} const git = async (ctx: GitContext, args: string[]): Promise => { const { exitCode, stdout, stderr } = await run(ctx, args) if (exitCode !== 0) { - throw new Error(`git ${args[0]} failed: ${stderr || stdout}`) + throw new Error(`git ${args[0]} failed: ${stderr.trimEnd() || stdout}`) } return stdout.trim() } -const tryGit = async (ctx: GitContext, args: string[]): Promise => { +const tryGit = async (ctx: GitContext, args: string[]): Promise => { const { exitCode, stdout } = await run(ctx, args) - return exitCode === 0 ? stdout.trim() : undefined + return exitCode === 0 ? stdout.trim() : '' } interface Remote { @@ -49,26 +59,25 @@ interface Remote { } export const getRemotes = async (ctx: GitContext): Promise => { - const names = ((await tryGit(ctx, ['remote'])) ?? '').split('\n').filter(Boolean) + const names = (await tryGit(ctx, ['remote'])).split('\n').filter(Boolean) return Promise.all(names.map(async (name) => ({ name, url: await git(ctx, ['remote', 'get-url', name]) }))) } -export const getHead = (ctx: GitContext): Promise => tryGit(ctx, ['rev-parse', '--verify', 'HEAD']) +export const getHead = (ctx: GitContext): Promise => tryGit(ctx, ['rev-parse', '--verify', 'HEAD']) -export const getGitVersion = async (ctx: GitContext): Promise => { - const output = await tryGit(ctx, ['version']).catch(() => undefined) - return output ? /\d+\.\d+(\.\d+)?/.exec(output)?.[0] : undefined +export const getGitVersion = async (ctx: GitContext): Promise => { + const output = await tryGit(ctx, ['version']).catch(() => '') + return semver.coerce(output)?.version ?? '' } // Full ref names, since short ones become ambiguous when a local branch is named like a remote one // (e.g. `origin/main`). -const stripRefPrefix = (ref: string | undefined, prefix: string) => - ref?.startsWith(prefix) ? ref.slice(prefix.length) : undefined +const stripRefPrefix = (ref: string, prefix: string) => (ref.startsWith(prefix) ? ref.slice(prefix.length) : '') -export const getCurrentBranch = async (ctx: GitContext): Promise => +export const getCurrentBranch = async (ctx: GitContext): Promise => stripRefPrefix(await tryGit(ctx, ['symbolic-ref', '--quiet', 'HEAD']), 'refs/heads/') -export const getUpstream = async (ctx: GitContext): Promise => +export const getUpstream = async (ctx: GitContext): Promise => stripRefPrefix(await tryGit(ctx, ['rev-parse', '--symbolic-full-name', '@{upstream}']), 'refs/remotes/') export const getWorkTreeStatus = async (ctx: GitContext): Promise<{ hasChanges: boolean; untracked: string[] }> => { @@ -81,7 +90,7 @@ export const getWorkTreeStatus = async (ctx: GitContext): Promise<{ hasChanges: const listRemoteBranches = async (ctx: GitContext, filter: string[]): Promise => { const output = await tryGit(ctx, ['branch', '--remotes', ...filter, '--format=%(refname:lstrip=2)']) - return (output ?? '').split('\n').filter((name) => name !== '' && !name.endsWith('/HEAD')) + return output.split('\n').filter((name) => name !== '' && !name.endsWith('/HEAD')) } export const getRemoteBranchesContaining = (ctx: GitContext, sha: string): Promise => diff --git a/src/commands/deploy/remote/source.ts b/src/commands/deploy/remote/source.ts index 43a7709439c..478559f9474 100644 --- a/src/commands/deploy/remote/source.ts +++ b/src/commands/deploy/remote/source.ts @@ -1,3 +1,5 @@ +import semver from 'semver' + import { isNetlifyGitUrl, isSameRepository } from '../../../utils/netlify-git.js' import type { SiteInfo } from '../../../utils/types.js' import { @@ -27,19 +29,15 @@ export interface SourceCommit { /** A problem the user can fix, as opposed to an unexpected failure. */ export class RemoteBuildError extends Error { - constructor(message: string) { - super(message) - this.name = 'RemoteBuildError' - } + readonly name = 'RemoteBuildError' } -const describeGitProblem = (version: string | undefined): string | undefined => { +const describeGitProblem = (version: string): string | undefined => { if (!version) { return "--remote uses git, which isn't installed or isn't on your PATH. Install git, then deploy again." } - const [major, minor] = version.split('.').map(Number) // 2.13 added `git branch --format`. - if (major < 2 || (major === 2 && minor < 13)) { + if (semver.lt(version, '2.13.0')) { return `--remote needs git 2.13 or later, but you have ${version}. Update git, then deploy again.` } return undefined @@ -89,7 +87,7 @@ const inspectClone = async (ctx: GitContext, repoUrl: string, productionBranch: await getUpstream(ctx), ...(currentBranch ? providerRemotes.map((remote) => `${remote}/${currentBranch}`) : []), ] - const ref = candidates.find((candidate) => candidate !== undefined && pushedTo.includes(candidate)) + const ref = candidates.find((candidate) => pushedTo.includes(candidate)) return { pushed: true, head, branch: ref?.slice((remoteOf(ref) ?? '').length + 1), untracked } } diff --git a/tests/unit/commands/deploy/remote/git.test.ts b/tests/unit/commands/deploy/remote/git.test.ts index d155fd5d469..e6c75a6ce8b 100644 --- a/tests/unit/commands/deploy/remote/git.test.ts +++ b/tests/unit/commands/deploy/remote/git.test.ts @@ -24,7 +24,7 @@ describe('inspecting a clone', () => { const commands: GitCommand[] = [] const ctx = { cwd: project, onCommand: (command: GitCommand) => commands.push(command) } - expect(await getHead(ctx)).toBeUndefined() + expect(await getHead(ctx)).toBe('') await expect(getWorkTreeStatus(ctx)).rejects.toThrow() expect(commands.map(({ args }) => args[0])).toEqual(['rev-parse', 'status']) From d46fc024bea82237dc8e115277ff44326d56750e Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 22 Oct 2026 15:30:56 -0400 Subject: [PATCH 7/8] fix(deploy): improve remote error messages --- src/commands/deploy/remote/source.ts | 4 ++-- tests/integration/commands/deploy/deploy-remote.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/deploy/remote/source.ts b/src/commands/deploy/remote/source.ts index 478559f9474..4b6c40f10da 100644 --- a/src/commands/deploy/remote/source.ts +++ b/src/commands/deploy/remote/source.ts @@ -105,7 +105,7 @@ export const resolveRemoteSource = async ({ const settings = siteInfo.build_settings if (!settings?.repo_url) { throw new RemoteBuildError( - 'This project has no repository for Netlify to build from, so it can only be deployed with a local build.', + "This project isn't connected to a git repository, so Netlify can't build it. Connect one in the project's build settings, or run netlify deploy without --remote to build and deploy locally.", ) } const productionBranch = settings.repo_branch @@ -133,7 +133,7 @@ export const resolveRemoteSource = async ({ } throw new RemoteBuildError( - "Remote builds aren't supported yet for projects on Netlify Git. Deploy without --remote to build locally.", + "Remote builds aren't supported yet for projects on Netlify Git. Run netlify deploy without --remote to build and deploy locally.", ) } diff --git a/tests/integration/commands/deploy/deploy-remote.test.ts b/tests/integration/commands/deploy/deploy-remote.test.ts index 2564bbb4e70..b9aad225e27 100644 --- a/tests/integration/commands/deploy/deploy-remote.test.ts +++ b/tests/integration/commands/deploy/deploy-remote.test.ts @@ -360,7 +360,7 @@ describe('deploy --remote', () => { await builder.build() harness = await setUp(builder, { ...siteInfo, build_settings: { repo_url: '' } }) - await expect(deployRemoteJson(builder, harness)).rejects.toThrow(/can only be deployed with a local build/) + await expect(deployRemoteJson(builder, harness)).rejects.toThrow(/isn't connected to a git repository/) expect(harness.buildRequests).toEqual([]) }) }) From aad897b9893c73c0ac26e99ea80d595dbfea3d43 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 22 Oct 2026 16:03:50 -0400 Subject: [PATCH 8/8] fix(deploy): improve clarity of --remote docs --- docs/commands/deploy.md | 8 ++++---- src/commands/deploy/index.ts | 8 ++++---- src/commands/deploy/remote/source.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 830ebe2a35b..573bdadc8f0 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -9,11 +9,11 @@ sidebar: Deploy your project to Netlify -Builds and deploys your project to Netlify. Creates a draft deploy by default. +Builds your project locally, or on Netlify with --remote, and deploys it to Netlify. Creates a draft deploy by default. Use --prod to deploy directly to your live site. The deploy command will: -- Build your project (unless --no-build is specified) +- Build your project locally (unless --no-build or --remote is specified) - Upload static files, functions, and edge functions - Process redirects and headers from netlify.toml or _redirects/_headers files - Provide deploy and function logs URLs @@ -44,7 +44,7 @@ netlify deploy - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `prod` (*boolean*) - Deploy to production -- `remote` (*boolean*) - Build and deploy on Netlify from your project source, instead of building locally. Netlify builds your checked-out commit, which must already be pushed to the git provider your project is connected to. +- `remote` (*boolean*) - Build your project on Netlify instead of locally. Useful when building with native packages, on devices with limited resources or missing toolchains. Build logs stream in real time and are available on Netlify. - `secret-env` (*string*) - Set a secret environment variable for this deploy only. Applies to deployed functions only. The value is masked in the Netlify UI and API. Can be specified multiple times. - `site` (*string*) - A project name or ID to deploy to - `site-name` (*string*) - Name for a new site. Implies --create-site if the site does not already exist. @@ -65,7 +65,7 @@ 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 --remote # Build a draft on Netlify from your checked-out commit +netlify deploy --remote # Build on Netlify and deploy a draft netlify deploy --remote --prod netlify deploy --context deploy-preview netlify deploy --env "NODE_ENV=production" --env "API_URL=https://api.example.com" diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 9a324087751..f55fdb05da1 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -26,11 +26,11 @@ export const createDeployCommand = (program: BaseCommand) => .description( `Deploy your project to Netlify -Builds and deploys your project to Netlify. Creates a draft deploy by default. +Builds your project locally, or on Netlify with --remote, and deploys it to Netlify. Creates a draft deploy by default. Use --prod to deploy directly to your live site. The deploy command will: -- Build your project (unless --no-build is specified) +- Build your project locally (unless --no-build or --remote is specified) - Upload static files, functions, and edge functions - Process redirects and headers from netlify.toml or _redirects/_headers files - Provide deploy and function logs URLs @@ -73,7 +73,7 @@ For detailed configuration options, see the Netlify documentation.`, .addOption( new Option( '--remote', - 'Build and deploy on Netlify from your project source, instead of building locally. Netlify builds your checked-out commit, which must already be pushed to the git provider your project is connected to.', + 'Build your project on Netlify instead of locally. Useful when building with native packages, on devices with limited resources or missing toolchains. Build logs stream in real time and are available on Netlify.', ).conflicts([...BUILD_RELATED_OPTIONS, 'alias', 'allowAnonymous', 'branch', 'createSite', 'siteName', 'trigger']), ) .addOption( @@ -137,7 +137,7 @@ For detailed configuration options, see the Netlify documentation.`, 'netlify deploy --message "A message with an $ENV_VAR"', 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', - 'netlify deploy --remote # Build a draft on Netlify from your checked-out commit', + 'netlify deploy --remote # Build on Netlify and deploy a draft', 'netlify deploy --remote --prod', 'netlify deploy --context deploy-preview', 'netlify deploy --env "NODE_ENV=production" --env "API_URL=https://api.example.com"', diff --git a/src/commands/deploy/remote/source.ts b/src/commands/deploy/remote/source.ts index 4b6c40f10da..cd2621aef11 100644 --- a/src/commands/deploy/remote/source.ts +++ b/src/commands/deploy/remote/source.ts @@ -105,7 +105,7 @@ export const resolveRemoteSource = async ({ const settings = siteInfo.build_settings if (!settings?.repo_url) { throw new RemoteBuildError( - "This project isn't connected to a git repository, so Netlify can't build it. Connect one in the project's build settings, or run netlify deploy without --remote to build and deploy locally.", + "This project isn't connected to a git repository, so Netlify can't build it. Connect one in the project's build settings, or run netlify deploy without --remote to build locally instead.", ) } const productionBranch = settings.repo_branch @@ -133,7 +133,7 @@ export const resolveRemoteSource = async ({ } throw new RemoteBuildError( - "Remote builds aren't supported yet for projects on Netlify Git. Run netlify deploy without --remote to build and deploy locally.", + "Remote builds aren't supported yet for projects on Netlify Git. Run netlify deploy without --remote to build locally instead.", ) }