diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d930e7cb..a95050c177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [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)) ### 🐛 Bug fixes diff --git a/packages/build-tools/src/__tests__/generic.test.ts b/packages/build-tools/src/__tests__/generic.test.ts index ebf185a995..a1be63aa08 100644 --- a/packages/build-tools/src/__tests__/generic.test.ts +++ b/packages/build-tools/src/__tests__/generic.test.ts @@ -52,6 +52,7 @@ describe(runGenericJobAsync, () => { child: jest.fn().mockReturnThis(), }, runBuildPhase: jest.fn(async (_phase: BuildPhase, fn: () => Promise) => fn()), + getReactNativeProjectDirectory: jest.fn(() => '/tmp/src'), }; mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined); diff --git a/packages/build-tools/src/builders/__tests__/custom.test.ts b/packages/build-tools/src/builders/__tests__/custom.test.ts index 7db7f6307d..0d3ee52430 100644 --- a/packages/build-tools/src/builders/__tests__/custom.test.ts +++ b/packages/build-tools/src/builders/__tests__/custom.test.ts @@ -163,4 +163,71 @@ describe(runCustomBuildAsync, () => { drainSpy.mockRestore(); executeSpy.mockRestore(); }); + + describe('with inline job steps (StepsConfigParser path)', () => { + let executeSpy: jest.SpyInstance; + + function createStepsCtx(steps: unknown[]): BuildContext { + const job = createTestIosJob(); + return new BuildContext( + { + ...job, + steps, + } as unknown as BuildJob, + { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + } + ); + } + + beforeEach(() => { + executeSpy = jest.spyOn(BuildWorkflow.prototype, 'executeAsync').mockResolvedValue(undefined); + }); + + afterEach(() => { + executeSpy.mockRestore(); + }); + + it('builds the composite function catalog and resolves a local composite function referenced by a step', async () => { + jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => { + vol.mkdirSync('/workingdir/env', { recursive: true }); + vol.fromJSON( + { + '.eas/functions/hello/function.yml': ` + name: Hello + runs: + steps: + - run: echo "hello from composite function" + `, + }, + '/workingdir/temporary-custom-build' + ); + return { handled: true }; + }); + + const stepsCtx = createStepsCtx([{ uses: './.eas/functions/hello', id: 'hello' }]); + + await expect(runCustomBuildAsync(stepsCtx)).resolves.toBeDefined(); + expect(executeSpy).toHaveBeenCalledTimes(1); + }); + + it('fails to parse when a referenced local composite function does not exist', async () => { + jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => { + vol.mkdirSync('/workingdir/env', { recursive: true }); + vol.mkdirSync('/workingdir/temporary-custom-build', { recursive: true }); + return { handled: true }; + }); + + const stepsCtx = createStepsCtx([{ uses: './.eas/functions/missing', id: 'missing' }]); + + await expect(runCustomBuildAsync(stepsCtx)).rejects.toThrow(); + expect(executeSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/build-tools/src/builders/custom.ts b/packages/build-tools/src/builders/custom.ts index 355ed631f2..7391c8e071 100644 --- a/packages/build-tools/src/builders/custom.ts +++ b/packages/build-tools/src/builders/custom.ts @@ -17,6 +17,7 @@ import { Artifacts, BuildContext } from '../context'; import { CustomBuildContext } from '../customBuildContext'; import { Datadog } from '../datadog'; import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs'; +import { buildCompositeFunctionCatalogAsync } from '../steps/compositeFunctions'; import { getEasFunctionGroups } from '../steps/easFunctionGroups'; import { getEasFunctions } from '../steps/easFunctions'; import { retryAsync } from '../utils/retry'; @@ -58,26 +59,30 @@ export async function runCustomBuildAsync(ctx: BuildContext): Promise< const globalContext = new BuildStepGlobalContext(customBuildCtx, false); const easFunctions = getEasFunctions(customBuildCtx); const easFunctionGroups = getEasFunctionGroups(customBuildCtx); - const parser = ctx.job.steps - ? new StepsConfigParser(globalContext, { - externalFunctions: easFunctions, - externalFunctionGroups: easFunctionGroups, - steps: ctx.job.steps, - hooks: ctx.job.hooks, - }) - : new BuildConfigParser(globalContext, { - externalFunctions: easFunctions, - externalFunctionGroups: easFunctionGroups, - configPath: path.join( - ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory), - nullthrows( - ctx.job.customBuildConfig?.path, - 'Steps or custom build config path are required in custom jobs' - ) - ), - }); const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => { try { + const parser = ctx.job.steps + ? new StepsConfigParser(globalContext, { + externalFunctions: easFunctions, + externalFunctionGroups: easFunctionGroups, + steps: ctx.job.steps, + hooks: ctx.job.hooks, + compositeFunctionCatalog: await buildCompositeFunctionCatalogAsync( + ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory), + { steps: ctx.job.steps, logger: ctx.logger } + ), + }) + : new BuildConfigParser(globalContext, { + externalFunctions: easFunctions, + externalFunctionGroups: easFunctionGroups, + configPath: path.join( + ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory), + nullthrows( + ctx.job.customBuildConfig?.path, + 'Steps or custom build config path are required in custom jobs' + ) + ), + }); return await parser.parseAsync(); } catch (parseError: any) { ctx.logger.error('Failed to parse the custom build config file.'); diff --git a/packages/build-tools/src/generic.ts b/packages/build-tools/src/generic.ts index 4bbb0c7082..2800ef2601 100644 --- a/packages/build-tools/src/generic.ts +++ b/packages/build-tools/src/generic.ts @@ -7,6 +7,7 @@ import nullthrows from 'nullthrows'; import { prepareProjectSourcesAsync } from './common/projectSources'; import { BuildContext } from './context'; import { CustomBuildContext } from './customBuildContext'; +import { buildCompositeFunctionCatalogAsync } from './steps/compositeFunctions'; import { getEasFunctionGroups } from './steps/easFunctionGroups'; import { getEasFunctions } from './steps/easFunctions'; import { uploadJobOutputsToWwwAsync } from './utils/outputs'; @@ -43,15 +44,21 @@ export async function runGenericJobAsync( const globalContext = new BuildStepGlobalContext(customBuildCtx, false); - const parser = new StepsConfigParser(globalContext, { - externalFunctions: getEasFunctions(customBuildCtx), - externalFunctionGroups: getEasFunctionGroups(customBuildCtx), - steps: ctx.job.steps, - hooks: ctx.job.hooks, - }); - const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => { try { + const compositeFunctionCatalog = await buildCompositeFunctionCatalogAsync( + ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory), + { steps: ctx.job.steps, logger: ctx.logger } + ); + + const parser = new StepsConfigParser(globalContext, { + externalFunctions: getEasFunctions(customBuildCtx), + externalFunctionGroups: getEasFunctionGroups(customBuildCtx), + steps: ctx.job.steps, + hooks: ctx.job.hooks, + compositeFunctionCatalog, + }); + return await parser.parseAsync(); } catch (parseError: any) { ctx.logger.error('Failed to parse the job definition file.'); diff --git a/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts b/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts new file mode 100644 index 0000000000..d095851ba8 --- /dev/null +++ b/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts @@ -0,0 +1,173 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { buildCompositeFunctionCatalogAsync } from '../compositeFunctions'; + +async function makeProjectWithCompositeFunctionAsync( + functionName: string, + contents: string +): Promise { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-test-')); + 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'); + return projectRoot; +} + +const setupCompositeFunctionContents = [ + 'name: Setup', + 'inputs:', + ' - name: greeting', + ' type: string', + ' default_value: hello', + 'outputs:', + ' version:', + ' value: ${{ steps.read.outputs.version }}', + 'runs:', + ' steps:', + ' - id: read', + ' run: set-output version "1.0.0"', +].join('\n'); + +describe(buildCompositeFunctionCatalogAsync, () => { + it('discovers, reads and validates referenced local composite functions keyed by normalized ref', async () => { + const projectRoot = await makeProjectWithCompositeFunctionAsync( + 'setup', + setupCompositeFunctionContents + ); + + const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ uses: './.eas/functions/setup', id: 'setup' }], + }); + + expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']); + const action = catalog['./.eas/functions/setup']; + expect(action.name).toBe('Setup'); + expect(action.runs.steps).toHaveLength(1); + expect(action.outputs?.version.value).toBe('${{ steps.read.outputs.version }}'); + }); + + it('loads nested composite functions transitively referenced by other composite functions', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-nested-')); + const innerDir = path.join(projectRoot, '.eas', 'functions', 'inner'); + const outerDir = path.join(projectRoot, '.eas', 'functions', 'outer'); + await fs.mkdir(innerDir, { recursive: true }); + await fs.mkdir(outerDir, { recursive: true }); + await fs.writeFile( + path.join(innerDir, 'function.yml'), + ['runs:', ' steps:', ' - run: echo inner'].join('\n'), + 'utf-8' + ); + await fs.writeFile( + path.join(outerDir, 'function.yml'), + ['runs:', ' steps:', ' - uses: ./.eas/functions/inner'].join('\n'), + 'utf-8' + ); + + const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ uses: './.eas/functions/outer', id: 'outer' }], + }); + + expect(Object.keys(catalog).sort()).toEqual([ + './.eas/functions/inner', + './.eas/functions/outer', + ]); + }); + + it('returns an empty catalog when there are no referenced composite functions', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-empty-')); + const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ run: 'echo hi' }], + }); + expect(catalog).toEqual({}); + }); + + it('ignores unreferenced malformed composite functions on disk', async () => { + const projectRoot = await makeProjectWithCompositeFunctionAsync( + 'broken', + ['name: Broken', 'runs:', ' steps: []'].join('\n') + ); + + const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ run: 'echo hi' }], + }); + expect(catalog).toEqual({}); + }); + + it('resolves composite functions referenced by an arbitrary path (arbitrary path style)', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-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 catalog = await buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ uses: './internal-functions/deploy', id: 'deploy' }], + }); + + expect(Object.keys(catalog)).toEqual(['./internal-functions/deploy']); + }); + + it('resolves composite functions defined with a function.yaml extension', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-yaml-')); + const functionDir = path.join(projectRoot, '.eas', 'functions', 'setup'); + await fs.mkdir(functionDir, { recursive: true }); + await fs.writeFile( + path.join(functionDir, 'function.yaml'), + ['runs:', ' steps:', ' - run: echo hi'].join('\n'), + 'utf-8' + ); + + const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ uses: './.eas/functions/setup', id: 'setup' }], + }); + + expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']); + }); + + it('loads a shared composite function above the EAS project root', async () => { + const sourceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-monorepo-')); + const projectRoot = path.join(sourceRoot, 'apps', 'my-app'); + const functionDir = path.join(sourceRoot, 'shared', 'functions', 'setup'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(functionDir, { recursive: true }); + await fs.writeFile( + path.join(functionDir, 'function.yml'), + ['runs:', ' steps:', ' - run: echo shared'].join('\n'), + 'utf-8' + ); + + const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ uses: '../../shared/functions/setup', id: 'setup' }], + }); + + expect(Object.keys(catalog)).toEqual(['../../shared/functions/setup']); + }); + + it('throws a clear error for a referenced composite function that does not exist', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-missing-')); + await expect( + buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ uses: './.eas/functions/missing', id: 'missing' }], + }) + ).rejects.toThrow( + /Local composite function "\.\/\.eas\/functions\/missing" was referenced by a step but no such composite function exists/ + ); + }); + + it('throws a clear error for a malformed referenced composite function config', async () => { + const projectRoot = await makeProjectWithCompositeFunctionAsync( + 'broken', + ['name: Broken', 'runs:', ' steps: []'].join('\n') + ); + await expect( + buildCompositeFunctionCatalogAsync(projectRoot, { + steps: [{ uses: './.eas/functions/broken', id: 'broken' }], + }) + ).rejects.toThrow(/must declare at least one step under "runs.steps"/); + }); +}); diff --git a/packages/build-tools/src/steps/compositeFunctions.ts b/packages/build-tools/src/steps/compositeFunctions.ts new file mode 100644 index 0000000000..599b3dabb3 --- /dev/null +++ b/packages/build-tools/src/steps/compositeFunctions.ts @@ -0,0 +1,86 @@ +/** + * Loads local function.yml files from the project and builds the catalog consumed by + * {@link StepsConfigParser}. Keeps filesystem I/O in build-tools; expansion logic lives in @expo/steps. + */ +import { bunyan } from '@expo/logger'; +import { + CompositeFunctionCatalog, + CompositeFunctionConfig, + CompositeFunctionConfigZ, + Step, +} from '@expo/eas-build-job'; +import { + buildCompositeFunctionCatalogFromStepsAsync, + resolveLocalCompositeFunctionPath, +} from '@expo/steps'; +import fs from 'fs/promises'; +import path from 'path'; +import YAML from 'yaml'; +import { ZodError, z } from 'zod'; + +async function loadLocalCompositeFunctionConfigAsync( + projectRoot: string, + compositeFunctionPath: string, + { logger }: { logger?: bunyan } = {} +): Promise { + const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath); + + for (const ext of ['yml', 'yaml'] as const) { + const absolutePath = path.join(resolvedPath, `function.${ext}`); + let rawContents: string; + try { + rawContents = await fs.readFile(absolutePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + continue; + } + 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, + } + ); + } + let config: CompositeFunctionConfig; + try { + config = CompositeFunctionConfigZ.parse(parsed); + } catch (err) { + if (err instanceof ZodError) { + throw new Error( + `Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(err)}` + ); + } + throw err; + } + logger?.debug( + `Loaded local composite function "${compositeFunctionPath}" from ${path.relative(projectRoot, absolutePath)}` + ); + 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/".` + ); +} + +export async function buildCompositeFunctionCatalogAsync( + projectRoot: string, + { steps, logger }: { steps: readonly Step[]; logger?: bunyan } +): Promise { + return buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: steps, + loadCompositeFunction: compositeFunctionPath => + loadLocalCompositeFunctionConfigAsync(projectRoot, compositeFunctionPath, { logger }), + }); +} diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts index 7b6e3bc995..92466e64ee 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts @@ -1,7 +1,7 @@ import { SETUP, - actionReadingInput, - echoInputAction, + compositeFunctionReadingInput, + echoInputCompositeFunction, parseCompositeFunctions, passThroughFunction, } from './StepsConfigParser-composite-functions-test-utils'; @@ -13,7 +13,7 @@ describe('StepsConfigParser local composite functions', () => { it('uses the composite function input default when the caller omits the value', async () => { const workflow = await parseCompositeFunctions({ catalog: { - [SETUP]: echoInputAction('greeting', { + [SETUP]: echoInputCompositeFunction('greeting', { name: 'greeting', type: 'string', default_value: 'hello', @@ -36,7 +36,7 @@ describe('StepsConfigParser local composite functions', () => { 'accepts an explicit %s value, leaving the command raw', async (_typeLabel, inputDef, withInputs, expectedCommand) => { const workflow = await parseCompositeFunctions({ - catalog: { [SETUP]: echoInputAction(inputDef.name, inputDef) }, + catalog: { [SETUP]: echoInputCompositeFunction(inputDef.name, inputDef) }, steps: [{ uses: SETUP, id: 'setup', with: withInputs }], }); expect(workflow.buildSteps[0].command).toBe(expectedCommand); @@ -45,7 +45,7 @@ describe('StepsConfigParser local composite functions', () => { it('ignores unknown caller inputs, matching function-step behavior', async () => { const workflow = await parseCompositeFunctions({ - catalog: { [SETUP]: actionReadingInput({ name: 'greeting', type: 'string' }) }, + catalog: { [SETUP]: compositeFunctionReadingInput({ name: 'greeting', type: 'string' }) }, steps: [{ uses: SETUP, id: 'setup', with: { greetng: 'hi' } }], externalFunctions: [passThroughFunction()], }); @@ -63,7 +63,10 @@ describe('StepsConfigParser local composite functions', () => { runs: { steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: 'static' } }] }, }, ], - ['referenced', actionReadingInput({ name: 'token', type: 'string', required: true })], + [ + 'referenced', + compositeFunctionReadingInput({ name: 'token', type: 'string', required: true }), + ], ])('rejects at parse time a missing required input that is %s', async (_, actionConfig) => { const error = await getErrorAsync(() => parseCompositeFunctions({ @@ -83,7 +86,7 @@ describe('StepsConfigParser local composite functions', () => { it('treats a required input set to an interpolation as provided, even one resolving to undefined', async () => { const workflow = await parseCompositeFunctions({ catalog: { - [SETUP]: actionReadingInput({ name: 'token', type: 'string', required: true }), + [SETUP]: compositeFunctionReadingInput({ name: 'token', type: 'string', required: true }), }, steps: [{ uses: SETUP, id: 'setup', with: { token: '${{ env.UNSET_VAR }}' } }], externalFunctions: [passThroughFunction()], @@ -97,13 +100,13 @@ describe('StepsConfigParser local composite functions', () => { it.each([ [ 'a provided input has the wrong type', - actionReadingInput({ name: 'count', type: 'number' }), + compositeFunctionReadingInput({ name: 'count', type: 'number' }), [{ uses: SETUP, id: 'setup', with: { count: 'two' } }], /Input parameter "count" for step ".+" must be of type "number"/, ], [ 'a string does not parse as JSON for a json input', - actionReadingInput({ name: 'config', type: 'json' }), + compositeFunctionReadingInput({ name: 'config', type: 'json' }), [{ uses: SETUP, id: 'setup', with: { config: 'literal' } }], /Input parameter "config" for step ".+" must be of type "json"/, ], @@ -147,7 +150,11 @@ describe('StepsConfigParser local composite functions', () => { it('falls back to the default resolved in the composite function scope when the caller passes null', async () => { const workflow = await parseCompositeFunctions({ catalog: { - [SETUP]: actionReadingInput({ name: 'greeting', type: 'string', default_value: 'hello' }), + [SETUP]: compositeFunctionReadingInput({ + name: 'greeting', + type: 'string', + default_value: 'hello', + }), }, steps: [{ uses: SETUP, id: 'setup', with: { greeting: null } }], externalFunctions: [passThroughFunction()], @@ -161,7 +168,7 @@ describe('StepsConfigParser local composite functions', () => { it('accepts a provided literal value that is one of allowed_values', async () => { const workflow = await parseCompositeFunctions({ catalog: { - [SETUP]: actionReadingInput({ + [SETUP]: compositeFunctionReadingInput({ name: 'greeting', type: 'string', allowed_values: ['hi', 'hello'], @@ -180,7 +187,7 @@ describe('StepsConfigParser local composite functions', () => { const error = await getErrorAsync(() => parseCompositeFunctions({ catalog: { - [SETUP]: actionReadingInput({ + [SETUP]: compositeFunctionReadingInput({ name: 'greeting', type: 'string', allowed_values: ['hi', 'hello'], diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts index 828f7ced71..185139f049 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts @@ -106,14 +106,14 @@ export function captureEnvFunction( }); } -export function echoInputAction(inputName: string, input: Record) { +export function echoInputCompositeFunction(inputName: string, input: Record) { return { inputs: [input], runs: { steps: [{ run: `echo "\${{ inputs.${inputName} }}"` }] }, }; } -export function actionReadingInput(input: Record) { +export function compositeFunctionReadingInput(input: Record) { return { inputs: [input], runs: { diff --git a/packages/steps/src/index.ts b/packages/steps/src/index.ts index d04f53d6e4..418facc9e8 100644 --- a/packages/steps/src/index.ts +++ b/packages/steps/src/index.ts @@ -17,4 +17,5 @@ export * from './interpolation'; export * from './utils/shell/spawn'; export * from './utils/jsepEval'; export * from './utils/hashFiles'; +export * from './utils/localCompositeFunctions'; export { StepMetric, StepMetricResult, WorkflowHookMetric } from './StepMetrics'; diff --git a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts index 305617eb50..ea552155ad 100644 --- a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts +++ b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts @@ -1,19 +1,24 @@ +import { CompositeFunctionConfigZ } from '@expo/eas-build-job'; +import path from 'path'; + import { + buildCompositeFunctionCatalogFromStepsAsync, isLocalCompositeFunctionPath, parseLocalCompositeFunctionPath, + resolveLocalCompositeFunctionPath, } from '../localCompositeFunctions'; describe(isLocalCompositeFunctionPath, () => { it('recognizes relative paths as local composite function paths', () => { expect(isLocalCompositeFunctionPath('./.eas/functions/setup')).toBe(true); - expect(isLocalCompositeFunctionPath('../../shared/functions/setup')).toBe(true); + expect(isLocalCompositeFunctionPath('../../shared/actions/setup')).toBe(true); expect(isLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe(true); }); it('rejects function ids and absolute or backslash-prefixed paths', () => { expect(isLocalCompositeFunctionPath('eas/build')).toBe(false); - expect(isLocalCompositeFunctionPath('/functions/setup')).toBe(false); - expect(isLocalCompositeFunctionPath('..\\functions\\setup')).toBe(false); + expect(isLocalCompositeFunctionPath('/actions/setup')).toBe(false); + expect(isLocalCompositeFunctionPath('..\\actions\\setup')).toBe(false); }); }); @@ -22,8 +27,8 @@ describe(parseLocalCompositeFunctionPath, () => { expect(parseLocalCompositeFunctionPath('./.eas/functions/setup')).toBe( './.eas/functions/setup' ); - expect(parseLocalCompositeFunctionPath('../../shared/functions/setup')).toBe( - '../../shared/functions/setup' + expect(parseLocalCompositeFunctionPath('../../shared/actions/setup')).toBe( + '../../shared/actions/setup' ); }); @@ -46,7 +51,7 @@ describe(parseLocalCompositeFunctionPath, () => { }); it('keeps the "./" prefix for under-root directories whose name starts with ".."', () => { - expect(parseLocalCompositeFunctionPath('./..functions/setup')).toBe('./..functions/setup'); + expect(parseLocalCompositeFunctionPath('./..actions/setup')).toBe('./..actions/setup'); }); it('canonicalizes paths pointing at the project root or its parent', () => { @@ -62,7 +67,7 @@ describe(parseLocalCompositeFunctionPath, () => { }); it('throws for backslash-based paths', () => { - expect(() => parseLocalCompositeFunctionPath('./functions\\setup')).toThrow( + expect(() => parseLocalCompositeFunctionPath('./compositeFunctions\\setup')).toThrow( /must not contain backslashes/ ); }); @@ -79,3 +84,154 @@ describe(parseLocalCompositeFunctionPath, () => { ); }); }); + +describe(buildCompositeFunctionCatalogFromStepsAsync, () => { + it('loads referenced composite functions transitively', async () => { + const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/outer', id: 'outer' }], + loadCompositeFunction: async compositeFunctionPath => { + if (compositeFunctionPath === './.eas/functions/outer') { + return CompositeFunctionConfigZ.parse({ + runs: { steps: [{ uses: './.eas/functions/inner' }] }, + }); + } + if (compositeFunctionPath === './.eas/functions/inner') { + return CompositeFunctionConfigZ.parse({ + runs: { steps: [{ run: 'echo inner' }] }, + }); + } + throw new Error(`missing ${compositeFunctionPath}`); + }, + }); + + expect(Object.keys(catalog).sort()).toEqual([ + './.eas/functions/inner', + './.eas/functions/outer', + ]); + }); + + it('loads each action once even when references are cyclic (cycles are reported at expansion time)', async () => { + const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/a', id: 'a' }], + loadCompositeFunction: async compositeFunctionPath => { + if (compositeFunctionPath === './.eas/functions/a') { + return CompositeFunctionConfigZ.parse({ + runs: { steps: [{ uses: './.eas/functions/b' }] }, + }); + } + if (compositeFunctionPath === './.eas/functions/b') { + return CompositeFunctionConfigZ.parse({ + runs: { steps: [{ uses: './.eas/functions/a' }] }, + }); + } + throw new Error(`missing ${compositeFunctionPath}`); + }, + }); + + expect(Object.keys(catalog).sort()).toEqual(['./.eas/functions/a', './.eas/functions/b']); + }); + + it('allows action chains of length 10 at catalog build time', async () => { + const chainLength = 10; + const paths = Array.from({ length: chainLength }, (_, index) => `./.eas/functions/a${index}`); + + const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: paths[0], id: 'root' }], + loadCompositeFunction: async compositeFunctionPath => { + const index = paths.indexOf(compositeFunctionPath); + if (index === -1) { + throw new Error(`missing ${compositeFunctionPath}`); + } + if (index === chainLength - 1) { + return CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo leaf' }] } }); + } + return CompositeFunctionConfigZ.parse({ + runs: { steps: [{ uses: paths[index + 1] }] }, + }); + }, + }); + + expect(Object.keys(catalog).sort()).toEqual(paths.sort()); + }); + + it('collects normalized action paths from steps', async () => { + const loadedPaths: string[] = []; + await buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/setup/' }, { uses: 'eas/build' }, { run: 'echo hi' }], + loadCompositeFunction: async compositeFunctionPath => { + loadedPaths.push(compositeFunctionPath); + return CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }); + }, + }); + expect(loadedPaths).toEqual(['./.eas/functions/setup']); + }); + + it('rejects interpolated local composite function paths', async () => { + await expect( + buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/${{ inputs.name }}' }], + loadCompositeFunction: async () => + CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }), + }) + ).rejects.toThrow(/must not contain interpolation/); + }); + + it('rejects working_directory on a root step that calls a local composite function', async () => { + await expect( + buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/setup', working_directory: 'packages/app' }], + loadCompositeFunction: async () => + CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }), + }) + ).rejects.toThrow(/"working_directory" is not supported on a step that calls/); + }); + + it('rejects working_directory on a nested step that calls a local composite function', async () => { + await expect( + buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/outer' }], + loadCompositeFunction: async compositeFunctionPath => { + if (compositeFunctionPath === './.eas/functions/outer') { + return CompositeFunctionConfigZ.parse({ + runs: { + steps: [{ uses: './.eas/functions/inner', working_directory: 'packages/app' }], + }, + }); + } + return CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo inner' }] } }); + }, + }) + ).rejects.toThrow(/"working_directory" is not supported on a step that calls/); + }); + + it('allows working_directory on a step that calls a function, not a local composite function', async () => { + const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: 'eas/build', working_directory: 'packages/app' }], + loadCompositeFunction: async () => + CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }), + }); + expect(Object.keys(catalog)).toEqual([]); + }); +}); + +describe(resolveLocalCompositeFunctionPath, () => { + const projectRoot = path.resolve('/tmp/project'); + + it('resolves a path under the conventional .eas/functions directory', () => { + expect(resolveLocalCompositeFunctionPath(projectRoot, './.eas/functions/setup')).toBe( + path.join(projectRoot, '.eas', 'functions', 'setup') + ); + }); + + it('resolves an arbitrary arbitrary path style path within the project', () => { + expect(resolveLocalCompositeFunctionPath(projectRoot, './internal-actions/deploy')).toBe( + path.join(projectRoot, 'internal-actions', 'deploy') + ); + }); + + it('resolves a composite function above the EAS project root', () => { + expect(resolveLocalCompositeFunctionPath(projectRoot, '../shared-actions/deploy')).toBe( + path.resolve(projectRoot, '../shared-actions/deploy') + ); + }); +}); diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index 93b30923e6..aff386cea4 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -1,3 +1,4 @@ +import { CompositeFunctionCatalog, CompositeFunctionConfig, Step } from '@expo/eas-build-job'; import path from 'path'; import { BuildConfigError } from '../errors'; @@ -13,7 +14,7 @@ function doesLocalCompositeFunctionPathRequireInterpolation(uses: string): boole export function parseLocalCompositeFunctionPath(uses: string): string { const trimmed = uses.trim(); - // The composite function catalog is built before the workflow runs, so a local path must be + // The composite function catalog is built before the workflow runs, so a local composite function path must be // known statically. if (doesLocalCompositeFunctionPathRequireInterpolation(trimmed)) { throw new BuildConfigError( @@ -40,3 +41,53 @@ export function isLocalCompositeFunctionPath(uses: string): boolean { export function getLocalCompositeFunctionCallWorkingDirectoryError(uses: string): string { return `"working_directory" is not supported on a step that calls a local composite function ("uses: ${uses.trim()}"). Set "working_directory" on the steps inside the composite function instead.`; } + +/** Loads only composite functions transitively referenced by `rootSteps`. Unreferenced files are ignored. */ +export async function buildCompositeFunctionCatalogFromStepsAsync({ + rootSteps, + loadCompositeFunction, +}: { + rootSteps: readonly Step[]; + loadCompositeFunction: (compositeFunctionPath: string) => Promise; +}): Promise { + const catalog: CompositeFunctionCatalog = {}; + + const loadRecursiveAsync = async (compositeFunctionPath: string): Promise => { + if (compositeFunctionPath in catalog) { + return; + } + + const config = await loadCompositeFunction(compositeFunctionPath); + catalog[compositeFunctionPath] = config; + + for (const nestedPath of collectLocalCompositeFunctionPathsFromSteps(config.runs.steps)) { + await loadRecursiveAsync(nestedPath); + } + }; + + for (const compositeFunctionPath of collectLocalCompositeFunctionPathsFromSteps(rootSteps)) { + await loadRecursiveAsync(compositeFunctionPath); + } + + return catalog; +} + +export function resolveLocalCompositeFunctionPath( + projectRoot: string, + compositeFunctionPath: string +): string { + return path.resolve(projectRoot, compositeFunctionPath); +} + +function collectLocalCompositeFunctionPathsFromSteps(steps: readonly Step[]): Set { + const paths = new Set(); + for (const step of steps) { + if (step.uses !== undefined && isLocalCompositeFunctionPath(step.uses)) { + if (step.working_directory !== undefined) { + throw new BuildConfigError(getLocalCompositeFunctionCallWorkingDirectoryError(step.uses)); + } + paths.add(parseLocalCompositeFunctionPath(step.uses)); + } + } + return paths; +}