mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a107f30620 | ||
|
|
67df0e2787 | ||
|
|
0a09c0432d | ||
|
|
e901de4198 | ||
|
|
be5b4ef0af |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Add eas/submit_testflight_beta_review to submit a processed build for beta review separately from metadata updates.
|
||||
@@ -69,6 +69,7 @@ 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';
|
||||
@@ -147,6 +148,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
createWaitForAscBuildProcessingBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
createSubmitTestFlightBetaReviewBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
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');
|
||||
});
|
||||
+45
-14
@@ -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> = {}): Promise<void> {
|
||||
async function waitAsync(diagnostics: Record<string, string | number> = {}): 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> = {}): Promise<void
|
||||
.executeAsync();
|
||||
}
|
||||
|
||||
async function uploadAsync(): Promise<void> {
|
||||
async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
|
||||
await fs.outputFile('/project/app.ipa', 'ipa');
|
||||
jest.mocked(readIpaInfoAsync).mockResolvedValue({
|
||||
bundleIdentifier: 'com.test.ipa',
|
||||
@@ -51,6 +51,7 @@ async function uploadAsync(): Promise<void> {
|
||||
apple_app_identifier: 'app',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }),
|
||||
},
|
||||
});
|
||||
await step.executeAsync();
|
||||
@@ -138,6 +139,18 @@ 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',
|
||||
@@ -207,16 +220,34 @@ it('reports missing privacy purpose strings when Apple does not name a key', asy
|
||||
});
|
||||
});
|
||||
|
||||
it('finishes the upload without waiting for processing', async () => {
|
||||
await expect(uploadAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
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('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();
|
||||
});
|
||||
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',
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
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.'
|
||||
'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}...`);
|
||||
|
||||
@@ -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({
|
||||
@@ -48,6 +49,12 @@ 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({
|
||||
@@ -65,6 +72,7 @@ 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))) {
|
||||
@@ -194,6 +202,21 @@ 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,4 +1,5 @@
|
||||
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';
|
||||
@@ -14,6 +15,12 @@ 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,
|
||||
@@ -45,6 +52,7 @@ 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(),
|
||||
@@ -52,6 +60,7 @@ 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,
|
||||
@@ -59,134 +68,164 @@ 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 });
|
||||
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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,6 +43,7 @@ const GetApi = {
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
attributes: z.object({
|
||||
externalBuildState: z.string().optional(),
|
||||
internalBuildState: z.enum([
|
||||
'PROCESSING',
|
||||
'PROCESSING_EXCEPTION',
|
||||
@@ -217,6 +218,19 @@ 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() }),
|
||||
|
||||
Reference in new issue
Block a user