diff --git a/CHANGELOG.md b/CHANGELOG.md index 854d90c5a1..844059307d 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 +- Calculate fingerprint on each update. ([#2687](https://github.com/expo/eas-cli/pull/2687) by [@quinlanj](https://github.com/quinlanj)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/build/build.ts b/packages/eas-cli/src/build/build.ts index c8fec7e583..5d9ebecc9f 100644 --- a/packages/eas-cli/src/build/build.ts +++ b/packages/eas-cli/src/build/build.ts @@ -671,7 +671,7 @@ async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync { const runtimeAndFingerprintMetadata = await computeAndMaybeUploadFingerprintFromExpoUpdatesAsync(ctx); - if (!runtimeAndFingerprintMetadata?.fingerprint) { + if (!runtimeAndFingerprintMetadata?.fingerprintHash) { const fingerprint = await computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync(ctx); return { ...runtimeAndFingerprintMetadata, @@ -690,10 +690,7 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync { const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({ exp: ctx.exp, @@ -727,7 +724,7 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync { const fingerprint = await createFingerprintAsync(ctx.projectDir, { workflow: ctx.workflow, - platform: ctx.platform, + platforms: [ctx.platform], env: ctx.env, }); if (!fingerprint) { diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 5b4f4185b7..3f01c636ca 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -13,6 +13,7 @@ import { getPaginatedQueryOptions } from '../../commandUtils/pagination'; import fetch from '../../fetch'; import { EnvironmentVariableEnvironment, + FingerprintInfoGroup, PublishUpdateGroupInput, StatuspageServiceName, UpdateInfoGroup, @@ -39,6 +40,7 @@ import { getRuntimeVersionInfoObjectsAsync, getUpdateMessageForCommandAsync, isUploadedAssetCountAboveWarningThreshold, + maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync, platformDisplayNames, resolveInputDirectoryAsync, uploadAssetsAsync, @@ -403,22 +405,33 @@ export default class UpdatePublish extends EasCommand { branchName, }); - const runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping = await Promise.all( - runtimeToPlatformsAndFingerprintInfoMapping.map(async info => { - return { - ...info, - fingerprintSource: info.fingerprint - ? ( - await maybeUploadFingerprintAsync({ - hash: info.runtimeVersion, - fingerprint: info.fingerprint, - graphqlClient, - }) - ).fingerprintSource ?? null - : null, - }; - }) - ); + const runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMappingFromExpoUpdates = + await Promise.all( + runtimeToPlatformsAndFingerprintInfoMapping.map(async info => { + return { + ...info, + fingerprintSource: info.fingerprint + ? ( + await maybeUploadFingerprintAsync({ + hash: info.runtimeVersion, + fingerprint: info.fingerprint, + graphqlClient, + }) + ).fingerprintSource ?? null + : null, + }; + }) + ); + + const runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping = + await maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync({ + projectDir, + graphqlClient, + runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping: + runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMappingFromExpoUpdates, + workflowsByPlatform: workflows, + env: undefined, + }); const runtimeVersionToRolloutInfoGroup = rolloutPercentage !== undefined @@ -436,7 +449,7 @@ export default class UpdatePublish extends EasCommand { // Sort the updates into different groups based on their platform specific runtime versions const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map( - ({ runtimeVersion, platforms, fingerprintSource }) => { + ({ runtimeVersion, platforms, fingerprintSource, fingerprintInfoGroup }) => { const localUpdateInfoGroup = Object.fromEntries( platforms.map(platform => [ platform, @@ -455,6 +468,18 @@ export default class UpdatePublish extends EasCommand { ]) ) : null; + const transformedFingerprintInfoGroup = Object.entries(fingerprintInfoGroup).reduce( + (prev, [platform, fingerprintInfo]) => { + return { + ...prev, + [platform]: { + ...fingerprintInfo, + fingerprintSource: transformFingerprintSource(fingerprintInfo.fingerprintSource), + }, + }; + }, + {} as FingerprintInfoGroup + ); return { branchId, @@ -463,6 +488,7 @@ export default class UpdatePublish extends EasCommand { runtimeFingerprintSource: fingerprintSource ? transformFingerprintSource(fingerprintSource) : null, + fingerprintInfoGroup: transformedFingerprintInfoGroup, runtimeVersion, message: updateMessage, gitCommitHash, diff --git a/packages/eas-cli/src/project/publish.ts b/packages/eas-cli/src/project/publish.ts index 5e11c256ed..707251c834 100644 --- a/packages/eas-cli/src/project/publish.ts +++ b/packages/eas-cli/src/project/publish.ts @@ -1,6 +1,6 @@ import { ExpoConfig, Platform as ExpoConfigPlatform } from '@expo/config'; import { Updates } from '@expo/config-plugins'; -import { Env, Workflow } from '@expo/eas-build-job'; +import { Env, FingerprintSource, Platform, Workflow } from '@expo/eas-build-job'; import JsonFile from '@expo/json-file'; import assert from 'assert'; import chalk from 'chalk'; @@ -12,6 +12,7 @@ import nullthrows from 'nullthrows'; import path from 'path'; import promiseLimit from 'promise-limit'; +import { maybeUploadFingerprintAsync } from './maybeUploadFingerprintAsync'; import { isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync } from './projectUtils'; import { resolveRuntimeVersionUsingCLIAsync } from './resolveRuntimeVersionAsync'; import { selectBranchOnAppAsync } from '../branch/queries'; @@ -47,7 +48,9 @@ import { ExpoUpdatesCLIModuleNotFoundError } from '../utils/expoUpdatesCli'; import chunk from '../utils/expodash/chunk'; import { truthy } from '../utils/expodash/filter'; import groupBy from '../utils/expodash/groupBy'; +import mapMapAsync from '../utils/expodash/mapMapAsync'; import uniqBy from '../utils/expodash/uniqBy'; +import { FingerprintOptions, createFingerprintsByKeyAsync } from '../utils/fingerprintCli'; import { Client } from '../vcs/vcs'; // update publish does not currently support web @@ -728,6 +731,16 @@ export type RuntimeVersionInfo = { fingerprintSources: object[]; isDebugFingerprintSource: boolean; } | null; + fingerprintHash: string | null; +}; + +type FingerprintInfoGroup = { + [key in UpdatePublishPlatform]?: FingerprintInfo; +}; + +type FingerprintInfo = { + fingerprintHash: string; + fingerprintSource: FingerprintSource; }; export async function getRuntimeVersionInfoObjectsAsync({ @@ -782,6 +795,7 @@ async function getRuntimeVersionInfoForPlatformAsync({ fingerprintSources: object[]; isDebugFingerprintSource: boolean; } | null; + fingerprintHash: string | null; }> { if (await isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync(projectDir)) { try { @@ -833,6 +847,7 @@ async function getRuntimeVersionInfoForPlatformAsync({ return { runtimeVersion: resolvedRuntimeVersion, fingerprint: null, + fingerprintHash: null, }; } @@ -858,9 +873,119 @@ export function getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersion runtimeVersionInfoObjects.map( runtimeVersionInfoObject => runtimeVersionInfoObject.runtimeVersionInfo.fingerprint )[0] ?? null, + fingerprintHash: + runtimeVersionInfoObjects.map( + runtimeVersionInfoObject => runtimeVersionInfoObject.runtimeVersionInfo.fingerprintHash + )[0] ?? null, + }; + } + ); +} + +export async function maybeCalculateFingerprintForRuntimeVersionInfoObjectsWithoutExpoUpdatesAsync({ + projectDir, + graphqlClient, + runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping, + workflowsByPlatform, + env, +}: { + projectDir: string; + graphqlClient: ExpoGraphqlClient; + runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping: (RuntimeVersionInfo & { + platforms: UpdatePublishPlatform[]; + fingerprintSource: FingerprintSource | null; + })[]; + workflowsByPlatform: Record; + env: Env | undefined; +}): Promise< + (RuntimeVersionInfo & { + platforms: UpdatePublishPlatform[]; + fingerprintSource: FingerprintSource | null; + fingerprintInfoGroup: FingerprintInfoGroup; + })[] +> { + const runtimesToComputeFingerprintsFor = + runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.filter( + infoGroup => !infoGroup.fingerprintHash + ); + const fingerprintOptionsByRuntimeAndPlatform = new Map(); + for (const infoGroup of runtimesToComputeFingerprintsFor) { + for (const platform of infoGroup.platforms) { + const runtimeAndPlatform = `${infoGroup.runtimeVersion}-${platform}`; + const options = { + platforms: [platform], + workflow: workflowsByPlatform[platform], + projectDir, + env, }; + fingerprintOptionsByRuntimeAndPlatform.set(runtimeAndPlatform, options); } + } + const fingerprintsByRuntimeAndPlatform = await createFingerprintsByKeyAsync( + projectDir, + fingerprintOptionsByRuntimeAndPlatform ); + const uploadedFingerprintsByRuntimeAndPlatform = await mapMapAsync( + fingerprintsByRuntimeAndPlatform, + async fingerprint => { + return { + ...fingerprint, + uploadedSource: ( + await maybeUploadFingerprintAsync({ + hash: fingerprint.hash, + fingerprint: { + fingerprintSources: fingerprint.sources, + isDebugFingerprintSource: fingerprint.isDebugSource, + }, + graphqlClient, + }) + ).fingerprintSource, + }; + } + ); + const runtimesWithComputedFingerprint = runtimesToComputeFingerprintsFor.map(runtimeInfo => { + const fingerprintInfoGroup: FingerprintInfoGroup = {}; + for (const platform of runtimeInfo.platforms) { + const runtimeAndPlatform = `${runtimeInfo.runtimeVersion}-${platform}`; + const fingerprint = uploadedFingerprintsByRuntimeAndPlatform.get(runtimeAndPlatform); + if (fingerprint && fingerprint.uploadedSource) { + fingerprintInfoGroup[platform] = { + fingerprintHash: fingerprint.hash, + fingerprintSource: fingerprint.uploadedSource, + }; + } + } + return { + ...runtimeInfo, + fingerprintInfoGroup, + }; + }); + + // These are runtimes whose fingerprint has already been computed and uploaded with EAS Update fingerprint runtime policy + const runtimesWithPreviouslyComputedFingerprints = + runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping + .filter( + ( + infoGroup + ): infoGroup is RuntimeVersionInfo & { + platforms: UpdatePublishPlatform[]; + fingerprintSource: FingerprintSource; + fingerprintHash: string; + } => !!infoGroup.fingerprintHash && !!infoGroup.fingerprintSource + ) + .map(infoGroup => { + const platform = infoGroup.platforms[0]; + return { + ...infoGroup, + fingerprintInfoGroup: { + [platform]: { + fingerprintHash: infoGroup.fingerprintHash, + fingerprintSource: infoGroup.fingerprintSource, + }, + }, + }; + }); + return [...runtimesWithComputedFingerprint, ...runtimesWithPreviouslyComputedFingerprints]; } export const platformDisplayNames: Record = { @@ -873,21 +998,6 @@ export const updatePublishPlatformToAppPlatform: Record( - map: ReadonlyMap, - mapper: (value: V, key: K) => Promise -): Promise> { - const resultingMap: Map = new Map(); - await Promise.all( - Array.from(map.keys()).map(async k => { - const initialValue = map.get(k) as V; - const result = await mapper(initialValue, k); - resultingMap.set(k, result); - }) - ); - return resultingMap; -}; - export async function getRuntimeToUpdateRolloutInfoGroupMappingAsync( graphqlClient: ExpoGraphqlClient, { diff --git a/packages/eas-cli/src/project/resolveRuntimeVersionAsync.ts b/packages/eas-cli/src/project/resolveRuntimeVersionAsync.ts index 6305e29072..eeb00bd380 100644 --- a/packages/eas-cli/src/project/resolveRuntimeVersionAsync.ts +++ b/packages/eas-cli/src/project/resolveRuntimeVersionAsync.ts @@ -27,6 +27,7 @@ export async function resolveRuntimeVersionUsingCLIAsync({ fingerprintSources: object[]; isDebugFingerprintSource: boolean; } | null; + fingerprintHash: string | null; }> { Log.debug('Using expo-updates runtimeversion:resolve CLI for runtime version resolution'); @@ -52,6 +53,9 @@ export async function resolveRuntimeVersionUsingCLIAsync({ isDebugFingerprintSource: useDebugFingerprintSource, } : null, + fingerprintHash: runtimeVersionResult.fingerprintSources + ? runtimeVersionResult.runtimeVersion + : null, }; } @@ -75,6 +79,7 @@ export async function resolveRuntimeVersionAsync({ fingerprintSources: object[]; isDebugFingerprintSource: boolean; } | null; + fingerprintHash: string | null; } | null> { if (!(await isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync(projectDir))) { // fall back to the previous behavior (using the @expo/config-plugins eas-cli dependency rather @@ -82,6 +87,7 @@ export async function resolveRuntimeVersionAsync({ return { runtimeVersion: await Updates.getRuntimeVersionNullableAsync(projectDir, exp, platform), fingerprint: null, + fingerprintHash: null, }; } diff --git a/packages/eas-cli/src/utils/expodash/mapMapAsync.ts b/packages/eas-cli/src/utils/expodash/mapMapAsync.ts new file mode 100644 index 0000000000..0c32b626cd --- /dev/null +++ b/packages/eas-cli/src/utils/expodash/mapMapAsync.ts @@ -0,0 +1,14 @@ +export default async function mapMapAsync( + map: ReadonlyMap, + mapper: (value: V, key: K) => Promise +): Promise> { + const resultingMap: Map = new Map(); + await Promise.all( + Array.from(map.keys()).map(async k => { + const initialValue = map.get(k) as V; + const result = await mapper(initialValue, k); + resultingMap.set(k, result); + }) + ); + return resultingMap; +} diff --git a/packages/eas-cli/src/utils/fingerprintCli.ts b/packages/eas-cli/src/utils/fingerprintCli.ts index 0c69da8139..9dc6b37b31 100644 --- a/packages/eas-cli/src/utils/fingerprintCli.ts +++ b/packages/eas-cli/src/utils/fingerprintCli.ts @@ -1,18 +1,21 @@ import { Env, Workflow } from '@expo/eas-build-job'; import { silent as silentResolveFrom } from 'resolve-from'; +import mapMapAsync from './expodash/mapMapAsync'; import Log from '../log'; import { ora } from '../ora'; +export type FingerprintOptions = { + workflow: Workflow; + platforms: string[]; + debug?: boolean; + env: Env | undefined; + cwd?: string; +}; + export async function createFingerprintAsync( projectDir: string, - options: { - workflow: Workflow; - platform: string; - debug?: boolean; - env: Env | undefined; - cwd?: string; - } + options: FingerprintOptions ): Promise<{ hash: string; sources: object[]; @@ -36,18 +39,11 @@ export async function createFingerprintAsync( const spinner = ora(`Computing project fingerprint`).start(); try { - const Fingerprint = require(fingerprintPath); - const fingerprintOptions: Record = {}; - if (options.platform) { - fingerprintOptions.platforms = [options.platform]; - } - if (options.workflow === Workflow.MANAGED) { - fingerprintOptions.ignorePaths = ['android/**/*', 'ios/**/*']; - } - if (options.debug) { - fingerprintOptions.debug = true; - } - const fingerprint = await Fingerprint.createFingerprintAsync(projectDir, fingerprintOptions); + const fingerprint = await createFingerprintWithoutLoggingAsync( + projectDir, + fingerprintPath, + options + ); spinner.succeed(`Computed project fingerprint`); return fingerprint; } catch (e) { @@ -60,3 +56,88 @@ export async function createFingerprintAsync( spinner.stop(); } } + +async function createFingerprintWithoutLoggingAsync( + projectDir: string, + fingerprintPath: string, + options: FingerprintOptions +): Promise<{ + hash: string; + sources: object[]; + isDebugSource: boolean; +}> { + const Fingerprint = require(fingerprintPath); + const fingerprintOptions: Record = {}; + if (options.platforms) { + fingerprintOptions.platforms = [...options.platforms]; + } + if (options.workflow === Workflow.MANAGED) { + fingerprintOptions.ignorePaths = ['android/**/*', 'ios/**/*']; + } + if (options.debug) { + fingerprintOptions.debug = true; + } + // eslint-disable-next-line @typescript-eslint/return-await + return await Fingerprint.createFingerprintAsync(projectDir, fingerprintOptions); +} + +/** + * Computes project fingerprints based on provided options and returns a map of fingerprint data keyed by a string. + * + * @param projectDir - The root directory of the project. + * @param fingerprintOptionsByKey - A map where each key is associated with options for generating the fingerprint. + * - **Key**: A unique identifier (`string`) for the fingerprint options. + * - **Value**: An object containing options for generating a fingerprint. + * + * @returns A promise that resolves to a map where each key corresponds to the input keys, and each value is an object containing fingerprint data. + * + * @throws Will throw an error if fingerprint computation fails. + */ +export async function createFingerprintsByKeyAsync( + projectDir: string, + fingerprintOptionsByKey: Map +): Promise< + Map< + string, + { + hash: string; + sources: object[]; + isDebugSource: boolean; + } + > +> { + // @expo/fingerprint is exported in the expo package for SDK 52+ + const fingerprintPath = silentResolveFrom(projectDir, 'expo/fingerprint'); + if (!fingerprintPath) { + return new Map(); + } + + if (process.env.EAS_SKIP_AUTO_FINGERPRINT) { + Log.log('Skipping project fingerprints'); + return new Map(); + } + + const timeoutId = setTimeout(() => { + Log.log('โŒ›๏ธ Computing the project fingerprints is taking longer than expected...'); + Log.log('โฉ To skip this step, set the environment variable: EAS_SKIP_AUTO_FINGERPRINT=1'); + }, 5000); + + const spinner = ora(`Computing project fingerprints`).start(); + try { + const fingerprintsByKey = await mapMapAsync( + fingerprintOptionsByKey, + async options => + await createFingerprintWithoutLoggingAsync(projectDir, fingerprintPath, options) + ); + spinner.succeed(`Computed project fingerprints`); + return fingerprintsByKey; + } catch (e) { + spinner.fail(`Failed to compute project fingerprints`); + Log.log('โฉ To skip this step, set the environment variable: EAS_SKIP_AUTO_FINGERPRINT=1'); + throw e; + } finally { + // Clear the timeout if the operation finishes before the time limit + clearTimeout(timeoutId); + spinner.stop(); + } +}