diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 504839b0484..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,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 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. @@ -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 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" 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..f55fdb05da1 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -9,17 +9,28 @@ 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') .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 @@ -59,6 +70,12 @@ For detailed configuration options, see the Netlify documentation.`, 'build', ), ) + .addOption( + new Option( + '--remote', + '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( 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 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"', '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..04f2dc5e088 --- /dev/null +++ b/src/commands/deploy/remote-deploy.ts @@ -0,0 +1,179 @@ +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 + 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) + } + } + + // After the checks above, so we never unlock production and then bail. + 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 }) + 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}`) + } + 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. 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..35d0d8a1ee8 --- /dev/null +++ b/src/commands/deploy/remote/git.ts @@ -0,0 +1,97 @@ +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[] + cwd: string + exitCode: number + stderr: string +} + +export type GitCommandObserver = (command: GitCommand) => void + +export interface GitContext { + cwd: string + onCommand?: GitCommandObserver +} + +// `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' } + 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.trimEnd() || stdout}`) + } + return stdout.trim() +} + +const tryGit = async (ctx: GitContext, args: string[]): Promise => { + const { exitCode, stdout } = await run(ctx, args) + return exitCode === 0 ? stdout.trim() : '' +} + +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(() => '') + 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, prefix: string) => (ref.startsWith(prefix) ? ref.slice(prefix.length) : '') + +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..cd2621aef11 --- /dev/null +++ b/src/commands/deploy/remote/source.ts @@ -0,0 +1,141 @@ +import semver from 'semver' + +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' + +export type RemoteSource = { + kind: 'external' + repoUrl: string + head: string + branch: string + 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 { + readonly name = 'RemoteBuildError' +} + +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." + } + // 2.13 added `git branch --format`. + 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 +} + +type CloneState = + | { pushed: true; head: string; branch?: string; untracked: string[] } + | { pushed: false; reason: string } + +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}, 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}, 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. 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. 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. + const currentBranch = await getCurrentBranch(ctx) + const candidates = [ + await getUpstream(ctx), + ...(currentBranch ? providerRemotes.map((remote) => `${remote}/${currentBranch}`) : []), + ] + const ref = candidates.find((candidate) => pushedTo.includes(candidate)) + return { pushed: true, head, branch: ref?.slice((remoteOf(ref) ?? '').length + 1), untracked } +} + +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 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 + + 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, 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 + 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(clone.reason) + } + + throw new RemoteBuildError( + "Remote builds aren't supported yet for projects on Netlify Git. Run netlify deploy without --remote to build locally instead.", + ) +} + +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..b9aad225e27 --- /dev/null +++ b/tests/integration/commands/deploy/deploy-remote.test.ts @@ -0,0 +1,374 @@ +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. With `stuck`, + * the deploy never finishes. + */ +const setUp = async ( + builder: SiteBuilder, + site: Record = siteInfo, + { + 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 || stuck ? '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 them and push them/, + ) + expect(harness.buildRequests).toEqual([]) + }) + }) + + 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() + 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/) + + await git(builder.directory, 'checkout', '--', 'index.html') + + await expect(deployRemoteJson(builder, harness, ['--prod'])).rejects.toThrow(/Deployments are "locked"/) + expect(harness.buildRequests).toEqual([]) + }) + }) + + 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\. Push it to main/, + ) + 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('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

' }) + 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(/isn't connected to a git repository/) + 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..e6c75a6ce8b --- /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)).toBe('') + 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..3c48cb065cc --- /dev/null +++ b/tests/unit/commands/deploy/remote/source.test.ts @@ -0,0 +1,281 @@ +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\. .*commit them and push them to main or another branch/, + ], + [ + '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\. Push it to main or another branch/, + ], + [ + 'outside a clone', + async (sandbox: GitSandbox) => sandbox.createDir('project', { 'index.html': 'v1' }), + /isn't a clone of .+\. Run this from a clone of it\./, + ], + [ + '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 .+\. Run this from a clone of it, or add a remote for it\./, + ], + [ + '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 .+\. 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() + const dir = await createProject(created) + + const result = resolve(dir, created.remote) + + await expect(result).rejects.toThrow(RemoteBuildError) + await expect(result).rejects.toThrow(reason) + }) +}) + +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 }) + }, + } +}