From cd85de9d1b6c88298263cd586c05bf1975ebee4b Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Sep 2025 16:11:40 +0200 Subject: [PATCH 1/7] Add native server deployments support for EAS Update --- CHANGELOG.md | 1 + packages/eas-cli/package.json | 2 +- packages/eas-cli/src/commands/update/index.ts | 29 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d0eb1a58..dace113e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder)) +- Support native server deployments in EAS Update ### 🐛 Bug fixes diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index a197eb4b6a..17e878ee00 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -1,7 +1,7 @@ { "name": "eas-cli", "description": "EAS command line tool", - "version": "16.18.0", + "version": "99.0.0", "author": "Expo ", "bin": { "eas": "./bin/run" diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 061f5fc61e..1bc35c129b 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -1,7 +1,10 @@ +import type { ExpoConfig } from '@expo/config'; import { Workflow } from '@expo/eas-build-job'; import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json'; import { Errors, Flags } from '@oclif/core'; import chalk from 'chalk'; +import fs from 'node:fs'; +import path from 'node:path'; import nullthrows from 'nullthrows'; import { getExpoWebsiteBaseUrl } from '../../api'; @@ -271,6 +274,10 @@ export default class UpdatePublish extends EasCommand { } } + // NOTE(@krystofwoldrich): This adds auto generated server url to the app config extras. + // This is done in-memory only to avoid breaking updates fingerprint. + await readDeployedServerUrlAsync(exp, inputDir); + // After possibly bundling, assert that the input directory can be found. const distRoot = await resolveInputDirectoryAsync(inputDir, { skipBundler }); @@ -758,3 +765,25 @@ export default class UpdatePublish extends EasCommand { }; } } +async function readDeployedServerUrlAsync(exp: ExpoConfig, inputDir: string): Promise { + const deploymentPath = path.join(inputDir, 'server-deployment.json'); + try { + const rawContent = await fs.promises.readFile(deploymentPath, 'utf-8'); + const { serverUrl } = JSON.parse(rawContent); + if (!serverUrl) { + Log.debug('No auto-deployed server URL found.'); + return; + } + + exp.extra ??= {}; + exp.extra.router ??= {}; + exp.extra.router.generatedOrigin = serverUrl; + Log.log(`Set origin to ${serverUrl}`); + } catch (error: any) { + if (error.code === 'ENOENT') { + Log.debug('No auto-deployed server URL file found.'); + } else { + Log.error(`Failed to read auto-deployed server URL from ${deploymentPath}.`); + } + } +} From 838c1edec90e1d104f4dfeea048b06a4821c6039 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Sep 2025 16:11:40 +0200 Subject: [PATCH 2/7] Use current eas-cli binary for expo export deployments --- packages/eas-cli/src/project/publish.ts | 12 +++++++++--- packages/eas-cli/src/utils/easCli.ts | 3 +++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/eas-cli/src/project/publish.ts b/packages/eas-cli/src/project/publish.ts index 48369edd85..68eb23ae82 100644 --- a/packages/eas-cli/src/project/publish.ts +++ b/packages/eas-cli/src/project/publish.ts @@ -42,6 +42,7 @@ import { truncateString as truncateUpdateMessage, } from '../update/utils'; import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads'; +import { easCliBin } from '../utils/easCli'; import { expoCommandAsync, shouldUseVersionedExpoCLI, @@ -223,6 +224,11 @@ export async function buildBundlesAsync({ throw new Error('Could not locate package.json'); } + const extendedEnv = { + ...extraEnv, + __EAS_BIN: easCliBin, + }; + // Legacy global Expo CLI if (!shouldUseVersionedExpoCLI(projectDir, exp)) { await expoCommandAsync( @@ -239,7 +245,7 @@ export async function buildBundlesAsync({ ...(clearCache ? ['--clear'] : []), ], { - extraEnv, + extraEnv: extendedEnv, } ); return; @@ -265,7 +271,7 @@ export async function buildBundlesAsync({ ...(clearCache ? ['--clear'] : []), ], { - extraEnv, + extraEnv: extendedEnv, } ); return; @@ -293,7 +299,7 @@ export async function buildBundlesAsync({ ...(clearCache ? ['--clear'] : []), ], { - extraEnv, + extraEnv: extendedEnv, } ); } diff --git a/packages/eas-cli/src/utils/easCli.ts b/packages/eas-cli/src/utils/easCli.ts index 17844a946e..84bab07285 100644 --- a/packages/eas-cli/src/utils/easCli.ts +++ b/packages/eas-cli/src/utils/easCli.ts @@ -1,3 +1,6 @@ +import { argv } from 'node:process'; + const packageJSON = require('../../package.json'); export const easCliVersion: string = packageJSON.version; +export const easCliBin: string = argv[1]; From fa5e71d0f27db083a1992093a9d7b8bafecd1199 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Sep 2025 16:11:40 +0200 Subject: [PATCH 3/7] use log with info --- packages/eas-cli/src/commands/update/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 1bc35c129b..12d79bbda3 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -778,7 +778,7 @@ async function readDeployedServerUrlAsync(exp: ExpoConfig, inputDir: string): Pr exp.extra ??= {}; exp.extra.router ??= {}; exp.extra.router.generatedOrigin = serverUrl; - Log.log(`Set origin to ${serverUrl}`); + Log.withInfo(`Set origin to ${serverUrl}`); } catch (error: any) { if (error.code === 'ENOENT') { Log.debug('No auto-deployed server URL file found.'); From d2393a7b0877cfbfcbd9b1d9855e08ddab047597 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Sep 2025 16:11:50 +0200 Subject: [PATCH 4/7] fixes --- CHANGELOG.md | 2 +- packages/eas-cli/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dace113e02..64bf84bf71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder)) -- Support native server deployments in EAS Update +- Support native server deployments in EAS Update ([#3155](https://github.com/expo/eas-cli/pull/3155) by [@krystofwoldrich](https://github.com/krystofwoldrich)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 17e878ee00..a197eb4b6a 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -1,7 +1,7 @@ { "name": "eas-cli", "description": "EAS command line tool", - "version": "99.0.0", + "version": "16.18.0", "author": "Expo ", "bin": { "eas": "./bin/run" From d18fdd8c38f350e53fa8eb4b9fad2f08719d0995 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Sep 2025 16:11:51 +0200 Subject: [PATCH 5/7] use generated config path env --- packages/eas-cli/src/commands/update/index.ts | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 12d79bbda3..56e0ba8832 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -1,10 +1,9 @@ -import type { ExpoConfig } from '@expo/config'; import { Workflow } from '@expo/eas-build-job'; import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json'; import { Errors, Flags } from '@oclif/core'; import chalk from 'chalk'; -import fs from 'node:fs'; -import path from 'node:path'; +import * as os from 'node:os'; +import * as path from 'node:path'; import nullthrows from 'nullthrows'; import { getExpoWebsiteBaseUrl } from '../../api'; @@ -255,6 +254,8 @@ export default class UpdatePublish extends EasCommand { ? { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' } : {}; + const generatedConfigPath = getTemporaryPath(); + // build bundle and upload assets for a new publish if (!skipBundler) { const bundleSpinner = ora().start('Exporting...'); @@ -265,7 +266,10 @@ export default class UpdatePublish extends EasCommand { exp, platformFlag: requestedPlatform, clearCache, - extraEnv: maybeServerEnv, + extraEnv: { + ...maybeServerEnv, + __EXPO_GENERATED_CONFIG_PATH: generatedConfigPath, + }, }); bundleSpinner.succeed('Exported bundle(s)'); } catch (e) { @@ -274,10 +278,6 @@ export default class UpdatePublish extends EasCommand { } } - // NOTE(@krystofwoldrich): This adds auto generated server url to the app config extras. - // This is done in-memory only to avoid breaking updates fingerprint. - await readDeployedServerUrlAsync(exp, inputDir); - // After possibly bundling, assert that the input directory can be found. const distRoot = await resolveInputDirectoryAsync(inputDir, { skipBundler }); @@ -358,7 +358,13 @@ export default class UpdatePublish extends EasCommand { uploadedAssetCount = uploadResults.uniqueUploadedAssetCount; assetLimitPerUpdateGroup = uploadResults.assetLimitPerUpdateGroup; - unsortedUpdateInfoGroups = await buildUnsortedUpdateInfoGroupAsync(assets, exp); + + const { exp: expAfterBuild } = await getDynamicPublicProjectConfigAsync({ + env: { + __EXPO_GENERATED_CONFIG_PATH: generatedConfigPath, + }, + }); + unsortedUpdateInfoGroups = await buildUnsortedUpdateInfoGroupAsync(assets, expAfterBuild); // NOTE(cedric): we assume that bundles are always uploaded, and always are part of // `uploadedAssetCount`, perferably we don't assume. For that, we need to refactor the @@ -765,25 +771,7 @@ export default class UpdatePublish extends EasCommand { }; } } -async function readDeployedServerUrlAsync(exp: ExpoConfig, inputDir: string): Promise { - const deploymentPath = path.join(inputDir, 'server-deployment.json'); - try { - const rawContent = await fs.promises.readFile(deploymentPath, 'utf-8'); - const { serverUrl } = JSON.parse(rawContent); - if (!serverUrl) { - Log.debug('No auto-deployed server URL found.'); - return; - } - exp.extra ??= {}; - exp.extra.router ??= {}; - exp.extra.router.generatedOrigin = serverUrl; - Log.withInfo(`Set origin to ${serverUrl}`); - } catch (error: any) { - if (error.code === 'ENOENT') { - Log.debug('No auto-deployed server URL file found.'); - } else { - Log.error(`Failed to read auto-deployed server URL from ${deploymentPath}.`); - } - } +function getTemporaryPath(): string { + return path.join(os.tmpdir(), Math.random().toString(36).substring(2)); } From 88a88a0557ad9960e9cf47d2a74afb6741116a06 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Sep 2025 21:44:13 +0200 Subject: [PATCH 6/7] add tmp file cleanup --- packages/eas-cli/src/commands/update/index.ts | 18 ++++++++++-------- packages/eas-cli/src/utils/temporaryPath.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 packages/eas-cli/src/utils/temporaryPath.ts diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 56e0ba8832..9a57c35dcd 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -2,8 +2,6 @@ import { Workflow } from '@expo/eas-build-job'; import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json'; import { Errors, Flags } from '@oclif/core'; import chalk from 'chalk'; -import * as os from 'node:os'; -import * as path from 'node:path'; import nullthrows from 'nullthrows'; import { getExpoWebsiteBaseUrl } from '../../api'; @@ -67,6 +65,7 @@ import uniqBy from '../../utils/expodash/uniqBy'; import formatFields from '../../utils/formatFields'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService'; +import { getTemporaryPath, safelyDeletePathAsync } from '../../utils/temporaryPath'; type RawUpdateFlags = { auto: boolean; @@ -169,6 +168,15 @@ export default class UpdatePublish extends EasCommand { }; async runAsync(): Promise { + const generatedConfigPath = getTemporaryPath(); + try { + await this.runUnsafeAsync(generatedConfigPath); + } finally { + await safelyDeletePathAsync(generatedConfigPath); + } + } + + private async runUnsafeAsync(generatedConfigPath: string): Promise { const { flags: rawFlags } = await this.parse(UpdatePublish); const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags); const { @@ -254,8 +262,6 @@ export default class UpdatePublish extends EasCommand { ? { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' } : {}; - const generatedConfigPath = getTemporaryPath(); - // build bundle and upload assets for a new publish if (!skipBundler) { const bundleSpinner = ora().start('Exporting...'); @@ -771,7 +777,3 @@ export default class UpdatePublish extends EasCommand { }; } } - -function getTemporaryPath(): string { - return path.join(os.tmpdir(), Math.random().toString(36).substring(2)); -} diff --git a/packages/eas-cli/src/utils/temporaryPath.ts b/packages/eas-cli/src/utils/temporaryPath.ts new file mode 100644 index 0000000000..3629d67972 --- /dev/null +++ b/packages/eas-cli/src/utils/temporaryPath.ts @@ -0,0 +1,13 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export function getTemporaryPath(): string { + return path.join(os.tmpdir(), Math.random().toString(36).substring(2)); +} + +export async function safelyDeletePathAsync(value: string): Promise { + try { + await fs.rm(value, { recursive: true, force: true }); + } catch {} +} From 434e00e7c2b287649634e4958bb60ec94e159d0a Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Thu, 4 Sep 2025 15:14:38 +0200 Subject: [PATCH 7/7] add test --- .../commands/update/__tests__/index.test.ts | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/update/__tests__/index.test.ts b/packages/eas-cli/src/commands/update/__tests__/index.test.ts index ce175341ab..2a547501cb 100644 --- a/packages/eas-cli/src/commands/update/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/index.test.ts @@ -21,8 +21,9 @@ import { jester } from '../../../credentials/__tests__/fixtures-constants'; import { UpdateFragment } from '../../../graphql/generated'; import { PublishMutation } from '../../../graphql/mutations/PublishMutation'; import { AppQuery } from '../../../graphql/queries/AppQuery'; -import { collectAssetsAsync, uploadAssetsAsync } from '../../../project/publish'; +import { buildBundlesAsync, collectAssetsAsync, uploadAssetsAsync } from '../../../project/publish'; import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync'; +import { getTemporaryPath, safelyDeletePathAsync } from '../../../utils/temporaryPath'; import { resolveVcsClient } from '../../../vcs'; const projectRoot = '/test-project'; @@ -65,6 +66,7 @@ jest.mock('../../../project/publish', () => ({ resolveInputDirectoryAsync: jest.fn((inputDir = 'dist') => path.join(projectRoot, inputDir)), uploadAssetsAsync: jest.fn(), })); +jest.mock('../../../utils/temporaryPath.ts'); describe(UpdatePublish.name, () => { afterEach(() => { @@ -177,6 +179,60 @@ describe(UpdatePublish.name, () => { // Ensure non-public config is not present expect(expoConfig).not.toHaveProperty('hooks'); }); + + it('creates a new update with a native deployment', async () => { + const flags = ['--non-interactive', '--branch=branch123', '--message=abc']; + + const { appJson } = mockTestProject(); + const { platforms, runtimeVersion } = mockTestExport(); + jest.mocked(buildBundlesAsync).mockImplementation(() => { + // Mock config changing during the build + // e.g. server url deployment + appJson.expo.extra = { + ...appJson.expo.extra, + afterBuild: 'mocked', + }; + + return Promise.resolve(); + }); + + // Mock the temporary path for the generated config + const mockedGeneratedConfigTmpPath = `/tmp/test-${Math.random().toString(36).substring(2)}`; + jest.mocked(getTemporaryPath).mockReturnValueOnce(mockedGeneratedConfigTmpPath); + + // Mock an existing branch, so we don't create a new one + jest.mocked(ensureBranchExistsAsync).mockResolvedValue({ + branch: { + id: 'branch123', + name: 'wat', + }, + createdBranch: false, + }); + + jest + .mocked(PublishMutation.publishUpdateGroupAsync) + .mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion }))); + + await new UpdatePublish(flags, commandOptions).run(); + + // Pull the publish data from the mocked publish function + const publishData = jest.mocked(PublishMutation.publishUpdateGroupAsync).mock.calls[0][1][0]; + // Pull the Expo config from the publish data + const expoConfig = nullthrows(publishData.updateInfoGroup).ios!.extra.expoClient; + + expect(getTemporaryPath).toHaveBeenCalledTimes(1); + expect(safelyDeletePathAsync).toHaveBeenCalledTimes(1); + expect(safelyDeletePathAsync).toHaveBeenCalledWith(mockedGeneratedConfigTmpPath); + + expect(buildBundlesAsync).toHaveBeenCalledWith( + expect.objectContaining({ + extraEnv: expect.objectContaining({ + __EXPO_GENERATED_CONFIG_PATH: mockedGeneratedConfigTmpPath, + }), + }) + ); + expect(expoConfig?.extra?.afterBuild).toEqual('mocked'); + }); }); /** Create a new in-memory project, copied from src/commands/update/__tests__/republish.test.ts */