mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af23af8e17 | ||
|
|
efbbe5bc19 | ||
|
|
574e3883e3 |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/eas-build-job': patch
|
||||
---
|
||||
|
||||
Add types and validation for documented GitHub workflow context fields.
|
||||
@@ -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,222 +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(): 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',
|
||||
},
|
||||
});
|
||||
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('finishes the upload without waiting for processing', async () => {
|
||||
await expect(uploadAsync()).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();
|
||||
});
|
||||
@@ -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/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,
|
||||
@@ -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))) {
|
||||
@@ -176,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') {
|
||||
@@ -194,10 +202,171 @@ 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;
|
||||
}
|
||||
|
||||
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,222 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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- ')}`;
|
||||
}
|
||||
}
|
||||
@@ -871,7 +871,7 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
});
|
||||
|
||||
describe('GitHub context event payload passthrough', () => {
|
||||
it('accepts arbitrary fields at all event levels', () => {
|
||||
it('preserves webhook fields and validates documented pull request details', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
@@ -895,7 +895,19 @@ describe('GitHub context event payload passthrough', () => {
|
||||
},
|
||||
pull_request: {
|
||||
number: 123,
|
||||
title: 'Fix the build',
|
||||
body: null,
|
||||
state: 'open',
|
||||
draft: true,
|
||||
merged: false,
|
||||
html_url: 'https://github.com/expo/expo/pull/123',
|
||||
user: { login: 'octocat', id: 1 },
|
||||
labels: [{ name: 'bug', color: 'red' }],
|
||||
head: { ref: 'fix-build', sha: 'abc123', repo: { name: 'expo' } },
|
||||
base: { ref: 'main', sha: 'def456' },
|
||||
created_at: '2026-10-01T00:00:00Z',
|
||||
updated_at: '2026-10-02T00:00:00Z',
|
||||
merged_at: null,
|
||||
},
|
||||
number: 123,
|
||||
schedule: '0 0 * * *',
|
||||
@@ -921,6 +933,34 @@ describe('GitHub context event payload passthrough', () => {
|
||||
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
|
||||
|
||||
expect(parsed.github?.event).toMatchObject(context.github.event);
|
||||
expect(() =>
|
||||
StaticWorkflowInterpolationContextZ.parse({
|
||||
...context,
|
||||
github: {
|
||||
...context.github,
|
||||
event: {
|
||||
...context.github.event,
|
||||
pull_request: { ...context.github.event.pull_request, title: 123 },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ number: 123 },
|
||||
{ number: 123, body: null, user: null, merged: null, merged_at: null },
|
||||
])('accepts partial pull request payloads: %j', pullRequest => {
|
||||
const github = {
|
||||
event_name: 'pull_request',
|
||||
sha: 'abc123',
|
||||
ref: 'refs/heads/main',
|
||||
ref_name: 'main',
|
||||
ref_type: 'branch',
|
||||
event: { pull_request: pullRequest },
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.shape.github.parse(github)).toEqual(github);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ const GitHubContextZ = z.object({
|
||||
.record(z.string(), z.unknown())
|
||||
.and(
|
||||
z.object({
|
||||
action: z.string().optional(),
|
||||
label: z
|
||||
.looseObject({
|
||||
name: z.string(),
|
||||
@@ -221,6 +222,19 @@ const GitHubContextZ = z.object({
|
||||
pull_request: z
|
||||
.looseObject({
|
||||
number: z.number(),
|
||||
title: z.string().optional(),
|
||||
body: z.string().nullable().optional(),
|
||||
state: z.enum(['open', 'closed']).optional(),
|
||||
draft: z.boolean().optional(),
|
||||
merged: z.boolean().nullable().optional(),
|
||||
html_url: z.string().optional(),
|
||||
user: z.looseObject({ login: z.string() }).nullable().optional(),
|
||||
labels: z.array(z.looseObject({ name: z.string() })).optional(),
|
||||
head: z.looseObject({ ref: z.string(), sha: z.string() }).optional(),
|
||||
base: z.looseObject({ ref: z.string(), sha: z.string() }).optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
merged_at: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
number: z.number().optional(),
|
||||
|
||||
Reference in new issue
Block a user