diff --git a/packages/build-tools/src/common/projectSources.ts b/packages/build-tools/src/common/projectSources.ts index b4195eb66a..f163bb63ea 100644 --- a/packages/build-tools/src/common/projectSources.ts +++ b/packages/build-tools/src/common/projectSources.ts @@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync( } `), { - buildId: ctx.env.EAS_BUILD_ID, + buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'), projectMetadataFile: { type: 'GCS', bucketKey: uploadSession.bucketKey, diff --git a/packages/build-tools/src/context.ts b/packages/build-tools/src/context.ts index e762b31635..989b0e7092 100644 --- a/packages/build-tools/src/context.ts +++ b/packages/build-tools/src/context.ts @@ -63,7 +63,7 @@ export interface BuildContextOptions { reportError?: ( msg: string, err?: Error, - options?: { tags?: Record; extras?: Record } + options?: { tags?: Record; extras?: Record } ) => void; skipNativeBuild?: boolean; metadata?: Metadata; @@ -80,7 +80,7 @@ export class BuildContext { public readonly reportError?: ( msg: string, err?: Error, - options?: { tags?: Record; extras?: Record } + options?: { tags?: Record; extras?: Record } ) => void; public readonly skipNativeBuild?: boolean; public readonly expoApiV2BaseUrl?: string; diff --git a/packages/build-tools/src/ios/fastlane.ts b/packages/build-tools/src/ios/fastlane.ts index f062ea09ce..95f5cbc6ae 100644 --- a/packages/build-tools/src/ios/fastlane.ts +++ b/packages/build-tools/src/ios/fastlane.ts @@ -93,7 +93,7 @@ export async function runFastlane( cwd, }: { logger?: bunyan; - env?: Record; + env?: Env; cwd?: string; } = {} ): Promise { diff --git a/packages/build-tools/src/steps/functions/createSubmissionEntity.ts b/packages/build-tools/src/steps/functions/createSubmissionEntity.ts index 65303d2741..72b5e6a71e 100644 --- a/packages/build-tools/src/steps/functions/createSubmissionEntity.ts +++ b/packages/build-tools/src/steps/functions/createSubmissionEntity.ts @@ -1,6 +1,7 @@ import { asyncResult } from '@expo/results'; import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import { Sentry } from '../../sentry'; import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure'; export function createSubmissionEntityFunction(): BuildFunction { @@ -54,18 +55,42 @@ export function createSubmissionEntityFunction(): BuildFunction { const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken; if (!robotAccessToken) { stepsCtx.logger.error('Failed to create submission entity: no robot access token found'); + Sentry.capture('Failed to create submission entity: missing robot access token'); return; } const buildId = inputs.build_id.value; if (!buildId) { stepsCtx.logger.error('Failed to create submission entity: no build ID provided'); + Sentry.capture('Failed to create submission entity: missing build ID', { + extras: { + buildId, + }, + }); return; } const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID; if (!workflowJobId) { stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found'); + Sentry.capture('Failed to create submission entity: missing workflow job ID', { + extras: { + buildId, + workflowJobId, + }, + }); + return; + } + + const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL; + if (!expoApiServerURL) { + stepsCtx.logger.error('Failed to create submission entity: no Expo API server URL found'); + Sentry.capture('Failed to create submission entity: missing Expo API server URL', { + extras: { + buildId, + workflowJobId, + }, + }); return; } @@ -83,7 +108,7 @@ export function createSubmissionEntityFunction(): BuildFunction { try { const response = await retryOnDNSFailure(fetch)( - new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL), + new URL('/v2/app-store-submissions/', expoApiServerURL), { method: 'POST', headers: { diff --git a/packages/build-tools/src/steps/functions/downloadArtifact.ts b/packages/build-tools/src/steps/functions/downloadArtifact.ts index df7a7893ac..39ab1a09ad 100644 --- a/packages/build-tools/src/steps/functions/downloadArtifact.ts +++ b/packages/build-tools/src/steps/functions/downloadArtifact.ts @@ -1,4 +1,4 @@ -import { UserError } from '@expo/eas-build-job'; +import { SystemError } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; import { asyncResult } from '@expo/results'; import { @@ -56,18 +56,23 @@ export function createDownloadArtifactFunction(): BuildFunction { const interpolationContext = stepsCtx.global.getInterpolationContext(); if (!('workflow' in interpolationContext)) { - throw new UserError( - 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW', - 'No workflow found in the interpolation context.' - ); + throw new SystemError('No workflow found in the interpolation context.', { + trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW', + }); } const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken; if (!robotAccessToken) { - throw new UserError( - 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN', - 'No robot access token found in the job secrets.' - ); + throw new SystemError('No robot access token found in the job secrets.', { + trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN', + }); + } + + const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL; + if (!expoApiServerURL) { + throw new SystemError('Missing Expo API server URL.', { + trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_EXPO_API_SERVER_URL', + }); } const workflowRunId = interpolationContext.workflow.id; @@ -82,7 +87,7 @@ export function createDownloadArtifactFunction(): BuildFunction { const { artifactPath } = await downloadArtifactAsync({ logger, workflowRunId, - expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + expoApiServerURL, robotAccessToken, params, }); diff --git a/packages/build-tools/src/steps/functions/restoreCache.ts b/packages/build-tools/src/steps/functions/restoreCache.ts index ceb25449b6..affd2bbfd6 100644 --- a/packages/build-tools/src/steps/functions/restoreCache.ts +++ b/packages/build-tools/src/steps/functions/restoreCache.ts @@ -69,6 +69,10 @@ export function createRestoreCacheFunction(): BuildFunction { .filter(key => key !== ''); const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const expoApiServerURL = nullthrows( + stepsCtx.global.staticContext.expoApiServerURL, + 'expoApiServerURL is not set' + ); const robotAccessToken = nullthrows( stepsCtx.global.staticContext.job.secrets?.robotAccessToken, 'robotAccessToken is not set' @@ -77,7 +81,7 @@ export function createRestoreCacheFunction(): BuildFunction { const { archivePath, matchedKey } = await downloadCacheAsync({ logger, jobId, - expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + expoApiServerURL, robotAccessToken, paths, key, diff --git a/packages/build-tools/src/steps/functions/saveCache.ts b/packages/build-tools/src/steps/functions/saveCache.ts index 16d968bb26..3255b14839 100644 --- a/packages/build-tools/src/steps/functions/saveCache.ts +++ b/packages/build-tools/src/steps/functions/saveCache.ts @@ -43,6 +43,10 @@ export function createSaveCacheFunction(): BuildFunction { .filter(path => path.length > 0); const key = z.string().parse(inputs.key.value); const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const expoApiServerURL = nullthrows( + stepsCtx.global.staticContext.expoApiServerURL, + 'expoApiServerURL is not set' + ); const robotAccessToken = nullthrows( stepsCtx.global.staticContext.job.secrets?.robotAccessToken, 'robotAccessToken is not set' @@ -61,7 +65,7 @@ export function createSaveCacheFunction(): BuildFunction { await uploadPublicCacheAsync({ logger, jobId, - expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + expoApiServerURL, robotAccessToken, archivePath, key, @@ -73,7 +77,7 @@ export function createSaveCacheFunction(): BuildFunction { await uploadCacheAsync({ logger, jobId, - expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + expoApiServerURL, robotAccessToken, archivePath, key, diff --git a/packages/build-tools/src/utils/__tests__/expoUpdates.test.ts b/packages/build-tools/src/utils/__tests__/expoUpdates.test.ts index ef346c79ae..6cb07b3a42 100644 --- a/packages/build-tools/src/utils/__tests__/expoUpdates.test.ts +++ b/packages/build-tools/src/utils/__tests__/expoUpdates.test.ts @@ -104,4 +104,33 @@ describe(expoUpdates.configureExpoUpdatesIfInstalledAsync, () => { expect(iosSetChannelNativelyAsync).toBeCalledTimes(1); expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1); }); + + it('does not fail on missing EAS_BUILD_ID when logging fingerprint diffs', async () => { + jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0'); + + const warn = jest.fn(); + const managedCtx: BuildContext = { + appConfig: {}, + env: {}, + job: { + platform: Platform.IOS, + }, + logger: { warn }, + metadata: { + runtimeVersion: 'runtime-version-from-local-machine', + }, + getReactNativeProjectDirectory: () => '/app', + } as any; + + await expect( + expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, { + resolvedRuntimeVersion: 'runtime-version-from-eas-build', + resolvedFingerprintSources: [], + }) + ).rejects.toThrow( + 'Runtime version calculated on local machine not equal to runtime version calculated during build.' + ); + + expect(warn).toHaveBeenCalledWith('Skipping fingerprint diff because EAS_BUILD_ID is not set'); + }); }); diff --git a/packages/build-tools/src/utils/expoUpdates.ts b/packages/build-tools/src/utils/expoUpdates.ts index 6f670d0584..a9f9e4313b 100644 --- a/packages/build-tools/src/utils/expoUpdates.ts +++ b/packages/build-tools/src/utils/expoUpdates.ts @@ -243,6 +243,12 @@ async function logDiffFingerprints({ ctx: BuildContext; }): Promise { const { resolvedRuntimeVersion, resolvedFingerprintSources } = resolvedRuntime; + const buildId = ctx.env.EAS_BUILD_ID; + + if (!buildId) { + ctx.logger.warn('Skipping fingerprint diff because EAS_BUILD_ID is not set'); + return; + } const fingerprintInfo = await ctx.graphqlClient .query( @@ -257,7 +263,7 @@ async function logDiffFingerprints({ } } `), - { id: ctx.env.EAS_BUILD_ID } + { id: buildId } ) .toPromise(); diff --git a/packages/eas-build-job/src/__tests__/common.test.ts b/packages/eas-build-job/src/__tests__/common.test.ts index 3b44e8e068..20944c680b 100644 --- a/packages/eas-build-job/src/__tests__/common.test.ts +++ b/packages/eas-build-job/src/__tests__/common.test.ts @@ -1,4 +1,18 @@ -import { StaticWorkflowInterpolationContextZ } from '../common'; +import { EnvSchema, StaticWorkflowInterpolationContextZ } from '../common'; + +describe('EnvSchema', () => { + it('accepts explicit undefined values', () => { + const env = { + DEFINED_ENV: 'value', + UNDEFINED_ENV: undefined, + }; + + const { value, error } = EnvSchema.validate(env); + + expect(error).toBeUndefined(); + expect(value).toEqual(env); + }); +}); describe('StaticWorkflowInterpolationContextZ', () => { it('accepts app and account context', () => { diff --git a/packages/eas-build-job/src/common.ts b/packages/eas-build-job/src/common.ts index a343805634..31e525c9c6 100644 --- a/packages/eas-build-job/src/common.ts +++ b/packages/eas-build-job/src/common.ts @@ -115,8 +115,8 @@ export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [ }), ]); -export type Env = Record; -export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string()); +export type Env = Record; +export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string().optional()); export type EnvironmentSecret = { name: string; diff --git a/packages/eas-build-job/src/context.ts b/packages/eas-build-job/src/context.ts index b114f50639..ba162fe6fa 100644 --- a/packages/eas-build-job/src/context.ts +++ b/packages/eas-build-job/src/context.ts @@ -11,7 +11,7 @@ type StaticJobOnlyInterpolationContext = { outputs: Record; } >; - expoApiServerURL: string; + expoApiServerURL: string | undefined; }; export type StaticJobInterpolationContext = diff --git a/packages/eas-cli/src/build/context.ts b/packages/eas-cli/src/build/context.ts index 2e4d133cd4..8604655487 100644 --- a/packages/eas-cli/src/build/context.ts +++ b/packages/eas-cli/src/build/context.ts @@ -1,5 +1,5 @@ import { ExpoConfig } from '@expo/config'; -import { Platform, Workflow } from '@expo/eas-build-job'; +import { Env, Platform, Workflow } from '@expo/eas-build-job'; import { BuildProfile, EasJson } from '@expo/eas-json'; import { LoggerLevel } from '@expo/logger'; import { NodePackageManager } from '@expo/package-manager'; @@ -65,5 +65,5 @@ export interface BuildContext { loggerLevel?: LoggerLevel; isVerboseLoggingEnabled: boolean; whatToTest?: string; - env: Record; + env: Env; } diff --git a/packages/eas-cli/src/build/createContext.ts b/packages/eas-cli/src/build/createContext.ts index fb839faab2..0893de1228 100644 --- a/packages/eas-cli/src/build/createContext.ts +++ b/packages/eas-cli/src/build/createContext.ts @@ -1,4 +1,4 @@ -import { Platform } from '@expo/eas-build-job'; +import { Env, Platform } from '@expo/eas-build-job'; import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json'; import JsonFile from '@expo/json-file'; import { LoggerLevel } from '@expo/logger'; @@ -69,7 +69,7 @@ export async function createBuildContextAsync({ refreshAdHocProvisioningProfile?: boolean; isVerboseLoggingEnabled: boolean; whatToTest?: string; - env: Record; + env: Env; }): Promise> { const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({ env, diff --git a/packages/eas-cli/src/build/local.ts b/packages/eas-cli/src/build/local.ts index 725ec92a8b..7217dfdab2 100644 --- a/packages/eas-cli/src/build/local.ts +++ b/packages/eas-cli/src/build/local.ts @@ -1,4 +1,4 @@ -import { Job, Metadata, version } from '@expo/eas-build-job'; +import { Env, Job, Metadata, version } from '@expo/eas-build-job'; import spawnAsync from '@expo/spawn-async'; import { ChildProcess } from 'child_process'; import semver from 'semver'; @@ -42,7 +42,7 @@ export async function runLocalBuildAsync( job: Job, metadata: Metadata, options: LocalBuildOptions, - env: Record + env: Env ): Promise { const { command, args } = await getCommandAndArgsAsync(job, metadata); let spinner; diff --git a/packages/eas-cli/src/fingerprint/utils.ts b/packages/eas-cli/src/fingerprint/utils.ts index 7a43abafb3..5a931c0369 100644 --- a/packages/eas-cli/src/fingerprint/utils.ts +++ b/packages/eas-cli/src/fingerprint/utils.ts @@ -1,4 +1,4 @@ -import { Platform, Workflow } from '@expo/eas-build-job'; +import { Env, Platform, Workflow } from '@expo/eas-build-job'; import { createFingerprintAsync } from './cli'; import { Fingerprint } from './types'; @@ -16,7 +16,7 @@ export async function getFingerprintInfoFromLocalProjectForPlatformsAsync( projectId: string, vcsClient: Client, platforms: AppPlatform[], - { env }: { env?: Record } = {} + { env }: { env?: Env } = {} ): Promise { const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient); const optionsFromWorkflow = getFingerprintOptionsFromWorkflow(platforms, workflows); diff --git a/packages/eas-cli/src/project/ios/entitlements.ts b/packages/eas-cli/src/project/ios/entitlements.ts index 8748ab198c..60d8282298 100644 --- a/packages/eas-cli/src/project/ios/entitlements.ts +++ b/packages/eas-cli/src/project/ios/entitlements.ts @@ -1,4 +1,5 @@ import { ExportedConfig, IOSConfig, compileModsAsync } from '@expo/config-plugins'; +import { Env } from '@expo/eas-build-job'; import { JSONObject } from '@expo/json-file'; import { getPrebuildConfigAsync } from '@expo/prebuild-config'; @@ -17,7 +18,7 @@ interface Target { export async function getManagedApplicationTargetEntitlementsAsync( projectDir: string, - env: Record, + env: Env, vcsClient: Client ): Promise { let expoConfigError: any; @@ -95,7 +96,7 @@ export async function getManagedApplicationTargetEntitlementsAsync( async function resolveManagedApplicationTargetEntitlementsWithBundledConfigAsync( projectDir: string, - env: Record, + env: Env, vcsClient: Client ): Promise { const originalProcessEnv = process.env; diff --git a/packages/eas-cli/src/project/ios/target.ts b/packages/eas-cli/src/project/ios/target.ts index f8ff5446a7..ed6179d09f 100644 --- a/packages/eas-cli/src/project/ios/target.ts +++ b/packages/eas-cli/src/project/ios/target.ts @@ -1,6 +1,6 @@ import { ExpoConfig } from '@expo/config'; import { IOSConfig, XcodeProject } from '@expo/config-plugins'; -import { Platform, Workflow } from '@expo/eas-build-job'; +import { Env, Platform, Workflow } from '@expo/eas-build-job'; import { JSONObject } from '@expo/json-file'; import Joi from 'joi'; import type { XCBuildConfiguration } from 'xcode'; @@ -26,7 +26,7 @@ interface UserDefinedTarget { interface ResolveTargetOptions { projectDir: string; exp: ExpoConfig; - env?: Record; + env?: Env; xcodeBuildContext: XcodeBuildContext; vcsClient: Client; } diff --git a/packages/eas-cli/src/submit/context.ts b/packages/eas-cli/src/submit/context.ts index 02720d85c0..ddcc358a88 100644 --- a/packages/eas-cli/src/submit/context.ts +++ b/packages/eas-cli/src/submit/context.ts @@ -1,5 +1,5 @@ import { ExpoConfig } from '@expo/config'; -import { Platform } from '@expo/eas-build-job'; +import { Env, Platform } from '@expo/eas-build-job'; import { SubmitProfile } from '@expo/eas-json'; import { v4 as uuidv4 } from 'uuid'; @@ -47,7 +47,7 @@ export interface SubmitArchiveFlags { export async function createSubmissionContextAsync(params: { archiveFlags: SubmitArchiveFlags; credentialsCtx?: CredentialsContext; - env?: Record; + env?: Env; nonInteractive: boolean; isVerboseFastlaneEnabled: boolean; groups: string[] | undefined; diff --git a/packages/worker/src/displayRuntimeInfo.ts b/packages/worker/src/displayRuntimeInfo.ts index 54bf167dfd..7df85b8af6 100644 --- a/packages/worker/src/displayRuntimeInfo.ts +++ b/packages/worker/src/displayRuntimeInfo.ts @@ -70,24 +70,30 @@ function printImageDescription(ctx: BuildContext): void { function printEnvs(ctx: BuildContext): void { const { logger, job } = ctx; const publicEnv: Record = {}; - const secretEnv: Record = {}; + // We don't expect environment secrets to be missing from ctx.env, + // but if they do we want to see it in logs as "=undefined". + const secretEnv: Record = {}; const instanceEnv: Record = {}; // skip development and testing to avoid leaking local credentials from envs to bucket if (config.env !== Environment.DEVELOPMENT && config.env !== Environment.TEST) { Object.entries(ctx.env).forEach(([key, value]) => { - instanceEnv[key] = value; + if (value !== undefined) { + instanceEnv[key] = value; + } }); } - Object.entries(job.builderEnvironment?.env ?? ({} as Record)).forEach( - ([key, value]) => { + Object.entries(job.builderEnvironment?.env ?? {}).forEach(([key, value]) => { + if (value !== undefined) { publicEnv[key] = value; delete instanceEnv[key]; } - ); + }); job.secrets?.environmentSecrets?.forEach(({ name, type }) => { if (type === EnvironmentSecretType.FILE) { + // ctx.env[name] for a FILE-typed environment secret + // should be a path to the file. secretEnv[name] = ctx.env[name]; } else { secretEnv[name] = '********';