From 9107eb97cc8a431daa2db10ce53da3acf0dbd9ed Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Tue, 24 Jun 2025 09:01:16 +0200 Subject: [PATCH 1/2] [eas-build] discourage Expo Go for production --- CHANGELOG.md | 2 + packages/eas-cli/src/build/createContext.ts | 17 ++-- .../eas-cli/src/build/runBuildAndSubmit.ts | 3 + .../eas-cli/src/build/utils/resourceClass.ts | 4 +- .../__tests__/discourageExpoGoForProd-test.ts | 83 +++++++++++++++++++ .../src/project/discourageExpoGoForProd.ts | 73 ++++++++++++++++ 6 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts create mode 100644 packages/eas-cli/src/project/discourageExpoGoForProd.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ace342d9..3cb83b9424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- Warn in `eas build` when creating a production build from an app that uses Expo Go for development ([#3073](https://github.com/expo/eas-cli/pull/3073) by [@vonovak](https://github.com/vonovak)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/build/createContext.ts b/packages/eas-cli/src/build/createContext.ts index 89b772aead..5b37dd6e6d 100644 --- a/packages/eas-cli/src/build/createContext.ts +++ b/packages/eas-cli/src/build/createContext.ts @@ -11,7 +11,7 @@ import { createAndroidContextAsync } from './android/build'; import { BuildContext, CommonContext } from './context'; import { createIosContextAsync } from './ios/build'; import { LocalBuildMode, LocalBuildOptions } from './local'; -import { resolveBuildResourceClassAsync } from './utils/resourceClass'; +import { resolveBuildResourceClass } from './utils/resourceClass'; import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager'; import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; @@ -73,8 +73,12 @@ export async function createBuildContextAsync({ env, }); const projectName = exp.slug; - const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); - const workflow = await resolveWorkflowAsync(projectDir, platform, vcsClient); + + const [account, workflow] = await Promise.all([ + getOwnerAccountForProjectIdAsync(graphqlClient, projectId), + resolveWorkflowAsync(projectDir, platform, vcsClient), + ]); + const accountId = account.id; const runFromCI = getenv.boolish('CI', false); const developmentClient = @@ -118,12 +122,7 @@ export async function createBuildContextAsync({ }; analytics.logEvent(BuildEvent.BUILD_COMMAND, analyticsEventProperties); - const resourceClass = await resolveBuildResourceClassAsync( - buildProfile, - platform, - resourceClassFlag - ); - + const resourceClass = resolveBuildResourceClass(buildProfile, platform, resourceClassFlag); const commonContext: CommonContext = { accountName: account.name, buildProfile, diff --git a/packages/eas-cli/src/build/runBuildAndSubmit.ts b/packages/eas-cli/src/build/runBuildAndSubmit.ts index 53d42b45d6..c5be6f1bef 100644 --- a/packages/eas-cli/src/build/runBuildAndSubmit.ts +++ b/packages/eas-cli/src/build/runBuildAndSubmit.ts @@ -49,6 +49,7 @@ import { CustomBuildConfigMetadata, validateCustomBuildConfigAsync, } from '../project/customBuildConfig'; +import { discourageExpoGoForProd } from '../project/discourageExpoGoForProd'; import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk'; import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig'; import { @@ -146,6 +147,8 @@ export async function runBuildAndSubmitAsync({ projectDir, }); + discourageExpoGoForProd(buildProfiles, projectDir, vcsClient); + for (const buildProfile of buildProfiles) { if (buildProfile.profile.image && ['default', 'stable'].includes(buildProfile.profile.image)) { Log.warn( diff --git a/packages/eas-cli/src/build/utils/resourceClass.ts b/packages/eas-cli/src/build/utils/resourceClass.ts index 7a305e6171..e33fad0733 100644 --- a/packages/eas-cli/src/build/utils/resourceClass.ts +++ b/packages/eas-cli/src/build/utils/resourceClass.ts @@ -28,11 +28,11 @@ const androidResourceClassToBuildResourceClassMapping: Record< [ResourceClass.MEDIUM]: BuildResourceClass.AndroidMedium, }; -export async function resolveBuildResourceClassAsync( +export function resolveBuildResourceClass( profile: BuildProfile, platform: Platform, resourceClassFlag?: ResourceClass -): Promise { +): BuildResourceClass { const profileResourceClass = profile.resourceClass; if (profileResourceClass && resourceClassFlag && resourceClassFlag !== profileResourceClass) { diff --git a/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts b/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts new file mode 100644 index 0000000000..01f48067b1 --- /dev/null +++ b/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts @@ -0,0 +1,83 @@ +import { Platform, Workflow } from '@expo/eas-build-job'; +import getenv from 'getenv'; +import resolveFrom from 'resolve-from'; + +import type { ProfileData } from '../../utils/profiles'; +import { resolveVcsClient } from '../../vcs'; +import { detectExpoGoProdBuildAsync } from '../discourageExpoGoForProd'; + +jest.mock('getenv'); +jest.mock('resolve-from'); +jest.mock('../workflow', () => ({ + resolveWorkflowPerPlatformAsync: jest.fn(), +})); + +const mockResolveWorkflowPerPlatformAsync = jest.mocked( + require('../workflow').resolveWorkflowPerPlatformAsync +); + +const projectDir = '/app'; +const vcsClient = resolveVcsClient(); + +const createMockBuildProfile = (profileName: string): ProfileData<'build'> => ({ + profileName, + platform: Platform.ANDROID, + profile: {} as any, +}); + +describe(detectExpoGoProdBuildAsync, () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getenv.boolish).mockReturnValue(false); + jest.mocked(resolveFrom.silent).mockReturnValue(undefined); // expo-dev-client is not installed + }); + + describe('should return false', () => { + it.each([ + ['non-production profiles', [createMockBuildProfile('development')]], + ['undefined buildProfiles', undefined], + ['empty buildProfiles', []], + ])('should return false for %s', async (_, buildProfiles) => { + const result = await detectExpoGoProdBuildAsync(buildProfiles, projectDir, vcsClient); + + expect(result).toBe(false); + expect(mockResolveWorkflowPerPlatformAsync).not.toHaveBeenCalled(); + }); + + it('when expo-dev-client is installed - that signals a development build', async () => { + jest.mocked(resolveFrom.silent).mockReturnValue('/path/to/expo-dev-client/package.json'); + const buildProfiles = [createMockBuildProfile('production')]; + + const result = await detectExpoGoProdBuildAsync(buildProfiles, projectDir, vcsClient); + + expect(result).toBe(false); + expect(mockResolveWorkflowPerPlatformAsync).not.toHaveBeenCalled(); + }); + + it('when either platform is "generic" - likely a bare RN project', async () => { + mockResolveWorkflowPerPlatformAsync.mockResolvedValue({ + android: Workflow.GENERIC, + ios: Workflow.GENERIC, + }); + const buildProfiles = [createMockBuildProfile('production')]; + + const result = await detectExpoGoProdBuildAsync(buildProfiles, projectDir, vcsClient); + + expect(result).toBe(false); + }); + }); + + describe('should return true', () => { + it('when production profile is used, there are no native directories (or are gitignored) AND expo-dev-client is not installed', async () => { + mockResolveWorkflowPerPlatformAsync.mockResolvedValue({ + android: Workflow.MANAGED, + ios: Workflow.MANAGED, + }); + const buildProfiles = [createMockBuildProfile('production')]; + + const result = await detectExpoGoProdBuildAsync(buildProfiles, projectDir, vcsClient); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/eas-cli/src/project/discourageExpoGoForProd.ts b/packages/eas-cli/src/project/discourageExpoGoForProd.ts new file mode 100644 index 0000000000..8ba7d45cb3 --- /dev/null +++ b/packages/eas-cli/src/project/discourageExpoGoForProd.ts @@ -0,0 +1,73 @@ +import { Workflow } from '@expo/eas-build-job'; +import chalk from 'chalk'; +import getenv from 'getenv'; +import resolveFrom from 'resolve-from'; + +import { resolveWorkflowPerPlatformAsync } from './workflow'; +import Log, { learnMore } from '../log'; +import type { ProfileData } from '../utils/profiles'; +import type { Client } from '../vcs/vcs'; + +const suppressionEnvVarName = 'EAS_BUILD_NO_EXPO_GO_WARNING'; + +export const discourageExpoGoForProd = ( + buildProfiles: ProfileData<'build'>[] | undefined, + projectDir: string, + vcsClient: Client +): void => { + detectExpoGoProdBuildAsync(buildProfiles, projectDir, vcsClient) + .then(usesExpoGo => { + if (usesExpoGo) { + Log.newLine(); + Log.warn( + `โš ๏ธ It appears you're trying to build an app based on Expo Go for production. Expo Go is not a suitable environment for production apps.` + ); + Log.warn( + learnMore('https://docs.expo.dev/develop/development-builds/expo-go-to-dev-build/', { + learnMoreMessage: 'Learn more about converting from Expo Go to a development build', + dim: false, + }) + ); + Log.warn( + chalk.dim(`To suppress this warning, set ${chalk.bold(`${suppressionEnvVarName}=true`)}.`) + ); + Log.newLine(); + } + }) + .catch(err => { + Log.warn('Error detecting whether Expo Go is used:', err); + }); +}; + +export async function detectExpoGoProdBuildAsync( + buildProfiles: ProfileData<'build'>[] | undefined, + projectDir: string, + vcsClient: Client +): Promise { + const shouldSuppressWarning = getenv.boolish(suppressionEnvVarName, false); + + const isProductionBuild = buildProfiles?.map(it => it.profileName).includes('production'); + if (shouldSuppressWarning || !isProductionBuild) { + return false; + } + + const hasExpoDevClient = checkIfExpoDevClientInstalled(projectDir); + if (hasExpoDevClient) { + return false; + } + + return await checkIfManagedWorkflowAsync(projectDir, vcsClient); +} + +async function checkIfManagedWorkflowAsync( + projectDir: string, + vcsClient: Client +): Promise { + const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient); + + return workflows.android === Workflow.MANAGED && workflows.ios === Workflow.MANAGED; +} + +function checkIfExpoDevClientInstalled(projectDir: string): boolean { + return resolveFrom.silent(projectDir, 'expo-dev-client/package.json') !== undefined; +} From 6b3940aa1e75547896aa33599ffa4e2d4dfed05e Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 26 Jun 2025 12:58:05 +0200 Subject: [PATCH 2/2] address review --- .../eas-cli/src/build/runBuildAndSubmit.ts | 4 +- packages/eas-cli/src/build/utils/devClient.ts | 4 +- .../__tests__/discourageExpoGoForProd-test.ts | 9 ++- .../src/project/discourageExpoGoForProd.ts | 62 +++++++++---------- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/packages/eas-cli/src/build/runBuildAndSubmit.ts b/packages/eas-cli/src/build/runBuildAndSubmit.ts index c5be6f1bef..cda7f1165b 100644 --- a/packages/eas-cli/src/build/runBuildAndSubmit.ts +++ b/packages/eas-cli/src/build/runBuildAndSubmit.ts @@ -49,7 +49,7 @@ import { CustomBuildConfigMetadata, validateCustomBuildConfigAsync, } from '../project/customBuildConfig'; -import { discourageExpoGoForProd } from '../project/discourageExpoGoForProd'; +import { discourageExpoGoForProdAsync } from '../project/discourageExpoGoForProd'; import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk'; import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig'; import { @@ -147,7 +147,7 @@ export async function runBuildAndSubmitAsync({ projectDir, }); - discourageExpoGoForProd(buildProfiles, projectDir, vcsClient); + await discourageExpoGoForProdAsync(buildProfiles, projectDir, vcsClient); for (const buildProfile of buildProfiles) { if (buildProfile.profile.image && ['default', 'stable'].includes(buildProfile.profile.image)) { diff --git a/packages/eas-cli/src/build/utils/devClient.ts b/packages/eas-cli/src/build/utils/devClient.ts index 814d32fe8f..afd51a52d1 100644 --- a/packages/eas-cli/src/build/utils/devClient.ts +++ b/packages/eas-cli/src/build/utils/devClient.ts @@ -24,7 +24,7 @@ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({ nonInteractive?: boolean; buildProfiles?: ProfileData<'build'>[]; }): Promise { - if (await isExpoDevClientInstalledAsync(projectDir)) { + if (isExpoDevClientInstalled(projectDir)) { return; } @@ -103,7 +103,7 @@ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({ } } -async function isExpoDevClientInstalledAsync(projectDir: string): Promise { +export function isExpoDevClientInstalled(projectDir: string): boolean { try { resolveFrom(projectDir, 'expo-dev-client/package.json'); return true; diff --git a/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts b/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts index 01f48067b1..3aba9e8258 100644 --- a/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts +++ b/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts @@ -4,7 +4,7 @@ import resolveFrom from 'resolve-from'; import type { ProfileData } from '../../utils/profiles'; import { resolveVcsClient } from '../../vcs'; -import { detectExpoGoProdBuildAsync } from '../discourageExpoGoForProd'; +import { detectExpoGoProdBuildAsync } from '../discourageExpoGoForProdAsync'; jest.mock('getenv'); jest.mock('resolve-from'); @@ -29,7 +29,10 @@ describe(detectExpoGoProdBuildAsync, () => { beforeEach(() => { jest.clearAllMocks(); jest.mocked(getenv.boolish).mockReturnValue(false); - jest.mocked(resolveFrom.silent).mockReturnValue(undefined); // expo-dev-client is not installed + jest.mocked(resolveFrom).mockImplementation(() => { + // expo-dev-client is not installed + throw new Error('Module not found'); + }); }); describe('should return false', () => { @@ -45,7 +48,7 @@ describe(detectExpoGoProdBuildAsync, () => { }); it('when expo-dev-client is installed - that signals a development build', async () => { - jest.mocked(resolveFrom.silent).mockReturnValue('/path/to/expo-dev-client/package.json'); + jest.mocked(resolveFrom).mockReturnValue('/path/to/expo-dev-client/package.json'); const buildProfiles = [createMockBuildProfile('production')]; const result = await detectExpoGoProdBuildAsync(buildProfiles, projectDir, vcsClient); diff --git a/packages/eas-cli/src/project/discourageExpoGoForProd.ts b/packages/eas-cli/src/project/discourageExpoGoForProd.ts index 8ba7d45cb3..2826282467 100644 --- a/packages/eas-cli/src/project/discourageExpoGoForProd.ts +++ b/packages/eas-cli/src/project/discourageExpoGoForProd.ts @@ -1,43 +1,47 @@ import { Workflow } from '@expo/eas-build-job'; import chalk from 'chalk'; import getenv from 'getenv'; -import resolveFrom from 'resolve-from'; import { resolveWorkflowPerPlatformAsync } from './workflow'; +import { isExpoDevClientInstalled } from '../build/utils/devClient'; import Log, { learnMore } from '../log'; import type { ProfileData } from '../utils/profiles'; import type { Client } from '../vcs/vcs'; const suppressionEnvVarName = 'EAS_BUILD_NO_EXPO_GO_WARNING'; -export const discourageExpoGoForProd = ( +export async function discourageExpoGoForProdAsync( buildProfiles: ProfileData<'build'>[] | undefined, projectDir: string, vcsClient: Client -): void => { - detectExpoGoProdBuildAsync(buildProfiles, projectDir, vcsClient) - .then(usesExpoGo => { - if (usesExpoGo) { - Log.newLine(); - Log.warn( - `โš ๏ธ It appears you're trying to build an app based on Expo Go for production. Expo Go is not a suitable environment for production apps.` - ); - Log.warn( - learnMore('https://docs.expo.dev/develop/development-builds/expo-go-to-dev-build/', { - learnMoreMessage: 'Learn more about converting from Expo Go to a development build', - dim: false, - }) - ); - Log.warn( - chalk.dim(`To suppress this warning, set ${chalk.bold(`${suppressionEnvVarName}=true`)}.`) - ); - Log.newLine(); - } - }) - .catch(err => { - Log.warn('Error detecting whether Expo Go is used:', err); - }); -}; +): Promise { + try { + const isExpoGoProdBuild = await detectExpoGoProdBuildAsync( + buildProfiles, + projectDir, + vcsClient + ); + if (!isExpoGoProdBuild) { + return; + } + Log.newLine(); + Log.warn( + `โš ๏ธ It appears you're trying to build an app based on Expo Go for production. Expo Go is not a suitable environment for production apps.` + ); + Log.warn( + learnMore('https://docs.expo.dev/develop/development-builds/expo-go-to-dev-build/', { + learnMoreMessage: 'Learn more about converting from Expo Go to a development build', + dim: false, + }) + ); + Log.warn( + chalk.dim(`To suppress this warning, set ${chalk.bold(`${suppressionEnvVarName}=true`)}.`) + ); + Log.newLine(); + } catch (err) { + Log.warn('Error detecting whether Expo Go is used:', err); + } +} export async function detectExpoGoProdBuildAsync( buildProfiles: ProfileData<'build'>[] | undefined, @@ -51,7 +55,7 @@ export async function detectExpoGoProdBuildAsync( return false; } - const hasExpoDevClient = checkIfExpoDevClientInstalled(projectDir); + const hasExpoDevClient = isExpoDevClientInstalled(projectDir); if (hasExpoDevClient) { return false; } @@ -67,7 +71,3 @@ async function checkIfManagedWorkflowAsync( return workflows.android === Workflow.MANAGED && workflows.ios === Workflow.MANAGED; } - -function checkIfExpoDevClientInstalled(projectDir: string): boolean { - return resolveFrom.silent(projectDir, 'expo-dev-client/package.json') !== undefined; -}