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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Validate local composite functions referenced in workflow files during `eas workflow:validate`, checking that the YAML files exist and are well-formed. ([#3931](https://github.com/expo/eas-cli/pull/3931) by [@sswrk](https://github.com/sswrk))
- [build-tools] Load local composite function catalogs for hook steps, in both steps-based and native builds. ([#4063](https://github.com/expo/eas-cli/pull/4063) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Validate local composite functions referenced from workflow job hooks during `eas workflow:validate`. ([#4064](https://github.com/expo/eas-cli/pull/4064) by [@sswrk](https://github.com/sswrk))
- [build-tools] Load local functions declaring `command` or `path`, so custom build functions moved from `.eas/build` configs into `.eas/functions` can be called from workflows. ([#4097](https://github.com/expo/eas-cli/pull/4097) by [@sswrk](https://github.com/sswrk))

### 🐛 Bug fixes

Expand Down
115 changes: 115 additions & 0 deletions packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,121 @@ describe(buildCompositeFunctionCatalogAsync, () => {
expect(catalog).toEqual({});
});

it('loads a referenced single-step command function', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'say-hi',
[
'name: Say hi',
'inputs:',
' - name',
'outputs:',
' - greeting',
'command: set-output greeting "Hi, ${ inputs.name }!"',
'shell: sh',
'supported_platforms:',
' - linux',
].join('\n')
);

const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/say-hi', id: 'greet' }],
});

const localFunction = catalog['./.eas/functions/say-hi'];
assert(isLegacyFunctionConfig(localFunction));
expect(localFunction.name).toBe('Say hi');
expect(localFunction.command).toBe('set-output greeting "Hi, ${ inputs.name }!"');
expect(localFunction.shell).toBe('sh');
expect(localFunction.supported_platforms).toEqual(['linux']);
expect(localFunction.inputs).toEqual(['name']);
expect(localFunction.outputs).toEqual(['greeting']);
});

it('resolves the module path of a single-step path function against the function directory', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'say-hi',
['path: ./my-function'].join('\n')
);
const moduleDir = path.join(projectRoot, '.eas', 'functions', 'say-hi', 'my-function');
await fs.mkdir(moduleDir, { recursive: true });
await fs.writeFile(path.join(moduleDir, 'package.json'), '{}', 'utf-8');

const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/say-hi', id: 'greet' }],
});

const localFunction = catalog['./.eas/functions/say-hi'];
assert(isLegacyFunctionConfig(localFunction));
expect(localFunction.path).toBe(moduleDir);
});

it('throws when the module directory of a path function does not exist', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'say-hi',
'path: ./my-function'
);

await expect(
buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/say-hi', id: 'greet' }],
})
).rejects.toThrow(
/Local function "\.\/\.eas\/functions\/say-hi" declares "path: \.\/my-function", but there is no such directory at .*my-function\./
);
});

it('throws when the module directory of a path function has no package.json', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'say-hi',
'path: ./my-function'
);
await fs.mkdir(path.join(projectRoot, '.eas', 'functions', 'say-hi', 'my-function'), {
recursive: true,
});

await expect(
buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/say-hi', id: 'greet' }],
})
).rejects.toThrow(/does not contain a package.json file/);
});

it('loads a catalog mixing composite and single-step functions', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'setup',
['runs:', ' steps:', ' - uses: ./.eas/functions/say-hi'].join('\n')
);
const sayHiDir = path.join(projectRoot, '.eas', 'functions', 'say-hi');
await fs.mkdir(sayHiDir, { recursive: true });
await fs.writeFile(path.join(sayHiDir, 'function.yml'), 'command: echo hi', 'utf-8');

const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
});

expect(Object.keys(catalog).sort()).toEqual([
'./.eas/functions/say-hi',
'./.eas/functions/setup',
]);
expect(isLegacyFunctionConfig(catalog['./.eas/functions/say-hi'])).toBe(true);
expect(isLegacyFunctionConfig(catalog['./.eas/functions/setup'])).toBe(false);
});

it('throws a clear error for a function config mixing both shapes', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'broken',
['command: echo hi', 'runs:', ' steps:', ' - run: echo hi'].join('\n')
);

await expect(
buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/broken', id: 'broken' }],
})
).rejects.toThrow(
'Invalid local function "./.eas/functions/broken": ✖ A local function must declare either "runs.steps" (a composite function) or "command"/"path" (a single-step function), not both.'
);
});

it('throws a clear error for a malformed referenced composite function config', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'broken',
Expand Down
63 changes: 57 additions & 6 deletions packages/build-tools/src/steps/compositeFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
import { bunyan } from '@expo/logger';
import {
CompositeFunctionCatalog,
CompositeFunctionConfig,
CompositeFunctionConfigZ,
Hooks,
LocalFunctionConfig,
LocalFunctionConfigZ,
Step,
isLegacyFunctionConfig,
parseHookKey,
} from '@expo/eas-build-job';
import {
buildCompositeFunctionCatalogFromStepsAsync,
resolveLegacyFunctionModulePath,
resolveLocalCompositeFunctionPath,
} from '@expo/steps';
import fs from 'fs/promises';
Expand All @@ -24,7 +26,7 @@ async function loadLocalCompositeFunctionConfigAsync(
projectRoot: string,
compositeFunctionPath: string,
{ logger }: { logger?: bunyan } = {}
): Promise<CompositeFunctionConfig> {
): Promise<LocalFunctionConfig> {
const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath);

for (const ext of ['yml', 'yaml'] as const) {
Expand Down Expand Up @@ -54,17 +56,25 @@ async function loadLocalCompositeFunctionConfigAsync(
}
);
}
let config: CompositeFunctionConfig;
let config: LocalFunctionConfig;
try {
config = CompositeFunctionConfigZ.parse(parsed);
config = LocalFunctionConfigZ.parse(parsed);
} catch (err) {
if (err instanceof ZodError) {
throw new Error(
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(err)}`
`Invalid local function "${compositeFunctionPath}": ${z.prettifyError(err)}`
);
}
throw err;
}
// The catalog is consumed by the steps parser, which expects an absolute module path.
if (isLegacyFunctionConfig(config) && config.path !== undefined) {
config.path = await resolveFunctionModulePathAsync({
projectRoot,
functionPath: compositeFunctionPath,
modulePath: config.path,
});
}
logger?.debug(
`Loaded local composite function "${compositeFunctionPath}" from ${path.relative(projectRoot, absolutePath)}`
);
Expand All @@ -76,6 +86,47 @@ async function loadLocalCompositeFunctionConfigAsync(
);
}

/**
* Single-step functions never join `workflow.buildFunctions`, so
* `BuildWorkflowValidator.validateCustomFunctionModulesAsync` will not check their modules. This
* is where a missing module has to be caught, before the job is parsed.
*/
async function resolveFunctionModulePathAsync({
projectRoot,
functionPath,
modulePath,
}: {
projectRoot: string;
functionPath: string;
modulePath: string;
}): Promise<string> {
const resolvedModulePath = resolveLegacyFunctionModulePath({
projectRoot,
functionPath,
modulePath,
});
if (!(await pathExistsAsync(resolvedModulePath))) {
throw new Error(
`Local function "${functionPath}" declares "path: ${modulePath}", but there is no such directory at ${resolvedModulePath}.`
);
}
if (!(await pathExistsAsync(path.join(resolvedModulePath, 'package.json')))) {
throw new Error(
`Local function "${functionPath}" declares "path: ${modulePath}", but the module directory ${resolvedModulePath} does not contain a package.json file.`
);
}
return resolvedModulePath;
}

async function pathExistsAsync(target: string): Promise<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}

export async function buildCompositeFunctionCatalogAsync(
projectRoot: string,
{ steps, hooks, logger }: { steps: readonly Step[]; hooks?: Hooks; logger?: bunyan }
Expand Down
Loading