diff --git a/.changeset/testflight-beta-review.md b/.changeset/testflight-beta-review.md new file mode 100644 index 0000000000..4be44c3dc4 --- /dev/null +++ b/.changeset/testflight-beta-review.md @@ -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. diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index 0dd0ac10c9..3f42441484 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -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), diff --git a/packages/build-tools/src/steps/functions/__tests__/submitTestFlightBetaReview.test.ts b/packages/build-tools/src/steps/functions/__tests__/submitTestFlightBetaReview.test.ts new file mode 100644 index 0000000000..2d5b81ff9c --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/submitTestFlightBetaReview.test.ts @@ -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 { + 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'); +}); diff --git a/packages/build-tools/src/steps/functions/submitTestFlightBetaReview.ts b/packages/build-tools/src/steps/functions/submitTestFlightBetaReview.ts new file mode 100644 index 0000000000..211ac5ea82 --- /dev/null +++ b/packages/build-tools/src/steps/functions/submitTestFlightBetaReview.ts @@ -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.'); + }, + }); +} diff --git a/packages/build-tools/src/steps/utils/ios/AscApiClient.ts b/packages/build-tools/src/steps/utils/ios/AscApiClient.ts index aaacbd88f7..9cf437edfb 100644 --- a/packages/build-tools/src/steps/utils/ios/AscApiClient.ts +++ b/packages/build-tools/src/steps/utils/ios/AscApiClient.ts @@ -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() }),