diff --git a/packages/build-tools/src/utils/__tests__/diffFingerprintsAsync.test.ts b/packages/build-tools/src/utils/__tests__/diffFingerprintsAsync.test.ts new file mode 100644 index 00000000..e614b89c --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/diffFingerprintsAsync.test.ts @@ -0,0 +1,84 @@ +import { vol } from 'memfs'; +import fs from 'fs-extra'; + +import { diffFingerprintsAsync } from '../diffFingerprintsAsync'; +import { + ExpoFingerprintCLIModuleNotFoundError, + expoFingerprintCommandAsync, +} from '../expoFingerprintCli'; + +jest.mock('../expoFingerprintCli', () => ({ + ...jest.requireActual('../expoFingerprintCli'), + expoFingerprintCommandAsync: jest.fn(), +})); + +jest.mock('fs'); + +describe(diffFingerprintsAsync, () => { + beforeEach(async () => { + vol.reset(); + }); + + it('falls back to local when CLI is not available', async () => { + await fs.mkdirp('test'); + await fs.writeFile( + 'test/fp1', + Buffer.from( + JSON.stringify({ + sources: [ + { + type: 'file', + filePath: './assets/images/adaptive-icon.png', + reasons: ['expoConfigExternalFile'], + hash: '19b53640a95efdc2ccc7fc20f3ea4d0d381bb5c4', + }, + ], + }) + ) + ); + + await fs.writeFile( + 'test/fp2', + Buffer.from( + JSON.stringify({ + sources: [ + { + type: 'file', + filePath: './assets/images/adaptive-icon.png', + reasons: ['expoConfigExternalFile'], + hash: '19b53640a95efdc2ccc7fc20f3ea4d0d381bb5c5', + }, + ], + }) + ) + ); + + jest + .mocked(expoFingerprintCommandAsync) + .mockRejectedValue(new ExpoFingerprintCLIModuleNotFoundError()); + + const diff = await diffFingerprintsAsync('test', 'test/fp1', 'test/fp2', { + env: {}, + logger: { debug: jest.fn() } as any, + }); + expect(diff).toEqual( + JSON.stringify([ + { + op: 'changed', + beforeSource: { + type: 'file', + filePath: './assets/images/adaptive-icon.png', + reasons: ['expoConfigExternalFile'], + hash: '19b53640a95efdc2ccc7fc20f3ea4d0d381bb5c4', + }, + afterSource: { + type: 'file', + filePath: './assets/images/adaptive-icon.png', + reasons: ['expoConfigExternalFile'], + hash: '19b53640a95efdc2ccc7fc20f3ea4d0d381bb5c5', + }, + }, + ]) + ); + }); +}); diff --git a/packages/build-tools/src/utils/diffFingerprintsAsync.ts b/packages/build-tools/src/utils/diffFingerprintsAsync.ts new file mode 100644 index 00000000..2df07546 --- /dev/null +++ b/packages/build-tools/src/utils/diffFingerprintsAsync.ts @@ -0,0 +1,56 @@ +import { BuildStepEnv } from '@expo/steps'; +import fs from 'fs-extra'; +import { bunyan } from '@expo/logger'; + +import { + ExpoFingerprintCLICommandFailedError, + ExpoFingerprintCLIInvalidCommandError, + ExpoFingerprintCLIModuleNotFoundError, + expoFingerprintCommandAsync, +} from './expoFingerprintCli'; +import { diffFingerprints } from './fingerprint'; + +export async function diffFingerprintsAsync( + projectDir: string, + fingerprint1File: string, + fingerprint2File: string, + { env, logger }: { env: BuildStepEnv; logger: bunyan } +): Promise { + try { + return await diffFingerprintsCommandAsync(projectDir, fingerprint1File, fingerprint2File, { + env, + }); + } catch (e) { + if ( + e instanceof ExpoFingerprintCLIModuleNotFoundError || + e instanceof ExpoFingerprintCLICommandFailedError || + e instanceof ExpoFingerprintCLIInvalidCommandError + ) { + logger.debug('Falling back to local fingerprint diff'); + return await diffFingerprintsFallbackAsync(fingerprint1File, fingerprint2File); + } + throw e; + } +} + +async function diffFingerprintsCommandAsync( + projectDir: string, + fingerprint1File: string, + fingerprint2File: string, + { env }: { env: BuildStepEnv } +): Promise { + return await expoFingerprintCommandAsync(projectDir, [fingerprint1File, fingerprint2File], { + env, + }); +} + +async function diffFingerprintsFallbackAsync( + fingerprint1File: string, + fingerprint2File: string +): Promise { + const [fingeprint1, fingerprint2] = await Promise.all([ + fs.readJSON(fingerprint1File), + fs.readJSON(fingerprint2File), + ]); + return JSON.stringify(diffFingerprints(fingeprint1, fingerprint2)); +} diff --git a/packages/build-tools/src/utils/expoFingerprintCli.ts b/packages/build-tools/src/utils/expoFingerprintCli.ts new file mode 100644 index 00000000..3bd6865f --- /dev/null +++ b/packages/build-tools/src/utils/expoFingerprintCli.ts @@ -0,0 +1,51 @@ +import resolveFrom from 'resolve-from'; +import spawnAsync from '@expo/turtle-spawn'; +import { BuildStepEnv } from '@expo/steps'; + +export class ExpoFingerprintCLIModuleNotFoundError extends Error {} +export class ExpoFingerprintCLIInvalidCommandError extends Error {} +export class ExpoFingerprintCLICommandFailedError extends Error {} + +function resolveExpoFingerprintCLI(projectRoot: string): string { + const expoPackageRoot = resolveFrom.silent(projectRoot, 'expo/package.json'); + try { + return ( + resolveFrom.silent(expoPackageRoot ?? projectRoot, '@expo/fingerprint/bin/cli') ?? + resolveFrom(expoPackageRoot ?? projectRoot, '@expo/fingerprint/bin/cli.js') + ); + } catch (e: any) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new ExpoFingerprintCLIModuleNotFoundError( + `The \`@expo/fingerprint\` package was not found.` + ); + } + throw e; + } +} + +export async function expoFingerprintCommandAsync( + projectDir: string, + args: string[], + { env }: { env: BuildStepEnv } +): Promise { + const expoFingerprintCli = resolveExpoFingerprintCLI(projectDir); + try { + const spawnResult = await spawnAsync(expoFingerprintCli, args, { + stdio: 'pipe', + cwd: projectDir, + env, + }); + return spawnResult.stdout; + } catch (e: any) { + if (e.stderr && typeof e.stderr === 'string') { + if (e.stderr.includes('Invalid command')) { + throw new ExpoFingerprintCLIInvalidCommandError( + `The command specified by ${args} was not valid in the \`@expo/fingerprint\` CLI.` + ); + } else { + throw new ExpoFingerprintCLICommandFailedError(e.stderr); + } + } + throw e; + } +} diff --git a/packages/build-tools/src/utils/expoUpdates.ts b/packages/build-tools/src/utils/expoUpdates.ts index 9b5d0d74..35a68066 100644 --- a/packages/build-tools/src/utils/expoUpdates.ts +++ b/packages/build-tools/src/utils/expoUpdates.ts @@ -1,5 +1,8 @@ import assert from 'assert'; +import os from 'os'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; import { Platform, Job, BuildJob, Workflow, FingerprintSourceType } from '@expo/eas-build-job'; import semver from 'semver'; import { ExpoConfig } from '@expo/config'; @@ -24,12 +27,8 @@ import { BuildContext } from '../context'; import getExpoUpdatesPackageVersionIfInstalledAsync from './getExpoUpdatesPackageVersionIfInstalledAsync'; import { resolveRuntimeVersionAsync } from './resolveRuntimeVersionAsync'; -import { - Fingerprint, - FingerprintSource, - diffFingerprints, - stringifyFingerprintDiff, -} from './fingerprint'; +import { diffFingerprintsAsync } from './diffFingerprintsAsync'; +import { stringifyFingerprintDiff } from './fingerprint'; export async function setRuntimeVersionNativelyAsync( ctx: BuildContext, @@ -85,7 +84,7 @@ export async function configureEASExpoUpdatesAsync(ctx: BuildContext): type ResolvedRuntime = { resolvedRuntimeVersion: string | null; - resolvedFingerprintSources?: FingerprintSource[] | null; + resolvedFingerprintSources?: object[] | null; }; export async function configureExpoUpdatesIfInstalledAsync( @@ -176,7 +175,7 @@ export async function resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({ env: BuildStepEnv; }): Promise<{ runtimeVersion: string | null; - fingerprintSources: FingerprintSource[] | null; + fingerprintSources: object[] | null; } | null> { const expoUpdatesPackageVersion = await getExpoUpdatesPackageVersionIfInstalledAsync(cwd, logger); if (expoUpdatesPackageVersion === null) { @@ -261,23 +260,34 @@ async function logDiffFingerprints({ try { const fingerprintSource = ctx.metadata.fingerprintSource; - let localFingerprint: Fingerprint | null = null; + let localFingerprintFile: string | null = null; if (fingerprintSource.type === FingerprintSourceType.URL) { const result = await fetch(fingerprintSource.url); - localFingerprint = await result.json(); + const localFingerprintJSON = await result.json(); + localFingerprintFile = path.join(os.tmpdir(), `eas-build-${uuidv4()}-local-fingerprint`); + await fs.writeFile(localFingerprintFile, JSON.stringify(localFingerprintJSON)); } else if (fingerprintSource.type === FingerprintSourceType.PATH) { - localFingerprint = await fs.readJson(fingerprintSource.path); + localFingerprintFile = fingerprintSource.path; } else { ctx.logger.warn(`Invalid fingerprint source type: ${fingerprintSource.type}`); } - if (localFingerprint) { + if (localFingerprintFile) { const easFingerprint = { hash: resolvedRuntimeVersion, sources: resolvedFingerprintSources, }; - const changes = diffFingerprints(localFingerprint, easFingerprint); + const easFingerprintFile = path.join(os.tmpdir(), `eas-build-${uuidv4()}-eas-fingerprint`); + await fs.writeFile(easFingerprintFile, JSON.stringify(easFingerprint)); + + const changesJSONString = await diffFingerprintsAsync( + ctx.getReactNativeProjectDirectory(), + localFingerprintFile, + easFingerprintFile, + { env: ctx.env, logger: ctx.logger } + ); + const changes = JSON.parse(changesJSONString); if (changes.length) { ctx.logger.warn('Difference between local and EAS fingerprints:'); ctx.logger.warn(stringifyFingerprintDiff(changes)); diff --git a/packages/build-tools/src/utils/resolveRuntimeVersionAsync.ts b/packages/build-tools/src/utils/resolveRuntimeVersionAsync.ts index 7dd321bf..393c894d 100644 --- a/packages/build-tools/src/utils/resolveRuntimeVersionAsync.ts +++ b/packages/build-tools/src/utils/resolveRuntimeVersionAsync.ts @@ -6,7 +6,6 @@ import { BuildStepEnv } from '@expo/steps'; import { ExpoUpdatesCLIModuleNotFoundError, expoUpdatesCommandAsync } from './expoUpdatesCli'; import { isModernExpoUpdatesCLIWithRuntimeVersionCommandSupported } from './expoUpdates'; -import { FingerprintSource } from './fingerprint'; export async function resolveRuntimeVersionAsync({ exp, @@ -26,7 +25,7 @@ export async function resolveRuntimeVersionAsync({ env: BuildStepEnv; }): Promise<{ runtimeVersion: string | null; - fingerprintSources: FingerprintSource[] | null; + fingerprintSources: object[] | null; } | null> { if (!isModernExpoUpdatesCLIWithRuntimeVersionCommandSupported(expoUpdatesPackageVersion)) { logger.debug('Using expo-updates config plugin for runtime version resolution');