From a491a8073e1f6bc1934dfdfa5b23c27dbaa544a7 Mon Sep 17 00:00:00 2001 From: sswrk Date: Mon, 27 Jul 2026 16:53:57 +0200 Subject: [PATCH] [eas-cli] Validate legacy command/path local functions referenced from workflows --- CHANGELOG.md | 1 + .../__tests__/compositeFunctions-test.ts | 98 +++++++++++++++++++ .../workflow/compositeFunctions.ts | 50 +++++++++- 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94dcb0758a..63091245d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This is the log of notable changes to EAS CLI and related packages. - [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)) +- [eas-cli] Validate local functions declaring `command` or `path` referenced from workflows, including that the module directory of a `path` function exists. ([#4098](https://github.com/expo/eas-cli/pull/4098) by [@sswrk](https://github.com/sswrk)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts b/packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts index a2d8626962..d5077c3cde 100644 --- a/packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts +++ b/packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts @@ -306,6 +306,104 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { ).resolves.toBeUndefined(); }); + it('validates a referenced single-step command function', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-command-')); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'say-hi', + ['inputs:', ' - name', 'command: echo "Hi, ${ inputs.name }!"'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/say-hi', with: { name: 'World' } }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + it('validates a referenced single-step path function whose module exists', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-path-')); + await makeProjectWithCompositeFunctionAsync(projectRoot, 'say-hi', 'path: ./my-function'); + 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 workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/say-hi' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + it('throws when the module of a referenced path function is missing', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-path-')); + await makeProjectWithCompositeFunctionAsync(projectRoot, 'say-hi', 'path: ./my-function'); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/say-hi' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).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 of a referenced path function has no package.json', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-path-')); + await makeProjectWithCompositeFunctionAsync(projectRoot, 'say-hi', 'path: ./my-function'); + await fs.mkdir(path.join(projectRoot, '.eas', 'functions', 'say-hi', 'my-function'), { + recursive: true, + }); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/say-hi' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).rejects.toThrow(/does not contain a package.json file/); + }); + + it('throws when a referenced function mixes the composite and single-step shapes', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-mixed-')); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'broken', + ['command: echo hi', 'runs:', ' steps:', ' - run: echo hi'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/broken' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).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 when a referenced local composite function is malformed', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-')); await makeProjectWithCompositeFunctionAsync( diff --git a/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts b/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts index a673239886..1ccba1e047 100644 --- a/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts +++ b/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts @@ -1,6 +1,11 @@ -import { CompositeFunctionConfig, CompositeFunctionConfigZ } from '@expo/eas-build-job'; +import { + LocalFunctionConfig, + LocalFunctionConfigZ, + isLegacyFunctionConfig, +} from '@expo/eas-build-job'; import { buildCompositeFunctionCatalogFromStepsAsync, + resolveLegacyFunctionModulePath, resolveLocalCompositeFunctionPath, } from '@expo/steps'; import { promises as fs } from 'fs'; @@ -26,7 +31,7 @@ export async function validateWorkflowLocalCompositeFunctionsAsync( async function loadLocalCompositeFunctionConfigAsync( projectDir: string, compositeFunctionPath: string -): Promise { +): Promise { const resolvedPath = resolveLocalCompositeFunctionPath(projectDir, compositeFunctionPath); for (const ext of COMPOSITE_FUNCTION_FILE_EXTENSIONS) { @@ -35,6 +40,9 @@ async function loadLocalCompositeFunctionConfigAsync( path.join(resolvedPath, `function.${ext}`) ); if (config) { + if (isLegacyFunctionConfig(config) && config.path !== undefined) { + await validateFunctionModulePathAsync(projectDir, compositeFunctionPath, config.path); + } Log.debug(`Validated local composite function "${compositeFunctionPath}"`); return config; } @@ -45,10 +53,42 @@ async function loadLocalCompositeFunctionConfigAsync( ); } +/** A `path` function ships a prebuilt JavaScript module; a missing one must not reach the build. */ +async function validateFunctionModulePathAsync( + projectDir: string, + functionPath: string, + modulePath: string +): Promise { + const resolvedModulePath = resolveLegacyFunctionModulePath({ + projectRoot: projectDir, + 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.` + ); + } +} + +async function pathExistsAsync(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + async function readCompositeFunctionConfigFileAsync( compositeFunctionPath: string, absolutePath: string -): Promise { +): Promise { let rawContents: string; try { rawContents = await fs.readFile(absolutePath, 'utf-8'); @@ -76,10 +116,10 @@ async function readCompositeFunctionConfigFileAsync( ); } - const result = CompositeFunctionConfigZ.safeParse(parsed); + const result = LocalFunctionConfigZ.safeParse(parsed); if (!result.success) { throw new Error( - `Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(result.error)}` + `Invalid local function "${compositeFunctionPath}": ${z.prettifyError(result.error)}` ); }