Compare commits

..
Author SHA1 Message Date
szdziedzic 3b836ef30c [eas-cli] Add --os-version to eas simulator 2026-10-26 16:24:00 +01:00
Stanisław Chmiela bcb5d96ea8 [cocoapods-proxy] Add Sonatype snapshot CocoaPods proxy support (#3736)
## Summary

Adds `central.sonatype.com` support to the packaged `expo-cocoapods-proxy` gem used by iOS workers.

## Change

- Adds `central.sonatype.com` to the `Pod::Downloader::Http` proxy allowlist.
- Adds spec coverage for Sonatype repository artifact URLs.

## Code Requiring This

This PR is for CocoaPods `:http` pod source downloads. It does not proxy React Native's `Net::HTTP` metadata lookup.

React Native returns Sonatype artifact URLs to CocoaPods as `:http` pod sources here:

- RNCore returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rncore.rb#L170-L173
- ReactNativeDependencies returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rndependencies.rb#L205-L208
- Hermes returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/sdks/hermes-engine/hermes-utils.rb#L190-L193

CocoaPods/cocoapods-downloader then uses `Pod::Downloader::Http` for those `:http` params:

- CocoaPods passes the pod source params to `Downloader.for_target(...).download`: https://github.com/CocoaPods/CocoaPods/blob/1.12.1/lib/cocoapods/downloader.rb#L107-L110
- `cocoapods-downloader` maps `:http` to `Http`: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L22-L30
- `Downloader.for_target` instantiates that downloader with the `:http` URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L51-L64
- `Pod::Downloader::Http#download_file` uses curl for that URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader/http.rb#L12-L22

## Validation

- `rspec` in `packages/expo-cocoapods-proxy`: 16 examples, 0 failures
- `git diff --check`
2026-10-26 15:51:09 +01:00
15 changed files with 376 additions and 512 deletions

No files matched your search

@@ -1,5 +0,0 @@
---
'@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.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add `--os-version` to `eas simulator` to select the iOS Simulator runtime version.
@@ -67,7 +67,6 @@ 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';
@@ -145,7 +144,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
@@ -0,0 +1,112 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -1,239 +0,0 @@
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();
}
);
@@ -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/upload_to_asc with wait_for_processing: true first.'
);
}
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
@@ -14,7 +15,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({
@@ -184,10 +184,10 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const { errors = [], warnings = [] } = assetDeliveryState;
if (warnings.length > 0) {
stepsCtx.logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
stepsCtx.logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
}
if (assetDeliveryState.state === 'FAILED') {
@@ -208,19 +208,165 @@ export function createUploadToAscBuildFunction(): BuildFunction {
return;
}
await waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger: stepsCtx.logger,
ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined,
appleAppIdentifier: undefined,
ascAppBundleIdentifier,
bundleShortVersion,
});
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;
}
},
});
}
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<string>();
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,
@@ -1,245 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { AscApiClient } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'wait_for_asc_build_processing',
name: 'Wait for App Store Connect processing',
__metricsId: 'eas/wait_for_asc_build_processing',
inputProviders: [
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'apple_app_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_short_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (ctx, { inputs }) => {
const parsedInputs = z
.object({
asc_api_key_path: z.string(),
build_upload_id: z.string(),
bundle_identifier: z.string().optional(),
apple_app_identifier: z.string().optional(),
bundle_short_version: z.string().optional(),
})
.parse({
asc_api_key_path: inputs.asc_api_key_path.value,
build_upload_id: inputs.build_upload_id.value,
bundle_identifier: inputs.bundle_identifier.value,
apple_app_identifier: inputs.apple_app_identifier.value,
bundle_short_version: inputs.bundle_short_version.value,
});
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
await waitForAscBuildProcessingAsync({
client,
buildUploadId: parsedInputs.build_upload_id,
logger: ctx.logger,
ipaBundleIdentifier: parsedInputs.bundle_identifier,
appleAppIdentifier: parsedInputs.apple_app_identifier,
ascAppBundleIdentifier: undefined,
bundleShortVersion: parsedInputs.bundle_short_version,
});
},
});
}
export async function waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger,
ipaBundleIdentifier,
appleAppIdentifier,
ascAppBundleIdentifier,
bundleShortVersion,
}: {
client: AscApiClient;
buildUploadId: string;
logger: bunyan;
ipaBundleIdentifier: string | undefined;
appleAppIdentifier: string | undefined;
ascAppBundleIdentifier: string | undefined;
bundleShortVersion: string | undefined;
}): Promise<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 < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
if (!ascAppBundleIdentifier && appleAppIdentifier) {
try {
const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
ascAppBundleIdentifier = app.data.attributes.bundleId;
} catch {
// Keep the processing error if app details are unavailable.
}
}
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
}
break;
}
}
function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
@@ -302,8 +302,4 @@ export namespace AscApiUtils {
.join('\n') || ' (none)'
);
}
export function itemizeMessages(messages: { description: string; code: string }[]): string {
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
}
+13 -1
View File
@@ -46796,7 +46796,19 @@
"inputFields": [
{
"name": "deviceIdentifier",
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 16 Pro\").",
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 17 Pro\"). If omitted,\nthe default device of the selected iOS version is used.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "osVersion",
"description": "iOS version of the Simulator runtime, as major.minor (e.g. \"18.6\") or as a major version\n(e.g. \"26\") for the newest available release of that major. Each version runs on the macOS\nworker image that has that runtime, so the session also uses that image's Xcode. Unavailable\nversions are rejected with the list of available ones. Not every account can select a version\nyet. If omitted, the default iOS version is used.",
"type": {
"kind": "SCALAR",
"name": "String",
@@ -1223,6 +1223,54 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('ios');
});
it('forwards --os-version in the iOS create options', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--os-version',
' 18.5 ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ ios: { osVersion: '18.5' } })
);
});
it('forwards --os-version together with --device', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--device',
'iPhone 16 Pro',
'--os-version',
'26',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro', osVersion: '26' } })
);
});
it('rejects --os-version for Android before creating a session', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--os-version',
'18.5',
]);
await expect(command.runAsync()).rejects.toThrow(
'--os-version is only supported with --platform ios.'
);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('omits resourceClass when --resource-class is not set', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
@@ -108,6 +108,10 @@ export default class Simulator extends EasCommand {
description:
'Android SDK system image package to use for the emulator (e.g. "system-images;android-35-ext15;google_apis_playstore;x86_64"). Defaults to a system image chosen by the runner. Only supported with --platform android.',
}),
'os-version': Flags.string({
description:
'iOS version of the Simulator runtime, as major.minor (e.g. "18.6") or as a major version (e.g. "26") for the newest available release of that major. Unavailable versions are rejected with the list of available ones. Defaults to the server default. Only supported with --platform ios.',
}),
'build-id': Flags.string({
description: 'EAS Build to install and launch before the simulator session is ready.',
exclusive: ['build-fingerprint', 'application-archive-url', 'expo-go'],
@@ -257,6 +261,7 @@ export default class Simulator extends EasCommand {
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
const deviceIdentifier = flags.device?.trim() || undefined;
const systemImagePackage = flags['system-image']?.trim() || undefined;
const osVersion = flags['os-version']?.trim() || undefined;
const buildId = flags['build-id']?.trim() || undefined;
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -303,6 +308,9 @@ export default class Simulator extends EasCommand {
if (systemImagePackage && platform !== AppPlatform.Android) {
throw new EasCommandError('--system-image is only supported with --platform android.');
}
if (osVersion && platform !== AppPlatform.Ios) {
throw new EasCommandError('--os-version is only supported with --platform ios.');
}
let egressAllow: string[] = [];
try {
egressAllow = parseEgressAllowList(flags['egress-allow'] ?? []);
@@ -379,8 +387,13 @@ export default class Simulator extends EasCommand {
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
? deviceIdentifier || osVersion
? {
ios: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(osVersion ? { osVersion } : {}),
},
}
: {}
: deviceIdentifier || systemImagePackage
? {
+12 -1
View File
@@ -6613,8 +6613,19 @@ export type CreateGitHubRepositorySettingsInput = {
};
export type CreateIosDeviceRunSessionInput = {
/** Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). */
/**
* Simulator device name or UDID to use for the session (e.g. "iPhone 17 Pro"). If omitted,
* the default device of the selected iOS version is used.
*/
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
/**
* iOS version of the Simulator runtime, as major.minor (e.g. "18.6") or as a major version
* (e.g. "26") for the newest available release of that major. Each version runs on the macOS
* worker image that has that runtime, so the session also uses that image's Xcode. Unavailable
* versions are rejected with the list of available ones. Not every account can select a version
* yet. If omitted, the default iOS version is used.
*/
osVersion?: InputMaybe<Scalars['String']['input']>;
};
export type CreateIosSubmissionInput = {
@@ -17,6 +17,7 @@ module Pod
%r{https?://(api\.)?github\.com/},
%r{https?://sourceforge\.net/},
%r{https?://repo1\.maven\.org/},
%r{https?://central\.sonatype\.com/},
%r{https?://boostorg\.jfrog\.io/},
%r{https?://www\.sqlite\.org/},
%r{https?://download\.videolan\.org/},
@@ -142,6 +142,17 @@ RSpec.describe ExpoCocoaPodsProxy do
downloader.download_file(path.to_s)
end
it 'fetches Sonatype snapshot artifacts via proxy' do
path = Pathname.new('some/fake/path')
url = 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/react/react-native-artifacts/0.82.0-SNAPSHOT/maven-metadata.xml'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(downloader).to receive(:download_file_via_proxy).once {}
downloader.download_file(path.to_s)
end
it 'does not proxy when url doesn\'t match an allowlist regex' do
path = Pathname.new('some/fake/path')
url = 'https://not-github.com/expo/test-repo/archive/main.tar.gz'