diff --git a/packages/build-tools/package.json b/packages/build-tools/package.json index da4fc15c..73a1646f 100644 --- a/packages/build-tools/package.json +++ b/packages/build-tools/package.json @@ -45,7 +45,9 @@ "nullthrows": "^1.1.1", "plist": "^3.1.0", "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", "resolve-from": "^5.0.0", + "retry": "^0.13.1", "semver": "^7.6.2" }, "devDependencies": { @@ -56,6 +58,8 @@ "@types/node-fetch": "^2.6.11", "@types/node-forge": "^1.3.11", "@types/plist": "^3.0.5", + "@types/promise-retry": "^1.1.6", + "@types/retry": "^0.12.5", "@types/semver": "^7.5.8", "@types/uuid": "^9.0.8", "jest": "^29.7.0", diff --git a/packages/build-tools/src/customBuildContext.ts b/packages/build-tools/src/customBuildContext.ts index 22630722..7692fb14 100644 --- a/packages/build-tools/src/customBuildContext.ts +++ b/packages/build-tools/src/customBuildContext.ts @@ -96,6 +96,7 @@ export class CustomBuildContext implements ExternalBuild public staticContext(): Omit { return { ...this.job.workflowInterpolationContext, + expoApiServerURL: this.env.__API_SERVER_URL, job: this.job, metadata: this.metadata ?? null, env: this.env, diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index c7613a2b..805716e7 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -27,6 +27,7 @@ import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildC import { calculateEASUpdateRuntimeVersionFunction } from './functions/calculateEASUpdateRuntimeVersion'; import { createRepackBuildFunction } from './functions/repack'; import { eagerBundleBuildFunction } from './functions/eagerBundle'; +import { createSubmissionEntityFunction } from './functions/createSubmissionEntity'; export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { const functions = [ @@ -56,6 +57,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { calculateEASUpdateRuntimeVersionFunction(), createRepackBuildFunction(), + + createSubmissionEntityFunction(), ]; if (ctx.hasBuildJob()) { diff --git a/packages/build-tools/src/steps/functions/createSubmissionEntity.ts b/packages/build-tools/src/steps/functions/createSubmissionEntity.ts new file mode 100644 index 00000000..3e1916b1 --- /dev/null +++ b/packages/build-tools/src/steps/functions/createSubmissionEntity.ts @@ -0,0 +1,133 @@ +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import { asyncResult } from '@expo/results'; + +import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure'; + +export function createSubmissionEntityFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'create_submission_entity', + name: 'Create Submission Entity', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'build_id', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + + // AndroidSubmissionConfig + BuildStepInput.createProvider({ + id: 'track', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'release_status', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'rollout', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: false, + }), + BuildStepInput.createProvider({ + id: 'changes_not_sent_for_review', + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: false, + }), + + // IosSubmissionConfig + BuildStepInput.createProvider({ + id: 'apple_id_username', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'asc_app_identifier', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + fn: async (stepsCtx, { inputs }) => { + const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken; + if (!robotAccessToken) { + stepsCtx.logger.error('Failed to create submission entity: no robot access token found'); + return; + } + + const buildId = inputs.build_id.value; + if (!buildId) { + stepsCtx.logger.error('Failed to create submission entity: no build ID provided'); + return; + } + + const workflowJobId = stepsCtx.global.staticContext.env.__WORKFLOW_JOB_ID; + if (!workflowJobId) { + stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found'); + return; + } + + // This is supposed to provide fallback for `''` -> `undefined`. + // We _not_ want to use nullish coalescing. + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + const track = inputs.track.value || undefined; + const releaseStatus = inputs.release_status.value || undefined; + const rollout = inputs.rollout.value || undefined; + const changesNotSentForReview = inputs.changes_not_sent_for_review.value || undefined; + + const appleIdUsername = inputs.apple_id_username.value || undefined; + const ascAppIdentifier = inputs.asc_app_identifier.value || undefined; + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + + try { + const response = await retryOnDNSFailure(fetch)( + new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL), + { + method: 'POST', + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflowJobId, + turtleBuildId: buildId, + // We can pass mixed object here because the configs are disjoint. + config: { + // AndroidSubmissionConfig + track, + releaseStatus, + rollout, + changesNotSentForReview, + + // IosSubmissionConfig + appleIdUsername, + ascAppIdentifier, + }, + }), + } + ); + + if (!response.ok) { + const textResult = await asyncResult(response.text()); + throw new Error( + `Unexpected response from server (${response.status}): ${textResult.value}` + ); + } + + const jsonResult = await asyncResult(response.json()); + if (!jsonResult.ok) { + stepsCtx.logger.warn( + `Submission created. Failed to parse response. ${jsonResult.reason}` + ); + return; + } + + const data = jsonResult.value.data; + stepsCtx.logger.info(`Submission created:\n ID: ${data.id}\n URL: ${data.url}`); + } catch (e) { + stepsCtx.logger.error(`Failed to create submission entity. ${e}`); + } + }, + }); +} diff --git a/packages/build-tools/src/utils/promiseRetryWithCondition.ts b/packages/build-tools/src/utils/promiseRetryWithCondition.ts new file mode 100644 index 00000000..a62983d2 --- /dev/null +++ b/packages/build-tools/src/utils/promiseRetryWithCondition.ts @@ -0,0 +1,20 @@ +import promiseRetry from 'promise-retry'; +import { OperationOptions } from 'retry'; + +export function promiseRetryWithCondition Promise>( + fn: TFn, + retryConditionFn: (error: any) => boolean, + options: OperationOptions = { retries: 3, factor: 2 } +): (...funcArgs: Parameters) => Promise> { + return (...funcArgs) => + promiseRetry>(async (retry) => { + try { + return await fn(...funcArgs); + } catch (e) { + if (retryConditionFn(e)) { + retry(e); + } + throw e; + } + }, options); +} diff --git a/packages/build-tools/src/utils/retryOnDNSFailure.ts b/packages/build-tools/src/utils/retryOnDNSFailure.ts new file mode 100644 index 00000000..586b0fbc --- /dev/null +++ b/packages/build-tools/src/utils/retryOnDNSFailure.ts @@ -0,0 +1,19 @@ +import { OperationOptions } from 'retry'; + +import { promiseRetryWithCondition } from './promiseRetryWithCondition'; + +export function isDNSError(e: Error & { code: any }): boolean { + return e.code === 'ENOTFOUND' || e.code === 'EAI_AGAIN'; +} + +export function retryOnDNSFailure Promise>( + fn: TFn, + options?: OperationOptions +): (...funcArgs: Parameters) => Promise> { + return promiseRetryWithCondition(fn, isDNSError, { + retries: 3, + factor: 2, + minTimeout: 100, + ...options, + }); +} diff --git a/packages/eas-build-job/src/context.ts b/packages/eas-build-job/src/context.ts index 1328cef6..77a3b88b 100644 --- a/packages/eas-build-job/src/context.ts +++ b/packages/eas-build-job/src/context.ts @@ -12,6 +12,7 @@ type StaticJobOnlyInterpolationContext = { outputs: Record; } >; + expoApiServerURL: string; }; export type StaticJobInterpolationContext = diff --git a/packages/steps/src/cli/cli.ts b/packages/steps/src/cli/cli.ts index 32486db5..b042cf43 100644 --- a/packages/steps/src/cli/cli.ts +++ b/packages/steps/src/cli/cli.ts @@ -33,6 +33,7 @@ export class CliContextProvider implements ExternalBuildContextProvider { job: {} as Job, metadata: {} as Metadata, env: this.env as Env, + expoApiServerURL: 'http://api.expo.test', }; } public updateEnv(env: BuildStepEnv): void { diff --git a/yarn.lock b/yarn.lock index b402f257..8b41a64c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1982,6 +1982,13 @@ "@types/node" "*" xmlbuilder ">=11.0.1" +"@types/promise-retry@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@types/promise-retry/-/promise-retry-1.1.6.tgz#3c48826d8a27f68f9d4900fc7448f08a1532db44" + integrity sha512-EC1+OMXV0PZb0pf+cmyxc43MEP2CDumZe4AfuxWboxxEixztIebknpJPZAX5XlodGF1OY+C1E/RAeNGzxf+bJA== + dependencies: + "@types/retry" "*" + "@types/prompts@^2.4.9": version "2.4.9" resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.4.9.tgz#8775a31e40ad227af511aa0d7f19a044ccbd371e" @@ -1997,6 +2004,11 @@ dependencies: "@types/node" "*" +"@types/retry@*", "@types/retry@^0.12.5": + version "0.12.5" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e" + integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw== + "@types/semver@^7.5.8": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -8313,6 +8325,11 @@ retry@^0.12.0: resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"