diff --git a/.changeset/separate-asc-processing-step.md b/.changeset/separate-asc-processing-step.md new file mode 100644 index 0000000000..6df6114b4a --- /dev/null +++ b/.changeset/separate-asc-processing-step.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Add `eas/wait_for_asc_build_processing` to wait for an existing App Store Connect upload. Keep `eas/upload_to_asc`'s `wait_for_processing` input supported through the same processing logic. diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index aaa8a91338..0dd0ac10c9 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -67,6 +67,7 @@ import { createUploadArtifactBuildFunction } from './functions/uploadArtifact'; import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings'; import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle'; import { createUploadToAscBuildFunction } from './functions/uploadToAsc'; +import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing'; import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata'; import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken'; import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric'; @@ -144,6 +145,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { createSubmissionEntityFunction(), createSubmitToGooglePlayBuildFunction(), createUploadToAscBuildFunction(), + createWaitForAscBuildProcessingBuildFunction(), createUpdateTestFlightMetadataBuildFunction(), createReportMaestroTestResultsFunction(ctx), diff --git a/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts b/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts deleted file mode 100644 index 538408b532..0000000000 --- a/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - isClosedVersionTrainError, - isInvalidBundleIdentifierError, - isMissingPurposeStringError, - isSdkVersionIssueError, - parseMissingUsageDescriptionKeys, -} from '../uploadToAsc'; - -describe(isClosedVersionTrainError, () => { - it('returns true when all errors are closed-version-train codes', () => { - expect( - isClosedVersionTrainError([ - { code: '90062' }, - { code: '90186' }, - { code: '90478' }, - { code: '90062' }, - ]) - ).toBe(true); - }); - - it('returns false when any other error code is present', () => { - expect( - isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }]) - ).toBe(false); - }); - - it('returns false when there are no errors', () => { - expect(isClosedVersionTrainError([])).toBe(false); - }); -}); - -describe(isInvalidBundleIdentifierError, () => { - it('returns true when all errors are invalid-bundle-id codes', () => { - expect( - isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }]) - ).toBe(true); - }); - - it('returns false when any other error code is present', () => { - expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false); - }); - - it('returns false when there are no errors', () => { - expect(isInvalidBundleIdentifierError([])).toBe(false); - }); -}); - -describe(isMissingPurposeStringError, () => { - it('returns true when all errors are missing-purpose-string codes', () => { - expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true); - }); - - it('returns false when any other error code is present', () => { - expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false); - }); - - it('returns false when there are no errors', () => { - expect(isMissingPurposeStringError([])).toBe(false); - }); -}); - -describe(isSdkVersionIssueError, () => { - it('returns true when all errors are SDK-version-issue codes', () => { - expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true); - }); - - it('returns false when any other error code is present', () => { - expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false); - }); - - it('returns false when there are no errors', () => { - expect(isSdkVersionIssueError([])).toBe(false); - }); -}); - -describe(parseMissingUsageDescriptionKeys, () => { - it('extracts missing UsageDescription keys from ASC messages', () => { - expect( - parseMissingUsageDescriptionKeys([ - { - description: - 'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)', - }, - { - description: - 'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)', - }, - ]) - ).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']); - }); - - it('deduplicates repeated keys', () => { - expect( - parseMissingUsageDescriptionKeys([ - { - description: - 'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)', - }, - { - description: - 'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)', - }, - ]) - ).toEqual(['NSCameraUsageDescription']); - }); - - it('returns an empty array when no usage description key is present', () => { - expect( - parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }]) - ).toEqual([]); - }); -}); diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts new file mode 100644 index 0000000000..a0ea0d8dab --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts @@ -0,0 +1,239 @@ +import fs from 'fs-extra'; +import nock from 'nock'; +import { generateKeyPairSync } from 'node:crypto'; +import { setTimeout } from 'node:timers/promises'; + +import { + createCustomBuildContextMock, + createGlobalContextMock, +} from '../../../__tests__/utils/context'; +import { createMockLogger } from '../../../__tests__/utils/logger'; +import { getEasFunctions } from '../../easFunctions'; +import { readIpaInfoAsync } from '../readIpaInfo'; +import { createUploadToAscBuildFunction } from '../uploadToAsc'; + +jest.unmock('node-fetch'); +jest.mock('node:timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../readIpaInfo', () => ({ + ...jest.requireActual('../readIpaInfo'), + readIpaInfoAsync: jest.fn(), +})); + +const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +const api = () => nock('https://api.appstoreconnect.apple.com'); +const logger = createMockLogger(); +const globalContext = () => createGlobalContextMock({ projectTargetDirectory: '/project', logger }); + +async function waitAsync(diagnostics: Record = {}): Promise { + const fn = getEasFunctions(createCustomBuildContextMock()).find( + fn => fn.getFullId() === 'eas/wait_for_asc_build_processing' + )!; + await fn + .createBuildStepFromFunctionCall(globalContext(), { + callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload', ...diagnostics }, + }) + .executeAsync(); +} + +async function uploadAsync(waitForProcessing?: boolean): Promise { + await fs.outputFile('/project/app.ipa', 'ipa'); + jest.mocked(readIpaInfoAsync).mockResolvedValue({ + bundleIdentifier: 'com.test.ipa', + bundleVersion: '1', + bundleShortVersion: '1.0', + dtPlatformName: 'iphoneos', + }); + mockUpload(); + const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(globalContext(), { + callInputs: { + ipa_path: 'app.ipa', + asc_api_key_path: 'key.json', + apple_app_identifier: 'app', + bundle_version: '1', + bundle_short_version: '1.0', + ...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }), + }, + }); + await step.executeAsync(); +} + +function mockProcessing(state: string, errors: { code: string; description: string }[] = []): void { + api() + .get('/v1/buildUploads/upload') + .query(true) + .reply(200, { + data: { type: 'buildUploads', id: 'upload', attributes: { state: { state, errors } } }, + }); +} + +function mockApp(): void { + api() + .get('/v1/apps/app') + .query(true) + .reply(200, { + data: { type: 'apps', id: 'app', attributes: { name: 'Test app', bundleId: 'com.test.app' } }, + }); +} + +function mockUpload(): void { + mockApp(); + api() + .post('/v1/buildUploads') + .reply(201, { data: { type: 'buildUploads', id: 'upload' } }); + api() + .post('/v1/buildUploadFiles') + .reply(201, { + data: { + type: 'buildUploadFiles', + id: 'file', + attributes: { + uploadOperations: [ + { + length: 3, + offset: 0, + partNumber: 1, + method: 'PUT', + requestHeaders: [], + url: 'https://upload.example.com/ipa', + }, + ], + }, + }, + }); + nock('https://upload.example.com').put('/ipa', 'ipa').reply(200); + api() + .get('/v1/buildUploadFiles/file') + .query(true) + .twice() + .reply(200, { + data: { + type: 'buildUploadFiles', + id: 'file', + attributes: { assetDeliveryState: { state: 'COMPLETE' } }, + }, + }); +} + +beforeAll(() => nock.disableNetConnect()); +afterAll(() => nock.enableNetConnect()); +beforeEach(async () => { + await fs.outputJson('/project/key.json', { + key_id: 'TESTKEY', + key: privateKey.export({ type: 'pkcs8', format: 'pem' }), + }); + jest.mocked(setTimeout).mockResolvedValue(undefined); +}); +afterEach(() => { + try { + expect(nock.pendingMocks()).toEqual([]); + } finally { + nock.cleanAll(); + jest.restoreAllMocks(); + } +}); + +it('polls an existing upload through awaiting and processing states until complete', async () => { + mockProcessing('AWAITING_UPLOAD'); + mockProcessing('PROCESSING'); + mockProcessing('COMPLETE'); + await expect(waitAsync()).resolves.toBeUndefined(); +}); + +it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => { + mockProcessing( + 'FAILED', + ['90054', '90055'].map(code => ({ code, description: 'Bundle mismatch' })) + ); + mockApp(); + api().get('/v1/apps').query(true).reply(200, { data: [] }); + await expect( + waitAsync({ bundle_identifier: 'com.test.ipa', apple_app_identifier: 'app' }) + ).rejects.toMatchObject({ + errorCode: 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID', + message: expect.stringContaining( + 'IPA bundle identifier: com.test.ipa\nApp Store Connect app bundle identifier: com.test.app' + ), + }); +}); + +it.each<[string, string[], string]>([ + ['SDK version', ['90725'], 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'], + ['closed version train', ['90062', '90186', '90478'], 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'], +])('reports a %s processing error', async (_label, codes, errorCode) => { + mockProcessing( + 'FAILED', + codes.map(code => ({ code, description: 'Rejected' })) + ); + await expect(waitAsync()).rejects.toMatchObject({ errorCode }); +}); + +it.each( + [ + [], + ['OTHER'], + ['90725', 'OTHER'], + ['90062', 'OTHER'], + ['90054', 'OTHER'], + ['90683', 'OTHER'], + ].map(codes => ({ codes })) +)('keeps unclassified or mixed processing errors generic: $codes', async ({ codes }) => { + mockProcessing( + 'FAILED', + codes.map(code => ({ code, description: 'Rejected' })) + ); + await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.'); +}); + +it('reports missing privacy keys once each', async () => { + mockProcessing('FAILED', [ + { + code: '90683', + description: 'Missing NSCameraUsageDescription and NSPhotoLibraryUsageDescription.', + }, + { code: '90683', description: 'Missing NSCameraUsageDescription.' }, + ]); + await expect(waitAsync()).rejects.toMatchObject({ + errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING', + message: expect.stringContaining( + 'Missing keys reported by App Store Connect:\n- NSCameraUsageDescription\n- NSPhotoLibraryUsageDescription\n\n' + ), + }); +}); + +it('reports missing privacy purpose strings when Apple does not name a key', async () => { + mockProcessing('FAILED', [{ code: '90683', description: 'Missing purpose string.' }]); + await expect(waitAsync()).rejects.toMatchObject({ + errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING', + message: expect.not.stringContaining('Missing keys reported by App Store Connect:'), + }); +}); + +it.each([undefined, false, true])( + 'keeps upload wait_for_processing=%s compatible', + async waitForProcessing => { + if (waitForProcessing) { + mockProcessing('PROCESSING'); + mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]); + } + if (waitForProcessing) { + await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({ + errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE', + }); + } else { + await expect(uploadAsync(waitForProcessing)).resolves.toBeUndefined(); + } + } +); + +it.each(['upload', 'wait'])( + 'preserves the successful timeout return for the %s step', + async step => { + mockProcessing('PROCESSING'); + const start = Date.now(); + jest.spyOn(Date, 'now').mockReturnValue(start); + jest.mocked(setTimeout).mockImplementationOnce(async () => { + jest.spyOn(Date, 'now').mockReturnValue(start + 30 * 60 * 1000); + }); + await expect(step === 'upload' ? uploadAsync(true) : waitAsync()).resolves.toBeUndefined(); + } +); diff --git a/packages/build-tools/src/steps/functions/updateTestFlightMetadata.ts b/packages/build-tools/src/steps/functions/updateTestFlightMetadata.ts index 3bcb910b2a..2d9b39632c 100644 --- a/packages/build-tools/src/steps/functions/updateTestFlightMetadata.ts +++ b/packages/build-tools/src/steps/functions/updateTestFlightMetadata.ts @@ -93,7 +93,7 @@ export async function updateTestFlightMetadataAsync({ if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) { throw new UserError( 'EAS_TESTFLIGHT_BUILD_NOT_READY', - 'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.' + 'The uploaded build is not ready for TestFlight metadata. Run eas/wait_for_asc_build_processing first, or use eas/upload_to_asc with wait_for_processing: true.' ); } logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`); diff --git a/packages/build-tools/src/steps/functions/uploadToAsc.ts b/packages/build-tools/src/steps/functions/uploadToAsc.ts index c1deb05321..8d9f75aa17 100644 --- a/packages/build-tools/src/steps/functions/uploadToAsc.ts +++ b/packages/build-tools/src/steps/functions/uploadToAsc.ts @@ -1,4 +1,3 @@ -import { UserError } from '@expo/eas-build-job'; import { asyncResult } from '@expo/results'; import { BuildFunction, @@ -15,6 +14,7 @@ import { z } from 'zod'; import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient'; import { AscApiUtils } from '../utils/ios/AscApiUtils'; import { readIpaInfoAsync } from './readIpaInfo'; +import { waitForAscBuildProcessingAsync } from './waitForAscBuildProcessing'; export function createUploadToAscBuildFunction(): BuildFunction { return new BuildFunction({ @@ -184,10 +184,10 @@ export function createUploadToAscBuildFunction(): BuildFunction { const { errors = [], warnings = [] } = assetDeliveryState; if (warnings.length > 0) { - stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`); + stepsCtx.logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`); } if (errors.length > 0) { - stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`); + stepsCtx.logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`); } if (assetDeliveryState.state === 'FAILED') { @@ -208,165 +208,19 @@ export function createUploadToAscBuildFunction(): BuildFunction { return; } - stepsCtx.logger.info('Checking build upload status...'); - const waitingForBuildStartedAt = Date.now(); - const waitingLogIntervalMs = 10 * 1000; - let lastWaitLogTime = 0; - let lastWaitLogState: string | null = null; - while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) { - const { - data: { - attributes: { state }, - }, - } = await client.getAsync( - `/v1/buildUploads/:id`, - { 'fields[buildUploads]': ['state', 'build'], include: ['build'] }, - { id: buildUploadId } - ); - - if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') { - const now = Date.now(); - if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) { - stepsCtx.logger.info( - `Waiting for build upload to complete... (status = ${state.state})` - ); - lastWaitLogTime = now; - lastWaitLogState = state.state; - } - await setTimeout(2000); - continue; - } - - stepsCtx.logger.info('\n'); - - const { errors = [], warnings = [], infos = [] } = state; - if (infos.length > 0) { - stepsCtx.logger.info(`Infos:\n${itemizeMessages(infos)}\n`); - } - if (warnings.length > 0) { - stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`); - } - if (errors.length > 0) { - stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`); - } - - if (state.state === 'FAILED') { - if (isSdkVersionIssueError(errors)) { - throw new UserError( - 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE', - 'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' + - 'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' + - 'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.', - { - docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/', - } - ); - } - if (isInvalidBundleIdentifierError(errors)) { - const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath)); - const ipaBundleIdentifier = ipaInfoResult.ok - ? ipaInfoResult.value.bundleIdentifier - : null; - - let visibleAppsSummary: string | null = null; - try { - const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 }); - visibleAppsSummary = AscApiUtils.formatAppsList(apps); - } catch { - // Ok to fail, this is just trying to be helpful. - } - - throw new UserError( - 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID', - `Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` + - `IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` + - `App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` + - 'Bundle identifier cannot be changed for an existing App Store Connect app. ' + - 'If you selected the wrong app, change the Apple app identifier in the submit profile. ' + - 'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' + - '\n\nOther App Store Connect apps visible to this API key:\n' + - (visibleAppsSummary ?? ' (unable to retrieve)'), - { - docsUrl: 'https://expo.fyi/asc-app-id', - } - ); - } - if (isMissingPurposeStringError(errors)) { - const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors); - throw new UserError( - 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING', - `Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` + - `${ - missingUsageDescriptionKeys.length > 0 - ? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join( - '\n- ' - )}\n\n` - : '' - }` + - 'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' + - 'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' + - 'If you do not use CNG, update your app target Info.plist directly.', - { - docsUrl: 'https://docs.expo.dev/guides/permissions/#ios', - } - ); - } - if (isClosedVersionTrainError(errors)) { - throw new UserError( - 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN', - `Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` + - 'This usually means the version train is closed or lower than a previously approved version. ' + - 'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.' - ); - } - throw new Error(`Build upload (ID: ${buildUploadId}) failed.`); - } else if (state.state === 'COMPLETE') { - stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`); - } - break; - } + await waitForAscBuildProcessingAsync({ + client, + buildUploadId, + logger: stepsCtx.logger, + ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined, + appleAppIdentifier: undefined, + ascAppBundleIdentifier, + bundleShortVersion, + }); }, }); } -function itemizeMessages(messages: { description: string; code: string }[]): string { - return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`; -} - -export function isClosedVersionTrainError(messages: { code: string }[]): boolean { - return ( - messages.length > 0 && - messages.every(message => ['90062', '90186', '90478'].includes(message.code)) - ); -} - -export function isSdkVersionIssueError(messages: { code: string }[]): boolean { - return messages.length > 0 && messages.every(message => message.code === '90725'); -} - -export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean { - return ( - messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code)) - ); -} - -export function isMissingPurposeStringError(messages: { code: string }[]): boolean { - return messages.length > 0 && messages.every(message => message.code === '90683'); -} - -export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] { - const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g; - const keys = new Set(); - for (const message of messages) { - const matches = message.description.matchAll(usageDescriptionKeyRegex); - for (const match of matches) { - keys.add(match[1]); - } - } - - return [...keys]; -} - async function uploadChunksAsync({ uploadOperations, ipaPath, diff --git a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts new file mode 100644 index 0000000000..882629d00c --- /dev/null +++ b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts @@ -0,0 +1,245 @@ +import { UserError } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import path from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { z } from 'zod'; + +import { AscApiClient } from '../utils/ios/AscApiClient'; +import { AscApiUtils } from '../utils/ios/AscApiUtils'; + +export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'wait_for_asc_build_processing', + name: 'Wait for App Store Connect processing', + __metricsId: 'eas/wait_for_asc_build_processing', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'asc_api_key_path', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'build_upload_id', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'bundle_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'apple_app_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'bundle_short_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (ctx, { inputs }) => { + const parsedInputs = z + .object({ + asc_api_key_path: z.string(), + build_upload_id: z.string(), + bundle_identifier: z.string().optional(), + apple_app_identifier: z.string().optional(), + bundle_short_version: z.string().optional(), + }) + .parse({ + asc_api_key_path: inputs.asc_api_key_path.value, + build_upload_id: inputs.build_upload_id.value, + bundle_identifier: inputs.bundle_identifier.value, + apple_app_identifier: inputs.apple_app_identifier.value, + bundle_short_version: inputs.bundle_short_version.value, + }); + const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path); + const key = await AscApiUtils.loadApiKeyAsync({ keyPath }); + const client = new AscApiClient({ key, logger: ctx.logger }); + await waitForAscBuildProcessingAsync({ + client, + buildUploadId: parsedInputs.build_upload_id, + logger: ctx.logger, + ipaBundleIdentifier: parsedInputs.bundle_identifier, + appleAppIdentifier: parsedInputs.apple_app_identifier, + ascAppBundleIdentifier: undefined, + bundleShortVersion: parsedInputs.bundle_short_version, + }); + }, + }); +} + +export async function waitForAscBuildProcessingAsync({ + client, + buildUploadId, + logger, + ipaBundleIdentifier, + appleAppIdentifier, + ascAppBundleIdentifier, + bundleShortVersion, +}: { + client: AscApiClient; + buildUploadId: string; + logger: bunyan; + ipaBundleIdentifier: string | undefined; + appleAppIdentifier: string | undefined; + ascAppBundleIdentifier: string | undefined; + bundleShortVersion: string | undefined; +}): Promise { + logger.info('Checking build upload status...'); + const waitingForBuildStartedAt = Date.now(); + const waitingLogIntervalMs = 10 * 1000; + let lastWaitLogTime = 0; + let lastWaitLogState: string | null = null; + while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) { + const { + data: { + attributes: { state }, + }, + } = await client.getAsync( + `/v1/buildUploads/:id`, + { 'fields[buildUploads]': ['state', 'build'], include: ['build'] }, + { id: buildUploadId } + ); + + if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') { + const now = Date.now(); + if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) { + logger.info(`Waiting for build upload to complete... (status = ${state.state})`); + lastWaitLogTime = now; + lastWaitLogState = state.state; + } + await setTimeout(2000); + continue; + } + + logger.info('\n'); + + const { errors = [], warnings = [], infos = [] } = state; + if (infos.length > 0) { + logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`); + } + if (warnings.length > 0) { + logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`); + } + if (errors.length > 0) { + logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`); + } + + if (state.state === 'FAILED') { + if (isSdkVersionIssueError(errors)) { + throw new UserError( + 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE', + 'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' + + 'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' + + 'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.', + { + docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/', + } + ); + } + if (isInvalidBundleIdentifierError(errors)) { + if (!ascAppBundleIdentifier && appleAppIdentifier) { + try { + const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier }); + ascAppBundleIdentifier = app.data.attributes.bundleId; + } catch { + // Keep the processing error if app details are unavailable. + } + } + let visibleAppsSummary: string | null = null; + try { + const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 }); + visibleAppsSummary = AscApiUtils.formatAppsList(apps); + } catch { + // Ok to fail, this is just trying to be helpful. + } + + throw new UserError( + 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID', + `Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` + + `IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` + + `App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` + + 'Bundle identifier cannot be changed for an existing App Store Connect app. ' + + 'If you selected the wrong app, change the Apple app identifier in the submit profile. ' + + 'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' + + '\n\nOther App Store Connect apps visible to this API key:\n' + + (visibleAppsSummary ?? ' (unable to retrieve)'), + { + docsUrl: 'https://expo.fyi/asc-app-id', + } + ); + } + if (isMissingPurposeStringError(errors)) { + const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors); + throw new UserError( + 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING', + `Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` + + `${ + missingUsageDescriptionKeys.length > 0 + ? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join( + '\n- ' + )}\n\n` + : '' + }` + + 'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' + + 'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' + + 'If you do not use CNG, update your app target Info.plist directly.', + { + docsUrl: 'https://docs.expo.dev/guides/permissions/#ios', + } + ); + } + if (isClosedVersionTrainError(errors)) { + throw new UserError( + 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN', + `Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` + + 'This usually means the version train is closed or lower than a previously approved version. ' + + 'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.' + ); + } + throw new Error(`Build upload (ID: ${buildUploadId}) failed.`); + } else if (state.state === 'COMPLETE') { + logger.info(`Build upload (ID: ${buildUploadId}) complete!`); + } + break; + } +} + +function isClosedVersionTrainError(messages: { code: string }[]): boolean { + return ( + messages.length > 0 && + messages.every(message => ['90062', '90186', '90478'].includes(message.code)) + ); +} + +function isSdkVersionIssueError(messages: { code: string }[]): boolean { + return messages.length > 0 && messages.every(message => message.code === '90725'); +} + +function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean { + return ( + messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code)) + ); +} + +function isMissingPurposeStringError(messages: { code: string }[]): boolean { + return messages.length > 0 && messages.every(message => message.code === '90683'); +} + +function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] { + const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g; + const keys = new Set(); + for (const message of messages) { + const matches = message.description.matchAll(usageDescriptionKeyRegex); + for (const match of matches) { + keys.add(match[1]); + } + } + + return [...keys]; +} diff --git a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts index 8be9f98e5e..5ba2737892 100644 --- a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts +++ b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts @@ -302,4 +302,8 @@ export namespace AscApiUtils { .join('\n') || ' (none)' ); } + + export function itemizeMessages(messages: { description: string; code: string }[]): string { + return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`; + } }