diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a5322178..d7fe2587a5 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 development mode and remove inherited dotenv values when creating project fingerprints. ([#4246](https://github.com/expo/eas-cli/pull/4246) by [@ramonclaudio](https://github.com/ramonclaudio)) + ### ๐Ÿงน Chores ## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20 diff --git a/packages/eas-cli/src/fingerprint/__tests__/cli.test.ts b/packages/eas-cli/src/fingerprint/__tests__/cli.test.ts new file mode 100644 index 0000000000..be2ecf503e --- /dev/null +++ b/packages/eas-cli/src/fingerprint/__tests__/cli.test.ts @@ -0,0 +1,169 @@ +import { createFingerprintAsync, createFingerprintsByKeyAsync } from '../cli'; + +const mockCreateFingerprintAsync = jest.fn(); + +jest.mock('resolve-from', () => ({ + silent: jest.fn(() => 'expo/fingerprint'), +})); +jest.mock( + 'expo/fingerprint', + () => ({ + createFingerprintAsync: mockCreateFingerprintAsync, + }), + { virtual: true } +); +jest.mock('../../ora', () => ({ + ora: () => ({ + start() { + return this; + }, + succeed: jest.fn(), + fail: jest.fn(), + stop: jest.fn(), + }), +})); + +describe('Fingerprint env', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + DOTENV_VALUE: 'from-parent', + PARENT_DOTENV_VALUE: 'from-parent', + FROM_PROCESS: 'true', + NODE_ENV: 'production', + __EXPO_ENV_LOADED: '["DOTENV_VALUE","PARENT_DOTENV_VALUE"]', + __EXPO_CONFIG_MODE: 'production', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('removes inherited dotenv values and uses development mode', async () => { + const envBeforeFingerprint = process.env; + const envValuesBeforeFingerprint = { ...process.env }; + const fingerprintEnv = { + DOTENV_VALUE: 'from-eas', + NODE_ENV: 'staging', + __EXPO_ENV_LOADED: '["DOTENV_VALUE"]', + __EXPO_CONFIG_MODE: 'staging', + }; + const fingerprintEnvBefore = { ...fingerprintEnv }; + mockCreateFingerprintAsync.mockImplementationOnce(async () => { + expect(process.env).toMatchObject({ + DOTENV_VALUE: 'from-eas', + FROM_PROCESS: 'true', + NODE_ENV: 'development', + }); + expect(process.env.PARENT_DOTENV_VALUE).toBeUndefined(); + expect(process.env.__EXPO_ENV_LOADED).toBeUndefined(); + expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined(); + return { hash: 'hash', sources: [] }; + }); + + await createFingerprintAsync('/app', { + platforms: ['ios'], + env: fingerprintEnv, + }); + + expect(process.env).toBe(envBeforeFingerprint); + expect(process.env).toEqual(envValuesBeforeFingerprint); + expect(fingerprintEnv).toEqual(fingerprintEnvBefore); + }); + + it('runs fingerprints with different envs one at a time', async () => { + const envBeforeFingerprints = process.env; + let resolveIosFingerprint!: (value: { hash: string; sources: never[] }) => void; + let resolveAndroidFingerprint!: (value: { hash: string; sources: never[] }) => void; + mockCreateFingerprintAsync + .mockImplementationOnce( + () => + new Promise(resolve => { + expect(process.env.FINGERPRINT_TARGET).toBe('ios'); + resolveIosFingerprint = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise(resolve => { + expect(process.env.FINGERPRINT_TARGET).toBe('android'); + resolveAndroidFingerprint = resolve; + }) + ); + + const fingerprintsPromise = createFingerprintsByKeyAsync( + '/app', + new Map([ + ['ios', { platforms: ['ios'], env: { FINGERPRINT_TARGET: 'ios' } }], + ['android', { platforms: ['android'], env: { FINGERPRINT_TARGET: 'android' } }], + ]) + ); + + expect(mockCreateFingerprintAsync).toHaveBeenCalledTimes(1); + expect(process.env.FINGERPRINT_TARGET).toBe('ios'); + + resolveIosFingerprint({ hash: 'ios', sources: [] }); + await new Promise(resolve => setImmediate(resolve)); + + expect(mockCreateFingerprintAsync).toHaveBeenCalledTimes(2); + expect(process.env.FINGERPRINT_TARGET).toBe('android'); + + resolveAndroidFingerprint({ hash: 'android', sources: [] }); + await expect(fingerprintsPromise).resolves.toEqual( + new Map([ + ['ios', { hash: 'ios', sources: [] }], + ['android', { hash: 'android', sources: [] }], + ]) + ); + expect(process.env).toBe(envBeforeFingerprints); + }); + + it('keeps the env until parallel Fingerprint calls finish after a failure', async () => { + const envBeforeFingerprints = process.env; + let rejectFirstFingerprint!: (error: Error) => void; + let resolveSecondFingerprint!: (value: { hash: string; sources: never[] }) => void; + mockCreateFingerprintAsync + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectFirstFingerprint = reject; + }) + ) + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveSecondFingerprint = resolve; + }) + ); + + const fingerprintsPromise = createFingerprintsByKeyAsync( + '/app', + new Map([ + ['ios', { platforms: ['ios'], env: undefined }], + ['android', { platforms: ['android'], env: undefined }], + ]) + ); + let didReject = false; + const rejectionPromise = fingerprintsPromise.catch(error => { + didReject = true; + throw error; + }); + + expect(mockCreateFingerprintAsync).toHaveBeenCalledTimes(2); + expect(process.env.NODE_ENV).toBe('development'); + + rejectFirstFingerprint(new Error('fingerprint failed')); + await new Promise(resolve => setImmediate(resolve)); + expect(didReject).toBe(false); + expect(process.env.NODE_ENV).toBe('development'); + + resolveSecondFingerprint({ hash: 'android', sources: [] }); + await expect(rejectionPromise).rejects.toThrow('fingerprint failed'); + + expect(process.env).toBe(envBeforeFingerprints); + }); +}); diff --git a/packages/eas-cli/src/fingerprint/cli.ts b/packages/eas-cli/src/fingerprint/cli.ts index effa099ca4..1164ad1278 100644 --- a/packages/eas-cli/src/fingerprint/cli.ts +++ b/packages/eas-cli/src/fingerprint/cli.ts @@ -4,7 +4,7 @@ import { silent as silentResolveFrom } from 'resolve-from'; import { Fingerprint, FingerprintDiffItem } from './types'; import Log from '../log'; import { ora } from '../ora'; -import mapMapAsync from '../utils/expodash/mapMapAsync'; +import { getEnvWithoutInheritedDotenvValues } from '../utils/originalEnv'; export type FingerprintOptions = { workflow?: Workflow; @@ -83,6 +83,20 @@ async function createFingerprintWithoutLoggingAsync( Fingerprint & { isDebugSource: boolean; } +> { + return await withTemporaryEnvAsync(options.env ?? {}, () => + createFingerprintWithCurrentEnvAsync(projectDir, fingerprintPath, options) + ); +} + +async function createFingerprintWithCurrentEnvAsync( + projectDir: string, + fingerprintPath: string, + options: FingerprintOptions +): Promise< + Fingerprint & { + isDebugSource: boolean; + } > { const Fingerprint = require(fingerprintPath); const fingerprintOptions: Record = {}; @@ -105,14 +119,18 @@ async function createFingerprintWithoutLoggingAsync( } fingerprintOptions.silent = true; - return await withTemporaryEnvAsync(options.env ?? {}, () => - Fingerprint.createFingerprintAsync(projectDir, fingerprintOptions) - ); + return await Fingerprint.createFingerprintAsync(projectDir, fingerprintOptions); } -async function withTemporaryEnvAsync(envVars: Env, fn: () => Promise): Promise { - const originalEnv = { ...process.env }; - Object.assign(process.env, envVars); +async function withTemporaryEnvAsync(envVars: Env, fn: () => Promise): Promise { + const originalEnv = process.env; + process.env = { + ...getEnvWithoutInheritedDotenvValues(process.env), + ...envVars, + NODE_ENV: 'development', + }; + delete process.env.__EXPO_ENV_LOADED; + delete process.env.__EXPO_CONFIG_MODE; try { return await fn(); @@ -162,11 +180,41 @@ export async function createFingerprintsByKeyAsync( const spinner = ora(`Computing project fingerprints`).start(); try { - const fingerprintsByKey = await mapMapAsync( - fingerprintOptionsByKey, - async options => - await createFingerprintWithoutLoggingAsync(projectDir, fingerprintPath, options) - ); + // Fingerprint reads process.env, so only calls that use the same env can run together. + const fingerprintOptionsByEnv = new Map(); + for (const entry of fingerprintOptionsByKey.entries()) { + const env = entry[1].env; + const entries = fingerprintOptionsByEnv.get(env) ?? []; + entries.push(entry); + fingerprintOptionsByEnv.set(env, entries); + } + + const fingerprintsByKey = new Map< + string, + Fingerprint & { + isDebugSource: boolean; + } + >(); + for (const [env, entries] of fingerprintOptionsByEnv) { + const fingerprints = await withTemporaryEnvAsync(env ?? {}, async () => { + const fingerprintPromises = entries.map( + async ([key, options]) => + [ + key, + await createFingerprintWithCurrentEnvAsync(projectDir, fingerprintPath, options), + ] as const + ); + try { + return await Promise.all(fingerprintPromises); + } catch (error) { + await Promise.allSettled(fingerprintPromises); + throw error; + } + }); + for (const [key, fingerprint] of fingerprints) { + fingerprintsByKey.set(key, fingerprint); + } + } spinner.succeed(`Computed project fingerprints`); return fingerprintsByKey; } catch (e) {