From 734fcd9203b4b58059fbea7faeeb425e4823ca05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Tue, 24 Mar 2026 16:05:36 +0100 Subject: [PATCH 1/3] Deploys with alias create a branch with alias ID --- src/commands/deploy/deploy.ts | 5 +++++ src/lib/build.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 2503829766e..ee548d24feb 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -702,7 +702,12 @@ const handleBuild = async ({ return {} } const [token] = await getToken() + const alias = options.alias || options.branch + if (alias && !options.context) { + options.context = 'branch-deploy' + } const resolvedOptions = await getRunBuildOptions({ + branch: alias, cachedConfig, currentDir, defaultConfig, diff --git a/src/lib/build.ts b/src/lib/build.ts index dafc5d07f2f..2305e174a15 100644 --- a/src/lib/build.ts +++ b/src/lib/build.ts @@ -133,6 +133,7 @@ type EventHandler void | Promise> = { // This is stored as `netlify.cachedConfig` and can be passed to // `@netlify/build --cachedConfig`. export const getRunBuildOptions = async ({ + branch, cachedConfig, currentDir, defaultConfig, @@ -143,6 +144,7 @@ export const getRunBuildOptions = async ({ skewProtectionToken, token, }: { + branch?: string cachedConfig: CachedConfig currentDir: string defaultConfig?: undefined | DefaultConfig @@ -179,6 +181,7 @@ export const getRunBuildOptions = async ({ return { cachedConfig, defaultConfig: defaultConfig ?? {}, + branch, deployId, siteId: cachedConfig.siteInfo.id, accountId: cachedConfig.siteInfo.account_id, From 24d52078374263c3561be564b76bdecf9ecd4989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Tue, 24 Mar 2026 18:37:29 +0100 Subject: [PATCH 2/3] Use random id for cli deploys without alias --- src/commands/deploy/deploy.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index ee548d24feb..3cb33fa2825 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -634,7 +634,7 @@ const runDeploy = async ({ config, fnDir: functionDirectories, functionsConfig, - + branch: alias, statusCb: silent ? () => {} : deployProgressCb(), deployTimeout, syncFileLimit: SYNC_FILE_LIMIT, @@ -680,6 +680,7 @@ const runDeploy = async ({ } const handleBuild = async ({ + alias, cachedConfig, currentDir, defaultConfig, @@ -689,6 +690,7 @@ const handleBuild = async ({ packagePath, skewProtectionToken, }: { + alias?: string cachedConfig: CachedConfig currentDir: string defaultConfig?: DefaultConfig | undefined @@ -702,7 +704,6 @@ const handleBuild = async ({ return {} } const [token] = await getToken() - const alias = options.alias || options.branch if (alias && !options.context) { options.context = 'branch-deploy' } @@ -868,6 +869,7 @@ const printResults = ({ } const prepAndRunDeploy = async ({ + alias, api, command, config, @@ -879,6 +881,7 @@ const prepAndRunDeploy = async ({ workingDir, deployId, }: { + alias?: string options: DeployOptionValues command: BaseCommand workingDir: string @@ -886,7 +889,6 @@ const prepAndRunDeploy = async ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FIXME(serhalp) [key: string]: any }) => { - const alias = options.alias || options.branch // if a context is passed besides dev, we need to pull env vars from that specific context if (options.context && options.context !== 'dev') { command.netlify.cachedConfig.env = await getEnvelopeEnv({ @@ -1141,6 +1143,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) const deployToProduction = !options.draft && (options.prod || (options.prodIfUnlocked && !(siteData.published_deploy?.locked ?? false))) + const deployAlias = !deployToProduction && !alias ? `cli-${randomBytes(4).toString('hex')}` : alias let results = {} as Awaited> @@ -1150,7 +1153,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) } const draft = options.draft || (!deployToProduction && !alias) - const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip } + const createDeployBody = { draft, branch: deployAlias, include_upload_url: options.uploadSourceZip } // TODO: Type this properly in `@netlify/api`. const deployMetadata = (await api.createSiteDeploy({ @@ -1182,6 +1185,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) try { const settings = await detectFrameworkSettings(command, 'build') await handleBuild({ + alias: deployAlias, packagePath: command.workspacePackage, cachedConfig: command.netlify.cachedConfig, defaultConfig: getDefaultConfig(settings), @@ -1189,6 +1193,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) options, deployHandler: async ({ netlifyConfig }: { netlifyConfig: NetlifyConfig }) => { results = await prepAndRunDeploy({ + alias: deployAlias, command, options, workingDir, @@ -1219,6 +1224,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) } } else { results = await prepAndRunDeploy({ + alias: deployAlias, command, options, workingDir, From af674a22dc6578e7a53a2b3cc6aa334ded9341f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Tue, 24 Mar 2026 18:54:53 +0100 Subject: [PATCH 3/3] Use local git branch or random ID --- src/commands/deploy/deploy.ts | 18 ++++- .../commands/deploy/deploy.test.ts | 78 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 3cb33fa2825..953e7e23192 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -1,3 +1,4 @@ +import { execSync } from 'child_process' import { randomBytes } from 'crypto' import { type Stats } from 'fs' import { stat } from 'fs/promises' @@ -1125,6 +1126,20 @@ const ensureSiteExists = async ( return promptForSiteAction(options, command, site) } +const getLocalGitBranch = (): string | undefined => { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }).trim() + if (branch && branch !== 'HEAD') { + return branch + } + } catch { + // not in a git repo + } +} + export const deploy = async (options: DeployOptionValues, command: BaseCommand) => { const { workingDir } = command const { api, site, siteInfo } = command.netlify @@ -1143,7 +1158,8 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) const deployToProduction = !options.draft && (options.prod || (options.prodIfUnlocked && !(siteData.published_deploy?.locked ?? false))) - const deployAlias = !deployToProduction && !alias ? `cli-${randomBytes(4).toString('hex')}` : alias + const deployAlias = + !deployToProduction && !alias ? `cli-${getLocalGitBranch() ?? randomBytes(4).toString('hex')}` : alias let results = {} as Awaited> diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index fc4b02a687a..8f0ef745610 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -40,6 +40,84 @@ const withMockDeploy = async (fn: (mockApi: MockApi, deployState: DeployRouteSta } } +describe.concurrent('deploy branch handling', () => { + test('should set branch to cli-prefixed local git branch for non-prod deploys without alias', async (t) => { + await withMockDeploy(async (mockApi, deployState) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ + path: 'public/index.html', + content: '

test

', + }) + + await builder.build() + + await callCli( + ['deploy', '--json', '--no-build', '--dir', 'public'], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder }), + ).then(parseDeploy) + + const createRequest = mockApi.requests.find((r) => r.method === 'POST' && r.path.includes('/deploys')) + const createBody = createRequest?.body as { branch?: string } + expect(createBody.branch).toBeDefined() + expect(createBody.branch).toMatch(/^cli-/) + + const updateBody = deployState.getDeployBody() + expect(updateBody).not.toBeNull() + expect(updateBody!.branch).toBeDefined() + expect(updateBody!.branch).toMatch(/^cli-/) + }) + }) + }) + + test('should use explicit alias as branch without cli prefix', async (t) => { + await withMockDeploy(async (mockApi, deployState) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ + path: 'public/index.html', + content: '

test

', + }) + + await builder.build() + + await callCli( + ['deploy', '--json', '--no-build', '--dir', 'public', '--alias', 'my-custom-alias'], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder }), + ).then(parseDeploy) + + const createRequest = mockApi.requests.find((r) => r.method === 'POST' && r.path.includes('/deploys')) + const createBody = createRequest?.body as { branch?: string } + expect(createBody.branch).toBe('my-custom-alias') + + const updateBody = deployState.getDeployBody() + expect(updateBody).not.toBeNull() + expect(updateBody!.branch).toBe('my-custom-alias') + }) + }) + }) + + test('should not set branch for prod deploys without alias', async (t) => { + await withMockDeploy(async (mockApi) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ + path: 'public/index.html', + content: '

test

', + }) + + await builder.build() + + await callCli( + ['deploy', '--json', '--no-build', '--dir', 'public', '--prod'], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder }), + ).then(parseDeploy) + + const createRequest = mockApi.requests.find((r) => r.method === 'POST' && r.path.includes('/deploys')) + const createBody = createRequest?.body as { branch?: string } + expect(createBody.branch).toBeUndefined() + }) + }) + }) +}) + describe.concurrent('deploy command', () => { test('should deploy project when dir flag is passed', async (t) => { await withMockDeploy(async (mockApi, deployState) => {