Compare commits

..
11 changed files with 144 additions and 434 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Add a configurable timeout to `eas/wait_for_asc_build_processing` and fail when Apple does not finish processing before the deadline.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': major
---
Remove `wait_for_processing` from `eas/upload_to_asc`; use a separate `eas/wait_for_asc_build_processing` step to wait for App Store Connect processing.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Add eas/submit_testflight_beta_review to submit a processed build for beta review separately from metadata updates.
@@ -69,7 +69,6 @@ import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbed
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSubmitTestFlightBetaReviewBuildFunction } from './functions/submitTestFlightBetaReview';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -148,7 +147,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createSubmitTestFlightBetaReviewBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -1,104 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { getEasFunctions } from '../../easFunctions';
jest.unmock('node-fetch');
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const api = () => nock('https://api.appstoreconnect.apple.com');
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
beforeEach(async () => {
await fs.outputJson('/project/key.json', {
key_id: 'TESTKEY',
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
});
});
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
}
});
async function submitAsync(): Promise<void> {
const fn = getEasFunctions(createCustomBuildContextMock()).find(
fn => fn.getFullId() === 'eas/submit_testflight_beta_review'
)!;
await fn
.createBuildStepFromFunctionCall(
createGlobalContextMock({ projectTargetDirectory: '/project' }),
{
callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload' },
}
)
.executeAsync();
}
function mockBuild(state = 'COMPLETE'): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: {
type: 'buildUploads',
id: 'upload',
attributes: { state: { state } },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
});
}
function mockReviewState(externalBuildState: string): void {
api()
.get('/v1/builds/build/buildBetaDetail')
.query(true)
.reply(200, {
data: {
id: 'detail',
attributes: { internalBuildState: 'READY_FOR_BETA_TESTING', externalBuildState },
},
});
}
it.each(['READY_FOR_BETA_SUBMISSION', 'IN_BETA_REVIEW', 'READY_FOR_BETA_TESTING'])(
'submits for review only when needed (%s)',
async state => {
mockBuild();
mockReviewState(state);
if (state === 'READY_FOR_BETA_SUBMISSION') {
api()
.post('/v1/betaAppReviewSubmissions', {
data: {
type: 'betaAppReviewSubmissions',
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'review', type: 'betaAppReviewSubmissions' } });
}
await expect(submitAsync()).resolves.toBeUndefined();
}
);
it('does not submit an unprocessed build', async () => {
mockBuild('PROCESSING');
await expect(submitAsync()).rejects.toThrow('not ready for beta review');
});
it('does not treat an arbitrary conflict as success', async () => {
mockBuild();
mockReviewState('READY_FOR_BETA_SUBMISSION');
api()
.post('/v1/betaAppReviewSubmissions')
.reply(409, {
errors: [{ status: '409', code: 'STATE_ERROR', detail: 'Missing beta review information' }],
});
await expect(submitAsync()).rejects.toThrow('Missing beta review information');
});
@@ -24,7 +24,7 @@ const api = () => nock('https://api.appstoreconnect.apple.com');
const logger = createMockLogger();
const globalContext = () => createGlobalContextMock({ projectTargetDirectory: '/project', logger });
async function waitAsync(diagnostics: Record<string, string | number> = {}): Promise<void> {
async function waitAsync(diagnostics: Record<string, string> = {}): Promise<void> {
const fn = getEasFunctions(createCustomBuildContextMock()).find(
fn => fn.getFullId() === 'eas/wait_for_asc_build_processing'
)!;
@@ -35,7 +35,7 @@ async function waitAsync(diagnostics: Record<string, string | number> = {}): Pro
.executeAsync();
}
async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
async function uploadAsync(): Promise<void> {
await fs.outputFile('/project/app.ipa', 'ipa');
jest.mocked(readIpaInfoAsync).mockResolvedValue({
bundleIdentifier: 'com.test.ipa',
@@ -51,7 +51,6 @@ async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
apple_app_identifier: 'app',
bundle_version: '1',
bundle_short_version: '1.0',
...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }),
},
});
await step.executeAsync();
@@ -139,18 +138,6 @@ it('polls an existing upload through awaiting and processing states until comple
await expect(waitAsync()).resolves.toBeUndefined();
});
it('reports a processing timeout instead of success when Apple is still processing', async () => {
let now = 0;
jest.spyOn(Date, 'now').mockImplementation(() => now);
jest.mocked(setTimeout).mockImplementation(async () => {
now += 2000;
});
mockProcessing('PROCESSING');
await expect(waitAsync({ wait_processing_timeout_seconds: 1 })).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_PROCESSING_TIMEOUT',
});
});
it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => {
mockProcessing(
'FAILED',
@@ -220,34 +207,16 @@ it('reports missing privacy purpose strings when Apple does not name a key', asy
});
});
it.each([undefined, false, true])(
'keeps upload wait_for_processing=%s compatible',
async waitForProcessing => {
if (waitForProcessing) {
mockProcessing('PROCESSING');
mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]);
}
if (waitForProcessing) {
await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
});
} else {
await expect(uploadAsync(waitForProcessing)).resolves.toBeUndefined();
}
}
);
it('finishes the upload without waiting for processing', async () => {
await expect(uploadAsync()).resolves.toBeUndefined();
});
it.each(['upload', 'wait'])(
'reports the default processing timeout 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()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_PROCESSING_TIMEOUT',
});
}
);
it('preserves the successful timeout return for the wait step', 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()).resolves.toBeUndefined();
});
@@ -1,72 +0,0 @@
import { UserError } from '@expo/eas-build-job';
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';
export function createSubmitTestFlightBetaReviewBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'submit_testflight_beta_review',
name: 'Submit for TestFlight beta review',
__metricsId: 'eas/submit_testflight_beta_review',
inputProviders: [
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (ctx, { inputs }) => {
const { ascApiKeyPath, buildUploadId } = z
.object({ ascApiKeyPath: z.string(), buildUploadId: z.string() })
.parse({
ascApiKeyPath: inputs.asc_api_key_path.value,
buildUploadId: inputs.build_upload_id.value,
});
const keyPath = path.resolve(ctx.workingDirectory, ascApiKeyPath);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
const { data: upload } = await client.getAsync(
'/v1/buildUploads/:id',
{
'fields[buildUploads]': ['state', 'build'],
include: ['build'],
},
{ id: buildUploadId }
);
const buildId = upload.relationships?.build?.data?.id;
if (upload.attributes.state.state !== 'COMPLETE' || !buildId) {
throw new UserError(
'EAS_TESTFLIGHT_BUILD_NOT_READY',
'The uploaded build is not ready for beta review. Run eas/wait_for_asc_build_processing before this step.'
);
}
const { data } = await client.getAsync('/v1/builds/:id/buildBetaDetail', {}, { id: buildId });
const reviewedStates = [
'WAITING_FOR_BETA_REVIEW',
'IN_BETA_REVIEW',
'READY_FOR_BETA_TESTING',
'IN_BETA_TESTING',
];
if (reviewedStates.includes(data.attributes.externalBuildState ?? '')) {
ctx.logger.info('This build is already in beta review or available for external testing.');
return;
}
await client.postAsync('/v1/betaAppReviewSubmissions', {
data: {
type: 'betaAppReviewSubmissions',
relationships: { build: { data: { type: 'builds', id: buildId } } },
},
});
ctx.logger.info('Build submitted for beta review.');
},
});
}
@@ -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/wait_for_asc_build_processing first, or use eas/upload_to_asc with wait_for_processing: true.'
'The uploaded build is not ready for TestFlight metadata. Run eas/wait_for_asc_build_processing first.'
);
}
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
@@ -14,7 +14,6 @@ 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({
@@ -49,12 +48,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'wait_for_processing',
required: false,
defaultValue: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -72,7 +65,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const appleAppIdentifier = z.string().parse(inputs.apple_app_identifier.value);
const bundleVersion = z.string().parse(inputs.bundle_version.value);
const bundleShortVersion = z.string().parse(inputs.bundle_short_version.value);
const waitForProcessing = Boolean(inputs.wait_for_processing.value);
const ipaPath = path.resolve(stepsCtx.workingDirectory, ipaPathInput);
if (!(await fs.pathExists(ipaPath))) {
@@ -202,21 +194,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
stepsCtx.logger.info(`See your build in App Store Connect: ${buildUploadUrl}`);
if (!waitForProcessing) {
stepsCtx.logger.info('Skipping waiting for processing.');
return;
}
await waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger: stepsCtx.logger,
ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined,
appleAppIdentifier: undefined,
ascAppBundleIdentifier,
bundleShortVersion,
});
},
});
}
@@ -1,5 +1,4 @@
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';
@@ -15,12 +14,6 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
name: 'Wait for App Store Connect processing',
__metricsId: 'eas/wait_for_asc_build_processing',
inputProviders: [
BuildStepInput.createProvider({
id: 'wait_processing_timeout_seconds',
required: false,
defaultValue: 30 * 60,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
@@ -52,7 +45,6 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
.object({
asc_api_key_path: z.string(),
build_upload_id: z.string(),
wait_processing_timeout_seconds: z.number().finite().positive(),
bundle_identifier: z.string().optional(),
apple_app_identifier: z.string().optional(),
bundle_short_version: z.string().optional(),
@@ -60,7 +52,6 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
.parse({
asc_api_key_path: inputs.asc_api_key_path.value,
build_upload_id: inputs.build_upload_id.value,
wait_processing_timeout_seconds: inputs.wait_processing_timeout_seconds.value,
bundle_identifier: inputs.bundle_identifier.value,
apple_app_identifier: inputs.apple_app_identifier.value,
bundle_short_version: inputs.bundle_short_version.value,
@@ -68,164 +59,134 @@ 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 waitForAscBuildProcessingAsync({
client,
buildUploadId: parsedInputs.build_upload_id,
processingTimeoutSeconds: parsedInputs.wait_processing_timeout_seconds,
logger: ctx.logger,
ipaBundleIdentifier: parsedInputs.bundle_identifier,
appleAppIdentifier: parsedInputs.apple_app_identifier,
ascAppBundleIdentifier: undefined,
bundleShortVersion: parsedInputs.bundle_short_version,
});
const buildUploadId = parsedInputs.build_upload_id;
const ipaBundleIdentifier = parsedInputs.bundle_identifier;
const appleAppIdentifier = parsedInputs.apple_app_identifier;
const bundleShortVersion = parsedInputs.bundle_short_version;
const logger = ctx.logger;
let ascAppBundleIdentifier: string | undefined;
logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
if (!ascAppBundleIdentifier && appleAppIdentifier) {
try {
const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
ascAppBundleIdentifier = app.data.attributes.bundleId;
} catch {
// Keep the processing error if app details are unavailable.
}
}
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
}
break;
}
},
});
}
export async function waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger,
ipaBundleIdentifier,
appleAppIdentifier,
ascAppBundleIdentifier,
bundleShortVersion,
processingTimeoutSeconds = 30 * 60,
}: {
client: AscApiClient;
buildUploadId: string;
logger: bunyan;
ipaBundleIdentifier: string | undefined;
appleAppIdentifier: string | undefined;
ascAppBundleIdentifier: string | undefined;
bundleShortVersion: string | undefined;
processingTimeoutSeconds?: number;
}): Promise<void> {
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 < processingTimeoutSeconds * 1000) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
if (!ascAppBundleIdentifier && appleAppIdentifier) {
try {
const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
ascAppBundleIdentifier = app.data.attributes.bundleId;
} catch {
// Keep the processing error if app details are unavailable.
}
}
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
return;
}
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_PROCESSING_TIMEOUT',
`Apple did not finish processing build upload ${buildUploadId} within ${processingTimeoutSeconds} seconds. ` +
'The binary was uploaded. Check its status in App Store Connect before uploading it again, or increase wait_processing_timeout_seconds.'
);
}
function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
@@ -43,7 +43,6 @@ const GetApi = {
data: z.object({
id: z.string(),
attributes: z.object({
externalBuildState: z.string().optional(),
internalBuildState: z.enum([
'PROCESSING',
'PROCESSING_EXCEPTION',
@@ -218,19 +217,6 @@ const GetApi = {
} satisfies ApiSchema;
const PostApi = {
'/v1/betaAppReviewSubmissions': {
request: z.object({
data: z.object({
type: z.literal('betaAppReviewSubmissions'),
relationships: z.object({
build: z.object({ data: z.object({ type: z.literal('builds'), id: z.string() }) }),
}),
}),
}),
response: z.object({
data: z.object({ id: z.string(), type: z.literal('betaAppReviewSubmissions') }),
}),
},
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-builds-_id_-relationships-betagroups
'/v1/builds/:id/relationships/betaGroups': {
path: z.object({ id: z.string() }),