From 8139da3e4ecc657c9792cc5e9e4f1c31bccb8f48 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 24 Aug 2026 15:13:13 -0400 Subject: [PATCH 1/4] Pass env to app config --- .../DynamicProjectConfigContextField.ts | 2 + .../context/contextUtils/getProjectIdAsync.ts | 15 +++--- packages/eas-cli/src/project/expoConfig.ts | 47 +++++++++++++++---- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/context/DynamicProjectConfigContextField.ts b/packages/eas-cli/src/commandUtils/context/DynamicProjectConfigContextField.ts index 0e65790dd5..cde263c6ea 100644 --- a/packages/eas-cli/src/commandUtils/context/DynamicProjectConfigContextField.ts +++ b/packages/eas-cli/src/commandUtils/context/DynamicProjectConfigContextField.ts @@ -29,6 +29,7 @@ export class DynamicPublicProjectConfigContextField extends ContextField = {} ): Promise { // NOTE(cedric): we disable plugins to avoid writing plugin-generated content to `expo.extra` const exp = await getPrivateExpoConfigAsync(projectDir, { skipPlugins: true, ...options }); @@ -40,7 +40,7 @@ export async function saveProjectIdToAppConfigAsync( { extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } }, }, - { skipSDKVersionRequirement: true } + { skipSDKVersionRequirement: true, ...options } ); switch (result.type) { @@ -87,7 +87,7 @@ export async function saveProjectIdToAppConfigAsync( export async function getProjectIdAsync( sessionManager: SessionManager, exp: ExpoConfig, - options: { env?: Env; nonInteractive: boolean } + options: Pick & { nonInteractive: boolean } ): Promise { // all codepaths in this function require a logged-in user with access to the owning account // since they either query the app via graphql or create it, which includes getting info about @@ -111,7 +111,7 @@ export async function validateOrSetProjectIdAsync({ exp: ExpoConfig; graphqlClient: ExpoGraphqlClient; actor: Actor; - options: { env?: Env; nonInteractive: boolean }; + options: Pick & { nonInteractive: boolean }; cwd?: string; }): Promise { const localProjectId = exp.extra?.eas?.projectId; @@ -204,7 +204,10 @@ export async function validateOrSetProjectIdAsync({ const spinner = ora(`Linking local project to EAS project ${projectId}`).start(); try { - await saveProjectIdToAppConfigAsync(projectDir, projectId, options); + await saveProjectIdToAppConfigAsync(projectDir, projectId, { + env: options.env, + mode: options.mode, + }); spinner.succeed(`Linked local project to EAS project ${projectId}`); } catch (e: any) { spinner.fail(); diff --git a/packages/eas-cli/src/project/expoConfig.ts b/packages/eas-cli/src/project/expoConfig.ts index e1fec4493c..452193fff3 100644 --- a/packages/eas-cli/src/project/expoConfig.ts +++ b/packages/eas-cli/src/project/expoConfig.ts @@ -6,6 +6,9 @@ import path from 'path'; import { isExpoInstalled } from './projectUtils'; import { spawnExpoCommand } from '../utils/expoCli'; +import { getEnvWithoutInheritedDotenvValues } from '../utils/originalEnv'; + +export type ExpoConfigMode = 'development' | 'production'; export type PublicExpoConfig = Omit< ExpoConfig, @@ -18,6 +21,7 @@ export type PublicExpoConfig = Omit< export interface ExpoConfigOptions { env?: Env; + mode?: ExpoConfigMode; skipSDKVersionRequirement?: boolean; skipPlugins?: boolean; } @@ -29,14 +33,21 @@ interface ExpoConfigOptionsInternal extends ExpoConfigOptions { export async function createOrModifyExpoConfigAsync( projectDir: string, exp: Partial, - readOptions?: { skipSDKVersionRequirement?: boolean } + readOptions?: Pick ): ReturnType { ensureExpoConfigExists(projectDir); - if (readOptions) { - return await modifyConfigAsync(projectDir, exp, readOptions); - } else { - return await modifyConfigAsync(projectDir, exp); + const originalProcessEnv = process.env; + const { env, mode, ...configReadOptions } = readOptions ?? {}; + try { + process.env = getInProcessExpoConfigEnv({ env, mode }); + if (readOptions) { + return await modifyConfigAsync(projectDir, exp, configReadOptions); + } else { + return await modifyConfigAsync(projectDir, exp); + } + } finally { + process.env = originalProcessEnv; } } @@ -46,10 +57,7 @@ async function getExpoConfigInternalAsync( ): Promise { const originalProcessEnv: NodeJS.ProcessEnv = process.env; try { - process.env = { - ...process.env, - ...opts.env, - }; + process.env = getInProcessExpoConfigEnv(opts); let exp: ExpoConfig; if (isExpoInstalled(projectDir)) { @@ -59,6 +67,12 @@ async function getExpoConfigInternalAsync( { env: { EXPO_NO_DOTENV: '1', + ...(opts.mode + ? { + NODE_ENV: opts.mode, + __EXPO_CONFIG_MODE: opts.mode, + } + : {}), }, } ); @@ -84,6 +98,21 @@ async function getExpoConfigInternalAsync( } } +function getInProcessExpoConfigEnv( + opts: Pick +): NodeJS.ProcessEnv { + const configEnv = { + ...getEnvWithoutInheritedDotenvValues(process.env), + ...opts.env, + }; + if (opts.mode) { + configEnv.NODE_ENV = opts.mode; + } + delete configEnv.__EXPO_CONFIG_MODE; + delete configEnv.__EXPO_ENV_LOADED; + return configEnv; +} + const MinimalAppConfigSchema = Joi.object({ slug: Joi.string().required(), name: Joi.string().required(), From 05cb0871db2ef8bc8cf557f54a446b2f3fe00596 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 24 Aug 2026 15:14:54 -0400 Subject: [PATCH 2/4] Use env mode in eas updates --- packages/eas-cli/src/commands/update/index.ts | 56 ++++++++++++------- packages/eas-cli/src/update/configure.ts | 8 ++- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 8b960316f5..5af2f711f5 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -8,6 +8,7 @@ import { ensureBranchExistsAsync } from '../../branch/queries'; import { ensureRepoIsCleanAsync } from '../../build/utils/repository'; import { getUpdateGroupUrl } from '../../build/utils/url'; import EasCommand from '../../commandUtils/EasCommand'; +import { loadServerSideEnvironmentVariablesAsync } from '../../commandUtils/context/contextUtils/loadServerSideEnvironmentVariablesAsync'; import { EasNonInteractiveAndJsonFlags, EasUpdateEnvironmentRequiredFlag, @@ -204,7 +205,6 @@ export default class UpdatePublish extends EasCommand { ...this.ContextOptions.DynamicProjectConfig, ...this.ContextOptions.LoggedIn, ...this.ContextOptions.Vcs, - ...this.ContextOptions.ServerSideEnvironmentVariables, }; async runAsync(): Promise { @@ -236,10 +236,9 @@ export default class UpdatePublish extends EasCommand { getDynamicPrivateProjectConfigAsync, loggedIn: { graphqlClient }, vcsClient, - getServerSideEnvironmentVariablesAsync, } = await this.getContextAsync(UpdatePublish, { nonInteractive, - withServerSideEnvironment: environmentFromFlags ?? null, + withServerSideEnvironment: null, }); if (jsonFlag) { @@ -250,10 +249,10 @@ export default class UpdatePublish extends EasCommand { await ensureRepoIsCleanAsync(vcsClient, nonInteractive); const { - exp: expPossiblyWithoutEasUpdateConfigured, + exp: expBeforeLoadingEnvironment, projectId, projectDir, - } = await getDynamicPublicProjectConfigAsync(); + } = await getDynamicPublicProjectConfigAsync({ mode: 'production' }); let environment: string | undefined = environmentFromFlags; @@ -261,7 +260,7 @@ export default class UpdatePublish extends EasCommand { if ( !autoFlag && environmentFlagNeededForSdk550OrGreater({ - sdkVersion: expPossiblyWithoutEasUpdateConfigured.sdkVersion, + sdkVersion: expBeforeLoadingEnvironment.sdkVersion, environment: environmentFromFlags, }) ) { @@ -273,6 +272,26 @@ export default class UpdatePublish extends EasCommand { }); } + const updateEnv = environment + ? { + ...(await loadServerSideEnvironmentVariablesAsync({ + environment, + projectId, + graphqlClient, + })), + EXPO_NO_DOTENV: '1', + } + : undefined; + + const exportEnv = { + ...updateEnv, + NODE_ENV: 'production', + }; + + const expPossiblyWithoutEasUpdateConfigured = updateEnv + ? (await getDynamicPublicProjectConfigAsync({ env: updateEnv, mode: 'production' })).exp + : expBeforeLoadingEnvironment; + await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]); const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir); @@ -285,12 +304,18 @@ export default class UpdatePublish extends EasCommand { projectDir, projectId, vcsClient, - env: undefined, + env: updateEnv, manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null, }); - const { exp } = await getDynamicPublicProjectConfigAsync(); - const { exp: expPrivate } = await getDynamicPrivateProjectConfigAsync(); + const { exp } = await getDynamicPublicProjectConfigAsync({ + env: updateEnv, + mode: 'production', + }); + const { exp: expPrivate } = await getDynamicPrivateProjectConfigAsync({ + env: updateEnv, + mode: 'production', + }); const codeSigningInfo = await getCodeSigningInfoAsync(expPrivate, privateKeyPath); const branchName = await getBranchNameForCommandAsync({ @@ -311,13 +336,6 @@ export default class UpdatePublish extends EasCommand { jsonFlag, }); - const maybeServerEnv = environmentFromFlags - ? { - ...(await getServerSideEnvironmentVariablesAsync()), - EXPO_NO_DOTENV: '1', - } - : {}; - // build bundle and upload assets for a new publish if (!skipBundler) { const bundleSpinner = ora().start('Exporting...'); @@ -330,7 +348,7 @@ export default class UpdatePublish extends EasCommand { clearCache, noBytecode, sourceMaps, - extraEnv: maybeServerEnv, + extraEnv: exportEnv, }); bundleSpinner.succeed('Exported bundle(s)'); } catch (e) { @@ -479,7 +497,7 @@ export default class UpdatePublish extends EasCommand { ...workflows, web: Workflow.UNKNOWN, }, - env: maybeServerEnv, + env: updateEnv, }); const runtimeToPlatformsAndFingerprintInfoMapping = getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects( @@ -516,7 +534,7 @@ export default class UpdatePublish extends EasCommand { runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping: runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMappingFromExpoUpdates, workflowsByPlatform: workflows, - env: undefined, + env: updateEnv, }); const runtimeVersionToRolloutInfoGroup = diff --git a/packages/eas-cli/src/update/configure.ts b/packages/eas-cli/src/update/configure.ts index 62fdfcf57c..76854f1dc8 100644 --- a/packages/eas-cli/src/update/configure.ts +++ b/packages/eas-cli/src/update/configure.ts @@ -98,6 +98,7 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({ projectDir, platform, workflows, + env, manifestHostOverride, }: { exp: ExpoConfig; @@ -105,6 +106,7 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({ projectDir: string; platform: RequestedPlatform; workflows: Record; + env: Env | undefined; manifestHostOverride: string | null; }): Promise<{ projectChanged: boolean; exp: ExpoConfig }> { const modifyConfig: Partial = {}; @@ -144,7 +146,10 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({ } const mergedExp = mergeExpoConfig(exp, modifyConfig); - const result = await createOrModifyExpoConfigAsync(projectDir, mergedExp); + const result = await createOrModifyExpoConfigAsync(projectDir, mergedExp, { + env, + mode: 'production', + }); switch (result.type) { case 'success': @@ -415,6 +420,7 @@ export async function ensureEASUpdateIsConfiguredAsync({ projectId, platform, workflows, + env, manifestHostOverride, }); From 011c004ab9194f3a3a0ee57dfad6b7f195164269 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 24 Aug 2026 15:15:36 -0400 Subject: [PATCH 3/4] Update tests --- .../DynamicProjectConfigContextField-test.ts | 52 +++++++ .../__tests__/getProjectIdAsync-test.ts | 32 +++-- .../commands/update/__tests__/index.test.ts | 130 +++++++++++++----- .../src/project/__tests__/expoConfig.test.ts | 115 +++++++++++++++- .../src/update/__tests__/configure-test.ts | 44 ++++++ 5 files changed, 325 insertions(+), 48 deletions(-) create mode 100644 packages/eas-cli/src/commandUtils/context/__tests__/DynamicProjectConfigContextField-test.ts diff --git a/packages/eas-cli/src/commandUtils/context/__tests__/DynamicProjectConfigContextField-test.ts b/packages/eas-cli/src/commandUtils/context/__tests__/DynamicProjectConfigContextField-test.ts new file mode 100644 index 0000000000..6d59ea7b26 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/context/__tests__/DynamicProjectConfigContextField-test.ts @@ -0,0 +1,52 @@ +import { + DynamicPrivateProjectConfigContextField, + DynamicPublicProjectConfigContextField, +} from '../DynamicProjectConfigContextField'; +import { findProjectDirAndVerifyProjectSetupAsync } from '../contextUtils/findProjectDirAndVerifyProjectSetupAsync'; +import { getProjectIdAsync } from '../contextUtils/getProjectIdAsync'; +import { getPrivateExpoConfigAsync, getPublicExpoConfigAsync } from '../../../project/expoConfig'; + +jest.mock('../contextUtils/findProjectDirAndVerifyProjectSetupAsync'); +jest.mock('../contextUtils/getProjectIdAsync'); +jest.mock('../../../project/expoConfig'); + +describe('dynamic project config context fields', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ['public config', DynamicPublicProjectConfigContextField, getPublicExpoConfigAsync], + ['private config', DynamicPrivateProjectConfigContextField, getPrivateExpoConfigAsync], + ])( + "uses the caller's env and mode for %s", + async (_description, ContextField, getExpoConfigAsync) => { + jest.mocked(findProjectDirAndVerifyProjectSetupAsync).mockResolvedValue('/app'); + jest.mocked(getExpoConfigAsync).mockResolvedValue({ name: 'app', slug: 'app' }); + jest.mocked(getProjectIdAsync).mockResolvedValue('project-id'); + + const getProjectConfigAsync = await new ContextField().getValueAsync({ + analytics: {} as any, + nonInteractive: true, + sessionManager: {} as any, + }); + const options = { + env: { APP_VARIANT: 'preview' }, + mode: 'production' as const, + }; + + await getProjectConfigAsync(options); + + expect(getExpoConfigAsync).toHaveBeenNthCalledWith(1, '/app', options); + expect(getExpoConfigAsync).toHaveBeenNthCalledWith(2, '/app', options); + expect(getProjectIdAsync).toHaveBeenCalledWith( + expect.anything(), + { name: 'app', slug: 'app' }, + { + ...options, + nonInteractive: true, + } + ); + } + ); +}); diff --git a/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts b/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts index 99c85e9902..0239a75ff9 100644 --- a/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts +++ b/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts @@ -312,17 +312,24 @@ describe(getProjectIdAsync, () => { }); it('fetches the project ID when not in app config, and sets it in the config', async () => { - jest - .mocked(getConfig) - .mockReturnValue({ exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any); - jest.mocked(modifyConfigAsync).mockResolvedValue({ - type: 'success', - config: { - sdkVersion: '52.0.0', - name: 'test', - slug: 'test', - extra: { eas: { projectId: '2345' } }, - }, + const originalProcessEnv = process.env; + jest.mocked(getConfig).mockImplementation(() => { + expect(process.env.APP_VARIANT).toBe('preview'); + expect(process.env.NODE_ENV).toBe('production'); + return { exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any; + }); + jest.mocked(modifyConfigAsync).mockImplementation(async () => { + expect(process.env.APP_VARIANT).toBe('preview'); + expect(process.env.NODE_ENV).toBe('production'); + return { + type: 'success', + config: { + sdkVersion: '52.0.0', + name: 'test', + slug: 'test', + extra: { eas: { projectId: '2345' } }, + }, + }; }); jest .mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync) @@ -332,11 +339,14 @@ describe(getProjectIdAsync, () => { sessionManager, { sdkVersion: '52.0.0', name: 'test', slug: 'test' }, { + env: { APP_VARIANT: 'preview' }, + mode: 'production', nonInteractive: false, } ); expect(projectId).toEqual('2345'); + expect(process.env).toBe(originalProcessEnv); expect(modifyConfigAsync).toHaveBeenCalledTimes(1); expect(modifyConfigAsync).toHaveBeenCalledWith( 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 346e0f55a3..65646463b9 100644 --- a/packages/eas-cli/src/commands/update/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/index.test.ts @@ -13,7 +13,6 @@ import { DynamicPublicProjectConfigContextField, } from '../../../commandUtils/context/DynamicProjectConfigContextField'; import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField'; -import { ServerSideEnvironmentVariablesContextField } from '../../../commandUtils/context/ServerSideEnvironmentVariablesContextField'; import VcsClientContextField from '../../../commandUtils/context/VcsClientContextField'; import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides'; @@ -23,7 +22,13 @@ import { UpdateFragment } from '../../../graphql/generated'; import { PublishMutation } from '../../../graphql/mutations/PublishMutation'; import { AppQuery } from '../../../graphql/queries/AppQuery'; import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; -import { collectAssetsAsync, uploadAssetsAsync } from '../../../project/publish'; +import { + buildBundlesAsync, + collectAssetsAsync, + maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync, + uploadAssetsAsync, +} from '../../../project/publish'; +import { ensureEASUpdateIsConfiguredAsync } from '../../../update/configure'; import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync'; import { selectAsync } from '../../../prompts'; import { resolveVcsClient } from '../../../vcs'; @@ -67,6 +72,13 @@ jest.mock('../../../project/publish', () => ({ ...jest.requireActual('../../../project/publish'), buildBundlesAsync: jest.fn(), collectAssetsAsync: jest.fn(), + maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync: jest.fn( + async (args: any) => + args.runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map((info: any) => ({ + ...info, + fingerprintInfoGroup: {}, + })) + ), resolveInputDirectoryAsync: jest.fn((inputDir = 'dist') => path.join(projectRoot, inputDir)), uploadAssetsAsync: jest.fn(), })); @@ -113,6 +125,9 @@ describe(UpdatePublish.name, () => { await new UpdatePublish(flags, commandOptions).run(); expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled(); + expect(buildBundlesAsync).toHaveBeenCalledWith( + expect.objectContaining({ extraEnv: { NODE_ENV: 'production' } }) + ); }); it('creates a new update with --non-interactive, --channel, and --message', async () => { @@ -154,10 +169,11 @@ describe(UpdatePublish.name, () => { expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled(); }); - it('prompts for environment when SDK >= 55 and --environment is not provided', async () => { + it('uses the prompted environment for app config, export, and Fingerprint', async () => { const flags = ['--branch=branch123', '--message=abc']; - mockTestProject({ expoConfig: { sdkVersion: '55.0.0' } }); + const { getDynamicPrivateProjectConfigAsync, getDynamicPublicProjectConfigAsync, projectId } = + mockTestProject({ expoConfig: { sdkVersion: '55.0.0' } }); const { platforms, runtimeVersion } = mockTestExport(); jest.mocked(ensureBranchExistsAsync).mockResolvedValue({ @@ -176,10 +192,13 @@ describe(UpdatePublish.name, () => { })) ); - jest.mocked(selectAsync).mockResolvedValue('production'); + jest.mocked(selectAsync).mockResolvedValue('preview'); jest .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) .mockResolvedValue([]); + jest + .mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync) + .mockResolvedValue([{ name: 'APP_VARIANT', value: 'from-eas' }] as any); const ciValue = process.env.CI; try { @@ -194,6 +213,40 @@ describe(UpdatePublish.name, () => { } expect(selectAsync).toHaveBeenCalled(); + expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith( + expect.anything(), + { + appId: projectId, + environment: 'preview', + } + ); + const updateEnv = { + APP_VARIANT: 'from-eas', + EXPO_NO_DOTENV: '1', + }; + expect(getDynamicPublicProjectConfigAsync).toHaveBeenCalledWith({ mode: 'production' }); + expect(getDynamicPublicProjectConfigAsync).toHaveBeenCalledWith({ + env: updateEnv, + mode: 'production', + }); + expect(getDynamicPrivateProjectConfigAsync).toHaveBeenCalledWith({ + env: updateEnv, + mode: 'production', + }); + expect(ensureEASUpdateIsConfiguredAsync).toHaveBeenCalledWith( + expect.objectContaining({ env: updateEnv }) + ); + expect(buildBundlesAsync).toHaveBeenCalledWith( + expect.objectContaining({ + extraEnv: { + ...updateEnv, + NODE_ENV: 'production', + }, + }) + ); + expect( + maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync + ).toHaveBeenCalledWith(expect.objectContaining({ env: updateEnv })); }); it('errors when SDK >= 55, --environment is not provided, and --non-interactive is set', async () => { @@ -402,7 +455,12 @@ function mockTestProject({ }: { configuredProjectId?: string; expoConfig?: Partial; -} = {}): { projectId: string; appJson: AppJSONConfig } { +} = {}): { + projectId: string; + appJson: AppJSONConfig; + getDynamicPrivateProjectConfigAsync: jest.Mock; + getDynamicPublicProjectConfigAsync: jest.Mock; +} { const packageJSON: PackageJSONConfig = { name: 'testing123', version: '0.1.0', @@ -438,38 +496,35 @@ function mockTestProject({ const graphqlClient = instance(mock({})); jest.mocked(getConfig).mockReturnValue(mockManifest as any); + const getDynamicPrivateProjectConfigAsync = jest.fn(async () => { + const exp = { ...mockManifest.exp }; + return { + exp, + projectDir: projectRoot, + projectId: configuredProjectId, + }; + }); jest .spyOn(DynamicPrivateProjectConfigContextField.prototype, 'getValueAsync') - .mockResolvedValue(async () => { - const exp = { ...mockManifest.exp }; - return { - exp, - projectDir: projectRoot, - projectId: configuredProjectId, - }; - }); - jest - .spyOn(ServerSideEnvironmentVariablesContextField.prototype, 'getValueAsync') - .mockResolvedValue(async () => { - return {}; - }); + .mockResolvedValue(getDynamicPrivateProjectConfigAsync); + const getDynamicPublicProjectConfigAsync = jest.fn(async () => { + const exp = { + name: mockManifest.exp.name, + version: mockManifest.exp.version, + slug: mockManifest.exp.slug, + sdkVersion: mockManifest.exp.sdkVersion, + owner: mockManifest.exp.owner, + extra: mockManifest.exp.extra, + }; + return { + exp, + projectDir: projectRoot, + projectId: configuredProjectId, + }; + }); jest .spyOn(DynamicPublicProjectConfigContextField.prototype, 'getValueAsync') - .mockResolvedValue(async () => { - const exp = { - name: mockManifest.exp.name, - version: mockManifest.exp.version, - slug: mockManifest.exp.slug, - sdkVersion: mockManifest.exp.sdkVersion, - owner: mockManifest.exp.owner, - extra: mockManifest.exp.extra, - }; - return { - exp, - projectDir: projectRoot, - projectId: configuredProjectId, - }; - }); + .mockResolvedValue(getDynamicPublicProjectConfigAsync); jest.spyOn(LoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({ actor: jester, @@ -489,7 +544,12 @@ function mockTestProject({ ownerAccount: jester.accounts[0], }); - return { projectId: configuredProjectId, appJson: appJSON }; + return { + projectId: configuredProjectId, + appJson: appJSON, + getDynamicPrivateProjectConfigAsync, + getDynamicPublicProjectConfigAsync, + }; } /** Create a new in-memory export of the project */ diff --git a/packages/eas-cli/src/project/__tests__/expoConfig.test.ts b/packages/eas-cli/src/project/__tests__/expoConfig.test.ts index 8482629613..92d6aa55ff 100644 --- a/packages/eas-cli/src/project/__tests__/expoConfig.test.ts +++ b/packages/eas-cli/src/project/__tests__/expoConfig.test.ts @@ -1,18 +1,129 @@ -import { getConfigFilePaths, modifyConfigAsync } from '@expo/config'; +import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config'; import JsonFile from '@expo/json-file'; import { writeFileSync } from 'fs-extra'; -import { createOrModifyExpoConfigAsync } from '../expoConfig'; +import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../expoConfig'; +import { isExpoInstalled } from '../projectUtils'; +import { spawnExpoCommand } from '../../utils/expoCli'; jest.mock('fs-extra'); jest.mock('@expo/config'); jest.mock('@expo/json-file'); +jest.mock('../projectUtils'); +jest.mock('../../utils/expoCli'); + +const originalEnv = process.env; beforeEach(() => { jest.resetAllMocks(); + process.env = { ...originalEnv }; +}); + +afterAll(() => { + process.env = originalEnv; }); describe('expoConfig', () => { + it.each([ + ['the existing env', undefined], + ['production mode', 'production'], + ] as const)('uses %s when Expo CLI reads app config', async (_description, mode) => { + const env = mode ? { APP_VARIANT: 'from-eas' } : undefined; + process.env.DOTENV_VALUE = 'from-parent'; + process.env.NODE_ENV = 'staging'; + process.env.__EXPO_ENV_LOADED = '["DOTENV_VALUE"]'; + process.env.__EXPO_CONFIG_MODE = 'staging'; + jest.mocked(getConfigFilePaths).mockReturnValue({ + staticConfigPath: '/app/app.json', + dynamicConfigPath: null, + }); + jest.mocked(isExpoInstalled).mockReturnValue(true); + jest.mocked(spawnExpoCommand).mockImplementation(() => { + expect(process.env.APP_VARIANT).toBe(mode ? 'from-eas' : undefined); + expect(process.env.DOTENV_VALUE).toBeUndefined(); + expect(process.env.NODE_ENV).toBe(mode ?? 'staging'); + expect(process.env.__EXPO_ENV_LOADED).toBeUndefined(); + expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined(); + return Promise.resolve({ + stdout: JSON.stringify({ name: 'app', slug: 'app' }), + }) as any; + }); + + await getPrivateExpoConfigAsync('/app', { env, mode }); + + expect(spawnExpoCommand).toHaveBeenCalledWith('/app', ['config', '--json'], { + env: { + EXPO_NO_DOTENV: '1', + ...(mode ? { NODE_ENV: mode, __EXPO_CONFIG_MODE: mode } : {}), + }, + }); + expect(process.env.APP_VARIANT).toBeUndefined(); + expect(process.env.DOTENV_VALUE).toBe('from-parent'); + expect(process.env.NODE_ENV).toBe('staging'); + expect(process.env.__EXPO_ENV_LOADED).toBe('["DOTENV_VALUE"]'); + expect(process.env.__EXPO_CONFIG_MODE).toBe('staging'); + }); + + it('uses the selected env and production mode with the bundled config fallback', async () => { + process.env.DOTENV_VALUE = 'from-parent'; + process.env.NODE_ENV = 'staging'; + process.env.__EXPO_ENV_LOADED = '["DOTENV_VALUE"]'; + process.env.__EXPO_CONFIG_MODE = 'staging'; + const env = { + DOTENV_VALUE: 'from-eas', + __EXPO_ENV_LOADED: '["DOTENV_VALUE"]', + }; + jest.mocked(getConfigFilePaths).mockReturnValue({ + staticConfigPath: '/app/app.json', + dynamicConfigPath: null, + }); + jest.mocked(isExpoInstalled).mockReturnValue(false); + jest.mocked(getConfig).mockImplementation(() => { + expect(process.env.DOTENV_VALUE).toBe('from-eas'); + expect(process.env.NODE_ENV).toBe('production'); + expect(process.env.__EXPO_ENV_LOADED).toBeUndefined(); + expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined(); + return { exp: { name: 'app', slug: 'app' } } as any; + }); + + await getPrivateExpoConfigAsync('/app', { env, mode: 'production' }); + + expect(env).toEqual({ + DOTENV_VALUE: 'from-eas', + __EXPO_ENV_LOADED: '["DOTENV_VALUE"]', + }); + expect(process.env.DOTENV_VALUE).toBe('from-parent'); + expect(process.env.NODE_ENV).toBe('staging'); + expect(process.env.__EXPO_ENV_LOADED).toBe('["DOTENV_VALUE"]'); + expect(process.env.__EXPO_CONFIG_MODE).toBe('staging'); + }); + + it('uses the selected env and production mode when it modifies app config', async () => { + process.env.NODE_ENV = 'staging'; + process.env.__EXPO_CONFIG_MODE = 'staging'; + jest.mocked(getConfigFilePaths).mockReturnValue({ + staticConfigPath: '/app/app.json', + dynamicConfigPath: null, + }); + jest.mocked(modifyConfigAsync).mockImplementation(async () => { + expect(process.env.APP_VARIANT).toBe('preview'); + expect(process.env.NODE_ENV).toBe('production'); + expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined(); + return { type: 'success', config: {} as any }; + }); + + await createOrModifyExpoConfigAsync( + '/app', + {}, + { env: { APP_VARIANT: 'preview' }, mode: 'production' } + ); + + expect(modifyConfigAsync).toHaveBeenCalledWith('/app', {}, {}); + expect(process.env.APP_VARIANT).toBeUndefined(); + expect(process.env.NODE_ENV).toBe('staging'); + expect(process.env.__EXPO_CONFIG_MODE).toBe('staging'); + }); + describe('createOrModifyExpoConfigAsync', () => { it('should create a new app config file if it does not exist', async () => { jest.mocked(getConfigFilePaths).mockReturnValue({ diff --git a/packages/eas-cli/src/update/__tests__/configure-test.ts b/packages/eas-cli/src/update/__tests__/configure-test.ts index 8877ba6cb7..4984749497 100644 --- a/packages/eas-cli/src/update/__tests__/configure-test.ts +++ b/packages/eas-cli/src/update/__tests__/configure-test.ts @@ -4,8 +4,20 @@ import { DEFAULT_BARE_RUNTIME_VERSION, DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49, DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48, + ensureEASUpdateIsConfiguredAsync, getDefaultRuntimeVersion, } from '../configure'; +import { createOrModifyExpoConfigAsync } from '../../project/expoConfig'; +import { RequestedPlatform } from '../../platform'; +import { + isExpoUpdatesInstalledAsDevDependency, + isExpoUpdatesInstalledOrAvailable, +} from '../../project/projectUtils'; +import { resolveWorkflowPerPlatformAsync } from '../../project/workflow'; + +jest.mock('../../project/expoConfig'); +jest.mock('../../project/projectUtils'); +jest.mock('../../project/workflow'); describe(getDefaultRuntimeVersion, () => { it('gets the right rtv version/policy', () => { @@ -50,3 +62,35 @@ describe(getDefaultRuntimeVersion, () => { ); }); }); + +describe(ensureEASUpdateIsConfiguredAsync, () => { + it('uses the selected env when it updates app config', async () => { + const env = { APP_VARIANT: 'from-eas', EXPO_NO_DOTENV: '1' }; + const exp = { name: 'app', slug: 'app', sdkVersion: '55.0.0' }; + jest.mocked(isExpoUpdatesInstalledOrAvailable).mockReturnValue(true); + jest.mocked(isExpoUpdatesInstalledAsDevDependency).mockReturnValue(false); + jest.mocked(resolveWorkflowPerPlatformAsync).mockResolvedValue({ + android: Workflow.MANAGED, + ios: Workflow.MANAGED, + }); + jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ + type: 'success', + config: exp, + } as any); + + await ensureEASUpdateIsConfiguredAsync({ + env, + exp, + manifestHostOverride: null, + platform: RequestedPlatform.All, + projectDir: '/app', + projectId: 'project-id', + vcsClient: {} as any, + }); + + expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/app', expect.any(Object), { + env, + mode: 'production', + }); + }); +}); From 1103746a5d95149bd349435978f27a27e49bc72d Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 24 Aug 2026 15:29:44 -0400 Subject: [PATCH 4/4] Add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a5322178..687dd9dc8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [eas-cli] Use the selected EAS environment throughout `eas update`. ([#4269](https://github.com/expo/eas-cli/pull/4269) by [@ramonclaudio](https://github.com/ramonclaudio)) + ### ๐Ÿงน Chores ## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20