From cb2551687a56b218ab15c601593ec8c9b81aa4a8 Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 5 Aug 2026 12:08:56 +0200 Subject: [PATCH 1/6] [steps] Rename local composite function naming to local function A local function can now be a composite function or a single-step command/path function, so the "composite function" naming throughout packages/steps (and its build-tools/eas-cli consumers) is renamed to the more general "local function". Pure rename: no behavior, wording, or type changes. --- packages/build-tools/src/builders/custom.ts | 8 +- packages/build-tools/src/common/jobHooks.ts | 21 +- packages/build-tools/src/generic.ts | 10 +- ...nctions-test.ts => localFunctions-test.ts} | 45 ++--- ...ompositeFunctions.ts => localFunctions.ts} | 6 +- .../src/commandUtils/workflow/validation.ts | 6 +- packages/steps/src/BuildConfig.ts | 6 +- ...onExpander.ts => LocalFunctionExpander.ts} | 56 +++--- packages/steps/src/StepsConfigParser.ts | 90 ++++----- .../src/__tests__/BuildWorkflow-hooks-test.ts | 24 ++- ...Parser-composite-functions-scoping-test.ts | 6 +- ...igParser-composite-functions-test-utils.ts | 2 +- .../__tests__/StepsConfigParser-hooks-test.ts | 76 ++++---- packages/steps/src/hooks.ts | 31 ++- .../__tests__/localCompositeFunctions-test.ts | 184 ++++++++---------- .../src/utils/localCompositeFunctions.ts | 95 +++++---- 16 files changed, 311 insertions(+), 355 deletions(-) rename packages/eas-cli/src/commandUtils/workflow/__tests__/{compositeFunctions-test.ts => localFunctions-test.ts} (84%) rename packages/eas-cli/src/commandUtils/workflow/{compositeFunctions.ts => localFunctions.ts} (80%) rename packages/steps/src/{CompositeFunctionExpander.ts => LocalFunctionExpander.ts} (90%) diff --git a/packages/build-tools/src/builders/custom.ts b/packages/build-tools/src/builders/custom.ts index 13b93626d4..9d23280ff5 100644 --- a/packages/build-tools/src/builders/custom.ts +++ b/packages/build-tools/src/builders/custom.ts @@ -4,8 +4,8 @@ import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, - buildLocalCompositeFunctionCatalogAsync, - createLocalCompositeFunctionLoader, + buildLocalFunctionCatalogAsync, + createLocalFunctionLoader, errors, } from '@expo/steps'; import assert from 'assert'; @@ -70,11 +70,11 @@ export async function runCustomBuildAsync(ctx: BuildContext): Promise< steps: ctx.job.steps, hooks: ctx.job.hooks, // Eager for job steps (always run), lazy loader for hooks (running anchors only). - compositeFunctionCatalog: await buildLocalCompositeFunctionCatalogAsync(projectRoot, { + localFunctionCatalog: await buildLocalFunctionCatalogAsync(projectRoot, { rootSteps: ctx.job.steps, logger: ctx.logger, }), - loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, { + loadLocalFunction: createLocalFunctionLoader(projectRoot, { logger: ctx.logger, }), }) diff --git a/packages/build-tools/src/common/jobHooks.ts b/packages/build-tools/src/common/jobHooks.ts index f4913bcdc2..ddac40a0c0 100644 --- a/packages/build-tools/src/common/jobHooks.ts +++ b/packages/build-tools/src/common/jobHooks.ts @@ -13,8 +13,8 @@ import { BuildStepGlobalContext, HookEntry, constructHookEntriesAsync, - createLocalCompositeFunctionLoader, - extendCompositeFunctionCatalogFromStepsAsync, + createLocalFunctionLoader, + extendLocalFunctionCatalogFromStepsAsync, validateHookStepsAsync, } from '@expo/steps'; @@ -98,11 +98,10 @@ export async function parseJobHooksAsync( // outputs accumulate across keys. const hookEntriesByKey: Partial> = {}; const orderedSteps: BuildStep[] = []; - const compositeFunctionCatalog: LocalFunctionCatalog = {}; - const loadCompositeFunction = createLocalCompositeFunctionLoader( - ctx.getReactNativeProjectDirectory(), - { logger: ctx.logger } - ); + const localFunctionCatalog: LocalFunctionCatalog = {}; + const loadLocalFunction = createLocalFunctionLoader(ctx.getReactNativeProjectDirectory(), { + logger: ctx.logger, + }); for (const anchor of wrappedAnchors) { for (const side of ['before', 'after'] as const) { const key: HookKey = `${side}_${anchor}`; @@ -113,15 +112,15 @@ export async function parseJobHooksAsync( let entries: HookEntry[]; try { // Extended per key so a bad `uses:` path is attributed to that hook key. - await extendCompositeFunctionCatalogFromStepsAsync({ - catalog: compositeFunctionCatalog, + await extendLocalFunctionCatalogFromStepsAsync({ + catalog: localFunctionCatalog, rootSteps: steps, - loadCompositeFunction, + loadLocalFunction, }); entries = await constructHookEntriesAsync(globalContext, steps, { externalFunctions, externalFunctionGroups, - compositeFunctionCatalog, + localFunctionCatalog, }); } catch (err) { throw new UserError( diff --git a/packages/build-tools/src/generic.ts b/packages/build-tools/src/generic.ts index b7a8ee5c4f..6475c2b7d5 100644 --- a/packages/build-tools/src/generic.ts +++ b/packages/build-tools/src/generic.ts @@ -4,8 +4,8 @@ import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, - buildLocalCompositeFunctionCatalogAsync, - createLocalCompositeFunctionLoader, + buildLocalFunctionCatalogAsync, + createLocalFunctionLoader, errors, } from '@expo/steps'; import fs from 'fs/promises'; @@ -54,7 +54,7 @@ export async function runGenericJobAsync( try { const projectRoot = ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory); // Eager for job steps (always run), lazy loader for hooks (running anchors only). - const compositeFunctionCatalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, { + const localFunctionCatalog = await buildLocalFunctionCatalogAsync(projectRoot, { rootSteps: ctx.job.steps, logger: ctx.logger, }); @@ -64,8 +64,8 @@ export async function runGenericJobAsync( externalFunctionGroups: getEasFunctionGroups(customBuildCtx), steps: ctx.job.steps, hooks: ctx.job.hooks, - compositeFunctionCatalog, - loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, { + localFunctionCatalog, + loadLocalFunction: createLocalFunctionLoader(projectRoot, { logger: ctx.logger, }), }); diff --git a/packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts b/packages/eas-cli/src/commandUtils/workflow/__tests__/localFunctions-test.ts similarity index 84% rename from packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts rename to packages/eas-cli/src/commandUtils/workflow/__tests__/localFunctions-test.ts index d751373abb..6f1e3bb23f 100644 --- a/packages/eas-cli/src/commandUtils/workflow/__tests__/compositeFunctions-test.ts +++ b/packages/eas-cli/src/commandUtils/workflow/__tests__/localFunctions-test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; -import { validateWorkflowLocalCompositeFunctionsAsync } from '../compositeFunctions'; +import { validateWorkflowLocalFunctionsAsync } from '../localFunctions'; async function makeProjectWithCompositeFunctionAsync( projectRoot: string, @@ -14,7 +14,7 @@ async function makeProjectWithCompositeFunctionAsync( await fs.writeFile(path.join(functionDir, 'function.yml'), contents, 'utf-8'); } -describe(validateWorkflowLocalCompositeFunctionsAsync, () => { +describe(validateWorkflowLocalFunctionsAsync, () => { it('validates referenced local composite functions', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-')); await makeProjectWithCompositeFunctionAsync( @@ -31,7 +31,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }; await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + validateWorkflowLocalFunctionsAsync(workflow, projectRoot) ).resolves.toBeUndefined(); }); @@ -45,9 +45,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }, }; - await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) - ).rejects.toThrow( + await expect(validateWorkflowLocalFunctionsAsync(workflow, projectRoot)).rejects.toThrow( /Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/ ); }); @@ -64,9 +62,9 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }, }; - await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) - ).rejects.toThrow(/must not contain interpolation/); + await expect(validateWorkflowLocalFunctionsAsync(workflow, projectRoot)).rejects.toThrow( + /must not contain interpolation/ + ); }); it('validates local composite functions referenced from job hooks', async () => { @@ -88,7 +86,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }; await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + validateWorkflowLocalFunctionsAsync(workflow, projectRoot) ).resolves.toBeUndefined(); }); @@ -105,9 +103,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }, }; - await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) - ).rejects.toThrow( + await expect(validateWorkflowLocalFunctionsAsync(workflow, projectRoot)).rejects.toThrow( /Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/ ); }); @@ -131,7 +127,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }; await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + validateWorkflowLocalFunctionsAsync(workflow, projectRoot) ).resolves.toBeUndefined(); }); @@ -158,7 +154,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }; await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) + validateWorkflowLocalFunctionsAsync(workflow, projectRoot) ).resolves.toBeUndefined(); }); @@ -179,9 +175,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }, }; - await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot) - ).rejects.toThrow( + await expect(validateWorkflowLocalFunctionsAsync(workflow, projectRoot)).rejects.toThrow( /Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/ ); }); @@ -197,13 +191,10 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }; await expect( - validateWorkflowLocalCompositeFunctionsAsync({ defaults: 'garbage', jobs }, projectRoot) + validateWorkflowLocalFunctionsAsync({ defaults: 'garbage', jobs }, projectRoot) ).resolves.toBeUndefined(); await expect( - validateWorkflowLocalCompositeFunctionsAsync( - { defaults: { hooks: 'garbage' }, jobs }, - projectRoot - ) + validateWorkflowLocalFunctionsAsync({ defaults: { hooks: 'garbage' }, jobs }, projectRoot) ).resolves.toBeUndefined(); }); @@ -229,7 +220,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }; await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir) + validateWorkflowLocalFunctionsAsync(workflow, projectDir) ).resolves.toBeUndefined(); }); @@ -252,9 +243,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }, }; - await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir) - ).rejects.toThrow( + await expect(validateWorkflowLocalFunctionsAsync(workflow, projectDir)).rejects.toThrow( /Local composite function "\.\/\.eas\/functions\/notify" was referenced by a step but no such composite function exists/ ); }); @@ -279,7 +268,7 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => { }; await expect( - validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir) + validateWorkflowLocalFunctionsAsync(workflow, projectDir) ).resolves.toBeUndefined(); }); }); diff --git a/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts b/packages/eas-cli/src/commandUtils/workflow/localFunctions.ts similarity index 80% rename from packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts rename to packages/eas-cli/src/commandUtils/workflow/localFunctions.ts index 8e611b7c73..2964940e73 100644 --- a/packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts +++ b/packages/eas-cli/src/commandUtils/workflow/localFunctions.ts @@ -1,12 +1,12 @@ -import { buildLocalCompositeFunctionCatalogAsync } from '@expo/steps'; +import { buildLocalFunctionCatalogAsync } from '@expo/steps'; import Log from '../../log'; -export async function validateWorkflowLocalCompositeFunctionsAsync( +export async function validateWorkflowLocalFunctionsAsync( parsedYaml: any, projectDir: string ): Promise { - await buildLocalCompositeFunctionCatalogAsync(projectDir, { + await buildLocalFunctionCatalogAsync(projectDir, { rootSteps: stepsFromWorkflow(parsedYaml), logger: { debug: message => { diff --git a/packages/eas-cli/src/commandUtils/workflow/validation.ts b/packages/eas-cli/src/commandUtils/workflow/validation.ts index 19e95bafe0..9bbc99991d 100644 --- a/packages/eas-cli/src/commandUtils/workflow/validation.ts +++ b/packages/eas-cli/src/commandUtils/workflow/validation.ts @@ -4,7 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import * as YAML from 'yaml'; -import { validateWorkflowLocalCompositeFunctionsAsync } from './compositeFunctions'; +import { validateWorkflowLocalFunctionsAsync } from './localFunctions'; import { buildProfileNamesFromProjectAsync } from './buildProfileUtils'; import { getExpoApiWorkflowSchemaURL } from '../../api'; import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation'; @@ -47,8 +47,8 @@ export async function validateWorkflowFileAsync( Log.debug(`Validating workflow structure...`); validateWorkflowStructure(parsedYaml, workflowSchema); - Log.debug(`Validating workflow local composite functions...`); - await validateWorkflowLocalCompositeFunctionsAsync(parsedYaml, projectDir); + Log.debug(`Validating workflow local functions...`); + await validateWorkflowLocalFunctionsAsync(parsedYaml, projectDir); // Check for other errors using the server-side validation Log.debug(`Validating workflow on server...`); diff --git a/packages/steps/src/BuildConfig.ts b/packages/steps/src/BuildConfig.ts index a3af990c94..7480d26b0d 100644 --- a/packages/steps/src/BuildConfig.ts +++ b/packages/steps/src/BuildConfig.ts @@ -8,7 +8,7 @@ import { BuildRuntimePlatform } from './BuildRuntimePlatform'; import { BuildStepEnv } from './BuildStepEnv'; import { BuildStepInputValueType, BuildStepInputValueTypeName } from './BuildStepInput'; import { BuildConfigError, BuildWorkflowError } from './errors'; -import { isLocalCompositeFunctionPath } from './utils/localCompositeFunctions'; +import { isLocalFunctionPath } from './utils/localCompositeFunctions'; import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX } from './utils/template'; export type BuildFunctions = Record; @@ -439,9 +439,7 @@ export function validateAllFunctionsExist( } } const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet); - const compositeFunctionPaths = calledFunctionsOrFunctionGroup.filter( - isLocalCompositeFunctionPath - ); + const compositeFunctionPaths = calledFunctionsOrFunctionGroup.filter(isLocalFunctionPath); if (compositeFunctionPaths.length > 0) { throw new BuildConfigError( `Local composite functions (${compositeFunctionPaths diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/LocalFunctionExpander.ts similarity index 90% rename from packages/steps/src/CompositeFunctionExpander.ts rename to packages/steps/src/LocalFunctionExpander.ts index f20e743dc8..bc843e9240 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/LocalFunctionExpander.ts @@ -33,9 +33,9 @@ import { CompositeBuildStep } from './CompositeBuildStep'; import { BuildConfigError } from './errors'; import { duplicates } from './utils/expodash/duplicates'; import { - getLocalCompositeFunctionCallWorkingDirectoryError, - isLocalCompositeFunctionPath, - parseLocalCompositeFunctionPath, + getLocalFunctionCallWorkingDirectoryError, + isLocalFunctionPath, + parseLocalFunctionPath, } from './utils/localCompositeFunctions'; import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step'; @@ -46,8 +46,8 @@ export type FunctionMaps = { buildFunctionGroupById: BuildFunctionGroupById; }; -type CompositeFunctionCall = { - compositeFunctionPath: string; +type LocalFunctionCall = { + functionPath: string; /** Caller-assigned id used as prefix for all inner step ids. */ syntheticStepId: string; name?: string; @@ -64,10 +64,10 @@ type StepOverrides = { ifCondition?: string; }; -export class CompositeFunctionExpander { +export class LocalFunctionExpander { constructor( private readonly ctx: BuildStepGlobalContext, - private readonly compositeFunctionCatalog: LocalFunctionCatalog, + private readonly localFunctionCatalog: LocalFunctionCatalog, private readonly functionMaps: FunctionMaps ) {} @@ -79,15 +79,15 @@ export class CompositeFunctionExpander { return this.functionMaps.buildFunctionGroupById; } - public expandCompositeFunctionStep( + public expandLocalFunctionStep( step: FunctionStep, - compositeFunctionPath: string, + functionPath: string, syntheticStepId: string ): CompositeBuildStep { - this.rejectCompositeFunctionCallWorkingDirectory(step); + this.rejectLocalFunctionCallWorkingDirectory(step); return this.expand( { - compositeFunctionPath, + functionPath, syntheticStepId, name: step.name, callWith: step.with, @@ -99,18 +99,18 @@ export class CompositeFunctionExpander { } // The call step expands away; `working_directory` on it would never apply. - private rejectCompositeFunctionCallWorkingDirectory(step: FunctionStep): void { + private rejectLocalFunctionCallWorkingDirectory(step: FunctionStep): void { if (step.working_directory !== undefined) { - throw new BuildConfigError(getLocalCompositeFunctionCallWorkingDirectoryError(step.uses)); + throw new BuildConfigError(getLocalFunctionCallWorkingDirectoryError(step.uses)); } } // `visited.size` is the current composite function nesting depth. - private expand(call: CompositeFunctionCall, visited: ReadonlySet): CompositeBuildStep { - const { compositeFunctionPath, syntheticStepId } = call; + private expand(call: LocalFunctionCall, visited: ReadonlySet): CompositeBuildStep { + const { functionPath: compositeFunctionPath, syntheticStepId } = call; this.guardAgainstRunawayRecursion(compositeFunctionPath, visited); - const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath); + const compositeFunction = this.lookupLocalFunction(compositeFunctionPath); const compositeFunctionDisplayName = call.name ?? compositeFunction.name ?? compositeFunctionPath; const innerSteps = compositeFunction.runs.steps; @@ -185,8 +185,12 @@ export class CompositeFunctionExpander { } } - private lookupCompositeFunction(compositeFunctionPath: string): CompositeFunctionConfig { - const compositeFunction = this.compositeFunctionCatalog[compositeFunctionPath]; + private lookupLocalFunction(compositeFunctionPath: string): CompositeFunctionConfig { + // The catalog can hold either shape, but this expander does not dispatch on legacy + // functions yet, so a lookup here is always the composite shape. + const compositeFunction = this.localFunctionCatalog[compositeFunctionPath] as + | CompositeFunctionConfig + | undefined; if (!compositeFunction) { throw new BuildConfigError( `Local composite function "${compositeFunctionPath}" does not exist. Expected a "function.yml" (or "function.yaml") file at "${compositeFunctionPath}" relative to the EAS project root (convention: ".eas/functions/").` @@ -216,10 +220,10 @@ export class CompositeFunctionExpander { const overrides = this.resolveStepOverrides(innerStep); if (isStepFunctionStep(innerStep)) { - if (isLocalCompositeFunctionPath(innerStep.uses)) { - this.rejectCompositeFunctionCallWorkingDirectory(innerStep); - return this.expandNestedCompositeFunctionCall(innerStep, { - compositeFunctionPath: parseLocalCompositeFunctionPath(innerStep.uses), + if (isLocalFunctionPath(innerStep.uses)) { + this.rejectLocalFunctionCallWorkingDirectory(innerStep); + return this.expandNestedLocalFunctionCall(innerStep, { + functionPath: parseLocalFunctionPath(innerStep.uses), newId, overrides, scope, @@ -249,16 +253,16 @@ export class CompositeFunctionExpander { }; } - private expandNestedCompositeFunctionCall( + private expandNestedLocalFunctionCall( innerStep: FunctionStep, { - compositeFunctionPath, + functionPath, newId, overrides, scope, visited, }: { - compositeFunctionPath: string; + functionPath: string; newId: string; overrides: StepOverrides; scope: BuildStepCompositeFunctionScope; @@ -267,7 +271,7 @@ export class CompositeFunctionExpander { ): CompositeBuildStep { return this.expand( { - compositeFunctionPath, + functionPath, syntheticStepId: newId, name: innerStep.name, callWith: innerStep.with, diff --git a/packages/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts index 3986870558..c66e3100e7 100644 --- a/packages/steps/src/StepsConfigParser.ts +++ b/packages/steps/src/StepsConfigParser.ts @@ -15,7 +15,7 @@ import { import assert from 'node:assert'; import { AbstractConfigParser } from './AbstractConfigParser'; -import { CompositeFunctionExpander } from './CompositeFunctionExpander'; +import { LocalFunctionExpander } from './LocalFunctionExpander'; import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction'; import { BuildFunctionGroup, @@ -33,9 +33,9 @@ import { validateAllStepFunctionsExist, } from './hooks'; import { - extendCompositeFunctionCatalogFromStepsAsync, - isLocalCompositeFunctionPath, - parseLocalCompositeFunctionPath, + extendLocalFunctionCatalogFromStepsAsync, + isLocalFunctionPath, + parseLocalFunctionPath, } from './utils/localCompositeFunctions'; type ValidatedHooks = ReadonlyMap; @@ -43,11 +43,9 @@ type ValidatedHooks = ReadonlyMap Promise; + /** Pre-loaded local function configs keyed by normalized path (e.g. `./.eas/functions/setup`). */ + private readonly localFunctionCatalog: LocalFunctionCatalog; + private readonly loadLocalFunction?: (functionPath: string) => Promise; constructor( ctx: BuildStepGlobalContext, @@ -56,8 +54,8 @@ export class StepsConfigParser extends AbstractConfigParser { hooks, externalFunctions, externalFunctionGroups, - compositeFunctionCatalog, - loadCompositeFunction, + localFunctionCatalog, + loadLocalFunction, }: { steps: Step[]; // Required (not `hooks?:`) so a call site cannot silently forget to pass @@ -65,9 +63,9 @@ export class StepsConfigParser extends AbstractConfigParser { hooks: Hooks | undefined; externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; - compositeFunctionCatalog?: LocalFunctionCatalog; - /** Loads a hook composite missing from the catalog. When omitted, missing entries fail as unknown. */ - loadCompositeFunction?: (compositeFunctionPath: string) => Promise; + localFunctionCatalog?: LocalFunctionCatalog; + /** Loads a hook local function missing from the catalog. When omitted, missing entries fail as unknown. */ + loadLocalFunction?: (functionPath: string) => Promise; } ) { super(ctx, { @@ -78,8 +76,8 @@ export class StepsConfigParser extends AbstractConfigParser { this.steps = steps; this.hooks = hooks ?? {}; // Shallow copy so lazy loading never mutates a caller-owned catalog. - this.compositeFunctionCatalog = { ...(compositeFunctionCatalog ?? {}) }; - this.loadCompositeFunction = loadCompositeFunction; + this.localFunctionCatalog = { ...(localFunctionCatalog ?? {}) }; + this.loadLocalFunction = loadLocalFunction; } protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{ @@ -99,14 +97,10 @@ export class StepsConfigParser extends AbstractConfigParser { this.externalFunctionGroups ?? [] ); // Expander shares this catalog by reference; it grows as hook composites load. - const compositeFunctionExpander = new CompositeFunctionExpander( - this.ctx, - this.compositeFunctionCatalog, - { - buildFunctionById, - buildFunctionGroupById, - } - ); + const localFunctionExpander = new LocalFunctionExpander(this.ctx, this.localFunctionCatalog, { + buildFunctionById, + buildFunctionGroupById, + }); // Only the job's own steps are scanned — steps constructed from hooks are // never treated as anchors (no nesting). Construction order (before → @@ -118,7 +112,7 @@ export class StepsConfigParser extends AbstractConfigParser { for (const stepConfig of validatedSteps) { const maybeFunctionGroup = - isStepFunctionStep(stepConfig) && !isLocalCompositeFunctionPath(stepConfig.uses) + isStepFunctionStep(stepConfig) && !isLocalFunctionPath(stepConfig.uses) ? buildFunctionGroupById[stepConfig.uses] : undefined; if (maybeFunctionGroup !== undefined) { @@ -139,7 +133,7 @@ export class StepsConfigParser extends AbstractConfigParser { const anchorHooks = await this.constructAnchorHooksAsync( anchorId, validatedHooks, - compositeFunctionExpander + localFunctionExpander ); if (anchorHooks !== undefined) { hooksByAnchorStep.set(expandedStep, anchorHooks); @@ -151,13 +145,13 @@ export class StepsConfigParser extends AbstractConfigParser { const anchorId = StepsConfigParser.resolveStepAnchor(stepConfig, buildFunctionById); if (anchorId === undefined) { buildSteps.push( - ...this.createBuildStepsFromNonGroupStepConfig(stepConfig, compositeFunctionExpander) + ...this.createBuildStepsFromNonGroupStepConfig(stepConfig, localFunctionExpander) ); continue; } // Rejected regardless of expansion size: the anchor would land on an // expanded inner step, and hooks never fire inside a composite function. - if (isStepFunctionStep(stepConfig) && isLocalCompositeFunctionPath(stepConfig.uses)) { + if (isStepFunctionStep(stepConfig) && isLocalFunctionPath(stepConfig.uses)) { throw new BuildConfigError( 'Hook anchors are not supported on local composite function steps.' ); @@ -167,11 +161,11 @@ export class StepsConfigParser extends AbstractConfigParser { anchorId, 'before', validatedHooks, - compositeFunctionExpander + localFunctionExpander ); const createdSteps = this.createBuildStepsFromNonGroupStepConfig( stepConfig, - compositeFunctionExpander + localFunctionExpander ); assert( createdSteps.length === 1, @@ -183,7 +177,7 @@ export class StepsConfigParser extends AbstractConfigParser { anchorId, 'after', validatedHooks, - compositeFunctionExpander + localFunctionExpander ); if (before.length > 0 || after.length > 0) { hooksByAnchorStep.set(anchorStep, { anchor: anchorId, before, after }); @@ -260,19 +254,19 @@ export class StepsConfigParser extends AbstractConfigParser { private async constructAnchorHooksAsync( anchorId: HookAnchorId, validatedHooks: ValidatedHooks, - compositeFunctionExpander: CompositeFunctionExpander + localFunctionExpander: LocalFunctionExpander ): Promise { const before = await this.constructHookSideEntriesAsync( anchorId, 'before', validatedHooks, - compositeFunctionExpander + localFunctionExpander ); const after = await this.constructHookSideEntriesAsync( anchorId, 'after', validatedHooks, - compositeFunctionExpander + localFunctionExpander ); if (before.length === 0 && after.length === 0) { return undefined; @@ -284,7 +278,7 @@ export class StepsConfigParser extends AbstractConfigParser { anchorId: HookAnchorId, side: 'before' | 'after', validatedHooks: ValidatedHooks, - compositeFunctionExpander: CompositeFunctionExpander + localFunctionExpander: LocalFunctionExpander ): Promise { const hookSteps = validatedHooks.get(`${side}_${anchorId}`)?.steps; if (hookSteps === undefined) { @@ -302,13 +296,13 @@ export class StepsConfigParser extends AbstractConfigParser { }` ); } - if (this.loadCompositeFunction !== undefined) { + if (this.loadLocalFunction !== undefined) { // Load only once the anchor runs, so unused anchors do not fail on missing composites. try { - await extendCompositeFunctionCatalogFromStepsAsync({ - catalog: this.compositeFunctionCatalog, + await extendLocalFunctionCatalogFromStepsAsync({ + catalog: this.localFunctionCatalog, rootSteps: hookSteps, - loadCompositeFunction: this.loadCompositeFunction, + loadLocalFunction: this.loadLocalFunction, }); } catch (err) { if (err instanceof BuildConfigError) { @@ -324,7 +318,7 @@ export class StepsConfigParser extends AbstractConfigParser { } } try { - return constructHookEntriesFromValidatedSteps(this.ctx, hookSteps, compositeFunctionExpander); + return constructHookEntriesFromValidatedSteps(this.ctx, hookSteps, localFunctionExpander); } catch (err) { if (err instanceof BuildConfigError) { throw new BuildConfigError(`Invalid steps in "hooks.${side}_${anchorId}": ${err.message}`); @@ -335,13 +329,13 @@ export class StepsConfigParser extends AbstractConfigParser { private createBuildStepsFromNonGroupStepConfig( stepConfig: Step, - compositeFunctionExpander: CompositeFunctionExpander + localFunctionExpander: LocalFunctionExpander ): BuildStep[] { if (isStepShellStep(stepConfig)) { return [createBuildStepFromShellStep(this.ctx, stepConfig)]; } if (isStepFunctionStep(stepConfig)) { - return this.createBuildStepsFromFunctionStepConfig(stepConfig, compositeFunctionExpander); + return this.createBuildStepsFromFunctionStepConfig(stepConfig, localFunctionExpander); } throw new BuildConfigError( 'Invalid job step configuration detected. Step must be shell or function step' @@ -350,19 +344,19 @@ export class StepsConfigParser extends AbstractConfigParser { private createBuildStepsFromFunctionStepConfig( step: FunctionStep, - compositeFunctionExpander: CompositeFunctionExpander + localFunctionExpander: LocalFunctionExpander ): BuildStep[] { - if (isLocalCompositeFunctionPath(step.uses)) { - return compositeFunctionExpander - .expandCompositeFunctionStep( + if (isLocalFunctionPath(step.uses)) { + return localFunctionExpander + .expandLocalFunctionStep( step, - parseLocalCompositeFunctionPath(step.uses), + parseLocalFunctionPath(step.uses), BuildStep.getNewId(step.id) ) .getFlattenedSteps(); } - const buildFunction = compositeFunctionExpander.buildFunctionById[step.uses]; + const buildFunction = localFunctionExpander.buildFunctionById[step.uses]; assert(buildFunction, 'function ID must be ID of function or function group'); return [ diff --git a/packages/steps/src/__tests__/BuildWorkflow-hooks-test.ts b/packages/steps/src/__tests__/BuildWorkflow-hooks-test.ts index 364c0b6b61..f795f7e674 100644 --- a/packages/steps/src/__tests__/BuildWorkflow-hooks-test.ts +++ b/packages/steps/src/__tests__/BuildWorkflow-hooks-test.ts @@ -108,22 +108,20 @@ describe('BuildWorkflow hook execution', () => { hooks, externalFunctions, externalFunctionGroups, - compositeFunctionCatalog, + localFunctionCatalog, }: { steps: Step[]; hooks: Hooks | undefined; externalFunctions: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; - compositeFunctionCatalog?: Record; + localFunctionCatalog?: Record; }): Promise { const parser = new StepsConfigParser(ctx, { steps, hooks, externalFunctions, externalFunctionGroups, - compositeFunctionCatalog: compositeFunctionCatalog - ? makeCatalog(compositeFunctionCatalog) - : undefined, + localFunctionCatalog: localFunctionCatalog ? makeCatalog(localFunctionCatalog) : undefined, }); return await parser.parseAsync(); } @@ -627,7 +625,7 @@ describe('BuildWorkflow hook execution', () => { versionFunction('read-version', '1.2.3'), recordingFunction('second-child'), ], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/setup': { outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, runs: { @@ -659,7 +657,7 @@ describe('BuildWorkflow hook execution', () => { ], }, externalFunctions: [anchorFunction(), versionFunction('read-version', '1.2.3')], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/setup': { outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, runs: { steps: [{ id: 'read', uses: 'test/read-version' }] }, @@ -680,7 +678,7 @@ describe('BuildWorkflow hook execution', () => { steps: [{ uses: 'eas/install_node_modules' }], hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup', id: 'setup' }] }, externalFunctions: [anchorFunction(), versionFunction('read-version', '1.2.3')], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/setup': { outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, runs: { steps: [{ id: 'read', uses: 'test/read-version', if: '${{ false }}' }] }, @@ -710,7 +708,7 @@ describe('BuildWorkflow hook execution', () => { recordingFunction('on-success'), versionFunction('always-version', '9.9.9'), ], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/cleanup': { outputs: { last: { value: '${{ steps.always.outputs.version }}' } }, runs: { @@ -741,7 +739,7 @@ describe('BuildWorkflow hook execution', () => { recordingFunction('after-boom'), recordingFunction('always-child'), ], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/setup': { outputs: { note: { value: 'done' } }, runs: { @@ -776,7 +774,7 @@ describe('BuildWorkflow hook execution', () => { recordingFunction('push'), recordingFunction('always-child'), ], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/publish': { outputs: { note: { value: 'done' } }, runs: { @@ -815,7 +813,7 @@ describe('BuildWorkflow hook execution', () => { recordingFunction('plain-child'), recordingFunction('always-child'), ], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/setup': { outputs: { note: { value: 'done' } }, runs: { @@ -854,7 +852,7 @@ describe('BuildWorkflow hook execution', () => { versionFunction('read-version', '1.2.3'), captureFunction('consume', value => captured.push(value)), ], - compositeFunctionCatalog: { + localFunctionCatalog: { './.eas/functions/setup': { outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, runs: { steps: [{ id: 'read', uses: 'test/read-version' }] }, diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts index 8909654221..e9da6a68ad 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts @@ -620,7 +620,7 @@ describe('StepsConfigParser local composite functions', () => { const parser = new StepsConfigParser(ctx, { steps: [{ uses: SETUP, id: 'setup', if: "${{ env.DEPLOY == 'true' }}" }], hooks: undefined, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ [SETUP]: { runs: { steps: [{ id: 'inner', uses: 'eas/echo', env: { DEPLOY: 'false' } }] }, }, @@ -780,7 +780,7 @@ describe('StepsConfigParser local composite functions', () => { const parser = new StepsConfigParser(ctx, { steps: [{ uses: SETUP, id: 'setup', with: { msg: '${{ env.SECRET }}' } }], hooks: undefined, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ [SETUP]: { inputs: [{ name: 'msg', type: 'string', required: false }], runs: { @@ -823,7 +823,7 @@ describe('StepsConfigParser local composite functions', () => { const parser = new StepsConfigParser(ctx, { steps: [{ uses: SETUP, id: 'setup' }], hooks: undefined, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ [SETUP]: { inputs: [{ name: 'label', type: 'string', default_value: '${{ env.NAME }}' }], runs: { 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 572b3ec733..4882ea03a8 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts @@ -28,7 +28,7 @@ export async function parseCompositeFunctions(options: { const parser = new StepsConfigParser(ctx, { steps: options.steps, hooks: undefined, - compositeFunctionCatalog: makeCatalog(options.catalog ?? {}), + localFunctionCatalog: makeCatalog(options.catalog ?? {}), externalFunctions: options.externalFunctions, externalFunctionGroups: options.externalFunctionGroups, }); diff --git a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts index 1887103fcb..b80fad1b1a 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts @@ -45,16 +45,16 @@ async function parseWorkflowAsync({ hooks, externalFunctions, externalFunctionGroups, - compositeFunctionCatalog, - loadCompositeFunction, + localFunctionCatalog, + loadLocalFunction, }: { ctx: BuildStepGlobalContext; steps: Step[]; hooks: Hooks | undefined; externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; - compositeFunctionCatalog?: LocalFunctionCatalog; - loadCompositeFunction?: (compositeFunctionPath: string) => Promise; + localFunctionCatalog?: LocalFunctionCatalog; + loadLocalFunction?: (compositeFunctionPath: string) => Promise; }): Promise { const parser = new StepsConfigParser(ctx, { steps, @@ -64,8 +64,8 @@ async function parseWorkflowAsync({ createCheckoutFunction(), ], externalFunctionGroups, - compositeFunctionCatalog, - loadCompositeFunction, + localFunctionCatalog, + loadLocalFunction, }); return await parser.parseAsync(); } @@ -541,7 +541,7 @@ describe('StepsConfigParser hooks with composite functions', () => { before_install_node_modules: [{ run: 'echo never' }], after_install_node_modules: [{ run: 'echo never' }], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ uses: 'eas/install_node_modules' }] }, }, @@ -566,7 +566,7 @@ describe('StepsConfigParser hooks with composite functions', () => { before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }], after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ uses: 'eas/install_node_modules' }] }, }, @@ -587,7 +587,7 @@ describe('StepsConfigParser hooks with composite functions', () => { ctx, steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }], hooks: { before_install_node_modules: [{ run: 'echo never' }] }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo setup' }] }, }, @@ -604,7 +604,7 @@ describe('StepsConfigParser hooks with composite functions', () => { ctx, steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }], hooks: { before_install_node_modules: [{ run: 'echo never' }] }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo one' }, { run: 'echo two' }] }, }, @@ -620,7 +620,7 @@ describe('StepsConfigParser hooks with composite functions', () => { ctx, steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'some_future_anchor' }], hooks: { before_install_node_modules: [{ run: 'echo never' }] }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ uses: 'eas/install_node_modules' }] }, }, @@ -637,7 +637,7 @@ describe('StepsConfigParser hooks with composite functions', () => { hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup', id: 'setup' }], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, runs: { @@ -666,7 +666,7 @@ describe('StepsConfigParser hooks with composite functions', () => { { uses: './.eas/functions/setup', id: 'setup', if: '${{ failure() }}' }, ], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo hi' }] }, }, @@ -687,7 +687,7 @@ describe('StepsConfigParser hooks with composite functions', () => { { uses: './.eas/functions/setup', id: 'setup', working_directory: 'app' }, ], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo hi' }] }, }, @@ -706,7 +706,7 @@ describe('StepsConfigParser hooks with composite functions', () => { hooks: { before_install_node_modules: [{ uses: './.eas/functions/notify', id: 'notify' }], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/notify': { inputs: [{ name: 'message', type: 'string', required: true }], runs: { steps: [{ run: 'echo hi' }] }, @@ -727,7 +727,7 @@ describe('StepsConfigParser hooks with composite functions', () => { hooks: { before_install_node_modules: [{ uses: './.eas/functions/outer', id: 'top' }], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/outer': { runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }, { run: 'echo done' }], @@ -755,7 +755,7 @@ describe('StepsConfigParser hooks with composite functions', () => { before_install_node_modules: [{ uses: './.eas/functions/setup', id: 'setup' }], after_install_node_modules: [{ uses: './.eas/functions/teardown', id: 'teardown' }], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ id: 'prepare', run: 'echo prepare' }] }, }, @@ -779,7 +779,7 @@ describe('StepsConfigParser hooks with composite functions', () => { hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup', id: 'setup' }], }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { // Outputs node reuses the call id, forcing the collision with the job step. outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, @@ -800,13 +800,13 @@ describe('StepsConfigParser lazy hook composite loading', () => { }); function createLoader(entries: Record): { - loadCompositeFunction: (compositeFunctionPath: string) => Promise; + loadLocalFunction: (compositeFunctionPath: string) => Promise; loadedPaths: string[]; } { const loadedPaths: string[] = []; return { loadedPaths, - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { loadedPaths.push(compositeFunctionPath); const raw = entries[compositeFunctionPath]; if (raw === undefined) { @@ -824,7 +824,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { } it('loads a hook composite through the loader (normalized path) when the anchor is present', async () => { - const { loadCompositeFunction, loadedPaths } = createLoader({ + const { loadLocalFunction, loadedPaths } = createLoader({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo setup' }] } }, }); const workflow = await parseWorkflowAsync({ @@ -834,7 +834,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { // Trailing slash must normalize before the loader is called. before_install_node_modules: [{ uses: './.eas/functions/setup/', id: 'setup' }], }, - loadCompositeFunction, + loadLocalFunction, }); expect(loadedPaths).toEqual(['./.eas/functions/setup']); const anchorHooks = [...workflow.hooksByAnchorStep.values()][0]; @@ -853,7 +853,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { hooks: { before_install_node_modules: [{ uses: './.eas/functions/only-elsewhere' }], }, - loadCompositeFunction: rejectingLoader(), + loadLocalFunction: rejectingLoader(), }); expect(orderedDisplayNames(workflow)).toEqual(['Checkout']); }); @@ -886,7 +886,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { ctx, steps: [{ uses: 'eas/install_node_modules' }], hooks: { before_install_node_modules: [{ uses: './.eas/functions/missing' }] }, - loadCompositeFunction: createLoader({}).loadCompositeFunction, + loadLocalFunction: createLoader({}).loadLocalFunction, }); }); expect(error).toBeInstanceOf(BuildConfigError); @@ -900,7 +900,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { ctx, steps: [{ uses: 'eas/install_node_modules' }], hooks: { after_install_node_modules: [{ uses: './.eas/functions/missing' }] }, - loadCompositeFunction: createLoader({}).loadCompositeFunction, + loadLocalFunction: createLoader({}).loadLocalFunction, }); }); expect(error).toBeInstanceOf(BuildConfigError); @@ -917,9 +917,9 @@ describe('StepsConfigParser lazy hook composite loading', () => { { uses: './.eas/functions/setup', id: 'setup', working_directory: 'app' }, ], }, - loadCompositeFunction: createLoader({ + loadLocalFunction: createLoader({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo setup' }] } }, - }).loadCompositeFunction, + }).loadLocalFunction, }); }); expect(error).toBeInstanceOf(BuildConfigError); @@ -929,14 +929,14 @@ describe('StepsConfigParser lazy hook composite loading', () => { }); it('calls the loader once per composite across repeated occurrences of the same anchor', async () => { - const { loadCompositeFunction, loadedPaths } = createLoader({ + const { loadLocalFunction, loadedPaths } = createLoader({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo setup' }] } }, }); await parseWorkflowAsync({ ctx, steps: [{ uses: 'eas/install_node_modules' }, { uses: 'eas/install_node_modules' }], hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup' }] }, - loadCompositeFunction, + loadLocalFunction, }); expect(loadedPaths).toEqual(['./.eas/functions/setup']); }); @@ -946,16 +946,16 @@ describe('StepsConfigParser lazy hook composite loading', () => { ctx, steps: [{ uses: 'eas/install_node_modules' }], hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup' }] }, - compositeFunctionCatalog: makeCatalog({ + localFunctionCatalog: makeCatalog({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo setup' }] } }, }), - loadCompositeFunction: rejectingLoader(), + loadLocalFunction: rejectingLoader(), }); expect(workflow.hooksByAnchorStep.size).toBe(1); }); it('loads composites transitively referenced by a hook composite', async () => { - const { loadCompositeFunction, loadedPaths } = createLoader({ + const { loadLocalFunction, loadedPaths } = createLoader({ './.eas/functions/outer': { runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }] }, }, @@ -965,7 +965,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { ctx, steps: [{ uses: 'eas/install_node_modules' }], hooks: { before_install_node_modules: [{ uses: './.eas/functions/outer', id: 'top' }] }, - loadCompositeFunction, + loadLocalFunction, }); expect(loadedPaths.sort()).toEqual(['./.eas/functions/inner', './.eas/functions/outer']); const anchorHooks = [...workflow.hooksByAnchorStep.values()][0]; @@ -985,7 +985,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { installFunction.createBuildStepFromFunctionCall(globalCtx), ], }); - const { loadCompositeFunction, loadedPaths } = createLoader({ + const { loadLocalFunction, loadedPaths } = createLoader({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo setup' }] } }, }); const workflow = await parseWorkflowAsync({ @@ -994,7 +994,7 @@ describe('StepsConfigParser lazy hook composite loading', () => { hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup', id: 'setup' }] }, externalFunctions: [checkoutFunction, installFunction], externalFunctionGroups: [group], - loadCompositeFunction, + loadLocalFunction, }); expect(loadedPaths).toEqual(['./.eas/functions/setup']); expect(workflow.hooksByAnchorStep.size).toBe(1); @@ -1006,10 +1006,10 @@ describe('StepsConfigParser lazy hook composite loading', () => { ctx, steps: [{ uses: 'eas/install_node_modules' }], hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup' }] }, - compositeFunctionCatalog: callerCatalog, - loadCompositeFunction: createLoader({ + localFunctionCatalog: callerCatalog, + loadLocalFunction: createLoader({ './.eas/functions/setup': { runs: { steps: [{ run: 'echo setup' }] } }, - }).loadCompositeFunction, + }).loadLocalFunction, }); expect(Object.keys(callerCatalog)).toEqual([]); }); diff --git a/packages/steps/src/hooks.ts b/packages/steps/src/hooks.ts index 8aebb0ca85..933dcc6d80 100644 --- a/packages/steps/src/hooks.ts +++ b/packages/steps/src/hooks.ts @@ -13,12 +13,9 @@ import { BuildFunctionGroup, createBuildFunctionGroupByIdMapping } from './Build import { BuildStep } from './BuildStep'; import { BuildStepGlobalContext } from './BuildStepContext'; import { collectAggregateStepErrors } from './BuildWorkflowValidator'; -import { CompositeFunctionExpander } from './CompositeFunctionExpander'; +import { LocalFunctionExpander } from './LocalFunctionExpander'; import { BuildConfigError, BuildWorkflowError } from './errors'; -import { - isLocalCompositeFunctionPath, - parseLocalCompositeFunctionPath, -} from './utils/localCompositeFunctions'; +import { isLocalFunctionPath, parseLocalFunctionPath } from './utils/localCompositeFunctions'; import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step'; /** @@ -73,12 +70,12 @@ export async function constructHookEntriesAsync( { externalFunctions, externalFunctionGroups, - compositeFunctionCatalog, + localFunctionCatalog, }: { externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; - /** When omitted, composite `uses:` fail as missing from an empty catalog. */ - compositeFunctionCatalog?: LocalFunctionCatalog; + /** When omitted, local `uses:` paths fail as missing from an empty catalog. */ + localFunctionCatalog?: LocalFunctionCatalog; } ): Promise { // An empty array is a valid no-op (e.g. opting out of a default hook); @@ -98,7 +95,7 @@ export async function constructHookEntriesAsync( return constructHookEntriesFromValidatedSteps( ctx, validatedSteps, - new CompositeFunctionExpander(ctx, compositeFunctionCatalog ?? {}, { + new LocalFunctionExpander(ctx, localFunctionCatalog ?? {}, { buildFunctionById, buildFunctionGroupById, }) @@ -126,7 +123,7 @@ export async function validateHookStepsAsync( export function constructHookEntriesFromValidatedSteps( ctx: BuildStepGlobalContext, validatedSteps: Step[], - compositeFunctionExpander: CompositeFunctionExpander + localFunctionExpander: LocalFunctionExpander ): HookEntry[] { const entries: HookEntry[] = []; for (const step of validatedSteps) { @@ -136,19 +133,19 @@ export function constructHookEntriesFromValidatedSteps( }); continue; } - if (isLocalCompositeFunctionPath(step.uses)) { + if (isLocalFunctionPath(step.uses)) { entries.push({ - steps: compositeFunctionExpander - .expandCompositeFunctionStep( + steps: localFunctionExpander + .expandLocalFunctionStep( step, - parseLocalCompositeFunctionPath(step.uses), + parseLocalFunctionPath(step.uses), BuildStep.getNewId(step.id) ) .getFlattenedSteps(), }); continue; } - const maybeFunctionGroup = compositeFunctionExpander.buildFunctionGroupById[step.uses]; + const maybeFunctionGroup = localFunctionExpander.buildFunctionGroupById[step.uses]; if (maybeFunctionGroup !== undefined) { entries.push({ steps: maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(ctx, { @@ -158,7 +155,7 @@ export function constructHookEntriesFromValidatedSteps( }); continue; } - const buildFunction = compositeFunctionExpander.buildFunctionById[step.uses]; + const buildFunction = localFunctionExpander.buildFunctionById[step.uses]; assert(buildFunction, 'function ID must be ID of function or function group'); entries.push({ steps: [ @@ -211,7 +208,7 @@ export function validateAllStepFunctionsExist( ): void { const calledFunctionsOrFunctionGroupsSet = new Set(); for (const step of steps) { - if (step.uses && !isLocalCompositeFunctionPath(step.uses)) { + if (step.uses && !isLocalFunctionPath(step.uses)) { calledFunctionsOrFunctionGroupsSet.add(step.uses); } } diff --git a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts index 6d80281588..ac00dc4a7d 100644 --- a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts +++ b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts @@ -4,14 +4,14 @@ import os from 'os'; import path from 'path'; import { - buildCompositeFunctionCatalogFromStepsAsync, - buildLocalCompositeFunctionCatalogAsync, - createLocalCompositeFunctionLoader, - extendCompositeFunctionCatalogFromStepsAsync, - isLocalCompositeFunctionPath, - loadLocalCompositeFunctionConfigAsync, - parseLocalCompositeFunctionPath, - resolveLocalCompositeFunctionPath, + buildLocalFunctionCatalogAsync, + buildLocalFunctionCatalogFromStepsAsync, + createLocalFunctionLoader, + extendLocalFunctionCatalogFromStepsAsync, + isLocalFunctionPath, + loadLocalFunctionConfigAsync, + parseLocalFunctionPath, + resolveLocalFunctionPath, } from '../localCompositeFunctions'; async function makeCompositeFunctionAsync( @@ -25,88 +25,80 @@ async function makeCompositeFunctionAsync( await fs.writeFile(path.join(functionDir, fileName), contents, 'utf-8'); } -describe(isLocalCompositeFunctionPath, () => { +describe(isLocalFunctionPath, () => { it('recognizes relative paths as local composite function paths', () => { - expect(isLocalCompositeFunctionPath('./.eas/functions/setup')).toBe(true); - expect(isLocalCompositeFunctionPath('../../shared/actions/setup')).toBe(true); - expect(isLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe(true); + expect(isLocalFunctionPath('./.eas/functions/setup')).toBe(true); + expect(isLocalFunctionPath('../../shared/actions/setup')).toBe(true); + expect(isLocalFunctionPath(' ./.eas/functions/setup/ ')).toBe(true); }); it('rejects function ids and absolute or backslash-prefixed paths', () => { - expect(isLocalCompositeFunctionPath('eas/build')).toBe(false); - expect(isLocalCompositeFunctionPath('/actions/setup')).toBe(false); - expect(isLocalCompositeFunctionPath('..\\actions\\setup')).toBe(false); + expect(isLocalFunctionPath('eas/build')).toBe(false); + expect(isLocalFunctionPath('/actions/setup')).toBe(false); + expect(isLocalFunctionPath('..\\actions\\setup')).toBe(false); }); }); -describe(parseLocalCompositeFunctionPath, () => { +describe(parseLocalFunctionPath, () => { it('parses local composite function paths', () => { - expect(parseLocalCompositeFunctionPath('./.eas/functions/setup')).toBe( - './.eas/functions/setup' - ); - expect(parseLocalCompositeFunctionPath('../../shared/actions/setup')).toBe( - '../../shared/actions/setup' - ); + expect(parseLocalFunctionPath('./.eas/functions/setup')).toBe('./.eas/functions/setup'); + expect(parseLocalFunctionPath('../../shared/actions/setup')).toBe('../../shared/actions/setup'); }); it('normalizes local composite function paths', () => { - expect(parseLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe( - './.eas/functions/setup' - ); + expect(parseLocalFunctionPath(' ./.eas/functions/setup/ ')).toBe('./.eas/functions/setup'); }); it('collapses equivalent paths to the same canonical path', () => { - expect(parseLocalCompositeFunctionPath('././.eas/functions/setup')).toBe( + expect(parseLocalFunctionPath('././.eas/functions/setup')).toBe('./.eas/functions/setup'); + expect(parseLocalFunctionPath('./.eas/functions/other/../setup')).toBe( './.eas/functions/setup' ); - expect(parseLocalCompositeFunctionPath('./.eas/functions/other/../setup')).toBe( - './.eas/functions/setup' - ); - expect(parseLocalCompositeFunctionPath('../shared/other/../functions/setup')).toBe( + expect(parseLocalFunctionPath('../shared/other/../functions/setup')).toBe( '../shared/functions/setup' ); }); it('keeps the "./" prefix for under-root directories whose name starts with ".."', () => { - expect(parseLocalCompositeFunctionPath('./..actions/setup')).toBe('./..actions/setup'); + expect(parseLocalFunctionPath('./..actions/setup')).toBe('./..actions/setup'); }); it('canonicalizes paths pointing at the project root or its parent', () => { - expect(parseLocalCompositeFunctionPath('./')).toBe('./.'); - expect(parseLocalCompositeFunctionPath(' ./ ')).toBe('./.'); - expect(parseLocalCompositeFunctionPath('../')).toBe('..'); - expect(parseLocalCompositeFunctionPath('./..')).toBe('..'); + expect(parseLocalFunctionPath('./')).toBe('./.'); + expect(parseLocalFunctionPath(' ./ ')).toBe('./.'); + expect(parseLocalFunctionPath('../')).toBe('..'); + expect(parseLocalFunctionPath('./..')).toBe('..'); }); it('is stable when re-parsing its own canonical output', () => { - expect(parseLocalCompositeFunctionPath('./.')).toBe('./.'); - expect(parseLocalCompositeFunctionPath('../.')).toBe('..'); + expect(parseLocalFunctionPath('./.')).toBe('./.'); + expect(parseLocalFunctionPath('../.')).toBe('..'); }); it('throws for backslash-based paths', () => { - expect(() => parseLocalCompositeFunctionPath('./compositeFunctions\\setup')).toThrow( + expect(() => parseLocalFunctionPath('./compositeFunctions\\setup')).toThrow( /must not contain backslashes/ ); }); it('throws for interpolated local composite function paths', () => { - expect(() => parseLocalCompositeFunctionPath('./.eas/functions/${{ inputs.name }}')).toThrow( + expect(() => parseLocalFunctionPath('./.eas/functions/${{ inputs.name }}')).toThrow( /must not contain interpolation/ ); }); it('parses local composite function paths that contain }}${{ as literal characters', () => { - expect(parseLocalCompositeFunctionPath('./.eas/functions/weird}}${{name')).toBe( + expect(parseLocalFunctionPath('./.eas/functions/weird}}${{name')).toBe( './.eas/functions/weird}}${{name' ); }); }); -describe(buildCompositeFunctionCatalogFromStepsAsync, () => { +describe(buildLocalFunctionCatalogFromStepsAsync, () => { it('loads referenced composite functions transitively', async () => { - const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + const catalog = await buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: './.eas/functions/outer', id: 'outer' }], - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { if (compositeFunctionPath === './.eas/functions/outer') { return CompositeFunctionConfigZ.parse({ runs: { steps: [{ uses: './.eas/functions/inner' }] }, @@ -128,9 +120,9 @@ describe(buildCompositeFunctionCatalogFromStepsAsync, () => { }); it('loads each action once even when references are cyclic (cycles are reported at expansion time)', async () => { - const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + const catalog = await buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: './.eas/functions/a', id: 'a' }], - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { if (compositeFunctionPath === './.eas/functions/a') { return CompositeFunctionConfigZ.parse({ runs: { steps: [{ uses: './.eas/functions/b' }] }, @@ -152,9 +144,9 @@ describe(buildCompositeFunctionCatalogFromStepsAsync, () => { const chainLength = 10; const paths = Array.from({ length: chainLength }, (_, index) => `./.eas/functions/a${index}`); - const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + const catalog = await buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: paths[0], id: 'root' }], - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { const index = paths.indexOf(compositeFunctionPath); if (index === -1) { throw new Error(`missing ${compositeFunctionPath}`); @@ -173,9 +165,9 @@ describe(buildCompositeFunctionCatalogFromStepsAsync, () => { it('collects normalized action paths from steps', async () => { const loadedPaths: string[] = []; - await buildCompositeFunctionCatalogFromStepsAsync({ + await buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: './.eas/functions/setup/' }, { uses: 'eas/build' }, { run: 'echo hi' }], - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { loadedPaths.push(compositeFunctionPath); return CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }); }, @@ -185,9 +177,9 @@ describe(buildCompositeFunctionCatalogFromStepsAsync, () => { it('rejects interpolated local composite function paths', async () => { await expect( - buildCompositeFunctionCatalogFromStepsAsync({ + buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: './.eas/functions/${{ inputs.name }}' }], - loadCompositeFunction: async () => + loadLocalFunction: async () => CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }), }) ).rejects.toThrow(/must not contain interpolation/); @@ -195,9 +187,9 @@ describe(buildCompositeFunctionCatalogFromStepsAsync, () => { it('rejects working_directory on a root step that calls a local composite function', async () => { await expect( - buildCompositeFunctionCatalogFromStepsAsync({ + buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: './.eas/functions/setup', working_directory: 'packages/app' }], - loadCompositeFunction: async () => + loadLocalFunction: async () => CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }), }) ).rejects.toThrow(/"working_directory" is not supported on a step that calls/); @@ -205,9 +197,9 @@ describe(buildCompositeFunctionCatalogFromStepsAsync, () => { it('rejects working_directory on a nested step that calls a local composite function', async () => { await expect( - buildCompositeFunctionCatalogFromStepsAsync({ + buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: './.eas/functions/outer' }], - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { if (compositeFunctionPath === './.eas/functions/outer') { return CompositeFunctionConfigZ.parse({ runs: { @@ -222,26 +214,26 @@ describe(buildCompositeFunctionCatalogFromStepsAsync, () => { }); it('allows working_directory on a step that calls a function, not a local composite function', async () => { - const catalog = await buildCompositeFunctionCatalogFromStepsAsync({ + const catalog = await buildLocalFunctionCatalogFromStepsAsync({ rootSteps: [{ uses: 'eas/build', working_directory: 'packages/app' }], - loadCompositeFunction: async () => + loadLocalFunction: async () => CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }), }); expect(Object.keys(catalog)).toEqual([]); }); }); -describe(extendCompositeFunctionCatalogFromStepsAsync, () => { +describe(extendLocalFunctionCatalogFromStepsAsync, () => { it('extends the given catalog in place', async () => { const catalog = { './.eas/functions/existing': CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo existing' }] }, }), }; - await extendCompositeFunctionCatalogFromStepsAsync({ + await extendLocalFunctionCatalogFromStepsAsync({ catalog, rootSteps: [{ uses: './.eas/functions/setup' }], - loadCompositeFunction: async () => + loadLocalFunction: async () => CompositeFunctionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } }), }); expect(Object.keys(catalog).sort()).toEqual([ @@ -257,10 +249,10 @@ describe(extendCompositeFunctionCatalogFromStepsAsync, () => { runs: { steps: [{ run: 'echo setup' }] }, }), }; - await extendCompositeFunctionCatalogFromStepsAsync({ + await extendLocalFunctionCatalogFromStepsAsync({ catalog, rootSteps: [{ uses: './.eas/functions/setup' }], - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { loadedPaths.push(compositeFunctionPath); throw new Error(`must not be called: ${compositeFunctionPath}`); }, @@ -271,10 +263,10 @@ describe(extendCompositeFunctionCatalogFromStepsAsync, () => { it('recurses into nested references', async () => { const catalog = {}; - await extendCompositeFunctionCatalogFromStepsAsync({ + await extendLocalFunctionCatalogFromStepsAsync({ catalog, rootSteps: [{ uses: './.eas/functions/outer' }], - loadCompositeFunction: async compositeFunctionPath => { + loadLocalFunction: async compositeFunctionPath => { if (compositeFunctionPath === './.eas/functions/outer') { return CompositeFunctionConfigZ.parse({ runs: { steps: [{ uses: './.eas/functions/inner' }] }, @@ -290,29 +282,29 @@ describe(extendCompositeFunctionCatalogFromStepsAsync, () => { }); }); -describe(resolveLocalCompositeFunctionPath, () => { +describe(resolveLocalFunctionPath, () => { const projectRoot = path.resolve('/tmp/project'); it('resolves a path under the conventional .eas/functions directory', () => { - expect(resolveLocalCompositeFunctionPath(projectRoot, './.eas/functions/setup')).toBe( + expect(resolveLocalFunctionPath(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( + expect(resolveLocalFunctionPath(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( + expect(resolveLocalFunctionPath(projectRoot, '../shared-actions/deploy')).toBe( path.resolve(projectRoot, '../shared-actions/deploy') ); }); }); -describe(loadLocalCompositeFunctionConfigAsync, () => { +describe(loadLocalFunctionConfigAsync, () => { it('loads and validates a function.yml file', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-')); await makeCompositeFunctionAsync( @@ -321,10 +313,7 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { ['name: Setup', 'runs:', ' steps:', ' - run: echo setup'].join('\n') ); - const config = await loadLocalCompositeFunctionConfigAsync( - projectRoot, - './.eas/functions/setup' - ); + const config = await loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/setup'); expect(config.name).toBe('Setup'); expect(config.runs.steps).toHaveLength(1); @@ -339,10 +328,7 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { { fileName: 'function.yaml' } ); - const config = await loadLocalCompositeFunctionConfigAsync( - projectRoot, - './.eas/functions/setup' - ); + const config = await loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/setup'); expect(config.runs.steps).toHaveLength(1); }); @@ -361,10 +347,7 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { { fileName: 'function.yaml' } ); - const config = await loadLocalCompositeFunctionConfigAsync( - projectRoot, - './.eas/functions/setup' - ); + const config = await loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/setup'); expect(config.name).toBe('FromYml'); }); @@ -373,7 +356,7 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-')); await expect( - loadLocalCompositeFunctionConfigAsync(projectRoot, './.eas/functions/missing') + loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/missing') ).rejects.toThrow( /Local composite function "\.\/\.eas\/functions\/missing" was referenced by a step but no such composite function exists/ ); @@ -383,12 +366,12 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-')); await makeCompositeFunctionAsync(projectRoot, 'broken', 'runs: [unclosed'); - const error: Error = await loadLocalCompositeFunctionConfigAsync( + const error: Error = await loadLocalFunctionConfigAsync( projectRoot, './.eas/functions/broken' ).then( () => { - throw new Error('expected loadLocalCompositeFunctionConfigAsync to throw'); + throw new Error('expected loadLocalFunctionConfigAsync to throw'); }, err => err ); @@ -408,7 +391,7 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { ); await expect( - loadLocalCompositeFunctionConfigAsync(projectRoot, './.eas/functions/broken') + loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/broken') ).rejects.toThrow( /Invalid composite function "\.\/\.eas\/functions\/broken": .*must declare at least one step under "runs\.steps"/s ); @@ -421,12 +404,12 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { recursive: true, }); - const error: Error = await loadLocalCompositeFunctionConfigAsync( + const error: Error = await loadLocalFunctionConfigAsync( projectRoot, './.eas/functions/setup' ).then( () => { - throw new Error('expected loadLocalCompositeFunctionConfigAsync to throw'); + throw new Error('expected loadLocalFunctionConfigAsync to throw'); }, err => err ); @@ -453,11 +436,8 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { 'utf-8' ); - const underRoot = await loadLocalCompositeFunctionConfigAsync( - projectRoot, - './.eas/functions/setup' - ); - const aboveRoot = await loadLocalCompositeFunctionConfigAsync( + const underRoot = await loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/setup'); + const aboveRoot = await loadLocalFunctionConfigAsync( projectRoot, '../../shared/functions/deploy' ); @@ -475,7 +455,7 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { ); const logger = { debug: jest.fn() }; - await loadLocalCompositeFunctionConfigAsync(projectRoot, './.eas/functions/setup', { logger }); + await loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/setup', { logger }); expect(logger.debug).toHaveBeenCalledWith( `Loaded local composite function "./.eas/functions/setup" from ${path.join( @@ -488,7 +468,7 @@ describe(loadLocalCompositeFunctionConfigAsync, () => { }); }); -describe(createLocalCompositeFunctionLoader, () => { +describe(createLocalFunctionLoader, () => { it('loads function.yml from disk for a normalized path', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-loader-')); await makeCompositeFunctionAsync( @@ -497,7 +477,7 @@ describe(createLocalCompositeFunctionLoader, () => { ['name: Setup', 'runs:', ' steps:', ' - run: echo setup'].join('\n') ); - const loader = createLocalCompositeFunctionLoader(projectRoot); + const loader = createLocalFunctionLoader(projectRoot); const config = await loader('./.eas/functions/setup'); expect(config.name).toBe('Setup'); @@ -507,7 +487,7 @@ describe(createLocalCompositeFunctionLoader, () => { it('rejects for a path with no composite function on disk', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-loader-')); - const loader = createLocalCompositeFunctionLoader(projectRoot); + const loader = createLocalFunctionLoader(projectRoot); await expect(loader('./.eas/functions/missing')).rejects.toThrow( /no such composite function exists/ @@ -515,7 +495,7 @@ describe(createLocalCompositeFunctionLoader, () => { }); }); -describe(buildLocalCompositeFunctionCatalogAsync, () => { +describe(buildLocalFunctionCatalogAsync, () => { it('builds a catalog keyed by normalized ref, loading transitively nested functions from disk', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-')); await makeCompositeFunctionAsync( @@ -529,7 +509,7 @@ describe(buildLocalCompositeFunctionCatalogAsync, () => { ['runs:', ' steps:', ' - run: echo inner'].join('\n') ); - const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, { + const catalog = await buildLocalFunctionCatalogAsync(projectRoot, { rootSteps: [{ uses: './.eas/functions/outer/', id: 'outer' }], }); @@ -549,7 +529,7 @@ describe(buildLocalCompositeFunctionCatalogAsync, () => { 'utf-8' ); - const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, { + const catalog = await buildLocalFunctionCatalogAsync(projectRoot, { rootSteps: [{ uses: './internal-functions/deploy' }], }); @@ -564,7 +544,7 @@ describe(buildLocalCompositeFunctionCatalogAsync, () => { ['runs:', ' steps:', ' - uses: ./.eas/functions/loop'].join('\n') ); - const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, { + const catalog = await buildLocalFunctionCatalogAsync(projectRoot, { rootSteps: [{ uses: './.eas/functions/loop' }], }); @@ -589,7 +569,7 @@ describe(buildLocalCompositeFunctionCatalogAsync, () => { ['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n') ); - const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, { + const catalog = await buildLocalFunctionCatalogAsync(projectRoot, { rootSteps: [{ uses: './.eas/functions/left' }, { uses: './.eas/functions/right' }], }); @@ -608,7 +588,7 @@ describe(buildLocalCompositeFunctionCatalogAsync, () => { ['name: Broken', 'runs:', ' steps: []'].join('\n') ); - const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, { + const catalog = await buildLocalFunctionCatalogAsync(projectRoot, { rootSteps: [{ run: 'echo hi' }], }); diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index 1ce40e718f..73ea155394 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -16,15 +16,15 @@ import { BuildConfigError } from '../errors'; const JOB_CONTEXT_INTERPOLATION_REGEXP = /\$\{\{(.+?)\}\}/; -function doesLocalCompositeFunctionPathRequireInterpolation(uses: string): boolean { +function doesLocalFunctionPathRequireInterpolation(uses: string): boolean { return JOB_CONTEXT_INTERPOLATION_REGEXP.test(uses); } -export function parseLocalCompositeFunctionPath(uses: string): string { +export function parseLocalFunctionPath(uses: string): string { const trimmed = uses.trim(); - // The composite function catalog is built before the workflow runs, so a local composite function path must be + // The local function catalog is built before the workflow runs, so a local function path must be // known statically. - if (doesLocalCompositeFunctionPathRequireInterpolation(trimmed)) { + if (doesLocalFunctionPathRequireInterpolation(trimmed)) { throw new BuildConfigError( `Local composite function path "${trimmed}" must not contain interpolation ("\${{ ... }}"). The "uses" path for a local composite function must be a static, literal path.` ); @@ -41,77 +41,74 @@ export function parseLocalCompositeFunctionPath(uses: string): string { return `./${normalized}`; } -export function isLocalCompositeFunctionPath(uses: string): boolean { +export function isLocalFunctionPath(uses: string): boolean { const trimmed = uses.trim(); return trimmed.startsWith('./') || trimmed.startsWith('../'); } -export function getLocalCompositeFunctionCallWorkingDirectoryError(uses: string): string { +export function getLocalFunctionCallWorkingDirectoryError(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({ +export async function buildLocalFunctionCatalogFromStepsAsync({ rootSteps, - loadCompositeFunction, + loadLocalFunction, }: { rootSteps: readonly Step[]; - loadCompositeFunction: (compositeFunctionPath: string) => Promise; + loadLocalFunction: (functionPath: string) => Promise; }): Promise { const catalog: LocalFunctionCatalog = {}; - await extendCompositeFunctionCatalogFromStepsAsync({ catalog, rootSteps, loadCompositeFunction }); + await extendLocalFunctionCatalogFromStepsAsync({ catalog, rootSteps, loadLocalFunction }); return catalog; } /** Extends `catalog` in place with functions transitively referenced by `rootSteps`. Skips paths already present. */ -export async function extendCompositeFunctionCatalogFromStepsAsync({ +export async function extendLocalFunctionCatalogFromStepsAsync({ catalog, rootSteps, - loadCompositeFunction, + loadLocalFunction, }: { catalog: LocalFunctionCatalog; rootSteps: readonly Step[]; - loadCompositeFunction: (compositeFunctionPath: string) => Promise; + loadLocalFunction: (functionPath: string) => Promise; }): Promise { - const loadRecursiveAsync = async (compositeFunctionPath: string): Promise => { - if (compositeFunctionPath in catalog) { + const loadRecursiveAsync = async (functionPath: string): Promise => { + if (functionPath in catalog) { return; } - const config = await loadCompositeFunction(compositeFunctionPath); - catalog[compositeFunctionPath] = config; + const config = await loadLocalFunction(functionPath); + catalog[functionPath] = config; - for (const nestedPath of collectLocalCompositeFunctionPathsFromSteps(config.runs.steps)) { + for (const nestedPath of collectLocalFunctionPathsFromSteps(config.runs.steps)) { await loadRecursiveAsync(nestedPath); } }; - for (const compositeFunctionPath of collectLocalCompositeFunctionPathsFromSteps(rootSteps)) { - await loadRecursiveAsync(compositeFunctionPath); + for (const functionPath of collectLocalFunctionPathsFromSteps(rootSteps)) { + await loadRecursiveAsync(functionPath); } } -export function resolveLocalCompositeFunctionPath( - projectRoot: string, - compositeFunctionPath: string -): string { - return path.resolve(projectRoot, compositeFunctionPath); +export function resolveLocalFunctionPath(projectRoot: string, functionPath: string): string { + return path.resolve(projectRoot, functionPath); } -export interface LocalCompositeFunctionLogger { +export interface LocalFunctionLogger { debug(message: string): void; } /** * Reads and validates the function.yml (or function.yaml) file of a local composite function. - * `compositeFunctionPath` is the normalized ref returned by {@link parseLocalCompositeFunctionPath}. + * `functionPath` is the normalized ref returned by {@link parseLocalFunctionPath}. */ -export async function loadLocalCompositeFunctionConfigAsync( +export async function loadLocalFunctionConfigAsync( projectRoot: string, - compositeFunctionPath: string, - { logger }: { logger?: LocalCompositeFunctionLogger } = {} + functionPath: string, + { logger }: { logger?: LocalFunctionLogger } = {} ): Promise { - const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath); + const resolvedPath = resolveLocalFunctionPath(projectRoot, functionPath); for (const ext of ['yml', 'yaml'] as const) { const absolutePath = path.join(resolvedPath, `function.${ext}`); @@ -123,7 +120,7 @@ export async function loadLocalCompositeFunctionConfigAsync( continue; } throw new Error( - `Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`, + `Failed to read local composite function "${functionPath}" from ${absolutePath}`, { cause: err as Error, } @@ -135,7 +132,7 @@ export async function loadLocalCompositeFunctionConfigAsync( parsed = YAML.parse(rawContents); } catch (err) { throw new Error( - `Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`, + `Failed to parse local composite function "${functionPath}" YAML at ${absolutePath}`, { cause: err as Error, } @@ -145,49 +142,49 @@ export async function loadLocalCompositeFunctionConfigAsync( const result = CompositeFunctionConfigZ.safeParse(parsed); if (!result.success) { throw new Error( - `Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(result.error)}` + `Invalid composite function "${functionPath}": ${z.prettifyError(result.error)}` ); } logger?.debug( - `Loaded local composite function "${compositeFunctionPath}" from ${path.relative(projectRoot, absolutePath)}` + `Loaded local composite function "${functionPath}" from ${path.relative(projectRoot, absolutePath)}` ); return result.data; } 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/".` + `Local composite function "${functionPath}" 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: ${functionPath}" resolves "${functionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/".` ); } /** Loader for the lazy hook path: bound to a project root, passed to {@link StepsConfigParser}. */ -export function createLocalCompositeFunctionLoader( +export function createLocalFunctionLoader( projectRoot: string, - { logger }: { logger?: LocalCompositeFunctionLogger } = {} -): (compositeFunctionPath: string) => Promise { - return async compositeFunctionPath => - await loadLocalCompositeFunctionConfigAsync(projectRoot, compositeFunctionPath, { logger }); + { logger }: { logger?: LocalFunctionLogger } = {} +): (functionPath: string) => Promise { + return async functionPath => + await loadLocalFunctionConfigAsync(projectRoot, functionPath, { logger }); } /** Builds the catalog of composite functions transitively referenced by `rootSteps`, loading each from disk. */ -export async function buildLocalCompositeFunctionCatalogAsync( +export async function buildLocalFunctionCatalogAsync( projectRoot: string, - { rootSteps, logger }: { rootSteps: readonly Step[]; logger?: LocalCompositeFunctionLogger } + { rootSteps, logger }: { rootSteps: readonly Step[]; logger?: LocalFunctionLogger } ): Promise { - return await buildCompositeFunctionCatalogFromStepsAsync({ + return await buildLocalFunctionCatalogFromStepsAsync({ rootSteps, - loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, { logger }), + loadLocalFunction: createLocalFunctionLoader(projectRoot, { logger }), }); } -function collectLocalCompositeFunctionPathsFromSteps(steps: readonly Step[]): Set { +function collectLocalFunctionPathsFromSteps(steps: readonly Step[]): Set { const paths = new Set(); for (const step of steps) { - if (step.uses !== undefined && isLocalCompositeFunctionPath(step.uses)) { + if (step.uses !== undefined && isLocalFunctionPath(step.uses)) { if (step.working_directory !== undefined) { - throw new BuildConfigError(getLocalCompositeFunctionCallWorkingDirectoryError(step.uses)); + throw new BuildConfigError(getLocalFunctionCallWorkingDirectoryError(step.uses)); } - paths.add(parseLocalCompositeFunctionPath(step.uses)); + paths.add(parseLocalFunctionPath(step.uses)); } } return paths; From 2164d1b1495f65140a5934068439a88284d3ace6 Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 5 Aug 2026 12:35:20 +0200 Subject: [PATCH 2/6] [steps] Extract parseBuildStepInputValueTypeName into a shared helper --- packages/steps/src/BuildStepInput.ts | 18 +++++++++++++- packages/steps/src/LocalFunctionExpander.ts | 27 ++++----------------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts index 2b7d61f505..7b315d3d34 100644 --- a/packages/steps/src/BuildStepInput.ts +++ b/packages/steps/src/BuildStepInput.ts @@ -2,7 +2,7 @@ import { JobInterpolationContext } from '@expo/eas-build-job'; import assert from 'assert'; import { BuildStepGlobalContext } from './BuildStepContext'; -import { BuildStepRuntimeError } from './errors'; +import { BuildConfigError, BuildStepRuntimeError } from './errors'; import { interpolateJobContext } from './interpolation'; import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, @@ -26,6 +26,22 @@ export type BuildStepInputValueType< ? number : Record; +/** Maps a local function input's declared `type` string onto the enum, shared by both function shapes. */ +export function parseBuildStepInputValueTypeName( + type: string, + { functionPath, inputName }: { functionPath: string; inputName: string } +): BuildStepInputValueTypeName { + const supported = Object.values(BuildStepInputValueTypeName) as string[]; + if (!supported.includes(type)) { + throw new BuildConfigError( + `Local function "${functionPath}" input "${inputName}" has unsupported type "${type}". Supported types: ${supported.join( + ', ' + )}.` + ); + } + return type as BuildStepInputValueTypeName; +} + export type BuildStepInputById = Record; export type BuildStepInputProvider = ( ctx: BuildStepGlobalContext, diff --git a/packages/steps/src/LocalFunctionExpander.ts b/packages/steps/src/LocalFunctionExpander.ts index bc843e9240..be58f4a5c3 100644 --- a/packages/steps/src/LocalFunctionExpander.ts +++ b/packages/steps/src/LocalFunctionExpander.ts @@ -26,8 +26,8 @@ import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; import { BuildStepInput, - BuildStepInputValueTypeName, getDisallowedInputValueError, + parseBuildStepInputValueTypeName, } from './BuildStepInput'; import { CompositeBuildStep } from './CompositeBuildStep'; import { BuildConfigError } from './errors'; @@ -419,11 +419,10 @@ export class LocalFunctionExpander { defaultValue: definition.defaultValue, required: definition.required, allowedValues: definition.allowedValues, - allowedValueTypeName: this.toInputValueTypeName( - definition.type, - compositeFunctionPath, - definition.name - ), + allowedValueTypeName: parseBuildStepInputValueTypeName(definition.type, { + functionPath: compositeFunctionPath, + inputName: definition.name, + }), }); if (callWith && Object.prototype.hasOwnProperty.call(callWith, definition.name)) { const value = callWith[definition.name]; @@ -447,20 +446,4 @@ export class LocalFunctionExpander { } return { inputs, providedInputKeys }; } - - private toInputValueTypeName( - type: string, - compositeFunctionPath: string, - inputName: string - ): BuildStepInputValueTypeName { - const supported = Object.values(BuildStepInputValueTypeName) as string[]; - if (!supported.includes(type)) { - throw new BuildConfigError( - `Composite function "${compositeFunctionPath}" input "${inputName}" has unsupported type "${type}". Supported types: ${supported.join( - ', ' - )}.` - ); - } - return type as BuildStepInputValueTypeName; - } } From 6c9416309598d89a623515e5131f0d10fb3f204d Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 5 Aug 2026 12:35:27 +0200 Subject: [PATCH 3/6] [steps] Skip recursing into single-step local functions when building the catalog --- .../__tests__/localCompositeFunctions-test.ts | 71 ++++++++++++++++++- .../src/utils/localCompositeFunctions.ts | 40 +++++++++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts index ac00dc4a7d..8ced9f5874 100644 --- a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts +++ b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts @@ -1,4 +1,4 @@ -import { CompositeFunctionConfigZ } from '@expo/eas-build-job'; +import { CompositeFunctionConfigZ, LocalFunctionConfigZ } from '@expo/eas-build-job'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -11,6 +11,7 @@ import { isLocalFunctionPath, loadLocalFunctionConfigAsync, parseLocalFunctionPath, + resolveLegacyFunctionModulePath, resolveLocalFunctionPath, } from '../localCompositeFunctions'; @@ -163,6 +164,39 @@ describe(buildLocalFunctionCatalogFromStepsAsync, () => { expect(Object.keys(catalog).sort()).toEqual(paths.sort()); }); + it('loads a single-step function without recursing into it', async () => { + const loadedPaths: string[] = []; + const catalog = await buildLocalFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/say-hi' }], + loadLocalFunction: async compositeFunctionPath => { + loadedPaths.push(compositeFunctionPath); + return LocalFunctionConfigZ.parse({ command: 'echo hi' }); + }, + }); + + expect(loadedPaths).toEqual(['./.eas/functions/say-hi']); + expect(Object.keys(catalog)).toEqual(['./.eas/functions/say-hi']); + }); + + it('loads a single-step function referenced from inside a composite function', async () => { + const catalog = await buildLocalFunctionCatalogFromStepsAsync({ + rootSteps: [{ uses: './.eas/functions/outer' }], + loadLocalFunction: async compositeFunctionPath => { + if (compositeFunctionPath === './.eas/functions/outer') { + return LocalFunctionConfigZ.parse({ + runs: { steps: [{ uses: './.eas/functions/say-hi' }] }, + }); + } + return LocalFunctionConfigZ.parse({ command: 'echo hi' }); + }, + }); + + expect(Object.keys(catalog).sort()).toEqual([ + './.eas/functions/outer', + './.eas/functions/say-hi', + ]); + }); + it('collects normalized action paths from steps', async () => { const loadedPaths: string[] = []; await buildLocalFunctionCatalogFromStepsAsync({ @@ -595,3 +629,38 @@ describe(buildLocalFunctionCatalogAsync, () => { expect(catalog).toEqual({}); }); }); + +describe(resolveLegacyFunctionModulePath, () => { + const projectRoot = path.resolve('/tmp/project'); + + it('resolves a relative module path against the function directory', () => { + expect( + resolveLegacyFunctionModulePath({ + projectRoot, + functionPath: './.eas/functions/say-hi', + modulePath: './my-function', + }) + ).toBe(path.join(projectRoot, '.eas', 'functions', 'say-hi', 'my-function')); + }); + + it('resolves a module path pointing outside the function directory', () => { + expect( + resolveLegacyFunctionModulePath({ + projectRoot, + functionPath: './.eas/functions/say-hi', + modulePath: '../shared/my-function', + }) + ).toBe(path.join(projectRoot, '.eas', 'functions', 'shared', 'my-function')); + }); + + it('passes an absolute module path through', () => { + const absolutePath = path.resolve('/opt/functions/say-hi'); + expect( + resolveLegacyFunctionModulePath({ + projectRoot, + functionPath: './.eas/functions/say-hi', + modulePath: absolutePath, + }) + ).toBe(absolutePath); + }); +}); diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index 73ea155394..26720dbc97 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -2,7 +2,9 @@ import { CompositeFunctionConfig, CompositeFunctionConfigZ, LocalFunctionCatalog, + LocalFunctionConfig, Step, + isLegacyFunctionConfig, } from '@expo/eas-build-job'; import fs from 'fs/promises'; import path from 'path'; @@ -11,7 +13,7 @@ import { z } from 'zod'; import { BuildConfigError } from '../errors'; -// Local composite functions referenced via `uses: ./path` or `uses: ../path` in EAS workflows. +// Local functions referenced via `uses: ./path` or `uses: ../path` in EAS workflows. // Not supported in `.eas/build/*.yml` custom build configs. const JOB_CONTEXT_INTERPOLATION_REGEXP = /\$\{\{(.+?)\}\}/; @@ -47,16 +49,16 @@ export function isLocalFunctionPath(uses: string): boolean { } export function getLocalFunctionCallWorkingDirectoryError(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.`; + return `"working_directory" is not supported on a step that calls a local function ("uses: ${uses.trim()}"). For a composite function, set "working_directory" on the steps inside it; for a single-step "command" function, change directories inside the command.`; } -/** Loads only composite functions transitively referenced by `rootSteps`. Unreferenced files are ignored. */ +/** Loads only functions transitively referenced by `rootSteps`. Unreferenced files are ignored. */ export async function buildLocalFunctionCatalogFromStepsAsync({ rootSteps, loadLocalFunction, }: { rootSteps: readonly Step[]; - loadLocalFunction: (functionPath: string) => Promise; + loadLocalFunction: (functionPath: string) => Promise; }): Promise { const catalog: LocalFunctionCatalog = {}; await extendLocalFunctionCatalogFromStepsAsync({ catalog, rootSteps, loadLocalFunction }); @@ -71,7 +73,7 @@ export async function extendLocalFunctionCatalogFromStepsAsync({ }: { catalog: LocalFunctionCatalog; rootSteps: readonly Step[]; - loadLocalFunction: (functionPath: string) => Promise; + loadLocalFunction: (functionPath: string) => Promise; }): Promise { const loadRecursiveAsync = async (functionPath: string): Promise => { if (functionPath in catalog) { @@ -81,6 +83,10 @@ export async function extendLocalFunctionCatalogFromStepsAsync({ const config = await loadLocalFunction(functionPath); catalog[functionPath] = config; + // Single-step functions are leaves: they have no steps that could reference other functions. + if (isLegacyFunctionConfig(config)) { + return; + } for (const nestedPath of collectLocalFunctionPathsFromSteps(config.runs.steps)) { await loadRecursiveAsync(nestedPath); } @@ -95,12 +101,32 @@ export function resolveLocalFunctionPath(projectRoot: string, functionPath: stri return path.resolve(projectRoot, functionPath); } +/** + * Resolves the `path` of a single-step local function against the function's own directory, the + * way a `.eas/build` config resolves it against the config file. Shared by every loader so the + * two cannot drift. + */ +export function resolveLegacyFunctionModulePath({ + projectRoot, + functionPath, + modulePath, +}: { + projectRoot: string; + functionPath: string; + modulePath: string; +}): string { + if (path.isAbsolute(modulePath)) { + return modulePath; + } + return path.resolve(resolveLocalFunctionPath(projectRoot, functionPath), modulePath); +} + export interface LocalFunctionLogger { debug(message: string): void; } /** - * Reads and validates the function.yml (or function.yaml) file of a local composite function. + * Reads and validates the function.yml (or function.yaml) file of a local function. * `functionPath` is the normalized ref returned by {@link parseLocalFunctionPath}. */ export async function loadLocalFunctionConfigAsync( @@ -166,7 +192,7 @@ export function createLocalFunctionLoader( await loadLocalFunctionConfigAsync(projectRoot, functionPath, { logger }); } -/** Builds the catalog of composite functions transitively referenced by `rootSteps`, loading each from disk. */ +/** Builds the catalog of local functions transitively referenced by `rootSteps`, loading each from disk. */ export async function buildLocalFunctionCatalogAsync( projectRoot: string, { rootSteps, logger }: { rootSteps: readonly Step[]; logger?: LocalFunctionLogger } From 5d10a8bc17cf6c9a825e2f06f3005d22ee8925b1 Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 5 Aug 2026 12:35:30 +0200 Subject: [PATCH 4/6] [steps] Add createBuildFunctionFromLegacyFunctionConfig --- packages/steps/src/utils/legacyFunction.ts | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/steps/src/utils/legacyFunction.ts diff --git a/packages/steps/src/utils/legacyFunction.ts b/packages/steps/src/utils/legacyFunction.ts new file mode 100644 index 0000000000..62cb99bea4 --- /dev/null +++ b/packages/steps/src/utils/legacyFunction.ts @@ -0,0 +1,70 @@ +/** + * Maps a single-step local function config (`command` or `path` in `function.yml`, the shape + * custom build functions use in `.eas/build/*.yml`) onto a {@link BuildFunction}, so calling one + * from a workflow runs it exactly like a custom build does. + * + * Legacy semantics are preserved: inputs and outputs are required unless declared otherwise, + * which is the opposite of the composite function default. + */ +import { LegacyFunctionConfig } from '@expo/eas-build-job'; + +import { BuildFunction } from '../BuildFunction'; +import { BuildRuntimePlatform } from '../BuildRuntimePlatform'; +import { + BuildStepInput, + BuildStepInputProvider, + BuildStepInputValueTypeName, + parseBuildStepInputValueTypeName, +} from '../BuildStepInput'; +import { BuildStepOutput, BuildStepOutputProvider } from '../BuildStepOutput'; + +type LegacyFunctionInput = NonNullable[number]; +type LegacyFunctionOutput = NonNullable[number]; + +/** `config.path`, when set, must already be absolute (the loaders resolve it). */ +export function createBuildFunctionFromLegacyFunctionConfig( + functionPath: string, + config: LegacyFunctionConfig +): BuildFunction { + return new BuildFunction({ + id: functionPath, + name: config.name, + command: config.command, + customFunctionModulePath: config.path, + shell: config.shell, + supportedRuntimePlatforms: config.supported_platforms?.map( + platform => platform satisfies `${BuildRuntimePlatform}` as BuildRuntimePlatform + ), + inputProviders: config.inputs?.map(input => createInputProvider(input, functionPath)), + outputProviders: config.outputs?.map(createOutputProvider), + }); +} + +function createInputProvider( + input: LegacyFunctionInput, + functionPath: string +): BuildStepInputProvider { + if (typeof input === 'string') { + return BuildStepInput.createProvider({ + id: input, + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + } + return BuildStepInput.createProvider({ + id: input.name, + required: input.required ?? true, + defaultValue: input.default_value, + allowedValues: input.allowed_values, + allowedValueTypeName: parseBuildStepInputValueTypeName(input.type, { + functionPath, + inputName: input.name, + }), + }); +} + +function createOutputProvider(output: LegacyFunctionOutput): BuildStepOutputProvider { + return typeof output === 'string' + ? BuildStepOutput.createProvider({ id: output, required: true }) + : BuildStepOutput.createProvider({ id: output.name, required: output.required ?? true }); +} From efa0f83a353a36e965f44c6fd169229e298623e1 Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 5 Aug 2026 12:35:50 +0200 Subject: [PATCH 5/6] [steps] Expand legacy command/path local functions to a single build step --- packages/steps/src/LocalFunctionExpander.ts | 92 +++-- packages/steps/src/StepsConfigParser.ts | 12 +- ...rser-composite-functions-expansion-test.ts | 2 +- ...igParser-composite-functions-test-utils.ts | 4 +- .../__tests__/StepsConfigParser-hooks-test.ts | 25 ++ ...StepsConfigParser-legacy-functions-test.ts | 383 ++++++++++++++++++ packages/steps/src/hooks.ts | 12 +- 7 files changed, 485 insertions(+), 45 deletions(-) create mode 100644 packages/steps/src/__tests__/StepsConfigParser-legacy-functions-test.ts diff --git a/packages/steps/src/LocalFunctionExpander.ts b/packages/steps/src/LocalFunctionExpander.ts index be58f4a5c3..00d3047c42 100644 --- a/packages/steps/src/LocalFunctionExpander.ts +++ b/packages/steps/src/LocalFunctionExpander.ts @@ -1,16 +1,18 @@ /** - * Expands local composite functions (`uses: ./path/to/function`) into a - * {@link CompositeBuildStep} tree at parse time. + * Expands local functions (`uses: ./path/to/function`) into build steps at parse time. * - * Each call becomes a node with prefixed child ids (`caller__inner`); the parser - * flattens the tree into the workflow. Expanded steps carry a - * {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and `${{ inputs.* }}` - * resolve against composite-function-local names. + * A composite function call becomes a {@link CompositeBuildStep} node with prefixed child ids + * (`caller__inner`); the parser flattens the tree into the workflow. Expanded steps carry a + * {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and `${{ inputs.* }}` resolve + * against composite-function-local names. A single-step (`command`/`path`) function call becomes + * one ordinary build step keeping the caller's own id. */ import { CompositeFunctionConfig, FunctionStep, + LegacyFunctionConfig, LocalFunctionCatalog, + LocalFunctionConfig, ShellStep, Step, isLegacyFunctionConfig, @@ -32,6 +34,7 @@ import { import { CompositeBuildStep } from './CompositeBuildStep'; import { BuildConfigError } from './errors'; import { duplicates } from './utils/expodash/duplicates'; +import { createBuildFunctionFromLegacyFunctionConfig } from './utils/legacyFunction'; import { getLocalFunctionCallWorkingDirectoryError, isLocalFunctionPath, @@ -48,10 +51,10 @@ export type FunctionMaps = { type LocalFunctionCall = { functionPath: string; - /** Caller-assigned id used as prefix for all inner step ids. */ + /** Caller-assigned id; for a composite function also the prefix of all inner step ids. */ syntheticStepId: string; name?: string; - /** Caller-provided input values, consumed when composite function inputs are interpolated. */ + /** Caller-provided input values, consumed when function inputs are interpolated. */ callWith?: Record; callIf?: string; parentScope?: BuildStepCompositeFunctionScope; @@ -79,13 +82,14 @@ export class LocalFunctionExpander { return this.functionMaps.buildFunctionGroupById; } + /** Steps a local function call expands to: many for a composite function, one otherwise. */ public expandLocalFunctionStep( step: FunctionStep, functionPath: string, syntheticStepId: string - ): CompositeBuildStep { + ): BuildStep[] { this.rejectLocalFunctionCallWorkingDirectory(step); - return this.expand( + const expanded = this.expandCall( { functionPath, syntheticStepId, @@ -96,6 +100,7 @@ export class LocalFunctionExpander { }, new Set() ); + return expanded instanceof CompositeBuildStep ? expanded.getFlattenedSteps() : [expanded]; } // The call step expands away; `working_directory` on it would never apply. @@ -105,12 +110,52 @@ export class LocalFunctionExpander { } } + private expandCall(call: LocalFunctionCall, visited: ReadonlySet): BuildStep { + const localFunction = this.lookupLocalFunction(call.functionPath); + if (isLegacyFunctionConfig(localFunction)) { + return this.createLegacyFunctionStep(call, localFunction); + } + return this.expand(call, localFunction, visited); + } + + /** + * A single-step function keeps the caller's id and takes the call-site `if` as its own + * condition; there is no expansion scope to hang it on. + */ + private createLegacyFunctionStep( + call: LocalFunctionCall, + config: LegacyFunctionConfig + ): BuildStep { + const { functionPath } = call; + // `buildFunctionById` doubles as the per-path cache: a `BuildFunction` is stateless, and + // registering it there puts the function in `workflow.buildFunctions`, so module validation + // and telemetry cover it. Legacy keys are `./`-prefixed paths, so they cannot collide with + // registered function ids. + let buildFunction = this.functionMaps.buildFunctionById[functionPath]; + if (!buildFunction) { + buildFunction = createBuildFunctionFromLegacyFunctionConfig(functionPath, config); + this.functionMaps.buildFunctionById[functionPath] = buildFunction; + } + return buildFunction.createBuildStepFromFunctionCall(this.ctx, { + id: call.syntheticStepId, + name: call.name ?? config.name ?? functionPath, + callInputs: call.callWith, + shell: config.shell, + env: call.inheritedEnv, + ifCondition: call.callIf, + compositeFunctionScope: call.parentScope, + }); + } + // `visited.size` is the current composite function nesting depth. - private expand(call: LocalFunctionCall, visited: ReadonlySet): CompositeBuildStep { + private expand( + call: LocalFunctionCall, + compositeFunction: CompositeFunctionConfig, + visited: ReadonlySet + ): CompositeBuildStep { const { functionPath: compositeFunctionPath, syntheticStepId } = call; this.guardAgainstRunawayRecursion(compositeFunctionPath, visited); - const compositeFunction = this.lookupLocalFunction(compositeFunctionPath); const compositeFunctionDisplayName = call.name ?? compositeFunction.name ?? compositeFunctionPath; const innerSteps = compositeFunction.runs.steps; @@ -185,23 +230,14 @@ export class LocalFunctionExpander { } } - private lookupLocalFunction(compositeFunctionPath: string): CompositeFunctionConfig { - // The catalog can hold either shape, but this expander does not dispatch on legacy - // functions yet, so a lookup here is always the composite shape. - const compositeFunction = this.localFunctionCatalog[compositeFunctionPath] as - | CompositeFunctionConfig - | undefined; - if (!compositeFunction) { + private lookupLocalFunction(functionPath: string): LocalFunctionConfig { + const localFunction = this.localFunctionCatalog[functionPath]; + if (!localFunction) { throw new BuildConfigError( - `Local composite function "${compositeFunctionPath}" does not exist. Expected a "function.yml" (or "function.yaml") file at "${compositeFunctionPath}" relative to the EAS project root (convention: ".eas/functions/").` + `Local function "${functionPath}" does not exist. Expected a "function.yml" (or "function.yaml") file at "${functionPath}" relative to the EAS project root (convention: ".eas/functions/").` ); } - if (isLegacyFunctionConfig(compositeFunction)) { - throw new BuildConfigError( - `Local function "${compositeFunctionPath}" uses the legacy command/path shape, which this expander does not support yet.` - ); - } - return compositeFunction; + return localFunction; } private expandInnerStep( @@ -268,8 +304,8 @@ export class LocalFunctionExpander { scope: BuildStepCompositeFunctionScope; visited: ReadonlySet; } - ): CompositeBuildStep { - return this.expand( + ): BuildStep { + return this.expandCall( { functionPath, syntheticStepId: newId, diff --git a/packages/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts index c66e3100e7..6b83983cd1 100644 --- a/packages/steps/src/StepsConfigParser.ts +++ b/packages/steps/src/StepsConfigParser.ts @@ -347,13 +347,11 @@ export class StepsConfigParser extends AbstractConfigParser { localFunctionExpander: LocalFunctionExpander ): BuildStep[] { if (isLocalFunctionPath(step.uses)) { - return localFunctionExpander - .expandLocalFunctionStep( - step, - parseLocalFunctionPath(step.uses), - BuildStep.getNewId(step.id) - ) - .getFlattenedSteps(); + return localFunctionExpander.expandLocalFunctionStep( + step, + parseLocalFunctionPath(step.uses), + BuildStep.getNewId(step.id) + ); } const buildFunction = localFunctionExpander.buildFunctionById[step.uses]; diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts index ba1aa7cff4..8373a46f77 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts @@ -163,7 +163,7 @@ describe('StepsConfigParser local composite functions', () => { parseCompositeFunctions({ steps: [{ uses: './.eas/functions/missing', id: 'x' }], }) - ).rejects.toThrow(/Local composite function ".\/.eas\/functions\/missing"/); + ).rejects.toThrow(/Local function ".\/.eas\/functions\/missing" does not exist/); }); it.each([ 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 4882ea03a8..a63dab1461 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts @@ -1,4 +1,4 @@ -import { CompositeFunctionConfigZ, LocalFunctionCatalog, Step } from '@expo/eas-build-job'; +import { LocalFunctionCatalog, LocalFunctionConfigZ, Step } from '@expo/eas-build-job'; import { createGlobalContextMock } from './utils/context'; import { BuildFunction } from '../BuildFunction'; @@ -13,7 +13,7 @@ export const SETUP = './.eas/functions/setup'; export function makeCatalog(entries: Record): LocalFunctionCatalog { const catalog: LocalFunctionCatalog = {}; for (const [compositeFunctionPath, raw] of Object.entries(entries)) { - catalog[compositeFunctionPath] = CompositeFunctionConfigZ.parse(raw); + catalog[compositeFunctionPath] = LocalFunctionConfigZ.parse(raw); } return catalog; } diff --git a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts index b80fad1b1a..7b9525ebba 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts @@ -656,6 +656,31 @@ describe('StepsConfigParser hooks with composite functions', () => { expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']); }); + it('parses a single-step function hook step into one entry with one step', async () => { + const workflow = await parseWorkflowAsync({ + ctx, + steps: [{ uses: 'eas/install_node_modules' }], + hooks: { + before_install_node_modules: [ + { uses: './.eas/functions/say-hi', id: 'greet', if: '${{ always() }}' }, + ], + }, + localFunctionCatalog: makeCatalog({ + './.eas/functions/say-hi': { name: 'Say hi', command: 'echo hi' }, + }), + }); + const anchorHooks = [...workflow.hooksByAnchorStep.values()][0]; + expect(anchorHooks.before).toHaveLength(1); + const [hookStep] = anchorHooks.before[0].steps; + expect(anchorHooks.before[0].steps).toHaveLength(1); + expect(hookStep.id).toBe('greet'); + expect(hookStep.displayName).toBe('Say hi'); + expect(hookStep.command).toBe('echo hi'); + // Unlike a composite call, the authored if: lands on the step itself. + expect(hookStep.ifCondition).toBe('${{ always() }}'); + expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']); + }); + it('does not copy the if condition of a composite hook step onto the entry', async () => { // The authored if: is applied inside the expansion scope, not on the entry. const workflow = await parseWorkflowAsync({ diff --git a/packages/steps/src/__tests__/StepsConfigParser-legacy-functions-test.ts b/packages/steps/src/__tests__/StepsConfigParser-legacy-functions-test.ts new file mode 100644 index 0000000000..f4dc4c79f5 --- /dev/null +++ b/packages/steps/src/__tests__/StepsConfigParser-legacy-functions-test.ts @@ -0,0 +1,383 @@ +import fs from 'fs/promises'; +import assert from 'node:assert'; +import path from 'node:path'; + +import { parseCompositeFunctions } from './StepsConfigParser-composite-functions-test-utils'; +import { getErrorAsync } from './utils/error'; +import { BuildWorkflow } from '../BuildWorkflow'; +import { BuildConfigError, BuildWorkflowError } from '../errors'; + +const SAY_HI = './.eas/functions/say-hi'; + +const createdDirectories: string[] = []; + +// Shell steps spawn in the project target directory, which the mock context does not create. +async function executeWorkflowAsync(workflow: BuildWorkflow): Promise { + const globalCtx = workflow.buildSteps[0].ctx.global; + for (const directory of [ + globalCtx.defaultWorkingDirectory, + globalCtx.stepsInternalBuildDirectory, + ]) { + await fs.mkdir(directory, { recursive: true }); + createdDirectories.push(directory); + } + await workflow.executeAsync(); +} + +afterEach(async () => { + await Promise.all( + createdDirectories.map(directory => fs.rm(directory, { recursive: true, force: true })) + ); + createdDirectories.length = 0; +}); + +describe('StepsConfigParser local single-step functions', () => { + describe('expansion', () => { + it('expands a command function into one step keeping the caller id', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SAY_HI]: { name: 'Say hi', command: 'echo hi' } }, + steps: [ + { + uses: SAY_HI, + id: 'greet', + name: 'Greet the world', + env: { GREETING: 'Hi' }, + if: '${{ always() }}', + }, + ], + }); + + expect(workflow.buildSteps).toHaveLength(1); + const [step] = workflow.buildSteps; + expect(step.id).toBe('greet'); + expect(step.displayName).toBe('Greet the world'); + expect(step.command).toBe('echo hi'); + expect(step.fn).toBeUndefined(); + expect(step.ifCondition).toBe('${{ always() }}'); + expect(step.stepEnvOverrides).toEqual({ GREETING: 'Hi' }); + }); + + it('falls back to the function name and then to the function path for the display name', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SAY_HI]: { name: 'Say hi', command: 'echo hi' }, + './.eas/functions/anonymous': { command: 'echo anonymous' }, + }, + steps: [ + { uses: SAY_HI, id: 'greet' }, + { uses: './.eas/functions/anonymous', id: 'anonymous' }, + ], + }); + + expect(workflow.buildSteps.map(step => step.displayName)).toEqual([ + 'Say hi', + './.eas/functions/anonymous', + ]); + }); + + it('generates a step id when the caller has none', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SAY_HI]: { command: 'echo hi' } }, + steps: [{ uses: SAY_HI }], + }); + + expect(workflow.buildSteps[0].id).toMatch(/^step-\d{3,}$/); + }); + + it('expands repeated calls to the same function into separate steps', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SAY_HI]: { command: 'echo hi' } }, + steps: [ + { uses: SAY_HI, id: 'first' }, + { uses: SAY_HI, id: 'second' }, + ], + }); + + expect(workflow.buildSteps.map(step => step.id)).toEqual(['first', 'second']); + }); + + it('forwards the function-level shell to the step', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SAY_HI]: { command: 'echo hi', shell: 'sh' } }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }); + + expect(workflow.buildSteps[0].shell).toBe('sh'); + }); + + it('expands a path function into a step calling the module', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SAY_HI]: { path: path.resolve(__dirname, './fixtures/my-custom-ts-function') }, + }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }); + + const [step] = workflow.buildSteps; + expect(step.command).toBeUndefined(); + expect(step.fn).toBeDefined(); + }); + + it('registers the expanded function in workflow.buildFunctions keyed by its path', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SAY_HI]: { command: 'echo hi' } }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }); + + expect(workflow.buildFunctions[SAY_HI]).toBeDefined(); + expect(workflow.buildFunctions[SAY_HI].command).toBe('echo hi'); + }); + + it('rejects working_directory on a step that calls a single-step function', async () => { + await expect( + parseCompositeFunctions({ + catalog: { [SAY_HI]: { command: 'echo hi' } }, + steps: [{ uses: SAY_HI, id: 'greet', working_directory: 'packages/app' }], + }) + ).rejects.toThrow( + /"working_directory" is not supported on a step that calls a local function/ + ); + }); + + it('throws a clear error for a function missing from the catalog', async () => { + await expect( + parseCompositeFunctions({ steps: [{ uses: SAY_HI, id: 'greet' }] }) + ).rejects.toThrow(/Local function ".\/.eas\/functions\/say-hi" does not exist/); + }); + }); + + describe('inputs', () => { + it('passes caller values to the function inputs', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SAY_HI]: { + inputs: ['name'], + outputs: ['greeting'], + command: 'set-output greeting "Hi, ${ inputs.name }!"', + }, + }, + steps: [{ uses: SAY_HI, id: 'greet', with: { name: 'World' } }], + }); + await executeWorkflowAsync(workflow); + + expect(workflow.buildSteps[0].getOutputValueByName('greeting')).toBe('Hi, World!'); + }); + + it('applies the declared default when the caller omits a value', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SAY_HI]: { + inputs: [{ name: 'name', type: 'string', default_value: 'World', required: false }], + outputs: ['greeting'], + command: 'set-output greeting "Hi, ${ inputs.name }!"', + }, + }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }); + await executeWorkflowAsync(workflow); + + expect(workflow.buildSteps[0].getOutputValueByName('greeting')).toBe('Hi, World!'); + }); + + it('treats shorthand inputs as required, unlike composite function inputs', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { [SAY_HI]: { inputs: ['name'], command: 'echo hi' } }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }) + ); + + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors[0].message).toBe( + 'Input parameter "name" for step "./.eas/functions/say-hi" is required but it was not set.' + ); + }); + + it('accepts an input declared as not required', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SAY_HI]: { + inputs: [{ name: 'name', type: 'string', required: false }], + command: 'echo hi', + }, + }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }); + + expect(workflow.buildSteps).toHaveLength(1); + }); + + it('rejects a value outside the declared allowed values', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { + [SAY_HI]: { + inputs: [ + { + name: 'platform', + type: 'string', + default_value: 'ios', + allowed_values: ['ios', 'android'], + }, + ], + command: 'echo hi', + }, + }, + steps: [{ uses: SAY_HI, id: 'greet', with: { platform: 'web' } }], + }) + ); + + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors[0].message).toBe( + 'Input parameter "platform" for step "./.eas/functions/say-hi" is set to "web" which is not one of the allowed values: "ios", "android".' + ); + }); + }); + + describe('outputs', () => { + it('exposes declared outputs to later steps', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SAY_HI]: { outputs: ['version'], command: 'set-output version "1.0.0"' }, + }, + steps: [ + { uses: SAY_HI, id: 'read' }, + { + id: 'copy', + run: 'set-output copied "${{ steps.read.outputs.version }}"', + outputs: [{ name: 'copied', required: true }], + }, + ], + }); + await executeWorkflowAsync(workflow); + + expect(workflow.buildSteps[0].getOutputValueByName('version')).toBe('1.0.0'); + expect(workflow.buildSteps[1].getOutputValueByName('copied')).toBe('1.0.0'); + }); + + it('treats shorthand outputs as required', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SAY_HI]: { outputs: ['version'], command: 'echo hi' } }, + steps: [{ uses: SAY_HI, id: 'read' }], + }); + + await expect(executeWorkflowAsync(workflow)).rejects.toThrow( + /Some required outputs have not been set: "version"/ + ); + }); + + it('accepts an output declared as not required', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SAY_HI]: { outputs: [{ name: 'version', required: false }], command: 'echo hi' }, + }, + steps: [{ uses: SAY_HI, id: 'read' }], + }); + + await expect(executeWorkflowAsync(workflow)).resolves.toBeUndefined(); + }); + }); + + describe('supported platforms', () => { + it('rejects a function that does not support the runtime platform', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { [SAY_HI]: { command: 'echo hi', supported_platforms: ['darwin'] } }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }) + ); + + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors[0].message).toBe( + 'Step "./.eas/functions/say-hi" is not allowed on platform "linux". Allowed platforms for this step are: "darwin".' + ); + }); + + it('accepts a function that supports the runtime platform', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SAY_HI]: { command: 'echo hi', supported_platforms: ['linux'] } }, + steps: [{ uses: SAY_HI, id: 'greet' }], + }); + + expect(workflow.buildSteps).toHaveLength(1); + }); + }); + + describe('inside a composite function', () => { + it('expands a single-step function called from a composite function', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + inputs: [{ name: 'name', type: 'string', default_value: 'World' }], + outputs: { greeting: { value: '${{ steps.inner.outputs.greeting }}' } }, + runs: { + steps: [{ id: 'inner', uses: SAY_HI, with: { name: '${{ inputs.name }}' } }], + }, + }, + [SAY_HI]: { + inputs: ['name'], + outputs: ['greeting'], + command: 'set-output greeting "Hi, ${ inputs.name }!"', + }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'outer', with: { name: 'Expo' } }], + }); + await executeWorkflowAsync(workflow); + + const [innerStep, outputsStep] = workflow.buildSteps; + expect(innerStep.id).toBe('outer__inner'); + expect(innerStep.getOutputValueByName('greeting')).toBe('Hi, Expo!'); + expect(outputsStep.id).toBe('outer'); + expect(outputsStep.getOutputValueByName('greeting')).toBe('Hi, Expo!'); + }); + + it('exposes the outputs of a nested single-step function to its siblings', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + runs: { + steps: [ + { id: 'inner', uses: SAY_HI }, + { + id: 'copy', + run: 'set-output copied "${{ steps.inner.outputs.version }}"', + outputs: [{ name: 'copied', required: true }], + }, + ], + }, + }, + [SAY_HI]: { outputs: ['version'], command: 'set-output version "1.0.0"' }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'outer' }], + }); + await executeWorkflowAsync(workflow); + + expect(workflow.buildSteps.map(step => step.id)).toEqual(['outer__inner', 'outer__copy']); + expect(workflow.buildSteps[1].getOutputValueByName('copied')).toBe('1.0.0'); + }); + + it('rejects working_directory on a nested call to a single-step function', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + runs: { + steps: [{ id: 'inner', uses: SAY_HI, working_directory: 'packages/app' }], + }, + }, + [SAY_HI]: { command: 'echo hi' }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'outer' }], + }) + ); + + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch( + /"working_directory" is not supported on a step that calls a local function/ + ); + }); + }); +}); diff --git a/packages/steps/src/hooks.ts b/packages/steps/src/hooks.ts index 933dcc6d80..5e59f274e6 100644 --- a/packages/steps/src/hooks.ts +++ b/packages/steps/src/hooks.ts @@ -135,13 +135,11 @@ export function constructHookEntriesFromValidatedSteps( } if (isLocalFunctionPath(step.uses)) { entries.push({ - steps: localFunctionExpander - .expandLocalFunctionStep( - step, - parseLocalFunctionPath(step.uses), - BuildStep.getNewId(step.id) - ) - .getFlattenedSteps(), + steps: localFunctionExpander.expandLocalFunctionStep( + step, + parseLocalFunctionPath(step.uses), + BuildStep.getNewId(step.id) + ), }); continue; } From 6d900059a527f2440176f7b78b136721ab72c10d Mon Sep 17 00:00:00 2001 From: sswrk Date: Mon, 27 Jul 2026 16:52:15 +0200 Subject: [PATCH 6/6] [steps] Load legacy command/path local functions and resolve module paths --- CHANGELOG.md | 1 + packages/steps/src/StepsConfigParser.ts | 6 +- .../__tests__/localCompositeFunctions-test.ts | 124 +++++++++++++++++- .../src/utils/localCompositeFunctions.ts | 64 ++++++++- 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4dd2d0de6..c405d026f5 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 - [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/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts index 6b83983cd1..382cce2edf 100644 --- a/packages/steps/src/StepsConfigParser.ts +++ b/packages/steps/src/StepsConfigParser.ts @@ -1,10 +1,10 @@ import { - CompositeFunctionConfig, FunctionStep, HookAnchorId, HookKey, Hooks, LocalFunctionCatalog, + LocalFunctionConfig, Step, isHookAnchorId, isStepFunctionStep, @@ -45,7 +45,7 @@ export class StepsConfigParser extends AbstractConfigParser { private readonly hooks: Hooks; /** Pre-loaded local function configs keyed by normalized path (e.g. `./.eas/functions/setup`). */ private readonly localFunctionCatalog: LocalFunctionCatalog; - private readonly loadLocalFunction?: (functionPath: string) => Promise; + private readonly loadLocalFunction?: (functionPath: string) => Promise; constructor( ctx: BuildStepGlobalContext, @@ -65,7 +65,7 @@ export class StepsConfigParser extends AbstractConfigParser { externalFunctionGroups?: BuildFunctionGroup[]; localFunctionCatalog?: LocalFunctionCatalog; /** Loads a hook local function missing from the catalog. When omitted, missing entries fail as unknown. */ - loadLocalFunction?: (functionPath: string) => Promise; + loadLocalFunction?: (functionPath: string) => Promise; } ) { super(ctx, { diff --git a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts index 8ced9f5874..92cda74a4c 100644 --- a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts +++ b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts @@ -1,4 +1,9 @@ -import { CompositeFunctionConfigZ, LocalFunctionConfigZ } from '@expo/eas-build-job'; +import { + CompositeFunctionConfigZ, + LocalFunctionConfigZ, + isLegacyFunctionConfig, +} from '@expo/eas-build-job'; +import assert from 'assert'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -11,6 +16,7 @@ import { isLocalFunctionPath, loadLocalFunctionConfigAsync, parseLocalFunctionPath, + resolveAndValidateLegacyFunctionModulePathAsync, resolveLegacyFunctionModulePath, resolveLocalFunctionPath, } from '../localCompositeFunctions'; @@ -349,6 +355,7 @@ describe(loadLocalFunctionConfigAsync, () => { const config = await loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/setup'); + assert(!isLegacyFunctionConfig(config)); expect(config.name).toBe('Setup'); expect(config.runs.steps).toHaveLength(1); }); @@ -364,6 +371,7 @@ describe(loadLocalFunctionConfigAsync, () => { const config = await loadLocalFunctionConfigAsync(projectRoot, './.eas/functions/setup'); + assert(!isLegacyFunctionConfig(config)); expect(config.runs.steps).toHaveLength(1); }); @@ -514,6 +522,7 @@ describe(createLocalFunctionLoader, () => { const loader = createLocalFunctionLoader(projectRoot); const config = await loader('./.eas/functions/setup'); + assert(!isLegacyFunctionConfig(config)); expect(config.name).toBe('Setup'); expect(config.runs.steps).toHaveLength(1); }); @@ -628,6 +637,73 @@ describe(buildLocalFunctionCatalogAsync, () => { expect(catalog).toEqual({}); }); + + it('loads a referenced single-step command function', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-')); + await makeCompositeFunctionAsync( + projectRoot, + '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 buildLocalFunctionCatalogAsync(projectRoot, { + rootSteps: [{ 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('rewrites the "path" of a single-step function to an absolute module path', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-')); + await makeCompositeFunctionAsync( + projectRoot, + 'say-hi', + ['name: 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 buildLocalFunctionCatalogAsync(projectRoot, { + rootSteps: [{ 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 a single-step function points at a module directory that does not exist', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-')); + await makeCompositeFunctionAsync( + projectRoot, + 'say-hi', + ['name: Say hi', 'path: ./my-function'].join('\n') + ); + + await expect( + buildLocalFunctionCatalogAsync(projectRoot, { + rootSteps: [{ uses: './.eas/functions/say-hi', id: 'greet' }], + }) + ).rejects.toThrow(/there is no such directory at /); + }); }); describe(resolveLegacyFunctionModulePath, () => { @@ -664,3 +740,49 @@ describe(resolveLegacyFunctionModulePath, () => { ).toBe(absolutePath); }); }); + +describe(resolveAndValidateLegacyFunctionModulePathAsync, () => { + it('resolves and returns the module path when it contains a package.json', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-legacy-')); + 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 resolvedModulePath = await resolveAndValidateLegacyFunctionModulePathAsync({ + projectRoot, + functionPath: './.eas/functions/say-hi', + modulePath: './my-function', + }); + + expect(resolvedModulePath).toBe(moduleDir); + }); + + it('throws when the module directory does not exist', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-legacy-')); + + await expect( + resolveAndValidateLegacyFunctionModulePathAsync({ + projectRoot, + functionPath: './.eas/functions/say-hi', + modulePath: './my-function', + }) + ).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 has no package.json', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-legacy-')); + await fs.mkdir(path.join(projectRoot, '.eas', 'functions', 'say-hi', 'my-function'), { + recursive: true, + }); + + await expect( + resolveAndValidateLegacyFunctionModulePathAsync({ + projectRoot, + functionPath: './.eas/functions/say-hi', + modulePath: './my-function', + }) + ).rejects.toThrow(/does not contain a package.json file/); + }); +}); diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index 26720dbc97..d4b7709c81 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -1,8 +1,7 @@ import { - CompositeFunctionConfig, - CompositeFunctionConfigZ, LocalFunctionCatalog, LocalFunctionConfig, + LocalFunctionConfigZ, Step, isLegacyFunctionConfig, } from '@expo/eas-build-job'; @@ -121,6 +120,49 @@ export function resolveLegacyFunctionModulePath({ return path.resolve(resolveLocalFunctionPath(projectRoot, functionPath), modulePath); } +/** + * Resolves the module path of a single-step `path` function and validates that it points at a + * directory containing a `package.json`. The workflow validator repeats the module check once the + * function joins `workflow.buildFunctions` at parse time, but this one fires earlier and also + * covers `eas workflow` validation, which builds the catalog without ever parsing a workflow. + * Shared by every loader so the validation cannot drift. + */ +export async function resolveAndValidateLegacyFunctionModulePathAsync({ + 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 interface LocalFunctionLogger { debug(message: string): void; } @@ -133,7 +175,7 @@ export async function loadLocalFunctionConfigAsync( projectRoot: string, functionPath: string, { logger }: { logger?: LocalFunctionLogger } = {} -): Promise { +): Promise { const resolvedPath = resolveLocalFunctionPath(projectRoot, functionPath); for (const ext of ['yml', 'yaml'] as const) { @@ -165,17 +207,27 @@ export async function loadLocalFunctionConfigAsync( ); } - const result = CompositeFunctionConfigZ.safeParse(parsed); + const result = LocalFunctionConfigZ.safeParse(parsed); if (!result.success) { throw new Error( `Invalid composite function "${functionPath}": ${z.prettifyError(result.error)}` ); } + const config = result.data; + // The catalog is consumed by the steps parser, which expects an absolute module path. + if (isLegacyFunctionConfig(config) && config.path !== undefined) { + config.path = await resolveAndValidateLegacyFunctionModulePathAsync({ + projectRoot, + functionPath, + modulePath: config.path, + }); + } + logger?.debug( `Loaded local composite function "${functionPath}" from ${path.relative(projectRoot, absolutePath)}` ); - return result.data; + return config; } throw new Error( @@ -187,7 +239,7 @@ export async function loadLocalFunctionConfigAsync( export function createLocalFunctionLoader( projectRoot: string, { logger }: { logger?: LocalFunctionLogger } = {} -): (functionPath: string) => Promise { +): (functionPath: string) => Promise { return async functionPath => await loadLocalFunctionConfigAsync(projectRoot, functionPath, { logger }); }