Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/separate-asc-processing-step.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/build-tools/src/steps/easFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -144,6 +145,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),

createReportMaestroTestResultsFunction(ctx),
Expand Down
112 changes: 0 additions & 112 deletions packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import fs from 'fs-extra';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { setTimeout } from 'node:timers/promises';

import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { getEasFunctions } from '../../easFunctions';
import { readIpaInfoAsync } from '../readIpaInfo';
import { createUploadToAscBuildFunction } from '../uploadToAsc';

jest.unmock('node-fetch');
jest.mock('node:timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../readIpaInfo', () => ({
...jest.requireActual('../readIpaInfo'),
readIpaInfoAsync: jest.fn(),
}));

const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const api = () => nock('https://api.appstoreconnect.apple.com');
const logger = createMockLogger();
const globalContext = () => createGlobalContextMock({ projectTargetDirectory: '/project', logger });

async function waitAsync(diagnostics: Record<string, string> = {}): Promise<void> {
const fn = getEasFunctions(createCustomBuildContextMock()).find(
fn => fn.getFullId() === 'eas/wait_for_asc_build_processing'
)!;
await fn
.createBuildStepFromFunctionCall(globalContext(), {
callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload', ...diagnostics },
})
.executeAsync();
}

async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
await fs.outputFile('/project/app.ipa', 'ipa');
jest.mocked(readIpaInfoAsync).mockResolvedValue({
bundleIdentifier: 'com.test.ipa',
bundleVersion: '1',
bundleShortVersion: '1.0',
dtPlatformName: 'iphoneos',
});
mockUpload();
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(globalContext(), {
callInputs: {
ipa_path: 'app.ipa',
asc_api_key_path: 'key.json',
apple_app_identifier: 'app',
bundle_version: '1',
bundle_short_version: '1.0',
...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }),
},
});
await step.executeAsync();
}

function mockProcessing(state: string, errors: { code: string; description: string }[] = []): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: { type: 'buildUploads', id: 'upload', attributes: { state: { state, errors } } },
});
}

function mockApp(): void {
api()
.get('/v1/apps/app')
.query(true)
.reply(200, {
data: { type: 'apps', id: 'app', attributes: { name: 'Test app', bundleId: 'com.test.app' } },
});
}

function mockUpload(): void {
mockApp();
api()
.post('/v1/buildUploads')
.reply(201, { data: { type: 'buildUploads', id: 'upload' } });
api()
.post('/v1/buildUploadFiles')
.reply(201, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: {
uploadOperations: [
{
length: 3,
offset: 0,
partNumber: 1,
method: 'PUT',
requestHeaders: [],
url: 'https://upload.example.com/ipa',
},
],
},
},
});
nock('https://upload.example.com').put('/ipa', 'ipa').reply(200);
api()
.get('/v1/buildUploadFiles/file')
.query(true)
.twice()
.reply(200, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: { assetDeliveryState: { state: 'COMPLETE' } },
},
});
}

beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
beforeEach(async () => {
await fs.outputJson('/project/key.json', {
key_id: 'TESTKEY',
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
});
jest.mocked(setTimeout).mockResolvedValue(undefined);
});
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
jest.restoreAllMocks();
}
});

it('polls an existing upload through awaiting and processing states until complete', async () => {
mockProcessing('AWAITING_UPLOAD');
mockProcessing('PROCESSING');
mockProcessing('COMPLETE');
await expect(waitAsync()).resolves.toBeUndefined();
});

it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => {
mockProcessing(
'FAILED',
['90054', '90055'].map(code => ({ code, description: 'Bundle mismatch' }))
);
mockApp();
api().get('/v1/apps').query(true).reply(200, { data: [] });
await expect(
waitAsync({ bundle_identifier: 'com.test.ipa', apple_app_identifier: 'app' })
).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
message: expect.stringContaining(
'IPA bundle identifier: com.test.ipa\nApp Store Connect app bundle identifier: com.test.app'
),
});
});

it.each<[string, string[], string]>([
['SDK version', ['90725'], 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'],
['closed version train', ['90062', '90186', '90478'], 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'],
])('reports a %s processing error', async (_label, codes, errorCode) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toMatchObject({ errorCode });
});

it.each(
[
[],
['OTHER'],
['90725', 'OTHER'],
['90062', 'OTHER'],
['90054', 'OTHER'],
['90683', 'OTHER'],
].map(codes => ({ codes }))
)('keeps unclassified or mixed processing errors generic: $codes', async ({ codes }) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.');
});

it('reports missing privacy keys once each', async () => {
mockProcessing('FAILED', [
{
code: '90683',
description: 'Missing NSCameraUsageDescription and NSPhotoLibraryUsageDescription.',
},
{ code: '90683', description: 'Missing NSCameraUsageDescription.' },
]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.stringContaining(
'Missing keys reported by App Store Connect:\n- NSCameraUsageDescription\n- NSPhotoLibraryUsageDescription\n\n'
),
});
});

it('reports missing privacy purpose strings when Apple does not name a key', async () => {
mockProcessing('FAILED', [{ code: '90683', description: 'Missing purpose string.' }]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.not.stringContaining('Missing keys reported by App Store Connect:'),
});
});

it.each([undefined, false, true])(
'keeps upload wait_for_processing=%s compatible',
async waitForProcessing => {
if (waitForProcessing) {
mockProcessing('PROCESSING');
mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]);
}
if (waitForProcessing) {
await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
});
} else {
await expect(uploadAsync(waitForProcessing)).resolves.toBeUndefined();
}
}
);

it.each(['upload', 'wait'])(
'preserves the successful timeout return for the %s step',
async step => {
mockProcessing('PROCESSING');
const start = Date.now();
jest.spyOn(Date, 'now').mockReturnValue(start);
jest.mocked(setTimeout).mockImplementationOnce(async () => {
jest.spyOn(Date, 'now').mockReturnValue(start + 30 * 60 * 1000);
});
await expect(step === 'upload' ? uploadAsync(true) : waitAsync()).resolves.toBeUndefined();
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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}...`);
Expand Down
Loading
Loading