From 1df3293e9965e83c037347a967da860ccac9d1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 17 Jan 2025 16:00:37 +0100 Subject: [PATCH] [eas-build-job] Allow `steps` in build jobs (#493) # Why For workflows it'll be much easier to let users define `steps` in the workflow file instead of having to do the custom build config and stuff. # How Added ability to pass `steps` in build jobs. Alongside, we need to send `workflowInteprolationContext`, so added that too. # Test Plan Tested manually by running both manual and GitHub workflow build jobs. Also ran a custom build and a regular build from `eas-cli`. --- packages/build-tools/src/builders/custom.ts | 38 +++++++---- packages/build-tools/src/generic.ts | 27 ++------ .../src/__tests__/android.test.ts | 52 +++++++++++++- .../src/__tests__/generic.test.ts | 67 ++++--------------- .../eas-build-job/src/__tests__/ios.test.ts | 50 ++++++++++++++ packages/eas-build-job/src/android.ts | 22 +++--- packages/eas-build-job/src/common.ts | 28 ++++++++ packages/eas-build-job/src/generic.ts | 19 +----- packages/eas-build-job/src/ios.ts | 22 +++--- 9 files changed, 198 insertions(+), 127 deletions(-) diff --git a/packages/build-tools/src/builders/custom.ts b/packages/build-tools/src/builders/custom.ts index 916e5ed3..cc5833c7 100644 --- a/packages/build-tools/src/builders/custom.ts +++ b/packages/build-tools/src/builders/custom.ts @@ -1,8 +1,9 @@ +import assert from 'assert'; import path from 'path'; -import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job'; -import { BuildConfigParser, BuildStepGlobalContext, errors } from '@expo/steps'; import nullthrows from 'nullthrows'; +import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job'; +import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps'; import { Artifacts, BuildContext } from '../context'; import { prepareProjectSourcesAsync } from '../common/projectSources'; @@ -25,23 +26,32 @@ export async function runCustomBuildAsync(ctx: BuildContext): Promise< ctx.updateEnv(env); customBuildCtx.updateEnv(ctx.env); } - const relativeConfigPath = nullthrows( - ctx.job.customBuildConfig?.path, - 'Custom build config must be defined for custom builds' - ); - const configPath = path.join( - ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory), - relativeConfigPath + + assert( + 'steps' in ctx.job || 'customBuildConfig' in ctx.job, + 'Steps or custom build config path are required in custom jobs' ); const globalContext = new BuildStepGlobalContext(customBuildCtx, false); const easFunctions = getEasFunctions(customBuildCtx); const easFunctionGroups = getEasFunctionGroups(customBuildCtx); - const parser = new BuildConfigParser(globalContext, { - externalFunctions: easFunctions, - externalFunctionGroups: easFunctionGroups, - configPath, - }); + const parser = ctx.job.steps + ? new StepsConfigParser(globalContext, { + externalFunctions: easFunctions, + externalFunctionGroups: easFunctionGroups, + steps: ctx.job.steps, + }) + : new BuildConfigParser(globalContext, { + externalFunctions: easFunctions, + externalFunctionGroups: easFunctionGroups, + configPath: path.join( + ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory), + nullthrows( + ctx.job.customBuildConfig?.path, + 'Steps or custom build config path are required in custom jobs' + ) + ), + }); const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => { try { return await parser.parseAsync(); diff --git a/packages/build-tools/src/generic.ts b/packages/build-tools/src/generic.ts index e8b899a0..c03af8ee 100644 --- a/packages/build-tools/src/generic.ts +++ b/packages/build-tools/src/generic.ts @@ -2,13 +2,7 @@ import fs from 'fs'; import path from 'path'; import { BuildPhase, Generic } from '@expo/eas-build-job'; -import { - BuildConfigParser, - BuildStepGlobalContext, - BuildWorkflow, - errors, - StepsConfigParser, -} from '@expo/steps'; +import { BuildStepGlobalContext, BuildWorkflow, errors, StepsConfigParser } from '@expo/steps'; import { Result, asyncResult } from '@expo/results'; import { BuildContext } from './context'; @@ -30,20 +24,11 @@ export async function runGenericJobAsync( const globalContext = new BuildStepGlobalContext(customBuildCtx, false); - const parser = ctx.job.steps - ? new StepsConfigParser(globalContext, { - externalFunctions: getEasFunctions(customBuildCtx), - externalFunctionGroups: getEasFunctionGroups(customBuildCtx), - steps: ctx.job.steps, - }) - : new BuildConfigParser(globalContext, { - externalFunctions: getEasFunctions(customBuildCtx), - externalFunctionGroups: getEasFunctionGroups(customBuildCtx), - configPath: path.join( - customBuildCtx.projectSourceDirectory, - ctx.job.customBuildConfig.path - ), - }); + const parser = new StepsConfigParser(globalContext, { + externalFunctions: getEasFunctions(customBuildCtx), + externalFunctionGroups: getEasFunctionGroups(customBuildCtx), + steps: ctx.job.steps, + }); const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => { try { diff --git a/packages/eas-build-job/src/__tests__/android.test.ts b/packages/eas-build-job/src/__tests__/android.test.ts index ac04423a..bb891f47 100644 --- a/packages/eas-build-job/src/__tests__/android.test.ts +++ b/packages/eas-build-job/src/__tests__/android.test.ts @@ -214,7 +214,7 @@ describe('Android.JobSchema', () => { expect(error?.message).toBe('"buildProfile" is required'); }); - test('valid custom build job', () => { + test('valid custom build job with path', () => { const customBuildJob = { mode: BuildMode.CUSTOM, type: Workflow.UNKNOWN, @@ -236,6 +236,56 @@ describe('Android.JobSchema', () => { expect(error).toBeFalsy(); }); + test('valid custom build job with steps', () => { + const customBuildJob = { + mode: BuildMode.CUSTOM, + type: Workflow.UNKNOWN, + platform: Platform.ANDROID, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], + outputs: {}, + initiatingUserId: randomUUID(), + appId: randomUUID(), + workflowInterpolationContext: { + after: { + setup: { + status: 'success', + outputs: {}, + }, + }, + needs: { + setup: { + status: 'success', + outputs: {}, + }, + }, + github: { + event_name: 'push', + sha: '123', + ref: 'master', + ref_name: 'master', + ref_type: 'branch', + }, + env: { EXPO_TOKEN: randomUUID() }, + }, + }; + + const { value, error } = Android.JobSchema.validate(customBuildJob, joiOptions); + expect(value).toMatchObject(customBuildJob); + expect(error).toBeFalsy(); + }); + test('can set github trigger options', () => { const job = { mode: BuildMode.BUILD, diff --git a/packages/eas-build-job/src/__tests__/generic.test.ts b/packages/eas-build-job/src/__tests__/generic.test.ts index f6f7d5e0..788c19f7 100644 --- a/packages/eas-build-job/src/__tests__/generic.test.ts +++ b/packages/eas-build-job/src/__tests__/generic.test.ts @@ -1,5 +1,7 @@ import { randomUUID } from 'crypto'; +import { ZodError } from 'zod'; + import { ArchiveSourceType, BuildTrigger, EnvironmentSecretType } from '../common'; import { Generic } from '../generic'; @@ -12,9 +14,14 @@ describe('Generic.JobZ', () => { gitCommitHash: '1234567890', gitRef: null, }, - customBuildConfig: { - path: 'path/to/custom-build-config.yml', - }, + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], secrets: { robotAccessToken: 'token', environmentSecrets: [ @@ -84,7 +91,7 @@ describe('Generic.JobZ', () => { expect(Generic.JobZ.parse(job)).toEqual(job); }); - it('errors when neither customBuildConfig.path nor steps are provided', () => { + it('errors when steps are not provided', () => { const job: Omit = { projectArchive: { type: ArchiveSourceType.GIT, @@ -114,56 +121,6 @@ describe('Generic.JobZ', () => { appId: randomUUID(), initiatingUserId: randomUUID(), }; - expect(() => Generic.JobZ.parse(job)).toThrow('Invalid input'); - }); - - it('errors when both customBuildConfig.path and steps are provided', () => { - const job: Omit & { - customBuildConfig: NonNullable; - steps: NonNullable; - } = { - projectArchive: { - type: ArchiveSourceType.GIT, - repositoryUrl: 'https://github.com/expo/expo.git', - gitCommitHash: '1234567890', - gitRef: null, - }, - secrets: { - robotAccessToken: 'token', - environmentSecrets: [ - { - name: 'secret-name', - value: 'secret-value', - type: EnvironmentSecretType.STRING, - }, - ], - }, - expoDevUrl: 'https://expo.dev/accounts/name/builds/id', - builderEnvironment: { - image: 'macos-sonoma-14.5-xcode-15.4', - node: '20.15.1', - env: { - KEY1: 'value1', - }, - }, - triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, - customBuildConfig: { - path: 'path/to/custom-build-config.yml', - }, - steps: [ - { - id: 'step1', - name: 'Step 1', - run: 'echo Hello, world!', - shell: 'sh', - env: { - KEY1: 'value1', - }, - }, - ], - appId: randomUUID(), - initiatingUserId: randomUUID(), - }; - expect(() => Generic.JobZ.parse(job)).toThrow('Invalid input'); + expect(() => Generic.JobZ.parse(job)).toThrow(ZodError); }); }); diff --git a/packages/eas-build-job/src/__tests__/ios.test.ts b/packages/eas-build-job/src/__tests__/ios.test.ts index c91bb143..00922276 100644 --- a/packages/eas-build-job/src/__tests__/ios.test.ts +++ b/packages/eas-build-job/src/__tests__/ios.test.ts @@ -129,6 +129,56 @@ describe('Ios.JobSchema', () => { expect(error).toBeFalsy(); }); + test('valid custom build job with steps', () => { + const customBuildJob = { + mode: BuildMode.CUSTOM, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], + outputs: {}, + initiatingUserId: randomUUID(), + appId: randomUUID(), + workflowInterpolationContext: { + after: { + setup: { + status: 'success', + outputs: {}, + }, + }, + needs: { + setup: { + status: 'success', + outputs: {}, + }, + }, + github: { + event_name: 'push', + sha: '123', + ref: 'master', + ref_name: 'master', + ref_type: 'branch', + }, + env: { EXPO_TOKEN: randomUUID() }, + }, + }; + + const { value, error } = Ios.JobSchema.validate(customBuildJob, joiOptions); + expect(value).toMatchObject(customBuildJob); + expect(error).toBeFalsy(); + }); + test('invalid generic job', () => { const genericJob = { secrets: { diff --git a/packages/eas-build-job/src/android.ts b/packages/eas-build-job/src/android.ts index 23403f02..5dc69e44 100644 --- a/packages/eas-build-job/src/android.ts +++ b/packages/eas-build-job/src/android.ts @@ -14,7 +14,11 @@ import { EnvironmentSecret, BuildTrigger, BuildMode, + StaticWorkflowInterpolationContextZ, + StaticWorkflowInterpolationContext, + CustomBuildConfigSchema, } from './common'; +import { Step } from './step'; export interface Keystore { dataBase64: string; @@ -100,6 +104,8 @@ export interface Job { customBuildConfig?: { path: string; }; + steps?: Step[]; + outputs?: Record; experimental?: { prebuildCommand?: string; @@ -111,7 +117,7 @@ export interface Job { }; loggerLevel?: LoggerLevel; - workflowInterpolationContext?: never; + workflowInterpolationContext?: StaticWorkflowInterpolationContext; initiatingUserId: string; appId: string; @@ -165,14 +171,6 @@ export const JobSchema = Joi.object({ buildType: Joi.string().valid(...Object.values(BuildType)), username: Joi.string(), - customBuildConfig: Joi.when('mode', { - is: Joi.string().valid(BuildMode.CUSTOM), - then: Joi.object({ - path: Joi.string(), - }).required(), - otherwise: Joi.any().strip(), - }), - experimental: Joi.object({ prebuildCommand: Joi.string(), }), @@ -187,4 +185,8 @@ export const JobSchema = Joi.object({ appId: Joi.string().required(), environment: Joi.string().valid('production', 'preview', 'development'), -}); + + workflowInterpolationContext: Joi.object().custom((workflowInterpolationContext) => + StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext) + ), +}).concat(CustomBuildConfigSchema); diff --git a/packages/eas-build-job/src/common.ts b/packages/eas-build-job/src/common.ts index 16887e7f..977aba99 100644 --- a/packages/eas-build-job/src/common.ts +++ b/packages/eas-build-job/src/common.ts @@ -2,6 +2,7 @@ import Joi from 'joi'; import { z } from 'zod'; import { BuildPhase, BuildPhaseResult } from './logs'; +import { validateSteps } from './step'; export enum BuildMode { BUILD = 'build', @@ -209,3 +210,30 @@ export type DynamicInterpolationContext = { export type WorkflowInterpolationContext = StaticWorkflowInterpolationContext & DynamicInterpolationContext; + +export const CustomBuildConfigSchema = Joi.object().when('.mode', { + is: BuildMode.CUSTOM, + then: Joi.object().when('.customBuildConfig.path', { + is: Joi.exist(), + then: Joi.object({ + customBuildConfig: Joi.object({ + path: Joi.string().required(), + }).required(), + steps: Joi.any().strip(), + outputs: Joi.any().strip(), + }), + otherwise: Joi.object({ + customBuildConfig: Joi.any().strip(), + steps: Joi.array() + .items(Joi.any()) + .required() + .custom((steps) => validateSteps(steps), 'steps validation'), + outputs: Joi.object().pattern(Joi.string(), Joi.string()).required(), + }), + }), + otherwise: Joi.object({ + customBuildConfig: Joi.any().strip(), + steps: Joi.any().strip(), + outputs: Joi.any().strip(), + }), +}); diff --git a/packages/eas-build-job/src/generic.ts b/packages/eas-build-job/src/generic.ts index 98118286..2e7649d6 100644 --- a/packages/eas-build-job/src/generic.ts +++ b/packages/eas-build-job/src/generic.ts @@ -25,7 +25,8 @@ export namespace Generic { cocoapods: z.string().optional(), }); - const CommonJobZ = z.object({ + export type Job = z.infer; + export const JobZ = z.object({ projectArchive: ArchiveSourceSchemaZ, secrets: z.object({ robotAccessToken: z.string(), @@ -42,25 +43,11 @@ export namespace Generic { initiatingUserId: z.string(), appId: z.string(), - }); - const PathJobZ = CommonJobZ.extend({ - customBuildConfig: z.object({ - path: z.string(), - }), - steps: z.never().optional(), - outputs: z.never().optional(), - }); - - const StepsJobZ = CommonJobZ.extend({ - customBuildConfig: z.never().optional(), steps: z.array(StepZ).min(1), outputs: z.record(z.string()).optional(), }); - export type Job = z.infer; - export const JobZ = z.union([PathJobZ, StepsJobZ]); - export type PartialJob = z.infer; - export const PartialJobZ = z.union([PathJobZ.partial(), StepsJobZ.partial()]); + export const PartialJobZ = JobZ.partial(); } diff --git a/packages/eas-build-job/src/ios.ts b/packages/eas-build-job/src/ios.ts index 01d80412..57bd9dd3 100644 --- a/packages/eas-build-job/src/ios.ts +++ b/packages/eas-build-job/src/ios.ts @@ -14,7 +14,11 @@ import { EnvironmentSecret, BuildTrigger, BuildMode, + StaticWorkflowInterpolationContextZ, + StaticWorkflowInterpolationContext, + CustomBuildConfigSchema, } from './common'; +import { Step } from './step'; export type DistributionType = 'store' | 'internal' | 'simulator'; @@ -114,6 +118,8 @@ export interface Job { customBuildConfig?: { path: string; }; + steps?: Step[]; + outputs?: Record; experimental?: { prebuildCommand?: string; @@ -125,7 +131,7 @@ export interface Job { }; loggerLevel?: LoggerLevel; - workflowInterpolationContext?: never; + workflowInterpolationContext?: StaticWorkflowInterpolationContext; initiatingUserId: string; appId: string; @@ -199,14 +205,6 @@ export const JobSchema = Joi.object({ username: Joi.string(), - customBuildConfig: Joi.when('mode', { - is: Joi.string().valid(BuildMode.CUSTOM), - then: Joi.object({ - path: Joi.string(), - }).required(), - otherwise: Joi.any().strip(), - }), - experimental: Joi.object({ prebuildCommand: Joi.string(), }), @@ -221,4 +219,8 @@ export const JobSchema = Joi.object({ appId: Joi.string().required(), environment: Joi.string().valid('production', 'preview', 'development'), -}); + + workflowInterpolationContext: Joi.object().custom((workflowInterpolationContext) => + StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext) + ), +}).concat(CustomBuildConfigSchema);