From 9e91091ae8001d5d050f4d55d0ab8b0c1e026d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Tue, 2 Dec 2025 11:20:59 +0100 Subject: [PATCH 1/3] Make cache optional --- packages/eas-build-job/src/android.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-build-job/src/android.ts b/packages/eas-build-job/src/android.ts index 00eeabef7..7c4ced7c5 100644 --- a/packages/eas-build-job/src/android.ts +++ b/packages/eas-build-job/src/android.ts @@ -82,7 +82,7 @@ export interface Job { }; secrets?: BuildSecrets; builderEnvironment?: BuilderEnvironment; - cache: Cache; + cache?: Cache; developmentClient?: boolean; version?: { versionCode?: string; From edcc4072c75f3d4b59ea5d28e45bf22928797e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Tue, 2 Dec 2025 12:12:25 +0100 Subject: [PATCH 2/3] Add Zod schemas --- packages/eas-build-job/src/android.ts | 112 ++++++++++++++++++++ packages/eas-build-job/src/common.ts | 68 ++++++++++++- packages/eas-build-job/src/generic.ts | 4 +- packages/eas-build-job/src/ios.ts | 135 +++++++++++++++++++++++++ packages/eas-build-job/src/job.ts | 6 ++ packages/eas-build-job/src/metadata.ts | 38 +++++++ 6 files changed, 360 insertions(+), 3 deletions(-) diff --git a/packages/eas-build-job/src/android.ts b/packages/eas-build-job/src/android.ts index 7c4ced7c5..9de7b0ea7 100644 --- a/packages/eas-build-job/src/android.ts +++ b/packages/eas-build-job/src/android.ts @@ -1,22 +1,28 @@ import Joi from 'joi'; +import { z } from 'zod'; import { LoggerLevel } from '@expo/logger'; import { ArchiveSource, ArchiveSourceSchema, + ArchiveSourceZ, Env, EnvSchema, + EnvZ, Platform, Workflow, Cache, CacheSchema, + CacheZ, EnvironmentSecretsSchema, + EnvironmentSecretsZ, EnvironmentSecret, BuildTrigger, BuildMode, StaticWorkflowInterpolationContextZ, StaticWorkflowInterpolationContext, CustomBuildConfigSchema, + CustomBuildConfigZ, } from './common'; import { Step } from './step'; @@ -34,6 +40,13 @@ const KeystoreSchema = Joi.object({ keyPassword: Joi.string().allow(''), }); +const KeystoreZ = z.object({ + dataBase64: z.string(), + keystorePassword: z.string(), + keyAlias: z.string(), + keyPassword: z.string().optional(), +}); + export enum BuildType { APK = 'apk', APP_BUNDLE = 'app-bundle', @@ -61,6 +74,17 @@ const BuilderEnvironmentSchema = Joi.object({ env: EnvSchema, }); +const BuilderEnvironmentZ = z.object({ + image: z.string().optional(), + node: z.string().optional(), + corepack: z.boolean().optional(), + yarn: z.string().optional(), + pnpm: z.string().optional(), + bun: z.string().optional(), + ndk: z.string().optional(), + env: EnvZ.optional(), +}); + export interface BuildSecrets { buildCredentials?: { keystore: Keystore; @@ -133,6 +157,12 @@ const SecretsSchema = Joi.object({ robotAccessToken: Joi.string(), }); +const SecretsZ = z.object({ + buildCredentials: z.object({ keystore: KeystoreZ }).optional(), + environmentSecrets: EnvironmentSecretsZ.optional(), + robotAccessToken: z.string().optional(), +}); + export const JobSchema = Joi.object({ mode: Joi.string() .valid(BuildMode.BUILD, BuildMode.CUSTOM, BuildMode.REPACK) @@ -192,3 +222,85 @@ export const JobSchema = Joi.object({ StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext) ), }).concat(CustomBuildConfigSchema); + +export const JobZ = z + .object({ + type: z.nativeEnum(Workflow), + projectArchive: ArchiveSourceZ, + platform: z.literal(Platform.ANDROID), + projectRootDirectory: z.string(), + updates: z + .object({ + channel: z.string().optional(), + }) + .optional(), + builderEnvironment: BuilderEnvironmentZ.optional(), + cache: CacheZ.default({ + disabled: false, + clear: false, + paths: [], + }), + developmentClient: z.boolean().optional(), + version: z + .object({ + versionCode: z.string().regex(/^\d+$/).optional(), + }) + .optional(), + buildArtifactPaths: z.array(z.string()).optional(), + + gradleCommand: z.string().optional(), + applicationArchivePath: z.string().optional(), + + buildType: z.nativeEnum(BuildType).optional(), + username: z.string().optional(), + + experimental: z + .object({ + prebuildCommand: z.string().optional(), + }) + .optional(), + expoBuildUrl: z.string().url().optional(), + githubTriggerOptions: z + .object({ + autoSubmit: z.boolean().default(false), + submitProfile: z.string().optional(), + }) + .optional(), + loggerLevel: z.nativeEnum(LoggerLevel).optional(), + + initiatingUserId: z.string(), + appId: z.string(), + + environment: z.string().optional(), + + workflowInterpolationContext: StaticWorkflowInterpolationContextZ.optional(), + }) + .and(CustomBuildConfigZ) + .and( + z.discriminatedUnion('triggeredBy', [ + z.object({ + triggeredBy: z.literal(BuildTrigger.GIT_BASED_INTEGRATION), + buildProfile: z.string(), + }), + z.object({ + triggeredBy: z.literal(BuildTrigger.EAS_CLI), + buildProfile: z.string().optional(), + }), + ]) + ) + .and( + z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal(BuildMode.CUSTOM), + secrets: SecretsZ.optional(), + }), + z.object({ + mode: z.literal(BuildMode.BUILD), + secrets: SecretsZ, + }), + z.object({ + mode: z.literal(BuildMode.REPACK), + secrets: SecretsZ, + }), + ]) + ); diff --git a/packages/eas-build-job/src/common.ts b/packages/eas-build-job/src/common.ts index 85a08d4dc..6aeb5374a 100644 --- a/packages/eas-build-job/src/common.ts +++ b/packages/eas-build-job/src/common.ts @@ -90,7 +90,7 @@ export const ArchiveSourceSchema = Joi.object({ }), }); -export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [ +export const ArchiveSourceZ = z.discriminatedUnion('type', [ z.object({ type: z.literal(ArchiveSourceType.GIT), repositoryUrl: z.string().url(), @@ -117,6 +117,7 @@ export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [ export type Env = Record; export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string()); +export const EnvZ = z.record(z.string(), z.string()); export type EnvironmentSecret = { name: string; @@ -136,6 +137,14 @@ export const EnvironmentSecretsSchema = Joi.array().items( .required(), }) ); +export const EnvironmentSecretsZ = z.array( + z.object({ + name: z.string(), + value: z.string(), + type: z.nativeEnum(EnvironmentSecretType), + }) +); + export const EnvironmentSecretZ = z.object({ name: z.string(), value: z.string(), @@ -166,6 +175,15 @@ export const CacheSchema = Joi.object({ paths: Joi.array().items(Joi.string()).default([]), }); +export const CacheZ = z.object({ + disabled: z.boolean().default(false), + clear: z.boolean().default(false), + key: z.string().max(128).optional(), + cacheDefaultPaths: z.boolean().optional(), + customPaths: z.array(z.string()).optional(), + paths: z.array(z.string()).default([]), +}); + export interface BuildPhaseStats { buildPhase: BuildPhase; result: BuildPhaseResult; @@ -286,6 +304,54 @@ export const CustomBuildConfigSchema = Joi.object().when('.mode', { }), }); +const CustomBuildConfigFieldsZ = z.object({ + customBuildConfig: z + .object({ + path: z.string(), + }) + .optional(), + steps: z + .array(z.any()) + .optional() + .refine((steps) => (steps ? validateSteps(steps) : true), 'steps validation'), + outputs: z.record(z.string(), z.string()).optional(), +}); + +const validateCustomBuildConfigRefinement = ( + data: z.infer, + ctx: z.RefinementCtx +) => { + if (!data.customBuildConfig?.path) { + if (!data.steps) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'steps are required when customBuildConfig.path is missing', + path: ['steps'], + }); + } + if (!data.outputs) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'outputs are required when customBuildConfig.path is missing', + path: ['outputs'], + }); + } + } +}; + +export const CustomBuildConfigZ = z.discriminatedUnion('mode', [ + z.object({ mode: z.literal(BuildMode.BUILD) }), + z.object({ mode: z.literal(BuildMode.RESIGN) }), + z + .object({ mode: z.literal(BuildMode.CUSTOM) }) + .merge(CustomBuildConfigFieldsZ) + .superRefine(validateCustomBuildConfigRefinement), + z + .object({ mode: z.literal(BuildMode.REPACK) }) + .merge(CustomBuildConfigFieldsZ) + .superRefine(validateCustomBuildConfigRefinement), +]); + export enum EasCliNpmTags { STAGING = 'latest-eas-build-staging', PRODUCTION = 'latest-eas-build', diff --git a/packages/eas-build-job/src/generic.ts b/packages/eas-build-job/src/generic.ts index 0844e0ab7..7a3d976f7 100644 --- a/packages/eas-build-job/src/generic.ts +++ b/packages/eas-build-job/src/generic.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { LoggerLevel } from '@expo/logger'; import { - ArchiveSourceSchemaZ, + ArchiveSourceZ, BuildTrigger, EnvironmentSecretZ, StaticWorkflowInterpolationContextZ, @@ -28,7 +28,7 @@ export namespace Generic { export type Job = z.infer; export const JobZ = z.object({ - projectArchive: ArchiveSourceSchemaZ, + projectArchive: ArchiveSourceZ, secrets: z.object({ robotAccessToken: z.string(), environmentSecrets: z.array(EnvironmentSecretZ), diff --git a/packages/eas-build-job/src/ios.ts b/packages/eas-build-job/src/ios.ts index 7cce3e0c9..99828ad9a 100644 --- a/packages/eas-build-job/src/ios.ts +++ b/packages/eas-build-job/src/ios.ts @@ -1,4 +1,5 @@ import Joi from 'joi'; +import { z } from 'zod'; import { LoggerLevel } from '@expo/logger'; import { @@ -16,12 +17,25 @@ import { BuildMode, StaticWorkflowInterpolationContextZ, StaticWorkflowInterpolationContext, + ArchiveSourceZ, + EnvZ, + CacheZ, + EnvironmentSecretsZ, + CustomBuildConfigZ, CustomBuildConfigSchema, } from './common'; import { Step } from './step'; export type DistributionType = 'store' | 'internal' | 'simulator'; +const TargetCredentialsZ = z.object({ + provisioningProfileBase64: z.string(), + distributionCertificate: z.object({ + dataBase64: z.string(), + password: z.string(), + }), +}); + const TargetCredentialsSchema = Joi.object().keys({ provisioningProfileBase64: Joi.string().required(), distributionCertificate: Joi.object({ @@ -35,6 +49,8 @@ export interface TargetCredentials { distributionCertificate: DistributionCertificate; } +const BuildCredentialsZ = z.record(z.string(), TargetCredentialsZ); + const BuildCredentialsSchema = Joi.object().pattern( Joi.string().required(), TargetCredentialsSchema @@ -60,6 +76,19 @@ export interface BuilderEnvironment { env?: Env; } +const BuilderEnvironmentZ = z.object({ + image: z.string().optional(), + node: z.string().optional(), + corepack: z.boolean().optional(), + yarn: z.string().optional(), + pnpm: z.string().optional(), + bun: z.string().optional(), + bundler: z.string().optional(), + fastlane: z.string().optional(), + cocoapods: z.string().optional(), + env: EnvZ.optional(), +}); + const BuilderEnvironmentSchema = Joi.object({ image: Joi.string(), node: Joi.string(), @@ -141,6 +170,12 @@ export interface Job { environment?: string; } +const SecretsZ = z.object({ + buildCredentials: BuildCredentialsZ.optional(), + environmentSecrets: EnvironmentSecretsZ.optional(), + robotAccessToken: z.string().optional(), +}); + const SecretsSchema = Joi.object({ buildCredentials: BuildCredentialsSchema, environmentSecrets: EnvironmentSecretsSchema, @@ -226,3 +261,103 @@ export const JobSchema = Joi.object({ StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext) ), }).concat(CustomBuildConfigSchema); + +export const JobZ = z + .object({ + triggeredBy: z.nativeEnum(BuildTrigger).default(BuildTrigger.EAS_CLI), + projectArchive: ArchiveSourceZ, + platform: z.literal(Platform.IOS), + updates: z + .object({ + channel: z.string().optional(), + }) + .optional(), + builderEnvironment: BuilderEnvironmentZ.optional(), + cache: CacheZ.default({ + disabled: false, + clear: false, + paths: [], + }), + developmentClient: z.boolean().optional(), + simulator: z.boolean().optional(), + version: z + .object({ + buildNumber: z.string().optional(), + }) + .optional(), + buildArtifactPaths: z.array(z.string()).optional(), + + scheme: z.string().optional(), + buildConfiguration: z.string().optional(), + applicationArchivePath: z.string().optional(), + + username: z.string().optional(), + + experimental: z + .object({ + prebuildCommand: z.string().optional(), + }) + .optional(), + expoBuildUrl: z.string().url().optional(), + githubTriggerOptions: z + .object({ + autoSubmit: z.boolean().default(false), + submitProfile: z.string().optional(), + }) + .optional(), + loggerLevel: z.nativeEnum(LoggerLevel).optional(), + + initiatingUserId: z.string(), + appId: z.string(), + + environment: z.string().optional(), + + workflowInterpolationContext: StaticWorkflowInterpolationContextZ.optional(), + }) + .and( + z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal(BuildMode.RESIGN), + type: z.nativeEnum(Workflow).optional(), + resign: z.object({ + applicationArchiveSource: ArchiveSourceZ, + }), + projectRootDirectory: z.string().optional(), + secrets: SecretsZ.optional(), + }), + z.object({ + mode: z.literal(BuildMode.BUILD), + type: z.nativeEnum(Workflow), + resign: z.undefined(), + projectRootDirectory: z.string(), + secrets: SecretsZ, + }), + z.object({ + mode: z.literal(BuildMode.CUSTOM), + type: z.nativeEnum(Workflow), + resign: z.undefined(), + projectRootDirectory: z.string(), + secrets: SecretsZ.optional(), + }), + z.object({ + mode: z.literal(BuildMode.REPACK), + type: z.nativeEnum(Workflow), + resign: z.undefined(), + projectRootDirectory: z.string(), + secrets: SecretsZ, + }), + ]) + ) + .and( + z.discriminatedUnion('triggeredBy', [ + z.object({ + triggeredBy: z.literal(BuildTrigger.GIT_BASED_INTEGRATION), + buildProfile: z.string(), + }), + z.object({ + triggeredBy: z.literal(BuildTrigger.EAS_CLI), + buildProfile: z.string().optional(), + }), + ]) + ) + .and(CustomBuildConfigZ); diff --git a/packages/eas-build-job/src/job.ts b/packages/eas-build-job/src/job.ts index 3a9738343..23ec624d7 100644 --- a/packages/eas-build-job/src/job.ts +++ b/packages/eas-build-job/src/job.ts @@ -1,4 +1,5 @@ import Joi from 'joi'; +import { z } from 'zod'; import { Platform } from './common'; import * as Android from './android'; @@ -16,6 +17,11 @@ export const JobSchema = Joi.object({ .when(Joi.object({ platform: Platform.ANDROID }).unknown(), { then: Android.JobSchema }) .when(Joi.object({ platform: Platform.IOS }).unknown(), { then: Ios.JobSchema }); +export const JobZ = z.discriminatedUnion('platform', [ + z.looseObject({ platform: Platform.ANDROID }).pipe(Android.JobZ), + z.looseObject({ platform: Platform.IOS }).pipe(Ios.JobZ), +]); + export function sanitizeBuildJob(rawJob: object): BuildJob { const { value, error } = JobSchema.validate(rawJob, { stripUnknown: true, diff --git a/packages/eas-build-job/src/metadata.ts b/packages/eas-build-job/src/metadata.ts index 61aee4871..2f6e22ecf 100644 --- a/packages/eas-build-job/src/metadata.ts +++ b/packages/eas-build-job/src/metadata.ts @@ -1,4 +1,5 @@ import Joi from 'joi'; +import { z } from 'zod'; import { Workflow } from './common'; @@ -204,6 +205,43 @@ export const MetadataSchema = Joi.object({ environment: Joi.string(), }); +export const MetadataZ = z.object({ + trackingContext: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + appVersion: z.string().optional(), + appBuildVersion: z.string().optional(), + cliVersion: z.string().optional(), + workflow: z.enum(['generic', 'managed']).optional(), + distribution: z.enum(['store', 'internal', 'simulator']).optional(), + credentialsSource: z.enum(['local', 'remote']).optional(), + sdkVersion: z.string().optional(), + runtimeVersion: z.string().optional(), + fingerprintHash: z.string().optional(), + reactNativeVersion: z.string().optional(), + channel: z.string().optional(), + appName: z.string().optional(), + appIdentifier: z.string().optional(), + buildProfile: z.string().optional(), + gitCommitHash: z + .string() + .length(40) + .regex(/^[0-9a-fA-F]+$/) + .optional(), + gitCommitMessage: z.string().max(4096).optional(), + isGitWorkingTreeDirty: z.boolean().optional(), + username: z.string().optional(), + iosEnterpriseProvisioning: z.enum(['adhoc', 'universal']).optional(), + message: z.string().max(1024).optional(), + runFromCI: z.boolean().optional(), + runWithNoWaitFlag: z.boolean().optional(), + customWorkflowName: z.string().optional(), + developmentClient: z.boolean().optional(), + requiredPackageManager: z.enum(['npm', 'pnpm', 'yarn', 'bun']).optional(), + simulator: z.boolean().optional(), + selectedImage: z.string().optional(), + customNodeVersion: z.string().optional(), + environment: z.string().optional(), +}); + export function sanitizeMetadata(metadata: object): Metadata { const { value, error } = MetadataSchema.validate(metadata, { stripUnknown: true, From 589df563bcb6347cacc80ec0239b5af60a5daf22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Tue, 2 Dec 2025 12:17:23 +0100 Subject: [PATCH 3/3] Fixup --- packages/build-tools/src/common/projectSources.ts | 4 ++-- packages/eas-build-job/src/index.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/build-tools/src/common/projectSources.ts b/packages/build-tools/src/common/projectSources.ts index eee98c72b..f5ce4138b 100644 --- a/packages/build-tools/src/common/projectSources.ts +++ b/packages/build-tools/src/common/projectSources.ts @@ -3,7 +3,7 @@ import fs from 'fs/promises'; import spawn from '@expo/turtle-spawn'; import fetch from 'node-fetch'; -import { ArchiveSourceType, Job, ArchiveSource, ArchiveSourceSchemaZ } from '@expo/eas-build-job'; +import { ArchiveSourceType, Job, ArchiveSource, ArchiveSourceZ } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; import downloadFile from '@expo/downloader'; import { z } from 'zod'; @@ -287,7 +287,7 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext): Promise