From 69c604d9e3dcdbdba7cfac24a00e56112975aad3 Mon Sep 17 00:00:00 2001 From: sswrk Date: Mon, 27 Jul 2026 16:38:42 +0200 Subject: [PATCH] [eas-build-job] Accept legacy command/path custom function shape in function.yml --- .../src/__tests__/compositeFunction.test.ts | 174 +++++++++++++++- .../eas-build-job/src/compositeFunction.ts | 187 +++++++++++++++++- 2 files changed, 354 insertions(+), 7 deletions(-) diff --git a/packages/eas-build-job/src/__tests__/compositeFunction.test.ts b/packages/eas-build-job/src/__tests__/compositeFunction.test.ts index cb8b5e4016..7b0c55ccbe 100644 --- a/packages/eas-build-job/src/__tests__/compositeFunction.test.ts +++ b/packages/eas-build-job/src/__tests__/compositeFunction.test.ts @@ -1,6 +1,17 @@ -import { ZodError } from 'zod'; +import { ZodError, z } from 'zod'; -import { CompositeFunctionConfigZ } from '../compositeFunction'; +import { + CompositeFunctionConfigZ, + LegacyFunctionConfigZ, + LocalFunctionConfigZ, + isLegacyFunctionConfig, +} from '../compositeFunction'; + +function parseErrorMessages(schema: z.ZodType, config: unknown): string[] { + const result = schema.safeParse(config); + expect(result.success).toBe(false); + return result.error!.issues.map(issue => issue.message); +} describe('CompositeFunctionConfigZ', () => { it('accepts a valid local composite function config', () => { @@ -75,4 +86,163 @@ describe('CompositeFunctionConfigZ', () => { ); } }); + + it('rejects the single-step output shape', () => { + const config = { + outputs: [{ name: 'version' }], + runs: { steps: [{ run: 'echo hello' }] }, + }; + expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError); + }); +}); + +describe('LegacyFunctionConfigZ', () => { + it('accepts a command function with inputs, outputs, shell and supported platforms', () => { + const config = { + name: 'Say hi', + description: 'Greets somebody', + inputs: [ + 'name', + { name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] }, + { name: 'loud', type: 'boolean', required: false }, + ], + outputs: ['greeted', { name: 'skipped', required: false }], + command: 'echo "${ inputs.greeting }, ${ inputs.name }!"', + shell: 'sh', + supported_platforms: ['darwin', 'linux'], + }; + expect(LegacyFunctionConfigZ.parse(config)).toEqual({ + ...config, + inputs: [ + 'name', + { name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] }, + { name: 'loud', type: 'boolean', required: false }, + ], + }); + }); + + it('accepts a path function', () => { + const config = { name: 'Build', path: './my-function' }; + expect(LegacyFunctionConfigZ.parse(config)).toEqual(config); + }); + + it('rejects a config declaring both command and path', () => { + expect(parseErrorMessages(LegacyFunctionConfigZ, { command: 'echo hi', path: './fn' })).toEqual([ + 'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.', + ]); + }); + + it('rejects a config declaring neither command nor path', () => { + expect(parseErrorMessages(LegacyFunctionConfigZ, { name: 'Nothing' })).toEqual([ + 'A local function must declare "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module).', + ]); + }); + + it('rejects unknown top-level keys', () => { + expect(parseErrorMessages(LegacyFunctionConfigZ, { command: 'echo hi', runz: {} })).toEqual([ + 'Unrecognized key: "runz"', + ]); + }); + + it('rejects unknown supported platforms', () => { + expect( + parseErrorMessages(LegacyFunctionConfigZ, { + command: 'echo hi', + supported_platforms: ['windows'], + }) + ).toEqual(['Invalid option: expected one of "darwin"|"linux"']); + }); + + it('rejects the composite output shape', () => { + expect( + LegacyFunctionConfigZ.safeParse({ + command: 'echo hi', + outputs: { version: { value: '1.0.0' } }, + }).success + ).toBe(false); + }); + + it('rejects runs.steps', () => { + expect( + LegacyFunctionConfigZ.safeParse({ + command: 'echo hi', + runs: { steps: [{ run: 'echo hello' }] }, + }).success + ).toBe(false); + }); +}); + +describe('LocalFunctionConfigZ', () => { + it('parses a composite function', () => { + const config = { + name: 'Setup', + inputs: ['greeting'], + outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, + runs: { steps: [{ id: 'read', run: 'set-output version "1.0.0"' }] }, + }; + const parsed = LocalFunctionConfigZ.parse(config); + expect(parsed).toEqual(config); + expect(isLegacyFunctionConfig(parsed)).toBe(false); + }); + + it('parses a command function', () => { + const config = { name: 'Say hi', inputs: ['name'], command: 'echo hi' }; + const parsed = LocalFunctionConfigZ.parse(config); + expect(parsed).toEqual(config); + expect(isLegacyFunctionConfig(parsed)).toBe(true); + }); + + it('parses a path function', () => { + const parsed = LocalFunctionConfigZ.parse({ path: './my-function' }); + expect(parsed).toEqual({ path: './my-function' }); + expect(isLegacyFunctionConfig(parsed)).toBe(true); + }); + + it.each([ + ['command', { command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } }], + ['path', { path: './my-function', runs: { steps: [{ run: 'echo hello' }] } }], + ])('rejects a config mixing runs.steps with %s', (_field, config) => { + expect(parseErrorMessages(LocalFunctionConfigZ, config)).toEqual([ + 'A local function must declare either "runs.steps" (a composite function) or "command"/"path" (a single-step function), not both.', + ]); + }); + + it('rejects a config declaring both command and path', () => { + expect(parseErrorMessages(LocalFunctionConfigZ, { command: 'echo hi', path: './fn' })).toEqual([ + 'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.', + ]); + }); + + it('rejects a config declaring none of runs.steps, command and path', () => { + expect(parseErrorMessages(LocalFunctionConfigZ, { name: 'Nothing' })).toEqual([ + 'A local function must declare "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module).', + ]); + }); + + it.each([ + ['a string', 'command: echo hi'], + ['an array', [{ command: 'echo hi' }]], + ['null', null], + ])('rejects %s in place of a mapping', (_description, config) => { + expect(parseErrorMessages(LocalFunctionConfigZ, config)).toEqual([ + 'A local function configuration file must contain a mapping of function fields.', + ]); + }); + + it('reports composite function errors with their field paths', () => { + const result = LocalFunctionConfigZ.safeParse({ runs: { steps: [] }, unknown_field: true }); + expect(result.success).toBe(false); + expect(z.prettifyError(result.error!)).toMatch(/unknown_field/); + expect(z.prettifyError(result.error!)).toMatch( + /must declare at least one step under "runs.steps"\.\n {2}→ at runs.steps/ + ); + }); + + it('reports single-step function errors with their field paths', () => { + const result = LocalFunctionConfigZ.safeParse({ command: 42 }); + expect(result.success).toBe(false); + expect(z.prettifyError(result.error!)).toMatch( + /expected string, received number\n {2}→ at command/ + ); + }); }); diff --git a/packages/eas-build-job/src/compositeFunction.ts b/packages/eas-build-job/src/compositeFunction.ts index 6e2eda2bbb..220bcb93f3 100644 --- a/packages/eas-build-job/src/compositeFunction.ts +++ b/packages/eas-build-job/src/compositeFunction.ts @@ -1,10 +1,15 @@ /** - * Schema for local composite functions, reusable step groups referenced via `uses:` in EAS - * workflows (`.eas/workflows/*.yml`) or inline job step definitions. + * Schema for local functions, reusable units of work referenced via `uses:` in EAS workflows + * (`.eas/workflows/*.yml`) or inline job step definitions. * - * This module defines the shape of a composite function configuration file (`function.yml`). - * Callers that load composite function files format validation errors from `CompositeFunctionConfigZ`. - * Local composite functions are not supported in `.eas/build/*.yml` custom build config files. + * This module defines the shape of a local function configuration file (`function.yml`). Such a + * file declares either a composite function (a group of steps under `runs.steps`) or a + * single-step function carrying a shell `command` or a `path` to a prebuilt JavaScript module, + * the shape custom build functions use in `.eas/build/*.yml` configs. The two shapes are mutually + * exclusive; `LocalFunctionConfigZ` accepts both and rejects mixtures. + * + * Callers that load local function files format validation errors from `LocalFunctionConfigZ`. + * Local functions are not supported in `.eas/build/*.yml` custom build config files. */ import { z } from 'zod'; @@ -91,6 +96,14 @@ export const CompositeFunctionConfigZ = z }) .describe('Steps executed when the composite function is invoked.'), }), + + // Single-step function fields, declared so a composite config narrows against + // `LegacyFunctionConfig` in TypeScript (the `StepZ` pattern). Runtime rejection already + // comes from `.strict()`. + command: z.never().optional(), + path: z.never().optional(), + shell: z.never().optional(), + supported_platforms: z.never().optional(), }) .strict(); @@ -111,4 +124,168 @@ export const CompositeFunctionConfigZ = z */ export type CompositeFunctionConfig = z.infer; +const LegacyFunctionOutputZ = z.union([ + z.string().describe('Shorthand for a required output name.'), + z + .object({ + name: z.string(), + required: z.boolean().optional(), + }) + .strict(), +]); + +// Spelled out instead of imported from @expo/steps (`BuildRuntimePlatform`): this package is the +// dependency, not the dependent. +const LegacyFunctionPlatformZ = z.enum(['darwin', 'linux']); + +const MIXED_SHAPES_ERROR = + 'A local function must declare either "runs.steps" (a composite function) or "command"/"path" (a single-step function), not both.'; +const COMMAND_AND_PATH_ERROR = + 'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.'; +const NO_SHAPE_ERROR = + 'A local function must declare "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module).'; + +/** + * The single-step function shape: the body of a custom build function from a `.eas/build/*.yml` + * config, usable as a `function.yml` verbatim. + */ +export const LegacyFunctionConfigZ = z + .object({ + /** + * @example + * name: Say hi + */ + name: z.string().optional().describe('Display name of the function.'), + description: z.string().optional(), + /** + * @example + * inputs: + * - greeting + * - name: platform + * type: string + * default_value: ios + */ + inputs: z + .array(CompositeFunctionInputZ) + .optional() + .describe('Inputs accepted by the function. Required unless declared otherwise.'), + /** + * @example + * outputs: + * - name: version + * - name: sha + * required: false + */ + outputs: z + .array(LegacyFunctionOutputZ) + .optional() + .describe('Outputs the function sets. Required unless declared otherwise.'), + /** + * @example + * command: echo "Hi, ${ inputs.name }!" + */ + command: z.string().optional().describe('Shell script executed when the function is invoked.'), + /** + * @example + * path: ./my-function + */ + path: z + .string() + .optional() + .describe( + 'Directory with a prebuilt JavaScript module and its package.json, resolved relative to the function file.' + ), + shell: z.string().optional().describe('Shell to run "command" with.'), + supported_platforms: z + .array(LegacyFunctionPlatformZ) + .optional() + .describe('Runtime platforms the function can run on.'), + + // See the note on `CompositeFunctionConfigZ.command`. + runs: z.never().optional(), + }) + .strict() + .superRefine((config, ctx) => { + if (config.command !== undefined && config.path !== undefined) { + ctx.addIssue({ code: 'custom', message: COMMAND_AND_PATH_ERROR }); + return; + } + if (config.command === undefined && config.path === undefined) { + ctx.addIssue({ code: 'custom', message: NO_SHAPE_ERROR }); + } + }); + +/** + * Structure of a single-step local function configuration file (`function.yml`). + * + * @example + * name: Say hi + * inputs: + * - name + * command: echo "Hi, ${ inputs.name }!" + */ +export type LegacyFunctionConfig = z.infer; + +// YAML never produces `undefined`, but an explicitly-undefined field in a hand-built object must +// read as absent so it routes the same way a missing field does. +function declaresField(config: Record, field: string): boolean { + return field in config && config[field] !== undefined; +} + +/** + * Both shapes of a local function configuration file, dispatched on which one the file declares. + * + * Sniffing the shape rather than unioning the two schemas keeps the errors precise: a bare union + * of strict objects reports every branch's failures for one typo. + */ +export const LocalFunctionConfigZ = z.unknown().transform((value, ctx) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + ctx.addIssue({ + code: 'custom', + message: 'A local function configuration file must contain a mapping of function fields.', + }); + return z.NEVER; + } + + const declared = value as Record; + const declaresRuns = declaresField(declared, 'runs'); + const declaresCommand = declaresField(declared, 'command'); + const declaresPath = declaresField(declared, 'path'); + + if (declaresRuns && (declaresCommand || declaresPath)) { + ctx.addIssue({ code: 'custom', message: MIXED_SHAPES_ERROR }); + return z.NEVER; + } + if (declaresCommand && declaresPath) { + ctx.addIssue({ code: 'custom', message: COMMAND_AND_PATH_ERROR }); + return z.NEVER; + } + if (!declaresRuns && !declaresCommand && !declaresPath) { + ctx.addIssue({ code: 'custom', message: NO_SHAPE_ERROR }); + return z.NEVER; + } + + const result = declaresRuns + ? CompositeFunctionConfigZ.safeParse(value) + : LegacyFunctionConfigZ.safeParse(value); + if (!result.success) { + // Re-emitted with their paths intact so `z.prettifyError` still points at the offending field. + // The cast only adds the index signature `addIssue` accepts on raw issues. + for (const issue of result.error.issues) { + ctx.addIssue(issue as z.core.$ZodSuperRefineIssue); + } + return z.NEVER; + } + return result.data; +}); + +/** A local function configuration file: either shape. */ +export type LocalFunctionConfig = CompositeFunctionConfig | LegacyFunctionConfig; + +export function isLegacyFunctionConfig( + config: LocalFunctionConfig +): config is LegacyFunctionConfig { + return config.runs === undefined; +} + export type CompositeFunctionCatalog = Record;