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..cda7f1165b 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 { discourageExpoGoForProdAsync } from '../project/discourageExpoGoForProd'; import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk'; import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig'; import { @@ -146,6 +147,8 @@ export async function runBuildAndSubmitAsync({ projectDir, }); + await discourageExpoGoForProdAsync(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/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/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..3aba9e8258 --- /dev/null +++ b/packages/eas-cli/src/project/__tests__/discourageExpoGoForProd-test.ts @@ -0,0 +1,86 @@ +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 '../discourageExpoGoForProdAsync'; + +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).mockImplementation(() => { + // expo-dev-client is not installed + throw new Error('Module not found'); + }); + }); + + 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).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..2826282467 --- /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 { 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 async function discourageExpoGoForProdAsync( + buildProfiles: ProfileData<'build'>[] | undefined, + projectDir: string, + vcsClient: Client +): 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, + 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 = isExpoDevClientInstalled(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; +}