Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,12 @@ 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);
});
});
94 changes: 94 additions & 0 deletions packages/eas-build-job/src/__tests__/legacyFunction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { ZodError } from 'zod';

import { LegacyCommandFunctionConfigZ, LegacyPathFunctionConfigZ } from '../legacyFunction';

describe('LegacyCommandFunctionConfigZ', () => {
it('accepts a command function with inputs, outputs, shell and supported platforms', () => {
const config = {
name: 'Say hi',
inputs: [
'name',
{ name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] },
{ name: 'loud', type: 'boolean', required: false },
{ name: 'suffix' },
],
outputs: ['greeted', { name: 'skipped', required: false }],
command: 'echo "${ inputs.greeting }, ${ inputs.name }!"',
shell: 'sh',
supported_platforms: ['darwin', 'linux'],
};
expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual({
...config,
inputs: [
'name',
{ name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] },
{ name: 'loud', type: 'boolean', required: false },
{ name: 'suffix', type: 'string' },
],
});
});

it('accepts a minimal config with only command', () => {
const config = { command: 'echo hi' };
expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual(config);
});

it('rejects unknown top-level keys', () => {
const config = { command: 'echo hi', runz: {} };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(/runz/);
});

it('rejects description, which the legacy shape does not support', () => {
const config = { command: 'echo hi', description: 'x' };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects unknown supported platforms', () => {
const config = { command: 'echo hi', supported_platforms: ['windows'] };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects the composite output shape', () => {
const config = { command: 'echo hi', outputs: { version: { value: '1.0.0' } } };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects a config declaring runs.steps', () => {
const config = { command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects a config also declaring path', () => {
const config = { command: 'echo hi', path: './fn' };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});
});

describe('LegacyPathFunctionConfigZ', () => {
it('accepts a minimal config with only path', () => {
const config = { path: './my-function' };
expect(LegacyPathFunctionConfigZ.parse(config)).toEqual(config);
});

it('accepts shell alongside path', () => {
const config = { path: './my-function', shell: 'sh' };
expect(LegacyPathFunctionConfigZ.parse(config)).toEqual(config);
});

it('rejects unknown top-level keys', () => {
const config = { path: './fn', run: 'echo' };
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError);
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(/run/);
});

it('rejects a config declaring runs.steps', () => {
const config = { path: './fn', runs: { steps: [{ run: 'echo hello' }] } };
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects a config also declaring command', () => {
const config = { path: './fn', command: 'echo hi' };
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError);
});
});
88 changes: 88 additions & 0 deletions packages/eas-build-job/src/__tests__/localFunction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { z } from 'zod';

import { LocalFunctionConfigZ, isLegacyFunctionConfig } from '../localFunction';

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);
}

const UNION_ERROR_MESSAGE =
'A local function must declare exactly one of "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module), and its fields must match that shape.';

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('narrows a legacy config on the path field', () => {
const parsed = LocalFunctionConfigZ.parse({ path: './my-function' });
expect(isLegacyFunctionConfig(parsed)).toBe(true);
if (isLegacyFunctionConfig(parsed) && parsed.path !== undefined) {
const modulePath: string = parsed.path;
expect(modulePath).toBe('./my-function');
}
});

it.each<[string, unknown]>([
['unknown top-level keys on a command function', { command: 'echo hi', runz: {} }],
['unknown top-level keys on a path function', { path: './fn', run: 'echo' }],
['unknown supported platforms', { command: 'echo hi', supported_platforms: ['windows'] }],
[
'the composite output shape on a single-step function',
{ command: 'echo hi', outputs: { version: { value: '1.0.0' } } },
],
[
'a config mixing runs.steps with command',
{ command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } },
],
[
'a config mixing runs.steps with path',
{ path: './my-function', runs: { steps: [{ run: 'echo hello' }] } },
],
['a config declaring both command and path', { command: 'echo hi', path: './fn' }],
['a config declaring none of runs.steps, command and path', { name: 'Nothing' }],
['an empty mapping', {}],
['a string in place of a mapping', 'command: echo hi'],
['an array in place of a mapping', [{ command: 'echo hi' }]],
['null in place of a mapping', null],
])('rejects %s with the generic union message', (_description, config) => {
expect(parseErrorMessages(LocalFunctionConfigZ, config)).toEqual([UNION_ERROR_MESSAGE]);
});

it('collapses branch errors into the union message in formatted output', () => {
const result = LocalFunctionConfigZ.safeParse({ command: 42 });
expect(result.success).toBe(false);
expect(z.prettifyError(result.error!)).toBe(`✖ ${UNION_ERROR_MESSAGE}`);
});

it('surfaces the min-steps issue of the composite branch with its field path', () => {
const result = LocalFunctionConfigZ.safeParse({ runs: { steps: [] } });
expect(result.success).toBe(false);
expect(z.prettifyError(result.error!)).toMatch(
/must declare at least one step under "runs.steps"\.\n {2}→ at runs.steps/
);
});
});
16 changes: 7 additions & 9 deletions packages/eas-build-job/src/compositeFunction.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
/**
* Schema for local composite functions, reusable step groups 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.
* Schema for the composite shape of a local function: a reusable group of steps declared under
* `runs.steps` in a `function.yml` file. One branch of `LocalFunctionConfigZ` in `./localFunction`.
*/
import { z } from 'zod';

Expand All @@ -20,7 +16,7 @@ const CompositeFunctionInputValueZ = z.union([
z.record(z.string(), z.unknown()),
]);

const CompositeFunctionInputZ = z.union([
export const CompositeFunctionInputZ = z.union([
z
.string()
.describe('Shorthand for an input name with default type "string" and no default value.'),
Expand Down Expand Up @@ -91,6 +87,10 @@ export const CompositeFunctionConfigZ = z
})
.describe('Steps executed when the composite function is invoked.'),
}),
command: z.never().optional(),
path: z.never().optional(),
shell: z.never().optional(),
supported_platforms: z.never().optional(),
})
.strict();

Expand All @@ -110,5 +110,3 @@ export const CompositeFunctionConfigZ = z
* run: set-output version "1.0.0"
*/
export type CompositeFunctionConfig = z.infer<typeof CompositeFunctionConfigZ>;

export type CompositeFunctionCatalog = Record<string, CompositeFunctionConfig>;
2 changes: 2 additions & 0 deletions packages/eas-build-job/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export * from './generic';
export * from './hooks';
export * from './step';
export * from './compositeFunction';
export * from './legacyFunction';
export * from './localFunction';
export * from './submission-config';
export * from './projectPackage';
export * from './deviceRunSession';
Expand Down
95 changes: 95 additions & 0 deletions packages/eas-build-job/src/legacyFunction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Legacy single-step local function shape: shell `command` or JS module `path` in `function.yml`.
* Branches of `LocalFunctionConfigZ` in `./localFunction`.
*/
import { z } from 'zod';

import { CompositeFunctionInputZ } from './compositeFunction';

const LegacyFunctionOutputZ = z.union([
z.string().describe('Shorthand for a required output name.'),
z
.object({
name: z.string(),
required: z.boolean().optional(),
})
.strict(),
]);

const LegacyFunctionPlatformZ = z.enum(['darwin', 'linux']);

const LegacyFunctionBaseZ = z.object({
/**
* @example
* name: Say hi
*/
name: z.string().optional().describe('Display name of the function.'),
/**
* @example
* inputs:
* - greeting
* - name: platform
* type: string
* default_value: ios
*/
inputs: z
.array(CompositeFunctionInputZ)
.optional()
.describe(
'Inputs accepted by the function. Each input is required unless it sets `required: false`.'
),
/**
* @example
* outputs:
* - name: version
* - name: sha
* required: false
*/
outputs: z
.array(LegacyFunctionOutputZ)
.optional()
.describe(
'Outputs the function sets. Each output is required unless it sets `required: false`.'
),
shell: z.string().optional().describe('Shell to run the function with.'),
supported_platforms: z
.array(LegacyFunctionPlatformZ)
.optional()
.describe('Runtime platforms the function can run on.'),
runs: z.never().optional(),
});

export const LegacyCommandFunctionConfigZ = LegacyFunctionBaseZ.extend({
/**
* @example
* command: echo "Hi, ${ inputs.name }!"
*/
command: z.string().describe('Shell script executed when the function is invoked.'),
path: z.never().optional(),
}).strict();

export const LegacyPathFunctionConfigZ = LegacyFunctionBaseZ.extend({
/**
* @example
* path: ./my-function
*/
path: z
.string()
.describe(
'Directory with a prebuilt JavaScript module and its package.json, resolved relative to the function file.'
),
command: z.never().optional(),
}).strict();

/**
* 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<typeof LegacyCommandFunctionConfigZ>
| z.infer<typeof LegacyPathFunctionConfigZ>;
40 changes: 40 additions & 0 deletions packages/eas-build-job/src/localFunction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Schema for local functions, reusable units of work referenced via `uses:` in EAS workflows
* (`.eas/workflows/*.yml`) or inline job step definitions.
*
* A local function configuration file (`function.yml`) declares either a composite function (a
* group of steps under `runs.steps`, see `./compositeFunction`) or a single-step function
* carrying a shell `command` or a `path` to a prebuilt JavaScript module (see `./legacyFunction`).
* This module unions the two shapes.
*
* Callers that load local function files format validation errors from `LocalFunctionConfigZ`.
* Any invalid file reports the single generic union message; parse a branch schema directly for
* field-level errors. `function.yml` files cannot be referenced from `.eas/build/*.yml` custom
* build configs.
*/
import { z } from 'zod';

import { CompositeFunctionConfig, CompositeFunctionConfigZ } from './compositeFunction';
import {
LegacyCommandFunctionConfigZ,
LegacyFunctionConfig,
LegacyPathFunctionConfigZ,
} from './legacyFunction';

export const LocalFunctionConfigZ = z.union(
[CompositeFunctionConfigZ, LegacyCommandFunctionConfigZ, LegacyPathFunctionConfigZ],
{
error:
'A local function must declare exactly one of "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module), and its fields must match that shape.',
}
);

export type LocalFunctionConfig = CompositeFunctionConfig | LegacyFunctionConfig;

export function isLegacyFunctionConfig(
config: LocalFunctionConfig
): config is LegacyFunctionConfig {
return config.runs === undefined;
}

export type CompositeFunctionCatalog = Record<string, CompositeFunctionConfig>;
Loading