mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -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.
|
||||
@@ -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');
|
||||
});
|
||||
+17
-3
@@ -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'
|
||||
)!;
|
||||
@@ -139,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',
|
||||
@@ -226,7 +238,7 @@ it.each([undefined, false, true])(
|
||||
);
|
||||
|
||||
it.each(['upload', 'wait'])(
|
||||
'preserves the successful timeout return for the %s step',
|
||||
'reports the default processing timeout for the %s step',
|
||||
async step => {
|
||||
mockProcessing('PROCESSING');
|
||||
const start = Date.now();
|
||||
@@ -234,6 +246,8 @@ it.each(['upload', 'wait'])(
|
||||
jest.mocked(setTimeout).mockImplementationOnce(async () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(start + 30 * 60 * 1000);
|
||||
});
|
||||
await expect(step === 'upload' ? uploadAsync(true) : waitAsync()).resolves.toBeUndefined();
|
||||
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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,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,
|
||||
@@ -46,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(),
|
||||
@@ -53,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,
|
||||
@@ -63,6 +71,7 @@ export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
|
||||
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,
|
||||
@@ -81,6 +90,7 @@ export async function waitForAscBuildProcessingAsync({
|
||||
appleAppIdentifier,
|
||||
ascAppBundleIdentifier,
|
||||
bundleShortVersion,
|
||||
processingTimeoutSeconds = 30 * 60,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
buildUploadId: string;
|
||||
@@ -89,13 +99,14 @@ export async function waitForAscBuildProcessingAsync({
|
||||
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 < 30 * 60 * 1000 /* 30 minutes */) {
|
||||
while (Date.now() - waitingForBuildStartedAt < processingTimeoutSeconds * 1000) {
|
||||
const {
|
||||
data: {
|
||||
attributes: { state },
|
||||
@@ -205,9 +216,14 @@ export async function waitForAscBuildProcessingAsync({
|
||||
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
|
||||
} else if (state.state === 'COMPLETE') {
|
||||
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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