From 6275c1e54c2d9776235380081ba0bad9b8ff6c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 11:09:18 +0200 Subject: [PATCH 1/9] [build-tools] add a separate App Store Connect processing step --- .changeset/separate-asc-processing-step.md | 5 + .../build-tools/src/steps/easFunctions.ts | 2 + .../waitForAscBuildProcessing.test.ts | 194 ++++++++++++++++++ .../functions/updateTestFlightMetadata.ts | 2 +- .../src/steps/functions/uploadToAsc.ts | 165 +-------------- .../functions/waitForAscBuildProcessing.ts | 63 ++++++ .../ios/__tests__/ascBuildProcessing.test.ts} | 2 +- .../src/steps/utils/ios/ascBuildProcessing.ts | 194 ++++++++++++++++++ 8 files changed, 469 insertions(+), 158 deletions(-) create mode 100644 .changeset/separate-asc-processing-step.md create mode 100644 packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts create mode 100644 packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts rename packages/build-tools/src/steps/{functions/__tests__/uploadToAsc.test.ts => utils/ios/__tests__/ascBuildProcessing.test.ts} (99%) create mode 100644 packages/build-tools/src/steps/utils/ios/ascBuildProcessing.ts diff --git a/.changeset/separate-asc-processing-step.md b/.changeset/separate-asc-processing-step.md new file mode 100644 index 0000000000..a8812f161a --- /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. Fail explicitly when processing exceeds 30 minutes. 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__/waitForAscBuildProcessing.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts new file mode 100644 index 0000000000..4a5d6a61b8 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts @@ -0,0 +1,194 @@ +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(); +} + +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' }), + }); + await fs.outputFile('/project/app.ipa', 'ipa'); + jest.mocked(readIpaInfoAsync).mockResolvedValue({ + bundleIdentifier: 'com.test.ipa', + bundleVersion: '1', + bundleShortVersion: '1.0', + dtPlatformName: 'iphoneos', + }); + 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.each([ + ['90725', 'SDK is too old', 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'], + ['90683', 'Missing NSCameraUsageDescription', 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING'], + ['90062', 'Version train is closed', 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'], +])('preserves actionable processing error %s', async (code, description, errorCode) => { + mockProcessing('FAILED', [{ code, description }]); + await expect(waitAsync()).rejects.toMatchObject({ errorCode }); +}); + +it('includes optional IPA and selected app details in bundle mismatch errors', async () => { + mockProcessing('FAILED', [{ code: '90054', description: 'Bundle mismatch' }]); + mockApp(); + api().get('/v1/apps').query(true).reply(200, { data: [] }); + await expect( + waitAsync({ ipa_path: 'app.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' + ), + }); + expect(readIpaInfoAsync).toHaveBeenCalledWith('/project/app.ipa'); +}); + +it('fails for an unclassified Apple processing error', async () => { + mockProcessing('FAILED', [{ code: 'OTHER', description: 'Rejected' }]); + await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.'); +}); + +it('fails when processing does not complete within 30 minutes', async () => { + 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(waitAsync()).rejects.toMatchObject({ + errorCode: 'EAS_ASC_BUILD_PROCESSING_TIMEOUT', + }); +}); + +it.each([undefined, false, true])( + 'keeps upload wait_for_processing=%s compatible', + async waitForProcessing => { + mockUpload(); + if (waitForProcessing) { + mockProcessing('PROCESSING'); + mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]); + } + 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 }), + }, + }); + if (waitForProcessing) { + await expect(step.executeAsync()).rejects.toMatchObject({ + errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE', + }); + } else { + await expect(step.executeAsync()).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..4f3876c680 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, @@ -14,6 +13,7 @@ import { z } from 'zod'; import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient'; import { AscApiUtils } from '../utils/ios/AscApiUtils'; +import { itemizeMessages, waitForAscBuildProcessingAsync } from '../utils/ios/ascBuildProcessing'; import { readIpaInfoAsync } from './readIpaInfo'; export function createUploadToAscBuildFunction(): BuildFunction { @@ -208,165 +208,18 @@ 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, + ipaPath, + 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..0835fbc92a --- /dev/null +++ b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts @@ -0,0 +1,63 @@ +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import path from 'node:path'; +import { z } from 'zod'; + +import { AscApiClient } from '../utils/ios/AscApiClient'; +import { AscApiUtils } from '../utils/ios/AscApiUtils'; +import { waitForAscBuildProcessingAsync } from '../utils/ios/ascBuildProcessing'; + +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: 'ipa_path', + 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 keyPath = path.resolve( + ctx.workingDirectory, + z.string().parse(inputs.asc_api_key_path.value) + ); + const buildUploadId = z.string().parse(inputs.build_upload_id.value); + const ipaPath = z.string().optional().parse(inputs.ipa_path.value); + const appleAppIdentifier = z.string().optional().parse(inputs.apple_app_identifier.value); + const bundleShortVersion = z.string().optional().parse(inputs.bundle_short_version.value); + const key = await AscApiUtils.loadApiKeyAsync({ keyPath }); + const client = new AscApiClient({ key, logger: ctx.logger }); + await waitForAscBuildProcessingAsync({ + client, + buildUploadId, + logger: ctx.logger, + ipaPath: ipaPath ? path.resolve(ctx.workingDirectory, ipaPath) : undefined, + appleAppIdentifier, + bundleShortVersion, + }); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts b/packages/build-tools/src/steps/utils/ios/__tests__/ascBuildProcessing.test.ts similarity index 99% rename from packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts rename to packages/build-tools/src/steps/utils/ios/__tests__/ascBuildProcessing.test.ts index 538408b532..2e9791078c 100644 --- a/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts +++ b/packages/build-tools/src/steps/utils/ios/__tests__/ascBuildProcessing.test.ts @@ -4,7 +4,7 @@ import { isMissingPurposeStringError, isSdkVersionIssueError, parseMissingUsageDescriptionKeys, -} from '../uploadToAsc'; +} from '../ascBuildProcessing'; describe(isClosedVersionTrainError, () => { it('returns true when all errors are closed-version-train codes', () => { diff --git a/packages/build-tools/src/steps/utils/ios/ascBuildProcessing.ts b/packages/build-tools/src/steps/utils/ios/ascBuildProcessing.ts new file mode 100644 index 0000000000..a5900d6f55 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/ascBuildProcessing.ts @@ -0,0 +1,194 @@ +import { UserError } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; +import { setTimeout } from 'node:timers/promises'; + +import { readIpaInfoAsync } from '../../functions/readIpaInfo'; + +import { AscApiClient } from './AscApiClient'; +import { AscApiUtils } from './AscApiUtils'; + +export async function waitForAscBuildProcessingAsync({ + client, + buildUploadId, + logger, + ipaPath, + appleAppIdentifier, + ascAppBundleIdentifier, + bundleShortVersion, +}: { + client: AscApiClient; + buildUploadId: string; + logger: bunyan; + ipaPath?: string; + appleAppIdentifier?: string; + ascAppBundleIdentifier?: string; + bundleShortVersion?: string; +}): 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${itemizeMessages(infos)}\n`); + } + if (warnings.length > 0) { + logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`); + } + if (errors.length > 0) { + 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 = ipaPath ? await asyncResult(readIpaInfoAsync(ipaPath)) : null; + const ipaBundleIdentifier = ipaInfoResult?.ok ? ipaInfoResult.value.bundleIdentifier : null; + + 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!`); + return; + } + throw new Error( + `Unexpected App Store Connect processing state: ${state.state}. Check the build in App Store Connect.` + ); + } + throw new UserError( + 'EAS_ASC_BUILD_PROCESSING_TIMEOUT', + `App Store Connect did not finish processing build upload ${buildUploadId} within 30 minutes. Check its status in App Store Connect before retrying.` + ); +} + +export 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]; +} From 9f3ecf60c3ab64705e77dc4eb944e6cfc2b079d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 11:14:25 +0200 Subject: [PATCH 2/9] [build-tools] remove repeated processing error tests --- .../__tests__/waitForAscBuildProcessing.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts index 4a5d6a61b8..679f427abc 100644 --- a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts @@ -124,15 +124,6 @@ it('polls an existing upload through awaiting and processing states until comple await expect(waitAsync()).resolves.toBeUndefined(); }); -it.each([ - ['90725', 'SDK is too old', 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'], - ['90683', 'Missing NSCameraUsageDescription', 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING'], - ['90062', 'Version train is closed', 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'], -])('preserves actionable processing error %s', async (code, description, errorCode) => { - mockProcessing('FAILED', [{ code, description }]); - await expect(waitAsync()).rejects.toMatchObject({ errorCode }); -}); - it('includes optional IPA and selected app details in bundle mismatch errors', async () => { mockProcessing('FAILED', [{ code: '90054', description: 'Bundle mismatch' }]); mockApp(); From 547496c588c80af95362e5bc4292db955eca38ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 11:19:14 +0200 Subject: [PATCH 3/9] [build-tools] move ASC polling into utilities and accept the bundle identifier --- .../waitForAscBuildProcessing.test.ts | 19 +- .../src/steps/functions/uploadToAsc.ts | 9 +- .../functions/waitForAscBuildProcessing.ts | 9 +- .../src/steps/utils/ios/AscApiUtils.ts | 183 +++++++++++++++++ .../utils/ios/__tests__/AscApiUtils.test.ts | 121 +++++++++++ .../ios/__tests__/ascBuildProcessing.test.ts | 112 ---------- .../src/steps/utils/ios/ascBuildProcessing.ts | 194 ------------------ 7 files changed, 321 insertions(+), 326 deletions(-) delete mode 100644 packages/build-tools/src/steps/utils/ios/__tests__/ascBuildProcessing.test.ts delete mode 100644 packages/build-tools/src/steps/utils/ios/ascBuildProcessing.ts diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts index 679f427abc..334a6f487b 100644 --- a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts @@ -99,13 +99,6 @@ beforeEach(async () => { key_id: 'TESTKEY', key: privateKey.export({ type: 'pkcs8', format: 'pem' }), }); - await fs.outputFile('/project/app.ipa', 'ipa'); - jest.mocked(readIpaInfoAsync).mockResolvedValue({ - bundleIdentifier: 'com.test.ipa', - bundleVersion: '1', - bundleShortVersion: '1.0', - dtPlatformName: 'iphoneos', - }); jest.mocked(setTimeout).mockResolvedValue(undefined); }); afterEach(() => { @@ -124,19 +117,18 @@ it('polls an existing upload through awaiting and processing states until comple await expect(waitAsync()).resolves.toBeUndefined(); }); -it('includes optional IPA and selected app details in bundle mismatch errors', async () => { +it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => { mockProcessing('FAILED', [{ code: '90054', description: 'Bundle mismatch' }]); mockApp(); api().get('/v1/apps').query(true).reply(200, { data: [] }); await expect( - waitAsync({ ipa_path: 'app.ipa', apple_app_identifier: 'app' }) + 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' ), }); - expect(readIpaInfoAsync).toHaveBeenCalledWith('/project/app.ipa'); }); it('fails for an unclassified Apple processing error', async () => { @@ -159,6 +151,13 @@ it('fails when processing does not complete within 30 minutes', async () => { it.each([undefined, false, true])( 'keeps upload wait_for_processing=%s compatible', async waitForProcessing => { + await fs.outputFile('/project/app.ipa', 'ipa'); + jest.mocked(readIpaInfoAsync).mockResolvedValue({ + bundleIdentifier: 'com.test.ipa', + bundleVersion: '1', + bundleShortVersion: '1.0', + dtPlatformName: 'iphoneos', + }); mockUpload(); if (waitForProcessing) { mockProcessing('PROCESSING'); diff --git a/packages/build-tools/src/steps/functions/uploadToAsc.ts b/packages/build-tools/src/steps/functions/uploadToAsc.ts index 4f3876c680..8bdf1b6e93 100644 --- a/packages/build-tools/src/steps/functions/uploadToAsc.ts +++ b/packages/build-tools/src/steps/functions/uploadToAsc.ts @@ -13,7 +13,6 @@ import { z } from 'zod'; import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient'; import { AscApiUtils } from '../utils/ios/AscApiUtils'; -import { itemizeMessages, waitForAscBuildProcessingAsync } from '../utils/ios/ascBuildProcessing'; import { readIpaInfoAsync } from './readIpaInfo'; export function createUploadToAscBuildFunction(): BuildFunction { @@ -184,10 +183,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,11 +207,11 @@ export function createUploadToAscBuildFunction(): BuildFunction { return; } - await waitForAscBuildProcessingAsync({ + await AscApiUtils.waitForAscBuildProcessingAsync({ client, buildUploadId, logger: stepsCtx.logger, - ipaPath, + ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined, ascAppBundleIdentifier, bundleShortVersion, }); diff --git a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts index 0835fbc92a..4b0f2ea190 100644 --- a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts +++ b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts @@ -4,7 +4,6 @@ import { z } from 'zod'; import { AscApiClient } from '../utils/ios/AscApiClient'; import { AscApiUtils } from '../utils/ios/AscApiUtils'; -import { waitForAscBuildProcessingAsync } from '../utils/ios/ascBuildProcessing'; export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { return new BuildFunction({ @@ -24,7 +23,7 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), BuildStepInput.createProvider({ - id: 'ipa_path', + id: 'bundle_identifier', required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), @@ -45,16 +44,16 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { z.string().parse(inputs.asc_api_key_path.value) ); const buildUploadId = z.string().parse(inputs.build_upload_id.value); - const ipaPath = z.string().optional().parse(inputs.ipa_path.value); + const ipaBundleIdentifier = z.string().optional().parse(inputs.bundle_identifier.value); const appleAppIdentifier = z.string().optional().parse(inputs.apple_app_identifier.value); const bundleShortVersion = z.string().optional().parse(inputs.bundle_short_version.value); const key = await AscApiUtils.loadApiKeyAsync({ keyPath }); const client = new AscApiClient({ key, logger: ctx.logger }); - await waitForAscBuildProcessingAsync({ + await AscApiUtils.waitForAscBuildProcessingAsync({ client, buildUploadId, logger: ctx.logger, - ipaPath: ipaPath ? path.resolve(ctx.workingDirectory, ipaPath) : undefined, + ipaBundleIdentifier, appleAppIdentifier, bundleShortVersion, }); diff --git a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts index 8be9f98e5e..a4b6116488 100644 --- a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts +++ b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts @@ -2,6 +2,7 @@ import { SystemError, UserError } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; import fs from 'fs-extra'; import * as jose from 'jose'; +import { setTimeout } from 'node:timers/promises'; import { z } from 'zod'; import { isConnectionInterruptedError } from '../../../utils/networkErrors'; @@ -302,4 +303,186 @@ export namespace AscApiUtils { .join('\n') || ' (none)' ); } + + export async function waitForAscBuildProcessingAsync({ + client, + buildUploadId, + logger, + ipaBundleIdentifier, + appleAppIdentifier, + ascAppBundleIdentifier, + bundleShortVersion, + }: { + client: AscApiClient; + buildUploadId: string; + logger: bunyan; + ipaBundleIdentifier?: string; + appleAppIdentifier?: string; + ascAppBundleIdentifier?: string; + bundleShortVersion?: string; + }): 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${itemizeMessages(infos)}\n`); + } + if (warnings.length > 0) { + logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`); + } + if (errors.length > 0) { + 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)) { + if (!ascAppBundleIdentifier && appleAppIdentifier) { + try { + const app = await 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 getAppsAsync({ client, limit: 5 }); + visibleAppsSummary = 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!`); + return; + } + throw new Error( + `Unexpected App Store Connect processing state: ${state.state}. Check the build in App Store Connect.` + ); + } + throw new UserError( + 'EAS_ASC_BUILD_PROCESSING_TIMEOUT', + `App Store Connect did not finish processing build upload ${buildUploadId} within 30 minutes. Check its status in App Store Connect before retrying.` + ); + } + + export 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]; + } } diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts b/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts index 54ee546f69..3c8c97e920 100644 --- a/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts +++ b/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts @@ -431,3 +431,124 @@ describe('commitBuildUploadFileAsync', () => { 15_000 ); }); + +describe(AscApiUtils.isClosedVersionTrainError, () => { + it('returns true when all errors are closed-version-train codes', () => { + expect( + AscApiUtils.isClosedVersionTrainError([ + { code: '90062' }, + { code: '90186' }, + { code: '90478' }, + { code: '90062' }, + ]) + ).toBe(true); + }); + + it('returns false when any other error code is present', () => { + expect( + AscApiUtils.isClosedVersionTrainError([ + { code: '90062' }, + { code: '90186' }, + { code: '12345' }, + ]) + ).toBe(false); + }); + + it('returns false when there are no errors', () => { + expect(AscApiUtils.isClosedVersionTrainError([])).toBe(false); + }); +}); + +describe(AscApiUtils.isInvalidBundleIdentifierError, () => { + it('returns true when all errors are invalid-bundle-id codes', () => { + expect( + AscApiUtils.isInvalidBundleIdentifierError([ + { code: '90054' }, + { code: '90055' }, + { code: '90055' }, + ]) + ).toBe(true); + }); + + it('returns false when any other error code is present', () => { + expect(AscApiUtils.isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe( + false + ); + }); + + it('returns false when there are no errors', () => { + expect(AscApiUtils.isInvalidBundleIdentifierError([])).toBe(false); + }); +}); + +describe(AscApiUtils.isMissingPurposeStringError, () => { + it('returns true when all errors are missing-purpose-string codes', () => { + expect(AscApiUtils.isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe( + true + ); + }); + + it('returns false when any other error code is present', () => { + expect(AscApiUtils.isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe( + false + ); + }); + + it('returns false when there are no errors', () => { + expect(AscApiUtils.isMissingPurposeStringError([])).toBe(false); + }); +}); + +describe(AscApiUtils.isSdkVersionIssueError, () => { + it('returns true when all errors are SDK-version-issue codes', () => { + expect(AscApiUtils.isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true); + }); + + it('returns false when any other error code is present', () => { + expect(AscApiUtils.isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false); + }); + + it('returns false when there are no errors', () => { + expect(AscApiUtils.isSdkVersionIssueError([])).toBe(false); + }); +}); + +describe(AscApiUtils.parseMissingUsageDescriptionKeys, () => { + it('extracts missing UsageDescription keys from ASC messages', () => { + expect( + AscApiUtils.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( + AscApiUtils.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( + AscApiUtils.parseMissingUsageDescriptionKeys([ + { description: 'Some other upload validation error.' }, + ]) + ).toEqual([]); + }); +}); diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/ascBuildProcessing.test.ts b/packages/build-tools/src/steps/utils/ios/__tests__/ascBuildProcessing.test.ts deleted file mode 100644 index 2e9791078c..0000000000 --- a/packages/build-tools/src/steps/utils/ios/__tests__/ascBuildProcessing.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - isClosedVersionTrainError, - isInvalidBundleIdentifierError, - isMissingPurposeStringError, - isSdkVersionIssueError, - parseMissingUsageDescriptionKeys, -} from '../ascBuildProcessing'; - -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/utils/ios/ascBuildProcessing.ts b/packages/build-tools/src/steps/utils/ios/ascBuildProcessing.ts deleted file mode 100644 index a5900d6f55..0000000000 --- a/packages/build-tools/src/steps/utils/ios/ascBuildProcessing.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { UserError } from '@expo/eas-build-job'; -import { bunyan } from '@expo/logger'; -import { asyncResult } from '@expo/results'; -import { setTimeout } from 'node:timers/promises'; - -import { readIpaInfoAsync } from '../../functions/readIpaInfo'; - -import { AscApiClient } from './AscApiClient'; -import { AscApiUtils } from './AscApiUtils'; - -export async function waitForAscBuildProcessingAsync({ - client, - buildUploadId, - logger, - ipaPath, - appleAppIdentifier, - ascAppBundleIdentifier, - bundleShortVersion, -}: { - client: AscApiClient; - buildUploadId: string; - logger: bunyan; - ipaPath?: string; - appleAppIdentifier?: string; - ascAppBundleIdentifier?: string; - bundleShortVersion?: string; -}): 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${itemizeMessages(infos)}\n`); - } - if (warnings.length > 0) { - logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`); - } - if (errors.length > 0) { - 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 = ipaPath ? await asyncResult(readIpaInfoAsync(ipaPath)) : null; - const ipaBundleIdentifier = ipaInfoResult?.ok ? ipaInfoResult.value.bundleIdentifier : null; - - 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!`); - return; - } - throw new Error( - `Unexpected App Store Connect processing state: ${state.state}. Check the build in App Store Connect.` - ); - } - throw new UserError( - 'EAS_ASC_BUILD_PROCESSING_TIMEOUT', - `App Store Connect did not finish processing build upload ${buildUploadId} within 30 minutes. Check its status in App Store Connect before retrying.` - ); -} - -export 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]; -} From b5442a5bb6cd07be1d55c1dd9162944c7f78d9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 11:29:07 +0200 Subject: [PATCH 4/9] [build-tools] keep timeout coverage in the base change --- .changeset/separate-asc-processing-step.md | 2 +- .../__tests__/waitForAscBuildProcessing.test.ts | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/.changeset/separate-asc-processing-step.md b/.changeset/separate-asc-processing-step.md index a8812f161a..6df6114b4a 100644 --- a/.changeset/separate-asc-processing-step.md +++ b/.changeset/separate-asc-processing-step.md @@ -2,4 +2,4 @@ '@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. Fail explicitly when processing exceeds 30 minutes. +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/functions/__tests__/waitForAscBuildProcessing.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts index 334a6f487b..c27722d427 100644 --- a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts @@ -136,18 +136,6 @@ it('fails for an unclassified Apple processing error', async () => { await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.'); }); -it('fails when processing does not complete within 30 minutes', async () => { - 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(waitAsync()).rejects.toMatchObject({ - errorCode: 'EAS_ASC_BUILD_PROCESSING_TIMEOUT', - }); -}); - it.each([undefined, false, true])( 'keeps upload wait_for_processing=%s compatible', async waitForProcessing => { From bae232ac307dc9a12967cb2844a3dc6c7c6925a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 11:37:12 +0200 Subject: [PATCH 5/9] [build-tools] preserve ASC processing timeout behavior --- .../waitForAscBuildProcessing.test.ts | 57 ++++++++++++------- .../src/steps/utils/ios/AscApiUtils.ts | 9 +-- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts index c27722d427..eedf8ec349 100644 --- a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts @@ -35,6 +35,28 @@ async function waitAsync(diagnostics: Record = {}): 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') @@ -139,34 +161,29 @@ it('fails for an unclassified Apple processing error', async () => { it.each([undefined, false, true])( 'keeps upload wait_for_processing=%s compatible', async waitForProcessing => { - await fs.outputFile('/project/app.ipa', 'ipa'); - jest.mocked(readIpaInfoAsync).mockResolvedValue({ - bundleIdentifier: 'com.test.ipa', - bundleVersion: '1', - bundleShortVersion: '1.0', - dtPlatformName: 'iphoneos', - }); - mockUpload(); if (waitForProcessing) { mockProcessing('PROCESSING'); mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]); } - 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 }), - }, - }); if (waitForProcessing) { - await expect(step.executeAsync()).rejects.toMatchObject({ + await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({ errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE', }); } else { - await expect(step.executeAsync()).resolves.toBeUndefined(); + 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/utils/ios/AscApiUtils.ts b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts index a4b6116488..79e4f65d0a 100644 --- a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts +++ b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts @@ -436,16 +436,9 @@ export namespace AscApiUtils { throw new Error(`Build upload (ID: ${buildUploadId}) failed.`); } else if (state.state === 'COMPLETE') { logger.info(`Build upload (ID: ${buildUploadId}) complete!`); - return; } - throw new Error( - `Unexpected App Store Connect processing state: ${state.state}. Check the build in App Store Connect.` - ); + break; } - throw new UserError( - 'EAS_ASC_BUILD_PROCESSING_TIMEOUT', - `App Store Connect did not finish processing build upload ${buildUploadId} within 30 minutes. Check its status in App Store Connect before retrying.` - ); } export function itemizeMessages(messages: { description: string; code: string }[]): string { From 44f5ec55c99772df821f93afa668570db4dcecae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 11:40:35 +0200 Subject: [PATCH 6/9] [build-tools] validate ASC wait inputs together --- .../functions/waitForAscBuildProcessing.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts index 4b0f2ea190..2603c64579 100644 --- a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts +++ b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts @@ -39,23 +39,31 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { }), ], fn: async (ctx, { inputs }) => { - const keyPath = path.resolve( - ctx.workingDirectory, - z.string().parse(inputs.asc_api_key_path.value) - ); - const buildUploadId = z.string().parse(inputs.build_upload_id.value); - const ipaBundleIdentifier = z.string().optional().parse(inputs.bundle_identifier.value); - const appleAppIdentifier = z.string().optional().parse(inputs.apple_app_identifier.value); - const bundleShortVersion = z.string().optional().parse(inputs.bundle_short_version.value); + 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 AscApiUtils.waitForAscBuildProcessingAsync({ client, - buildUploadId, + buildUploadId: parsedInputs.build_upload_id, logger: ctx.logger, - ipaBundleIdentifier, - appleAppIdentifier, - bundleShortVersion, + ipaBundleIdentifier: parsedInputs.bundle_identifier, + appleAppIdentifier: parsedInputs.apple_app_identifier, + bundleShortVersion: parsedInputs.bundle_short_version, }); }, }); From c0151e4ce049ac93d1ef45db722a044eb33d5348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 17:27:29 +0200 Subject: [PATCH 7/9] [build-tools] place ASC processing wait in its step module --- .../src/steps/functions/uploadToAsc.ts | 3 +- .../functions/waitForAscBuildProcessing.ts | 142 +++++++++++++++++- .../src/steps/utils/ios/AscApiUtils.ts | 138 ----------------- 3 files changed, 143 insertions(+), 140 deletions(-) diff --git a/packages/build-tools/src/steps/functions/uploadToAsc.ts b/packages/build-tools/src/steps/functions/uploadToAsc.ts index 8bdf1b6e93..2f310e3776 100644 --- a/packages/build-tools/src/steps/functions/uploadToAsc.ts +++ b/packages/build-tools/src/steps/functions/uploadToAsc.ts @@ -14,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({ @@ -207,7 +208,7 @@ export function createUploadToAscBuildFunction(): BuildFunction { return; } - await AscApiUtils.waitForAscBuildProcessingAsync({ + await waitForAscBuildProcessingAsync({ client, buildUploadId, logger: stepsCtx.logger, diff --git a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts index 2603c64579..4f94c95fbc 100644 --- a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts +++ b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts @@ -1,5 +1,8 @@ +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'; @@ -57,7 +60,7 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { 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 AscApiUtils.waitForAscBuildProcessingAsync({ + await waitForAscBuildProcessingAsync({ client, buildUploadId: parsedInputs.build_upload_id, logger: ctx.logger, @@ -68,3 +71,140 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { }, }); } + +export async function waitForAscBuildProcessingAsync({ + client, + buildUploadId, + logger, + ipaBundleIdentifier, + appleAppIdentifier, + ascAppBundleIdentifier, + bundleShortVersion, +}: { + client: AscApiClient; + buildUploadId: string; + logger: bunyan; + ipaBundleIdentifier?: string; + appleAppIdentifier?: string; + ascAppBundleIdentifier?: string; + bundleShortVersion?: string; +}): 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 (AscApiUtils.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 (AscApiUtils.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 (AscApiUtils.isMissingPurposeStringError(errors)) { + const missingUsageDescriptionKeys = AscApiUtils.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 (AscApiUtils.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; + } +} diff --git a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts index 79e4f65d0a..a6b031cc2e 100644 --- a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts +++ b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts @@ -2,7 +2,6 @@ import { SystemError, UserError } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; import fs from 'fs-extra'; import * as jose from 'jose'; -import { setTimeout } from 'node:timers/promises'; import { z } from 'zod'; import { isConnectionInterruptedError } from '../../../utils/networkErrors'; @@ -304,143 +303,6 @@ export namespace AscApiUtils { ); } - export async function waitForAscBuildProcessingAsync({ - client, - buildUploadId, - logger, - ipaBundleIdentifier, - appleAppIdentifier, - ascAppBundleIdentifier, - bundleShortVersion, - }: { - client: AscApiClient; - buildUploadId: string; - logger: bunyan; - ipaBundleIdentifier?: string; - appleAppIdentifier?: string; - ascAppBundleIdentifier?: string; - bundleShortVersion?: string; - }): 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${itemizeMessages(infos)}\n`); - } - if (warnings.length > 0) { - logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`); - } - if (errors.length > 0) { - 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)) { - if (!ascAppBundleIdentifier && appleAppIdentifier) { - try { - const app = await 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 getAppsAsync({ client, limit: 5 }); - visibleAppsSummary = 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; - } - } - export function itemizeMessages(messages: { description: string; code: string }[]): string { return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`; } From 524d0222ae6ebb164fa4fd75a4224020fc7d96bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 17:32:19 +0200 Subject: [PATCH 8/9] [build-tools] keep processing error helpers private --- .../waitForAscBuildProcessing.test.ts | 56 +++++++- .../functions/waitForAscBuildProcessing.ts | 44 ++++++- .../src/steps/utils/ios/AscApiUtils.ts | 34 ----- .../utils/ios/__tests__/AscApiUtils.test.ts | 121 ------------------ 4 files changed, 92 insertions(+), 163 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts index eedf8ec349..a0ea0d8dab 100644 --- a/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/waitForAscBuildProcessing.test.ts @@ -140,7 +140,10 @@ it('polls an existing upload through awaiting and processing states until comple }); it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => { - mockProcessing('FAILED', [{ code: '90054', description: 'Bundle mismatch' }]); + mockProcessing( + 'FAILED', + ['90054', '90055'].map(code => ({ code, description: 'Bundle mismatch' })) + ); mockApp(); api().get('/v1/apps').query(true).reply(200, { data: [] }); await expect( @@ -153,11 +156,58 @@ it('uses the supplied bundle identifier in mismatch errors without an IPA', asyn }); }); -it('fails for an unclassified Apple processing error', async () => { - mockProcessing('FAILED', [{ code: 'OTHER', description: 'Rejected' }]); +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 => { diff --git a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts index 4f94c95fbc..6e6d8474bd 100644 --- a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts +++ b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts @@ -130,7 +130,7 @@ export async function waitForAscBuildProcessingAsync({ } if (state.state === 'FAILED') { - if (AscApiUtils.isSdkVersionIssueError(errors)) { + 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. ' + @@ -141,7 +141,7 @@ export async function waitForAscBuildProcessingAsync({ } ); } - if (AscApiUtils.isInvalidBundleIdentifierError(errors)) { + if (isInvalidBundleIdentifierError(errors)) { if (!ascAppBundleIdentifier && appleAppIdentifier) { try { const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier }); @@ -173,8 +173,8 @@ export async function waitForAscBuildProcessingAsync({ } ); } - if (AscApiUtils.isMissingPurposeStringError(errors)) { - const missingUsageDescriptionKeys = AscApiUtils.parseMissingUsageDescriptionKeys(errors); + 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` + @@ -193,7 +193,7 @@ export async function waitForAscBuildProcessingAsync({ } ); } - if (AscApiUtils.isClosedVersionTrainError(errors)) { + 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. ` + @@ -208,3 +208,37 @@ export async function waitForAscBuildProcessingAsync({ 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 a6b031cc2e..5ba2737892 100644 --- a/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts +++ b/packages/build-tools/src/steps/utils/ios/AscApiUtils.ts @@ -306,38 +306,4 @@ export namespace AscApiUtils { export 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]; - } } diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts b/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts index 3c8c97e920..54ee546f69 100644 --- a/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts +++ b/packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts @@ -431,124 +431,3 @@ describe('commitBuildUploadFileAsync', () => { 15_000 ); }); - -describe(AscApiUtils.isClosedVersionTrainError, () => { - it('returns true when all errors are closed-version-train codes', () => { - expect( - AscApiUtils.isClosedVersionTrainError([ - { code: '90062' }, - { code: '90186' }, - { code: '90478' }, - { code: '90062' }, - ]) - ).toBe(true); - }); - - it('returns false when any other error code is present', () => { - expect( - AscApiUtils.isClosedVersionTrainError([ - { code: '90062' }, - { code: '90186' }, - { code: '12345' }, - ]) - ).toBe(false); - }); - - it('returns false when there are no errors', () => { - expect(AscApiUtils.isClosedVersionTrainError([])).toBe(false); - }); -}); - -describe(AscApiUtils.isInvalidBundleIdentifierError, () => { - it('returns true when all errors are invalid-bundle-id codes', () => { - expect( - AscApiUtils.isInvalidBundleIdentifierError([ - { code: '90054' }, - { code: '90055' }, - { code: '90055' }, - ]) - ).toBe(true); - }); - - it('returns false when any other error code is present', () => { - expect(AscApiUtils.isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe( - false - ); - }); - - it('returns false when there are no errors', () => { - expect(AscApiUtils.isInvalidBundleIdentifierError([])).toBe(false); - }); -}); - -describe(AscApiUtils.isMissingPurposeStringError, () => { - it('returns true when all errors are missing-purpose-string codes', () => { - expect(AscApiUtils.isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe( - true - ); - }); - - it('returns false when any other error code is present', () => { - expect(AscApiUtils.isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe( - false - ); - }); - - it('returns false when there are no errors', () => { - expect(AscApiUtils.isMissingPurposeStringError([])).toBe(false); - }); -}); - -describe(AscApiUtils.isSdkVersionIssueError, () => { - it('returns true when all errors are SDK-version-issue codes', () => { - expect(AscApiUtils.isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true); - }); - - it('returns false when any other error code is present', () => { - expect(AscApiUtils.isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false); - }); - - it('returns false when there are no errors', () => { - expect(AscApiUtils.isSdkVersionIssueError([])).toBe(false); - }); -}); - -describe(AscApiUtils.parseMissingUsageDescriptionKeys, () => { - it('extracts missing UsageDescription keys from ASC messages', () => { - expect( - AscApiUtils.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( - AscApiUtils.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( - AscApiUtils.parseMissingUsageDescriptionKeys([ - { description: 'Some other upload validation error.' }, - ]) - ).toEqual([]); - }); -}); From 1572a6420a98bef44021ad527072bbcad7fde5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 24 Oct 2026 17:38:41 +0200 Subject: [PATCH 9/9] [build-tools] require explicit processing diagnostic arguments --- packages/build-tools/src/steps/functions/uploadToAsc.ts | 1 + .../src/steps/functions/waitForAscBuildProcessing.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/build-tools/src/steps/functions/uploadToAsc.ts b/packages/build-tools/src/steps/functions/uploadToAsc.ts index 2f310e3776..8d9f75aa17 100644 --- a/packages/build-tools/src/steps/functions/uploadToAsc.ts +++ b/packages/build-tools/src/steps/functions/uploadToAsc.ts @@ -213,6 +213,7 @@ export function createUploadToAscBuildFunction(): BuildFunction { buildUploadId, logger: stepsCtx.logger, ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined, + appleAppIdentifier: undefined, ascAppBundleIdentifier, bundleShortVersion, }); diff --git a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts index 6e6d8474bd..882629d00c 100644 --- a/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts +++ b/packages/build-tools/src/steps/functions/waitForAscBuildProcessing.ts @@ -66,6 +66,7 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction { logger: ctx.logger, ipaBundleIdentifier: parsedInputs.bundle_identifier, appleAppIdentifier: parsedInputs.apple_app_identifier, + ascAppBundleIdentifier: undefined, bundleShortVersion: parsedInputs.bundle_short_version, }); }, @@ -84,10 +85,10 @@ export async function waitForAscBuildProcessingAsync({ client: AscApiClient; buildUploadId: string; logger: bunyan; - ipaBundleIdentifier?: string; - appleAppIdentifier?: string; - ascAppBundleIdentifier?: string; - bundleShortVersion?: string; + ipaBundleIdentifier: string | undefined; + appleAppIdentifier: string | undefined; + ascAppBundleIdentifier: string | undefined; + bundleShortVersion: string | undefined; }): Promise { logger.info('Checking build upload status...'); const waitingForBuildStartedAt = Date.now();