diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f51456fa1..94dcb0758a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts b/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts index bb9d61e1d3..bfb45ac198 100644 --- a/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts +++ b/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts @@ -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', diff --git a/packages/build-tools/src/steps/compositeFunctions.ts b/packages/build-tools/src/steps/compositeFunctions.ts index 9c68902c5d..28df3b0821 100644 --- a/packages/build-tools/src/steps/compositeFunctions.ts +++ b/packages/build-tools/src/steps/compositeFunctions.ts @@ -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'; @@ -24,7 +26,7 @@ async function loadLocalCompositeFunctionConfigAsync( projectRoot: string, compositeFunctionPath: string, { logger }: { logger?: bunyan } = {} -): Promise { +): Promise { const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath); for (const ext of ['yml', 'yaml'] as const) { @@ -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)}` ); @@ -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 { + 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 { + 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 }