From adf327fc29ce58b0341e544dc4199d29bbf0d658 Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 1 Jul 2026 17:37:27 +0200 Subject: [PATCH] [eas-cli] Validate local composite functions in workflow files --- CHANGELOG.md | 1 + .../__tests__/compositeFunctions-test.ts | 262 ++++++++++++++++++ .../workflow/compositeFunctions.ts | 95 +++++++ .../src/commandUtils/workflow/validation.ts | 4 + 4 files changed, 362 insertions(+) create mode 100644 packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts create mode 100644 packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fe16ff05..57d7e77ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-build-job] Add an `SSH_SESSION` build phase for upcoming worker SSH support. ([#4029](https://github.com/expo/eas-cli/pull/4029) by [@gwdp](https://github.com/gwdp)) - [build-tools] Add `ref` input to the `eas/checkout` step to check out a different git ref (branch, tag, or commit SHA) than the one that triggered the job. ([#4035](https://github.com/expo/eas-cli/pull/4035) by [@sswrk](https://github.com/sswrk)) - [build-tools] Load local composite function catalogs at build time so custom builds and generic jobs can resolve local composite functions referenced via `uses:`. ([#3930](https://github.com/expo/eas-cli/pull/3930) by [@sswrk](https://github.com/sswrk)) +- [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)) ### 🐛 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 new file mode 100644 index 0000000000..66bbf27797 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts @@ -0,0 +1,262 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { validateWorkflowLocalCompositeFunctionsAsync } from '../compositeFunctions'; + +async function makeProjectWithCompositeFunctionAsync( + projectRoot: string, + functionName: string, + contents: string +): Promise { + const functionDir = path.join(projectRoot, '.eas', 'functions', functionName); + await fs.mkdir(functionDir, { recursive: true }); + await fs.writeFile(path.join(functionDir, 'function.yml'), contents, 'utf-8'); +} + +describe(validateWorkflowLocalCompositeFunctionsAsync, () => { + it('validates referenced local composite functions', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-')); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'setup', + ['runs:', ' steps:', ' - run: echo setup'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/setup' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + it('resolves local composite functions from the EAS project directory in monorepos', async () => { + const repositoryRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-') + ); + const projectDir = path.join(repositoryRoot, 'apps', 'mobile'); + await fs.mkdir(projectDir, { recursive: true }); + await makeProjectWithCompositeFunctionAsync( + projectDir, + 'setup', + ['runs:', ' steps:', ' - run: echo setup'].join('\n') + ); + + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/setup' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir) + ).resolves.toBeUndefined(); + }); + + it('does not treat repository-root composite functions as valid when the EAS project is in a subdirectory', async () => { + const repositoryRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-') + ); + const projectDir = path.join(repositoryRoot, 'apps', 'mobile'); + await fs.mkdir(projectDir, { recursive: true }); + await makeProjectWithCompositeFunctionAsync( + repositoryRoot, + 'setup', + ['runs:', ' steps:', ' - run: echo setup'].join('\n') + ); + + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/setup' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir) + ).rejects.toThrow( + /Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/ + ); + }); + + it('ignores unreferenced malformed composite functions on disk', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-')); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'broken', + ['name: Broken', 'runs:', ' steps: []'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ run: 'echo hi' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + it('validates composite functions referenced by an arbitrary path (arbitrary path style)', async () => { + const projectRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'eas-workflow-functions-arbitrary-') + ); + const functionDir = path.join(projectRoot, 'internal-functions', 'deploy'); + await fs.mkdir(functionDir, { recursive: true }); + await fs.writeFile( + path.join(functionDir, 'function.yml'), + ['runs:', ' steps:', ' - run: echo deploy'].join('\n'), + 'utf-8' + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './internal-functions/deploy' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + it('rejects interpolated local composite function references', async () => { + const projectRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'eas-workflow-functions-interpolated-') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/${{ inputs.name }}' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).rejects.toThrow(/must not contain interpolation/); + }); + + it('throws when a referenced local composite function does not exist', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-missing-')); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/setup' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).rejects.toThrow( + /Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/ + ); + }); + + it('validates cyclic local composite functions (cycles are reported at expansion time)', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-cycle-')); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'loop', + ['runs:', ' steps:', ' - uses: ./.eas/functions/loop'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/loop' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + it('validates indirectly cyclic local composite functions (cycles are reported at expansion time)', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-cycle-')); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'a', + ['runs:', ' steps:', ' - uses: ./.eas/functions/b'].join('\n') + ); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'b', + ['runs:', ' steps:', ' - uses: ./.eas/functions/a'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/a' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + it('validates a shared local composite function referenced along multiple paths', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-diamond-')); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'shared', + ['runs:', ' steps:', ' - run: echo shared'].join('\n') + ); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'left', + ['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n') + ); + await makeProjectWithCompositeFunctionAsync( + projectRoot, + 'right', + ['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/left' }, { uses: './.eas/functions/right' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).resolves.toBeUndefined(); + }); + + 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( + projectRoot, + 'broken', + ['name: Broken', 'runs:', ' steps: []'].join('\n') + ); + const workflow = { + jobs: { + job: { + steps: [{ uses: './.eas/functions/broken' }], + }, + }, + }; + + await expect( + validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + ).rejects.toThrow(/must declare at least one step under "runs.steps"/); + }); +}); diff --git a/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts b/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts new file mode 100644 index 0000000000..9db7d818a5 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts @@ -0,0 +1,95 @@ +import { CompositeFunctionConfig, CompositeFunctionConfigZ } from '@expo/eas-build-job'; +import { + buildCompositeFunctionCatalogFromStepsAsync, + resolveLocalCompositeFunctionPath, +} from '@expo/steps'; +import { promises as fs } from 'fs'; +import path from 'path'; +import * as YAML from 'yaml'; +import { z } from 'zod'; + +import Log from '../../log'; + +const COMPOSITE_FUNCTION_FILE_EXTENSIONS = ['yml', 'yaml'] as const; + +export async function validateWorkflowLocalCompositeFunctionsAsync( + parsedYaml: any, + projectDir: string +): Promise { + await buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: stepsFromWorkflow(parsedYaml), + loadCompositeFunction: compositeFunctionPath => + loadLocalCompositeFunctionConfigAsync(projectDir, compositeFunctionPath), + }); +} + +async function loadLocalCompositeFunctionConfigAsync( + projectDir: string, + compositeFunctionPath: string +): Promise { + const resolvedPath = resolveLocalCompositeFunctionPath(projectDir, compositeFunctionPath); + + for (const ext of COMPOSITE_FUNCTION_FILE_EXTENSIONS) { + const config = await readCompositeFunctionConfigFileAsync( + compositeFunctionPath, + path.join(resolvedPath, `function.${ext}`) + ); + if (config) { + Log.debug(`Validated local composite function "${compositeFunctionPath}"`); + return config; + } + } + + throw new Error( + `Local composite function "${compositeFunctionPath}" was referenced by a step but no such composite function exists. A local composite function is resolved from a "function.yml" (or "function.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${compositeFunctionPath}" resolves "${compositeFunctionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/".` + ); +} + +async function readCompositeFunctionConfigFileAsync( + compositeFunctionPath: string, + absolutePath: string +): Promise { + let rawContents: string; + try { + rawContents = await fs.readFile(absolutePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return null; + } + throw new Error( + `Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`, + { + cause: err as Error, + } + ); + } + + let parsed: unknown; + try { + parsed = YAML.parse(rawContents); + } catch (err) { + throw new Error( + `Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`, + { + cause: err as Error, + } + ); + } + + const result = CompositeFunctionConfigZ.safeParse(parsed); + if (!result.success) { + throw new Error( + `Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(result.error)}` + ); + } + + return result.data; +} + +function stepsFromWorkflow(parsedYaml: any): any[] { + const jobs = parsedYaml?.jobs; + if (!jobs || typeof jobs !== 'object') { + return []; + } + return Object.values(jobs).flatMap((job: any) => (Array.isArray(job?.steps) ? job.steps : [])); +} diff --git a/packages/eas-cli/src/commandUtils/workflow/validation.ts b/packages/eas-cli/src/commandUtils/workflow/validation.ts index bc0fd73f53..19e95bafe0 100644 --- a/packages/eas-cli/src/commandUtils/workflow/validation.ts +++ b/packages/eas-cli/src/commandUtils/workflow/validation.ts @@ -4,6 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import * as YAML from 'yaml'; +import { validateWorkflowLocalCompositeFunctionsAsync } from './compositeFunctions'; import { buildProfileNamesFromProjectAsync } from './buildProfileUtils'; import { getExpoApiWorkflowSchemaURL } from '../../api'; import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation'; @@ -46,6 +47,9 @@ export async function validateWorkflowFileAsync( Log.debug(`Validating workflow structure...`); validateWorkflowStructure(parsedYaml, workflowSchema); + Log.debug(`Validating workflow local composite functions...`); + await validateWorkflowLocalCompositeFunctionsAsync(parsedYaml, projectDir); + // Check for other errors using the server-side validation Log.debug(`Validating workflow on server...`); await validateWorkflowOnServerAsync(graphqlClient, projectId, workflowFileContents);