-
Notifications
You must be signed in to change notification settings - Fork 87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[eas-cli] release fingerprint:compare #2821
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,14 +1,15 @@ | ||||||
import { Platform } from '@expo/eas-build-job'; | ||||||
import { Platform, Workflow } from '@expo/eas-build-job'; | ||||||
import { Flags } from '@oclif/core'; | ||||||
import chalk from 'chalk'; | ||||||
|
||||||
import EasCommand from '../../commandUtils/EasCommand'; | ||||||
import { fetchBuildsAsync, formatBuild } from '../../commandUtils/builds'; | ||||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; | ||||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; | ||||||
import { AppPlatform, BuildStatus } from '../../graphql/generated'; | ||||||
import { AppPlatform, BuildStatus, FingerprintFragment } from '../../graphql/generated'; | ||||||
import { FingerprintMutation } from '../../graphql/mutations/FingerprintMutation'; | ||||||
import { BuildQuery } from '../../graphql/queries/BuildQuery'; | ||||||
import { FingerprintQuery } from '../../graphql/queries/FingerprintQuery'; | ||||||
import Log from '../../log'; | ||||||
import { ora } from '../../ora'; | ||||||
import { RequestedPlatform } from '../../platform'; | ||||||
|
@@ -21,10 +22,33 @@ import { createFingerprintAsync, diffFingerprint } from '../../utils/fingerprint | |||||
import { abridgedDiff } from '../../utils/fingerprintDiff'; | ||||||
import formatFields, { FormatFieldsItem } from '../../utils/formatFields'; | ||||||
import { enableJsonOutput } from '../../utils/json'; | ||||||
import { Client } from '../../vcs/vcs'; | ||||||
|
||||||
export interface FingerprintCompareFlags { | ||||||
buildId?: string; | ||||||
hash1?: string; | ||||||
hash2?: string; | ||||||
nonInteractive: boolean; | ||||||
json: boolean; | ||||||
} | ||||||
|
||||||
export default class FingerprintCompare extends EasCommand { | ||||||
static override description = 'compare fingerprints of the current project, builds and updates'; | ||||||
static override hidden = true; | ||||||
static override strict = false; | ||||||
|
||||||
static override args = [ | ||||||
{ | ||||||
name: 'hash1', | ||||||
description: | ||||||
"If provided alone, HASH1 is compared against the current project's fingerprint.", | ||||||
required: false, | ||||||
}, | ||||||
{ | ||||||
name: 'hash2', | ||||||
description: 'If two hashes are provided, HASH1 is compared against HASH2.', | ||||||
required: false, | ||||||
}, | ||||||
]; | ||||||
|
||||||
static override flags = { | ||||||
'build-id': Flags.string({ | ||||||
|
@@ -42,8 +66,10 @@ export default class FingerprintCompare extends EasCommand { | |||||
}; | ||||||
|
||||||
async runAsync(): Promise<void> { | ||||||
const { flags } = await this.parse(FingerprintCompare); | ||||||
const { json: jsonFlag, 'non-interactive': nonInteractive, buildId: buildIdFromArg } = flags; | ||||||
const { args, flags } = await this.parse(FingerprintCompare); | ||||||
const { hash1, hash2 } = args; | ||||||
const { json, 'non-interactive': nonInteractive, 'build-id': buildId } = flags; | ||||||
const sanitizedFlagsAndArgs = { json, nonInteractive, buildId, hash1, hash2 }; | ||||||
|
||||||
const { | ||||||
projectId, | ||||||
|
@@ -54,70 +80,48 @@ export default class FingerprintCompare extends EasCommand { | |||||
nonInteractive, | ||||||
withServerSideEnvironment: null, | ||||||
}); | ||||||
if (jsonFlag) { | ||||||
if (json) { | ||||||
enableJsonOutput(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is probably not limited to this command, but I think the way it works is that we'd need to explicitly print to |
||||||
} | ||||||
|
||||||
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId); | ||||||
let buildId: string | null = buildIdFromArg; | ||||||
if (!buildId) { | ||||||
if (nonInteractive) { | ||||||
throw new Error('Build ID must be provided in non-interactive mode'); | ||||||
} | ||||||
|
||||||
buildId = await selectBuildToCompareAsync(graphqlClient, projectId, displayName, { | ||||||
filters: { hasFingerprint: true }, | ||||||
}); | ||||||
if (!buildId) { | ||||||
return; | ||||||
} | ||||||
} | ||||||
|
||||||
Log.log(`Comparing fingerprints of the current project and build ${buildId}…`); | ||||||
const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId); | ||||||
const fingerprintDebugUrl = buildWithFingerprint.fingerprint?.debugInfoUrl; | ||||||
if (!fingerprintDebugUrl) { | ||||||
Log.error('A fingerprint for the build could not be found.'); | ||||||
return; | ||||||
} | ||||||
const fingerprintResponse = await fetch(fingerprintDebugUrl); | ||||||
const fingerprint = (await fingerprintResponse.json()) as Fingerprint; | ||||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient); | ||||||
const buildPlatform = buildWithFingerprint.platform; | ||||||
const workflow = workflows[appPlatformToPlatform(buildPlatform)]; | ||||||
|
||||||
const projectFingerprint = await createFingerprintAsync(projectDir, { | ||||||
workflow, | ||||||
platforms: [appPlatformToString(buildPlatform)], | ||||||
debug: true, | ||||||
env: undefined, | ||||||
}); | ||||||
if (!projectFingerprint) { | ||||||
Log.error('Project fingerprints can only be computed for projects with SDK 52 or higher'); | ||||||
return; | ||||||
} | ||||||
const firstFingerprintInfo = await getFirstFingerprintInfoAsync( | ||||||
graphqlClient, | ||||||
projectId, | ||||||
sanitizedFlagsAndArgs | ||||||
); | ||||||
const { | ||||||
fingerprint: firstFingerprint, | ||||||
platforms: platformsFromFirstFingerprint, | ||||||
origin: firstFingerprintOrigin, | ||||||
} = firstFingerprintInfo; | ||||||
|
||||||
const uploadedFingerprint = await maybeUploadFingerprintAsync({ | ||||||
hash: fingerprint.hash, | ||||||
fingerprint: { | ||||||
fingerprintSources: fingerprint.sources, | ||||||
isDebugFingerprintSource: Log.isDebug, | ||||||
}, | ||||||
const secondFingerprintInfo = await getSecondFingerprintInfoAsync( | ||||||
graphqlClient, | ||||||
}); | ||||||
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, { | ||||||
hash: uploadedFingerprint.hash, | ||||||
source: uploadedFingerprint.fingerprintSource, | ||||||
}); | ||||||
projectDir, | ||||||
projectId, | ||||||
vcsClient, | ||||||
platformsFromFirstFingerprint, | ||||||
sanitizedFlagsAndArgs | ||||||
); | ||||||
const { fingerprint: secondFingerprint, origin: secondFingerprintOrigin } = | ||||||
secondFingerprintInfo; | ||||||
|
||||||
if (fingerprint.hash === projectFingerprint.hash) { | ||||||
Log.log(`✅ Project fingerprint matches build`); | ||||||
if (firstFingerprint.hash === secondFingerprint.hash) { | ||||||
Log.log( | ||||||
`✅ ${capitalizeFirstLetter( | ||||||
firstFingerprintOrigin | ||||||
)} matches fingerprint from ${secondFingerprintOrigin}` | ||||||
); | ||||||
return; | ||||||
} else { | ||||||
Log.log(`🔄 Project fingerprint differs from build`); | ||||||
Log.log( | ||||||
`🔄 ${capitalizeFirstLetter( | ||||||
firstFingerprintOrigin | ||||||
)} differs from ${secondFingerprintOrigin}` | ||||||
); | ||||||
} | ||||||
|
||||||
const fingerprintDiffs = diffFingerprint(projectDir, fingerprint, projectFingerprint); | ||||||
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint); | ||||||
if (!fingerprintDiffs) { | ||||||
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher'); | ||||||
return; | ||||||
|
@@ -170,6 +174,186 @@ export default class FingerprintCompare extends EasCommand { | |||||
} | ||||||
} | ||||||
|
||||||
function capitalizeFirstLetter(string: string): string { | ||||||
return string.charAt(0).toUpperCase() + string.slice(1); | ||||||
} | ||||||
|
||||||
async function getFirstFingerprintInfoAsync( | ||||||
graphqlClient: ExpoGraphqlClient, | ||||||
projectId: string, | ||||||
{ buildId: buildIdFromArg, hash1, nonInteractive }: FingerprintCompareFlags | ||||||
): Promise<{ fingerprint: Fingerprint; platforms: AppPlatform[]; origin: string }> { | ||||||
if (hash1) { | ||||||
const fingerprintFragment = await getFingerprintFragmentFromHashAsync( | ||||||
graphqlClient, | ||||||
projectId, | ||||||
hash1 | ||||||
); | ||||||
const fingerprint = await getFingerprintAsync(fingerprintFragment); | ||||||
return { | ||||||
fingerprint, | ||||||
platforms: inferPlatformsFromSource(fingerprint), | ||||||
origin: `hash ${hash1}`, | ||||||
}; | ||||||
} | ||||||
|
||||||
let buildId: string | null = buildIdFromArg ?? null; | ||||||
if (!buildId) { | ||||||
if (nonInteractive) { | ||||||
throw new Error('Build ID must be provided in non-interactive mode'); | ||||||
} | ||||||
|
||||||
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId); | ||||||
buildId = await selectBuildToCompareAsync(graphqlClient, projectId, displayName, { | ||||||
filters: { hasFingerprint: true }, | ||||||
}); | ||||||
if (!buildId) { | ||||||
throw new Error(); // exit, explanation already printed in prompt | ||||||
} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
so the output will have both:
(this matches the exit pattern for similar cases in other commands I believe) |
||||||
} | ||||||
|
||||||
Log.log(`Comparing fingerprints of the current project and build ${buildId}…`); | ||||||
const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId); | ||||||
if (!buildWithFingerprint.fingerprint) { | ||||||
throw new Error(`Fingerprint for build ${buildId} was not computed.`); | ||||||
} else if (!buildWithFingerprint.fingerprint.debugInfoUrl) { | ||||||
throw new Error(`Fingerprint source for build ${buildId} was not computed.`); | ||||||
} | ||||||
return { | ||||||
fingerprint: await getFingerprintAsync(buildWithFingerprint.fingerprint), | ||||||
platforms: [buildWithFingerprint.platform], | ||||||
origin: 'build', | ||||||
}; | ||||||
} | ||||||
|
||||||
async function getSecondFingerprintInfoAsync( | ||||||
graphqlClient: ExpoGraphqlClient, | ||||||
projectDir: string, | ||||||
projectId: string, | ||||||
vcsClient: Client, | ||||||
firstFingerprintPlatforms: AppPlatform[], | ||||||
{ hash2 }: FingerprintCompareFlags | ||||||
): Promise<{ fingerprint: Fingerprint; origin: string }> { | ||||||
if (hash2) { | ||||||
const fingerprintFragment = await getFingerprintFragmentFromHashAsync( | ||||||
graphqlClient, | ||||||
projectId, | ||||||
hash2 | ||||||
); | ||||||
if (!fingerprintFragment) { | ||||||
throw new Error(`Fingerprint with hash ${hash2} was not uploaded.`); | ||||||
} | ||||||
return { fingerprint: await getFingerprintAsync(fingerprintFragment), origin: `hash ${hash2}` }; | ||||||
} | ||||||
|
||||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient); | ||||||
const optionsFromWorkflow = getFingerprintOptionsFromWorkflow( | ||||||
firstFingerprintPlatforms, | ||||||
workflows | ||||||
); | ||||||
|
||||||
const projectFingerprint = await createFingerprintAsync(projectDir, { | ||||||
...optionsFromWorkflow, | ||||||
platforms: firstFingerprintPlatforms.map(appPlatformToString), | ||||||
debug: true, | ||||||
env: undefined, | ||||||
}); | ||||||
if (!projectFingerprint) { | ||||||
throw new Error('Project fingerprints can only be computed for projects with SDK 52 or higher'); | ||||||
} | ||||||
|
||||||
const uploadedFingerprint = await maybeUploadFingerprintAsync({ | ||||||
hash: projectFingerprint.hash, | ||||||
fingerprint: { | ||||||
fingerprintSources: projectFingerprint.sources, | ||||||
isDebugFingerprintSource: Log.isDebug, | ||||||
}, | ||||||
graphqlClient, | ||||||
}); | ||||||
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, { | ||||||
hash: uploadedFingerprint.hash, | ||||||
source: uploadedFingerprint.fingerprintSource, | ||||||
}); | ||||||
|
||||||
return { fingerprint: projectFingerprint, origin: 'project' }; | ||||||
} | ||||||
|
||||||
async function getFingerprintFragmentFromHashAsync( | ||||||
graphqlClient: ExpoGraphqlClient, | ||||||
projectId: string, | ||||||
hash: string | ||||||
): Promise<FingerprintFragment> { | ||||||
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId); | ||||||
const fingerprint = await FingerprintQuery.byHashAsync(graphqlClient, { | ||||||
appId: projectId, | ||||||
hash, | ||||||
}); | ||||||
Comment on lines
+286
to
+289
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. parallelize via edit: or, even better, only fetch the displayName in the error case? |
||||||
if (!fingerprint) { | ||||||
throw new Error(`Fingerprint with hash ${hash} was not uploaded for ${displayName}.`); | ||||||
} | ||||||
return fingerprint; | ||||||
} | ||||||
|
||||||
async function getFingerprintAsync(fingerprintFragment: FingerprintFragment): Promise<Fingerprint> { | ||||||
const fingerprintDebugUrl = fingerprintFragment.debugInfoUrl; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. optional:
Suggested change
|
||||||
if (!fingerprintDebugUrl) { | ||||||
throw new Error( | ||||||
`The source for fingerprint hash ${fingerprintFragment.hash} was not computed.` | ||||||
); | ||||||
} | ||||||
const fingerprintResponse = await fetch(fingerprintDebugUrl); | ||||||
return (await fingerprintResponse.json()) as Fingerprint; | ||||||
} | ||||||
|
||||||
function getFingerprintOptionsFromWorkflow( | ||||||
platforms: AppPlatform[], | ||||||
workflowsByPlatform: Record<Platform, Workflow> | ||||||
): { workflow?: Workflow; ignorePaths?: string[] } { | ||||||
if (platforms.length === 0) { | ||||||
throw new Error('Could not determine platform from fingerprint sources'); | ||||||
} | ||||||
|
||||||
// Single platform case | ||||||
if (platforms.length === 1) { | ||||||
const platform = platforms[0]; | ||||||
return { workflow: workflowsByPlatform[appPlatformToPlatform(platform)] }; | ||||||
} | ||||||
|
||||||
// Multiple platforms case | ||||||
const workflows = platforms.map(platform => workflowsByPlatform[appPlatformToPlatform(platform)]); | ||||||
|
||||||
// If all workflows are the same, return the common workflow | ||||||
const [firstWorkflow, ...restWorkflows] = workflows; | ||||||
if (restWorkflows.every(workflow => workflow === firstWorkflow)) { | ||||||
return { workflow: firstWorkflow }; | ||||||
} | ||||||
|
||||||
// Generate ignorePaths for mixed workflows | ||||||
const ignorePaths = platforms | ||||||
.filter(platform => workflowsByPlatform[appPlatformToPlatform(platform)] === Workflow.MANAGED) | ||||||
.map(platform => `${appPlatformToString(platform)}/**/*`); | ||||||
|
||||||
return { ignorePaths }; | ||||||
} | ||||||
|
||||||
function inferPlatformsFromSource(fingerprint: Fingerprint): AppPlatform[] { | ||||||
const sources = fingerprint.sources; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. talked about this offline, but I think if we can avoid this by limiting args or splitting out commands that'd be preferable. The main issue is that (I believe) it's possible to create a fingerprint for either platform or both that doesn't have either string being checked for (by using a specially-crafted .fingerprintIgnore or sourceSkips). |
||||||
const platforms = []; | ||||||
const containsAndroidReasons = sources.some(source => { | ||||||
return source.reasons.some(reason => /android/i.test(reason)); | ||||||
}); | ||||||
if (containsAndroidReasons) { | ||||||
platforms.push(AppPlatform.Android); | ||||||
} | ||||||
const containsIOSReasons = sources.some(source => { | ||||||
return source.reasons.some(reason => /ios/i.test(reason)); | ||||||
}); | ||||||
if (containsIOSReasons) { | ||||||
platforms.push(AppPlatform.Ios); | ||||||
} | ||||||
return platforms; | ||||||
} | ||||||
|
||||||
function printContentDiff(diff: FingerprintDiffItem): void { | ||||||
if (diff.op === 'added') { | ||||||
const sourceType = diff.addedSource.type; | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
while the command does the correct thing to error when an invalid combination of args and flags is passed in, I think it might be a bit tough to explain the different combinations that are allowed and what each combination does, and thus potentially cause a UX issue. See my note about maybe splitting them up into different commands based on use case so we can better require and explain different combinations of args.