From 20458173b4044b24f0780cbd74f29a413e6f5e42 Mon Sep 17 00:00:00 2001 From: sswrk Date: Tue, 14 Jul 2026 16:11:10 +0200 Subject: [PATCH 1/8] [steps] Expand composite function references into build steps --- packages/steps/src/BuildFunction.ts | 7 + packages/steps/src/BuildStep.ts | 123 +++++-- .../src/BuildStepCompositeFunctionScope.ts | 100 ++++++ packages/steps/src/BuildStepContext.ts | 40 ++- packages/steps/src/BuildStepInput.ts | 13 +- .../steps/src/CompositeFunctionExpander.ts | 340 ++++++++++++++++++ packages/steps/src/StepsConfigParser.ts | 106 ++++-- .../steps/src/__tests__/BuildStep-test.ts | 59 +++ .../src/__tests__/BuildStepContext-test.ts | 1 + ...rser-composite-functions-expansion-test.ts | 309 ++++++++++++++++ ...igParser-composite-functions-test-utils.ts | 50 +++ .../__tests__/StepsConfigParser-hooks-test.ts | 12 + packages/steps/src/hooks.ts | 32 +- .../__tests__/localCompositeFunctions-test.ts | 68 +++- .../src/utils/localCompositeFunctions.ts | 37 ++ packages/steps/src/utils/step.ts | 31 ++ 16 files changed, 1236 insertions(+), 92 deletions(-) create mode 100644 packages/steps/src/BuildStepCompositeFunctionScope.ts create mode 100644 packages/steps/src/CompositeFunctionExpander.ts create mode 100644 packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts create mode 100644 packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts create mode 100644 packages/steps/src/utils/step.ts diff --git a/packages/steps/src/BuildFunction.ts b/packages/steps/src/BuildFunction.ts index 6b95a7f0e6..e48c2ebab2 100644 --- a/packages/steps/src/BuildFunction.ts +++ b/packages/steps/src/BuildFunction.ts @@ -3,6 +3,7 @@ import assert from 'assert'; import { BuildRuntimePlatform } from './BuildRuntimePlatform'; import { BuildStep, BuildStepFunction } from './BuildStep'; +import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; import { BuildStepInputProvider } from './BuildStepInput'; @@ -117,6 +118,8 @@ export class BuildFunction { shell, env, ifCondition, + compositeFunctionScope, + isCompositeFunctionInternal, timeoutMs, }: { id?: string; @@ -126,6 +129,8 @@ export class BuildFunction { shell?: string; env?: BuildStepEnv; ifCondition?: string; + compositeFunctionScope?: BuildStepCompositeFunctionScope; + isCompositeFunctionInternal?: boolean; timeoutMs?: number; } = {} ): BuildStep { @@ -158,6 +163,8 @@ export class BuildFunction { supportedRuntimePlatforms: this.supportedRuntimePlatforms, env, ifCondition, + compositeFunctionScope, + isCompositeFunctionInternal, timeoutMs, __metricsId: this.__metricsId, __hookId: this.__hookId, diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index 3f35d22775..5f411295be 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -6,6 +6,7 @@ import path from 'path'; import util from 'util'; import { BuildRuntimePlatform } from './BuildRuntimePlatform'; +import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput'; @@ -23,11 +24,11 @@ import { } from './BuildTemporaryFiles'; import { BuildStepRuntimeError } from './errors'; import { interpolateJobContext } from './interpolation'; -import { evaluateIfCondition } from './utils/jsepEval'; +import { evaluateIfCondition as evaluateIfConditionExpression } from './utils/jsepEval'; import { BIN_PATH } from './utils/shell/bin'; import { getShellCommandAndArgs } from './utils/shell/command'; import { spawnAsync } from './utils/shell/spawn'; -import { interpolateWithInputs, interpolateWithOutputs } from './utils/template'; +import { interpolateWithInputs, interpolateWithOutputs, parseOutputPath } from './utils/template'; export enum BuildStepStatus { NEW = 'new', @@ -134,6 +135,9 @@ export class BuildStep extends BuildStepOutputAccessor { public readonly ctx: BuildStepContext; public readonly stepEnvOverrides: BuildStepEnv; public readonly ifCondition?: string; + public readonly compositeFunctionScope?: BuildStepCompositeFunctionScope; + /** Prefixed expansion steps; top-level composite function outputs stay public. */ + public readonly isCompositeFunctionInternal: boolean; public readonly timeoutMs?: number; public readonly __metricsId?: string; public readonly __hookId?: HookAnchorId; @@ -162,6 +166,8 @@ export class BuildStep extends BuildStepOutputAccessor { supportedRuntimePlatforms: maybeSupportedRuntimePlatforms, env, ifCondition, + compositeFunctionScope, + isCompositeFunctionInternal, timeoutMs, __metricsId, __hookId, @@ -177,6 +183,8 @@ export class BuildStep extends BuildStepOutputAccessor { supportedRuntimePlatforms?: BuildRuntimePlatform[]; env?: BuildStepEnv; ifCondition?: string; + compositeFunctionScope?: BuildStepCompositeFunctionScope; + isCompositeFunctionInternal?: boolean; timeoutMs?: number; __metricsId?: string; __hookId?: HookAnchorId; @@ -197,6 +205,8 @@ export class BuildStep extends BuildStepOutputAccessor { this.command = command; this.shell = shell ?? '/bin/bash -eo pipefail'; this.ifCondition = ifCondition; + this.compositeFunctionScope = compositeFunctionScope; + this.isCompositeFunctionInternal = isCompositeFunctionInternal ?? false; this.timeoutMs = timeoutMs; this.__metricsId = __metricsId; this.__hookId = __hookId; @@ -300,6 +310,7 @@ export class BuildStep extends BuildStepOutputAccessor { } catch (error) { // If the step succeeded, we expect the outputs to be collected successfully. if (this.status === BuildStepStatus.SUCCESS) { + // Rethrow so BuildWorkflow.recordFailure marks the global failure flag. throw error; } @@ -318,30 +329,58 @@ export class BuildStep extends BuildStepOutputAccessor { } public shouldExecuteStep(): boolean { - const hasAnyPreviousStepFailed = this.ctx.global.hasAnyPreviousStepFailed; - + if ( + this.compositeFunctionScope && + !this.compositeFunctionScope.isActive(evaluateIfConditionExpression) + ) { + return false; + } if (!this.ifCondition) { - return !hasAnyPreviousStepFailed; + return !this.ctx.global.hasAnyPreviousStepFailed; } + return this.evaluateIfCondition(this.ifCondition, { + scope: this.compositeFunctionScope, + env: this.getScriptEnv(), + inputs: this.compositeFunctionScope ? {} : this.evaluateOwnStepInputs(), + }); + } - return evaluateIfCondition( - this.ifCondition, - this.ctx.global.getIfConditionContext({ - inputs: - this.inputs?.reduce( - (acc, input) => { - acc[input.id] = input.getValue({ - interpolationContext: this.getInterpolationContext(), - }); - return acc; - }, - {} as Record - ) ?? {}, - env: this.getScriptEnv(), - }) + private evaluateOwnStepInputs(): Record { + return ( + this.inputs?.reduce( + (acc, input) => { + acc[input.id] = input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, + }); + return acc; + }, + {} as Record + ) ?? {} ); } + private evaluateIfCondition( + ifCondition: string, + { + scope, + env, + inputs, + }: { + scope: BuildStepCompositeFunctionScope | undefined; + env: BuildStepEnv; + inputs: Record; + } + ): boolean { + const baseContext = this.ctx.global.getIfConditionContext({ + inputs, + env, + }) as JobInterpolationContext; + // Overlay the scope last so `inputs`/`steps` come from the composite function while `eas.*` stays global. + const context = scope ? scope.getScopedInterpolationContext(baseContext) : baseContext; + return evaluateIfConditionExpression(ifCondition, context); + } + public skip(): void { this.status = BuildStepStatus.SKIPPED; this.ctx.logger.info( @@ -356,10 +395,11 @@ export class BuildStep extends BuildStepOutputAccessor { } private getInterpolationContext(): JobInterpolationContext { - return { + const base: JobInterpolationContext = { ...this.ctx.global.getInterpolationContext(), env: this.getScriptEnv(), }; + return this.compositeFunctionScope?.getScopedInterpolationContext(base) ?? base; } private async executeCommandAsync({ signal }: { signal: AbortSignal | null }): Promise { @@ -423,7 +463,12 @@ export class BuildStep extends BuildStepOutputAccessor { inputs: Object.fromEntries( Object.entries(this.inputById).map(([key, input]) => [ key, - { value: input.getValue({ interpolationContext: this.getInterpolationContext() }) }, + { + value: input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, + }), + }, ]) ), outputs: this.outputById, @@ -438,25 +483,43 @@ export class BuildStep extends BuildStepOutputAccessor { template: string, inputs?: BuildStepInput[] ): string { + // Actions support only `${{ }}`; a literal `${ steps.x.y }` reaching bash is the accepted + // behavior, so skip legacy output interpolation for composite-function-scoped steps. + const skipLegacyOutputInterpolation = this.compositeFunctionScope !== undefined; if (!inputs) { - return interpolateWithOutputs( - this.ctx.global.interpolate(template), - path => this.ctx.global.getStepOutputValue(path) ?? '' - ); + const interpolatedWithGlobalContext = this.ctx.global.interpolate(template); + return skipLegacyOutputInterpolation + ? interpolatedWithGlobalContext + : interpolateWithOutputs(interpolatedWithGlobalContext, path => + this.getLegacyStepOutputValue(path) + ); } const vars = inputs.reduce( (acc, input) => { - const value = input.getValue({ interpolationContext: this.getInterpolationContext() }); + const value = input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation, + }); acc[input.id] = typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? ''); return acc; }, {} as Record ); - return interpolateWithOutputs( - interpolateWithInputs(this.ctx.global.interpolate(template), vars), - path => this.ctx.global.getStepOutputValue(path) ?? '' + const interpolatedWithInputsAndGlobalContext = interpolateWithInputs( + this.ctx.global.interpolate(template), + vars ); + return skipLegacyOutputInterpolation + ? interpolatedWithInputsAndGlobalContext + : interpolateWithOutputs(interpolatedWithInputsAndGlobalContext, path => + this.getLegacyStepOutputValue(path) + ); + } + + private getLegacyStepOutputValue(path: string): string { + const { stepId, outputId } = parseOutputPath(path); + return this.ctx.global.getStepOutputValue(`steps.${stepId}.${outputId}`) ?? ''; } private async collectAndValidateOutputsAsync(outputsDir: string): Promise { diff --git a/packages/steps/src/BuildStepCompositeFunctionScope.ts b/packages/steps/src/BuildStepCompositeFunctionScope.ts new file mode 100644 index 0000000000..f4e0b113b7 --- /dev/null +++ b/packages/steps/src/BuildStepCompositeFunctionScope.ts @@ -0,0 +1,100 @@ +/** + * Runtime scope for one expanded composite function call. + * + * Composite functions are flattened into the workflow, but inner steps still need composite-function-local semantics: + * `${{ steps.read }}` must mean the inner `read` step, not a workflow step with the same id. + * This class overlays a short-id `steps` view on top of the global interpolation context. + * success()/failure() use global workflow status (GitHub composite action parity). + */ +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { BuildStepGlobalContext } from './BuildStepContext'; +import { BuildStepEnv } from './BuildStepEnv'; + +export type EvaluateIfExpression = ( + expression: string, + context: JobInterpolationContext +) => boolean; + +export class BuildStepCompositeFunctionScope { + public readonly parent?: BuildStepCompositeFunctionScope; + public readonly env?: BuildStepEnv; + private readonly ctx: BuildStepGlobalContext; + private readonly ifCondition?: string; + private readonly stepIdAliases: Map; + private cachedIsActive?: boolean; + + constructor({ + ctx, + parent, + ifCondition, + env, + stepIdAliases, + }: { + ctx: BuildStepGlobalContext; + parent?: BuildStepCompositeFunctionScope; + ifCondition?: string; + env?: BuildStepEnv; + stepIdAliases: Map; + }) { + this.ctx = ctx; + this.parent = parent; + this.ifCondition = ifCondition; + this.env = env; + this.stepIdAliases = stepIdAliases; + } + + /** + * Walks the parent chain; the call-site `if` gates the whole composite function call, not individual inner steps. + * Memoized: global status can change mid-expansion, and re-evaluating would flip a passed + * success() gate and skip remaining always()/failure() inner steps. + */ + public isActive(evaluate: EvaluateIfExpression): boolean { + if (this.parent && !this.parent.isActive(evaluate)) { + return false; + } + this.cachedIsActive ??= this.evaluateCallIfCondition(evaluate); + return this.cachedIsActive; + } + + // Call-site if uses caller env/steps/status. Input interpolation and inherited env + // template resolution are added when composite function inputs are wired up. + private evaluateCallIfCondition(evaluate: EvaluateIfExpression): boolean { + if (!this.ifCondition) { + return !this.ctx.hasAnyPreviousStepFailed; + } + const env = { ...this.ctx.env, ...(this.env ?? {}) }; + const baseContext = this.ctx.getIfConditionContext({ + inputs: {}, + env, + }) as JobInterpolationContext; + const context = this.parent + ? this.parent.getScopedInterpolationContext(baseContext) + : baseContext; + return evaluate(this.ifCondition, context); + } + + public resolveStepId(shortId: string): string | undefined { + return this.stepIdAliases.get(shortId); + } + + public getScopedInterpolationContext(base: JobInterpolationContext): JobInterpolationContext { + return { + ...base, + steps: this.buildStepsView(), + }; + } + + // Workflow hides prefixed ids; re-expose them under short aliases. + private buildStepsView(): JobInterpolationContext['steps'] { + const fullSteps = this.ctx.getFullStepsInterpolationView(); + const view: JobInterpolationContext['steps'] = {}; + for (const [shortId, prefixedId] of this.stepIdAliases) { + const step = fullSteps[prefixedId]; + if (step !== undefined) { + view[shortId] = step; + } + } + return view; + } +} diff --git a/packages/steps/src/BuildStepContext.ts b/packages/steps/src/BuildStepContext.ts index 786f1e44eb..09dd435d9d 100644 --- a/packages/steps/src/BuildStepContext.ts +++ b/packages/steps/src/BuildStepContext.ts @@ -47,6 +47,8 @@ export interface ExternalBuildContextProvider { export interface SerializedBuildStepGlobalContext { stepsInternalBuildDirectory: string; stepById: Record; + // Absent on older serialized payloads; treat as empty. + internalStepIds?: string[]; provider: SerializedExternalBuildContextProvider; skipCleanup: boolean; } @@ -58,6 +60,8 @@ export class BuildStepGlobalContext { private didCheckOut = false; private _hasAnyPreviousStepFailed = false; private stepById: Record = {}; + // Prefixed expansion steps, omitted from the workflow steps view. + private internalStepIds = new Set(); constructor( private readonly provider: ExternalBuildContextProvider, public readonly skipCleanup: boolean @@ -91,19 +95,30 @@ export class BuildStepGlobalContext { public get staticContext(): StaticJobInterpolationContext { return { ...this.provider.staticContext(), - steps: Object.fromEntries( - Object.values(this.stepById).map(step => [ + steps: this.buildStepsInterpolationMap({ includeInternal: false }), + }; + } + + /** Includes internal ids for {@link BuildStepCompositeFunctionScope}; workflow uses {@link staticContext}. */ + public getFullStepsInterpolationView(): StaticJobInterpolationContext['steps'] { + return this.buildStepsInterpolationMap({ includeInternal: true }); + } + + private buildStepsInterpolationMap({ + includeInternal, + }: { + includeInternal: boolean; + }): StaticJobInterpolationContext['steps'] { + return Object.fromEntries( + Object.values(this.stepById) + .filter(step => includeInternal || !this.internalStepIds.has(step.id)) + .map(step => [ step.id, { - outputs: Object.fromEntries( - step.outputs.map(output => { - return [output.id, output.rawValue]; - }) - ), + outputs: Object.fromEntries(step.outputs.map(output => [output.id, output.rawValue])), }, ]) - ), - }; + ); } public updateEnv(updatedEnv: BuildStepEnv): void { @@ -112,11 +127,14 @@ export class BuildStepGlobalContext { public registerStep(step: BuildStep): void { this.stepById[step.id] = step; + if (step.isCompositeFunctionInternal) { + this.internalStepIds.add(step.id); + } } public getStepOutputValue(path: string): string | undefined { const { stepId, outputId } = parseOutputPath(path); - if (!(stepId in this.stepById)) { + if (!(stepId in this.stepById) || this.internalStepIds.has(stepId)) { throw new BuildStepRuntimeError(`Step "${stepId}" does not exist.`); } return this.stepById[stepId].getOutputValueByName(outputId); @@ -263,6 +281,7 @@ export class BuildStepGlobalContext { stepById: Object.fromEntries( Object.entries(this.stepById).map(([id, step]) => [id, step.serialize()]) ), + internalStepIds: [...this.internalStepIds], provider: { projectSourceDirectory: this.provider.projectSourceDirectory, projectTargetDirectory: this.provider.projectTargetDirectory, @@ -295,6 +314,7 @@ export class BuildStepGlobalContext { for (const [id, stepOutputAccessor] of Object.entries(serialized.stepById)) { ctx.stepById[id] = BuildStepOutputAccessor.deserialize(stepOutputAccessor); } + ctx.internalStepIds = new Set(serialized.internalStepIds ?? []); ctx.stepsInternalBuildDirectory = serialized.stepsInternalBuildDirectory; return ctx; diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts index 3c8a9f288c..afe5e56a63 100644 --- a/packages/steps/src/BuildStepInput.ts +++ b/packages/steps/src/BuildStepInput.ts @@ -88,8 +88,10 @@ export class BuildStepInput< public getValue({ interpolationContext, + skipLegacyOutputInterpolation = false, }: { interpolationContext: JobInterpolationContext; + skipLegacyOutputInterpolation?: boolean; }): R extends true ? BuildStepInputValueType : BuildStepInputValueType | undefined { const rawValue = this._value ?? this.defaultValue; if (this.required && rawValue === undefined) { @@ -124,10 +126,13 @@ export class BuildStepInput< // so this will never be true. assert(interpolatedValue !== undefined); const valueInterpolatedWithGlobalContext = this.ctx.interpolate(interpolatedValue); - const valueInterpolatedWithOutputsAndGlobalContext = interpolateWithOutputs( - valueInterpolatedWithGlobalContext, - path => this.ctx.getStepOutputValue(path) ?? '' - ); + // Composite functions support only `${{ }}`; legacy `${ steps.* }` stays literal in composite-function-scoped inputs. + const valueInterpolatedWithOutputsAndGlobalContext = skipLegacyOutputInterpolation + ? valueInterpolatedWithGlobalContext + : interpolateWithOutputs( + valueInterpolatedWithGlobalContext, + path => this.ctx.getStepOutputValue(path) ?? '' + ); returnValue = this.parseInputValueToAllowedType(valueInterpolatedWithOutputsAndGlobalContext); } return returnValue; diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts new file mode 100644 index 0000000000..679ee2b8da --- /dev/null +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -0,0 +1,340 @@ +/** + * Expands local composite functions (`uses: ./path/to/function`) into concrete {@link BuildStep}s + * at parse time. + * + * A composite function call is not executed as a single step. Its `runs.steps` are flattened into the + * workflow with prefixed ids (`caller__inner`) so the existing step runner can execute them. + * Each expanded step carries a {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and + * `${{ inputs.* }}` inside the composite function resolve against composite-function-local names, not global ones. + */ +import { + CompositeFunctionCatalog, + CompositeFunctionConfig, + FunctionStep, + ShellStep, + Step, + isStepFunctionStep, + isStepShellStep, +} from '@expo/eas-build-job'; + +import { BuildFunctionById } from './BuildFunction'; +import { BuildFunctionGroupById } from './BuildFunctionGroup'; +import { BuildStep } from './BuildStep'; +import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; +import { BuildStepGlobalContext } from './BuildStepContext'; +import { BuildStepEnv } from './BuildStepEnv'; +import { BuildConfigError } from './errors'; +import { duplicates } from './utils/expodash/duplicates'; +import { + getLocalCompositeFunctionCallWorkingDirectoryError, + isLocalCompositeFunctionPath, + parseLocalCompositeFunctionPath, +} from './utils/localCompositeFunctions'; +import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step'; + +const MAX_COMPOSITE_FUNCTION_NESTING_DEPTH = 10; + +export type FunctionMaps = { + buildFunctionById: BuildFunctionById; + buildFunctionGroupById: BuildFunctionGroupById; +}; + +type CompositeFunctionCall = { + compositeFunctionPath: string; + /** Caller-assigned id used as prefix for all inner step ids and the composite function outputs step. */ + syntheticStepId: string; + /** Caller-provided input values, consumed when composite function inputs are interpolated. */ + callWith?: Record; + callIf?: string; + parentScope?: BuildStepCompositeFunctionScope; + inheritedEnv?: BuildStepEnv; +}; + +type StepOverrides = { + env?: BuildStepEnv; + workingDirectory?: string; + ifCondition?: string; +}; + +export class CompositeFunctionExpander { + constructor( + private readonly ctx: BuildStepGlobalContext, + private readonly compositeFunctionCatalog: CompositeFunctionCatalog, + private readonly functionMaps: FunctionMaps + ) {} + + public expandCompositeFunctionStep( + step: FunctionStep, + compositeFunctionPath: string, + syntheticStepId: string + ): BuildStep[] { + this.rejectCompositeFunctionCallWorkingDirectory(step); + return this.expand( + { + compositeFunctionPath, + syntheticStepId, + callWith: step.with, + callIf: step.if, + inheritedEnv: step.env, + }, + new Set() + ); + } + + // The call step expands away; `working_directory` on it would never apply. + private rejectCompositeFunctionCallWorkingDirectory(step: FunctionStep): void { + if (step.working_directory !== undefined) { + throw new BuildConfigError(getLocalCompositeFunctionCallWorkingDirectoryError(step.uses)); + } + } + + // `visited.size` is the current composite function nesting depth. + private expand(call: CompositeFunctionCall, visited: ReadonlySet): BuildStep[] { + const { compositeFunctionPath, syntheticStepId } = call; + this.guardAgainstRunawayRecursion(compositeFunctionPath, visited); + + const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath); + const innerSteps = compositeFunction.runs.steps; + + const { stepIdMap, newIds } = this.buildInnerStepIdMap(innerSteps, syntheticStepId, compositeFunctionPath); + + const nestedVisited = new Set(visited).add(compositeFunctionPath); + + const scope = new BuildStepCompositeFunctionScope({ + ctx: this.ctx, + parent: call.parentScope, + ifCondition: call.callIf, + env: call.inheritedEnv, + stepIdAliases: stepIdMap, + }); + + const buildSteps = innerSteps.flatMap((innerStep, index) => + this.expandInnerStep(innerStep, newIds[index], { + compositeFunctionPath, + scope, + nestedVisited, + }) + ); + + return buildSteps; + } + + private guardAgainstRunawayRecursion( + compositeFunctionPath: string, + visited: ReadonlySet + ): void { + if (visited.has(compositeFunctionPath)) { + const cyclePath = [...visited, compositeFunctionPath].join(' -> '); + throw new BuildConfigError( + `Detected a cycle while expanding composite functions: ${cyclePath}. A composite function cannot reference itself, directly or indirectly.` + ); + } + if (visited.size >= MAX_COMPOSITE_FUNCTION_NESTING_DEPTH) { + throw new BuildConfigError( + `Maximum composite function nesting depth (${MAX_COMPOSITE_FUNCTION_NESTING_DEPTH}) exceeded while expanding composite function "${compositeFunctionPath}".` + ); + } + } + + private lookupCompositeFunction(compositeFunctionPath: string): CompositeFunctionConfig { + const compositeFunction = this.compositeFunctionCatalog[compositeFunctionPath]; + 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/").` + ); + } + return compositeFunction; + } + + private expandInnerStep( + innerStep: Step, + newId: string, + { + compositeFunctionPath, + scope, + nestedVisited, + }: { + compositeFunctionPath: string; + scope: BuildStepCompositeFunctionScope; + nestedVisited: ReadonlySet; + } + ): BuildStep[] { + const overrides = this.resolveStepOverrides(innerStep); + + if (isStepFunctionStep(innerStep)) { + if (isLocalCompositeFunctionPath(innerStep.uses)) { + this.rejectCompositeFunctionCallWorkingDirectory(innerStep); + return this.expandNestedCompositeFunctionCall(innerStep, { + compositeFunctionPath: parseLocalCompositeFunctionPath(innerStep.uses), + newId, + overrides, + scope, + visited: nestedVisited, + }); + } + return this.createExpandedFunctionSteps(innerStep, { + newId, + overrides, + scope, + compositeFunctionPath, + }); + } + if (isStepShellStep(innerStep)) { + return [this.createExpandedShellStep(innerStep, { newId, overrides, scope })]; + } + throw new BuildConfigError( + `Invalid step configuration in composite function "${compositeFunctionPath}". Step must be a shell or function step.` + ); + } + + private resolveStepOverrides(innerStep: Step): StepOverrides { + return { + env: innerStep.env, + ifCondition: innerStep.if, + workingDirectory: innerStep.working_directory, + }; + } + + private expandNestedCompositeFunctionCall( + innerStep: FunctionStep, + { + compositeFunctionPath, + newId, + overrides, + scope, + visited, + }: { + compositeFunctionPath: string; + newId: string; + overrides: StepOverrides; + scope: BuildStepCompositeFunctionScope; + visited: ReadonlySet; + } + ): BuildStep[] { + return this.expand( + { + compositeFunctionPath, + syntheticStepId: newId, + callWith: innerStep.with, + callIf: overrides.ifCondition, + parentScope: scope, + inheritedEnv: overrides.env, + }, + visited + ); + } + + private createExpandedShellStep( + step: ShellStep, + { + newId, + overrides, + scope, + }: { + newId: string; + overrides: StepOverrides; + scope: BuildStepCompositeFunctionScope; + } + ): BuildStep { + const command = step.run; + const displayName = getShellStepDisplayName(step); + const outputs = + step.outputs && createBuildStepOutputsFromDefinition(this.ctx, step.outputs, displayName); + return new BuildStep(this.ctx, { + id: newId, + displayName, + outputs, + workingDirectory: overrides.workingDirectory, + shell: step.shell, + command, + env: overrides.env, + ifCondition: overrides.ifCondition, + compositeFunctionScope: scope, + isCompositeFunctionInternal: true, + __metricsId: step.__metrics_id, + }); + } + + private createExpandedFunctionSteps( + step: FunctionStep, + { + newId, + overrides, + scope, + compositeFunctionPath, + }: { + newId: string; + overrides: StepOverrides; + scope: BuildStepCompositeFunctionScope; + compositeFunctionPath: string; + } + ): BuildStep[] { + const functionId = step.uses; + const { buildFunctionById, buildFunctionGroupById } = this.functionMaps; + const maybeFunctionGroup = buildFunctionGroupById[functionId]; + if (maybeFunctionGroup) { + throw new BuildConfigError( + `Function group "${functionId}" cannot be used inside a composite function. Function groups expand to multiple steps with their own ids and do not support composite-function-level id, env, if, or working_directory overrides. Use individual function steps instead.` + ); + } + + const buildFunction = buildFunctionById[functionId]; + if (!buildFunction) { + throw new BuildConfigError( + `Composite function "${compositeFunctionPath}" calls non-existent function "${functionId}".` + ); + } + + const callInputs = step.with as Record | undefined; + + return [ + buildFunction.createBuildStepFromFunctionCall(this.ctx, { + id: newId, + name: step.name, + callInputs, + workingDirectory: overrides.workingDirectory, + shell: step.shell, + env: overrides.env, + ifCondition: overrides.ifCondition, + compositeFunctionScope: scope, + isCompositeFunctionInternal: true, + }), + ]; + } + + /** + * Maps composite-function-local step ids to globally unique ids and builds the alias table for scope. + * Without namespacing, two composite function calls with inner step `read` would collide in the workflow. + */ + private buildInnerStepIdMap( + innerSteps: Step[], + syntheticStepId: string, + compositeFunctionPath: string + ): { stepIdMap: Map; newIds: string[] } { + const stepIdMap = new Map(); + const newIds: string[] = []; + const declaredIdList = innerSteps.map(step => step.id).filter((id): id is string => !!id); + const duplicatedIds = duplicates(declaredIdList); + if (duplicatedIds.length > 0) { + throw new BuildConfigError( + `Composite function "${compositeFunctionPath}" declares duplicated step IDs: ${duplicatedIds + .map(id => `"${id}"`) + .join(', ')}. Step IDs within a composite function must be unique.` + ); + } + const declaredIds = new Set(declaredIdList); + let generatedStepIdCounter = 0; + for (const innerStep of innerSteps) { + let sourceId = innerStep.id; + if (!sourceId) { + do { + sourceId = `composite_function_step_${++generatedStepIdCounter}`; + } while (declaredIds.has(sourceId)); + } + const newId = `${syntheticStepId}__${sourceId}`; + stepIdMap.set(sourceId, newId); + newIds.push(newId); + } + return { stepIdMap, newIds }; + } +} diff --git a/packages/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts index f5bc793fcc..30e0b6e4af 100644 --- a/packages/steps/src/StepsConfigParser.ts +++ b/packages/steps/src/StepsConfigParser.ts @@ -1,4 +1,5 @@ import { + CompositeFunctionCatalog, FunctionStep, HookAnchorId, Hooks, @@ -12,6 +13,7 @@ import { import assert from 'node:assert'; import { AbstractConfigParser } from './AbstractConfigParser'; +import { CompositeFunctionExpander, FunctionMaps } from './CompositeFunctionExpander'; import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction'; import { BuildFunctionGroup, @@ -28,10 +30,16 @@ import { createBuildStepFromShellStep, validateAllStepFunctionsExist, } from './hooks'; +import { + isLocalCompositeFunctionPath, + parseLocalCompositeFunctionPath, +} from './utils/localCompositeFunctions'; export class StepsConfigParser extends AbstractConfigParser { private readonly steps: Step[]; private readonly hooks: Hooks; + /** Pre-loaded composite function configs keyed by normalized path (e.g. `./.eas/functions/setup`). */ + private readonly compositeFunctionCatalog: CompositeFunctionCatalog; constructor( ctx: BuildStepGlobalContext, @@ -40,6 +48,7 @@ export class StepsConfigParser extends AbstractConfigParser { hooks, externalFunctions, externalFunctionGroups, + compositeFunctionCatalog, }: { steps: Step[]; // Required (not `hooks?:`) so a call site cannot silently forget to pass @@ -47,6 +56,7 @@ export class StepsConfigParser extends AbstractConfigParser { hooks: Hooks | undefined; externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; + compositeFunctionCatalog?: CompositeFunctionCatalog; } ) { super(ctx, { @@ -56,6 +66,7 @@ export class StepsConfigParser extends AbstractConfigParser { this.steps = steps; this.hooks = hooks ?? {}; + this.compositeFunctionCatalog = compositeFunctionCatalog ?? {}; } protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{ @@ -76,6 +87,14 @@ export class StepsConfigParser extends AbstractConfigParser { const buildFunctionGroupById = createBuildFunctionGroupByIdMapping( this.externalFunctionGroups ?? [] ); + const compositeFunctionExpander = new CompositeFunctionExpander( + this.ctx, + this.compositeFunctionCatalog, + { + buildFunctionById, + buildFunctionGroupById, + } + ); // Only the job's own steps are scanned — steps constructed from hooks are // never treated as anchors (no nesting). Construction order (before → @@ -85,9 +104,10 @@ export class StepsConfigParser extends AbstractConfigParser { const hooksByAnchorStep = new Map(); for (const stepConfig of validatedSteps) { - const maybeFunctionGroup = isStepFunctionStep(stepConfig) - ? buildFunctionGroupById[stepConfig.uses] - : undefined; + const maybeFunctionGroup = + isStepFunctionStep(stepConfig) && !isLocalCompositeFunctionPath(stepConfig.uses) + ? buildFunctionGroupById[stepConfig.uses] + : undefined; if (maybeFunctionGroup !== undefined) { // The group expands FIRST (its internal steps get their ids), then the // anchors found among expanded steps get their hook steps constructed. @@ -113,14 +133,30 @@ export class StepsConfigParser extends AbstractConfigParser { continue; } + const maps = { buildFunctionById, buildFunctionGroupById }; const anchorId = StepsConfigParser.resolveStepAnchor(stepConfig, buildFunctionById); if (anchorId === undefined) { - buildSteps.push(this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById)); + buildSteps.push( + ...this.createBuildStepsFromNonGroupStepConfig( + stepConfig, + maps, + compositeFunctionExpander + ) + ); continue; } - const maps = { buildFunctionById, buildFunctionGroupById }; const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps); - const anchorStep = this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById); + const createdSteps = this.createBuildStepsFromNonGroupStepConfig( + stepConfig, + maps, + compositeFunctionExpander + ); + if (createdSteps.length !== 1) { + throw new BuildConfigError( + 'Hook anchors are not supported on local composite function steps that expand into multiple build steps.' + ); + } + const anchorStep = createdSteps[0]; buildSteps.push(anchorStep); const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps); if (before.length > 0 || after.length > 0) { @@ -214,36 +250,52 @@ export class StepsConfigParser extends AbstractConfigParser { return constructHookEntriesFromValidatedSteps(this.ctx, hookSteps, maps); } - private createBuildStepFromNonGroupStepConfig( + private createBuildStepsFromNonGroupStepConfig( stepConfig: Step, - buildFunctionById: BuildFunctionById - ): BuildStep { + maps: FunctionMaps, + compositeFunctionExpander: CompositeFunctionExpander + ): BuildStep[] { if (isStepShellStep(stepConfig)) { - return createBuildStepFromShellStep(this.ctx, stepConfig); - } else if (isStepFunctionStep(stepConfig)) { - return this.createBuildStepFromFunctionStepConfig(stepConfig, buildFunctionById); - } else { - throw new BuildConfigError( - 'Invalid job step configuration detected. Step must be shell or function step' + return [createBuildStepFromShellStep(this.ctx, stepConfig)]; + } + if (isStepFunctionStep(stepConfig)) { + return this.createBuildStepsFromFunctionStepConfig( + stepConfig, + maps, + compositeFunctionExpander ); } + throw new BuildConfigError( + 'Invalid job step configuration detected. Step must be shell or function step' + ); } - private createBuildStepFromFunctionStepConfig( + private createBuildStepsFromFunctionStepConfig( step: FunctionStep, - buildFunctionById: BuildFunctionById - ): BuildStep { + { buildFunctionById }: FunctionMaps, + compositeFunctionExpander: CompositeFunctionExpander + ): BuildStep[] { + if (isLocalCompositeFunctionPath(step.uses)) { + return compositeFunctionExpander.expandCompositeFunctionStep( + step, + parseCompositeFunctionPath(step.uses), + BuildStep.getNewId(step.id) + ); + } + const buildFunction = buildFunctionById[step.uses]; assert(buildFunction, 'function ID must be ID of function or function group'); - return buildFunction.createBuildStepFromFunctionCall(this.ctx, { - id: step.id, - name: step.name, - callInputs: step.with, - workingDirectory: step.working_directory, - shell: step.shell, - env: step.env, - ifCondition: step.if, - }); + return [ + buildFunction.createBuildStepFromFunctionCall(this.ctx, { + id: step.id, + name: step.name, + callInputs: step.with, + workingDirectory: step.working_directory, + shell: step.shell, + env: step.env, + ifCondition: step.if, + }), + ]; } } diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index c61a490bbc..8f80df3e57 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -10,10 +10,12 @@ import { GENERATED_STEP_ID_REGEX } from './utils/stepId'; import { BuildFunction } from '../BuildFunction'; import { BuildRuntimePlatform } from '../BuildRuntimePlatform'; import { BuildStep, BuildStepFunction, BuildStepStatus } from '../BuildStep'; +import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope'; import { BuildStepContext, BuildStepGlobalContext } from '../BuildStepContext'; import { BuildStepEnv } from '../BuildStepEnv'; import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput'; import { BuildStepOutput } from '../BuildStepOutput'; +import { BuildWorkflow } from '../BuildWorkflow'; import { BuildStepRuntimeError } from '../errors'; import { nullthrows } from '../utils/nullthrows'; import { spawnAsync } from '../utils/shell/spawn'; @@ -630,6 +632,43 @@ describe(BuildStep, () => { expect(error).toBeInstanceOf(BuildStepRuntimeError); expect(error.message).toMatch(/Some required outputs have not been set: "abc"/); }); + + it('skips a later default-gated inner step when a required output is missing on an earlier one', async () => { + const compositeFunctionScope = new BuildStepCompositeFunctionScope({ + ctx: baseStepCtx, + stepIdAliases: new Map(), + }); + + const firstStep = new BuildStep(baseStepCtx, { + id: 'first', + displayName: 'first', + command: 'echo 123', + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: 'first', + required: true, + }), + ], + compositeFunctionScope, + }); + + const secondStep = new BuildStep(baseStepCtx, { + id: 'second', + displayName: 'second', + command: 'echo 456', + compositeFunctionScope, + }); + + // Via workflow so recordFailure marks global status; second step then skips. + const workflow = new BuildWorkflow(baseStepCtx, { + buildSteps: [firstStep, secondStep], + buildFunctions: {}, + }); + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toMatch(/Some required outputs have not been set: "abc"/); + expect(secondStep.status).toBe(BuildStepStatus.SKIPPED); + }); }); describe('fn', () => { @@ -1120,6 +1159,26 @@ describe(BuildStep.deserialize, () => { }); describe(BuildStep.prototype.shouldExecuteStep, () => { + it('does not evaluate inputs for a step without an if condition after a failure', () => { + const ctx = createGlobalContextMock(); + ctx.markAsFailed(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + inputs: [ + new BuildStepInput(ctx, { + id: 'required', + stepDisplayName: 'Test 1', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + }); + + expect(step.shouldExecuteStep()).toBe(false); + }); + it('returns true when if condition is always and previous steps failed', () => { const ctx = createGlobalContextMock(); ctx.markAsFailed(); diff --git a/packages/steps/src/__tests__/BuildStepContext-test.ts b/packages/steps/src/__tests__/BuildStepContext-test.ts index 2cd83bb9b4..579f48618d 100644 --- a/packages/steps/src/__tests__/BuildStepContext-test.ts +++ b/packages/steps/src/__tests__/BuildStepContext-test.ts @@ -172,6 +172,7 @@ describe(BuildStepGlobalContext, () => { const mockStep = mock(); when(mockStep.id).thenReturn('abc'); + when(mockStep.isCompositeFunctionInternal).thenReturn(false); when(mockStep.getOutputValueByName('def')).thenReturn('ghi'); const step = instance(mockStep); diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts new file mode 100644 index 0000000000..b075344db1 --- /dev/null +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts @@ -0,0 +1,309 @@ +import assert from 'node:assert'; + +import { + SETUP, + echoFunction, + parseCompositeFunctions, +} from './StepsConfigParser-composite-functions-test-utils'; +import { getErrorAsync } from './utils/error'; +import { BuildFunctionGroup } from '../BuildFunctionGroup'; +import { BuildConfigError, BuildWorkflowError } from '../errors'; + +describe('StepsConfigParser local composite functions', () => { + describe('expansion', () => { + it('expands a single-level composite function and leaves input templates raw', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + name: 'Setup', + inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }], + runs: { + steps: [ + { id: 'read', run: 'set-output version "1.0.0"' }, + { run: 'echo "${{ inputs.greeting }}"' }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hi' } }], + }); + + expect(workflow.buildSteps).toHaveLength(2); + + const [readStep, echoStep] = workflow.buildSteps; + expect(readStep.id).toBe('setup__read'); + expect(readStep.displayName).toBe('read'); + expect(echoStep.id).toBe('setup__composite_function_step_1'); + expect(echoStep.command).toBe('echo "${{ inputs.greeting }}"'); + }); + + it('generates a synthetic id when the caller step has no id', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + name: 'Setup', + runs: { + steps: [ + { id: 'read', run: 'set-output version "1.0.0"' }, + { id: 'echo', run: 'echo "${{ steps.read.outputs.version }}"' }, + ], + }, + }, + }, + steps: [{ uses: SETUP }], + }); + + const [readStep, echoStep] = workflow.buildSteps; + const syntheticStepId = readStep.id.split('__')[0]; + + expect(workflow.buildSteps).toHaveLength(2); + expect(syntheticStepId).toMatch(/^step-\d{3,}$/); + expect(readStep.id).toBe(`${syntheticStepId}__read`); + expect(echoStep.command).toBe('echo "${{ steps.read.outputs.version }}"'); + }); + + it('keeps templated inner step names raw at parse time', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'env', type: 'string', default_value: 'staging' }], + runs: { + steps: [{ name: 'Deploy ${{ inputs.env }}', run: 'echo deploy' }], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { env: 'production' } }], + }); + + expect(workflow.buildSteps[0].displayName).toBe('Deploy ${{ inputs.env }}'); + }); + + it('derives the fallback display name of an unnamed inner shell step from the raw run', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'target', type: 'string', default_value: 'build' }], + runs: { + steps: [{ run: '${{ inputs.target }} do-thing' }], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { target: 'release' } }], + }); + + const [innerStep] = workflow.buildSteps; + expect(innerStep.command).toBe('${{ inputs.target }} do-thing'); + expect(innerStep.displayName).toBe('${{ inputs.target }} do-thing'); + }); + + it('expands nested composites with accumulated prefixes', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }] }, + }, + './.eas/functions/inner': { + runs: { steps: [{ id: 'leaf', run: 'echo leaf' }] }, + }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'top' }], + }); + expect(workflow.buildSteps.map(s => s.id)).toEqual(['top__mid__leaf']); + }); + + it('avoids collisions between generated inner step ids and declared inner step ids', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { run: 'echo first (no id)' }, + { id: 'composite_function_step_1', run: 'echo second (declared)' }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + }); + + expect(workflow.buildSteps.map(s => s.id)).toEqual([ + 'setup__composite_function_step_2', + 'setup__composite_function_step_1', + ]); + }); + }); + describe('resolution errors', () => { + it('throws a clear error for an unknown composite function', async () => { + await expect( + parseCompositeFunctions({ + steps: [{ uses: './.eas/functions/missing', id: 'x' }], + }) + ).rejects.toThrow(/Local composite function ".\/.eas\/functions\/missing"/); + }); + + it.each([ + [ + 'direct self-reference', + { + './.eas/functions/loop': { + runs: { steps: [{ uses: './.eas/functions/loop', id: 'again' }] }, + }, + }, + [{ uses: './.eas/functions/loop', id: 'loop' }], + ], + [ + 'indirect reference', + { + './.eas/functions/a': { runs: { steps: [{ uses: './.eas/functions/b', id: 'b' }] } }, + './.eas/functions/b': { runs: { steps: [{ uses: './.eas/functions/a', id: 'a' }] } }, + }, + [{ uses: './.eas/functions/a', id: 'a' }], + ], + ])('detects %s cycles', async (_, catalog, steps) => { + await expect(parseCompositeFunctions({ catalog, steps })).rejects.toThrow(/cycle/i); + }); + + it('errors when a composite function declares duplicated inner step ids', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { run: 'echo one', id: 'dup' }, + { run: 'echo two', id: 'dup' }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + }) + ); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toBe( + `Composite function "${SETUP}" declares duplicated step IDs: "dup". Step IDs within a composite function must be unique.` + ); + }); + + it('errors when an inner step references a non-existent function', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { + './.eas/functions/wrap': { + runs: { steps: [{ uses: 'eas/typo', id: 'echo' }] }, + }, + }, + steps: [{ uses: './.eas/functions/wrap', id: 'wrap' }], + externalFunctions: [echoFunction()], + }) + ); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toBe( + 'Composite function "./.eas/functions/wrap" calls non-existent function "eas/typo".' + ); + }); + + it('errors when an inner step uses a function group', async () => { + await expect( + parseCompositeFunctions({ + catalog: { + './.eas/functions/wrap': { + runs: { steps: [{ uses: 'eas/build', id: 'build' }] }, + }, + }, + steps: [{ uses: './.eas/functions/wrap', id: 'wrap' }], + externalFunctionGroups: [ + new BuildFunctionGroup({ + namespace: 'eas', + id: 'build', + createBuildStepsFromFunctionGroupCall: () => [], + }), + ], + }) + ).rejects.toThrow(/Function group "eas\/build" cannot be used inside a composite function/); + }); + + it('allows composite function chains at the maximum nesting depth without a cycle', async () => { + const entries: Record = {}; + for (let i = 0; i < 10; i++) { + entries[`./.eas/functions/a${i}`] = + i < 9 + ? { runs: { steps: [{ uses: `./.eas/functions/a${i + 1}`, id: `s${i}` }] } } + : { runs: { steps: [{ run: 'echo leaf' }] } }; + } + const workflow = await parseCompositeFunctions({ + catalog: entries, + steps: [{ uses: './.eas/functions/a0', id: 'top' }], + }); + expect(workflow).toBeDefined(); + }); + + it('throws when composite function nesting exceeds the maximum depth without a cycle', async () => { + const entries: Record = {}; + for (let i = 0; i <= 10; i++) { + entries[`./.eas/functions/a${i}`] = + i < 10 + ? { runs: { steps: [{ uses: `./.eas/functions/a${i + 1}`, id: `s${i}` }] } } + : { runs: { steps: [{ run: 'echo leaf' }] } }; + } + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: entries, + steps: [{ uses: './.eas/functions/a0', id: 'top' }], + }) + ); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch(/Maximum composite function nesting depth \(10\) exceeded/); + }); + }); + describe('step id collisions', () => { + it('reports a clear error when a user step id collides with an expanded composite function step id', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { [SETUP]: { runs: { steps: [{ id: 'read', run: 'true' }] } } }, + steps: [ + { id: 'setup__read', run: 'true' }, + { uses: SETUP, id: 'setup' }, + ], + }) + ); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors[0].message).toBe('Duplicated step IDs: "setup__read"'); + }); + }); + describe('expression tokenization', () => { + it('substitutes inputs adjacent to arithmetic operators in inner run commands', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [ + { name: 'total', type: 'number', default_value: 10 }, + { name: 'count', type: 'number', default_value: 2 }, + ], + runs: { + steps: [{ run: 'echo ${{ inputs.total/inputs.count }}' }], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + }); + expect(workflow.buildSteps[0].command).toBe('echo ${{ inputs.total/inputs.count }}'); + }); + + it('does not truncate inner expressions at }} inside string literals', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'platform', type: 'string', default_value: 'android' }], + runs: { + steps: [{ run: 'echo hi', if: '${{ contains(inputs.platform, "a}}b") }}' }], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + }); + expect(workflow.buildSteps[0].ifCondition).toBe('${{ contains(inputs.platform, "a}}b") }}'); + }); + }); +}); diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts new file mode 100644 index 0000000000..48b2f0a616 --- /dev/null +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts @@ -0,0 +1,50 @@ +import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/eas-build-job'; + +import { createGlobalContextMock } from './utils/context'; +import { BuildFunction } from '../BuildFunction'; +import { BuildFunctionGroup } from '../BuildFunctionGroup'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput'; +import { BuildWorkflow } from '../BuildWorkflow'; +import { StepsConfigParser } from '../StepsConfigParser'; + +export const SETUP = './.eas/functions/setup'; + +export function makeCatalog(entries: Record): CompositeFunctionCatalog { + const catalog: CompositeFunctionCatalog = {}; + for (const [compositeFunctionPath, raw] of Object.entries(entries)) { + catalog[compositeFunctionPath] = CompositeFunctionConfigZ.parse(raw); + } + return catalog; +} + +export async function parseCompositeFunctions(options: { + catalog?: Record; + steps: Step[]; + externalFunctions?: BuildFunction[]; + externalFunctionGroups?: BuildFunctionGroup[]; +}): Promise { + const ctx = createGlobalContextMock(); + const parser = new StepsConfigParser(ctx, { + steps: options.steps, + hooks: undefined, + compositeFunctionCatalog: makeCatalog(options.catalog ?? {}), + externalFunctions: options.externalFunctions, + externalFunctionGroups: options.externalFunctionGroups, + }); + return parser.parseAsync(); +} + +export function echoFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'echo', + fn: () => {}, + inputProviders: [ + BuildStepInput.createProvider({ + id: 'value', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + }); +} diff --git a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts index 75e979ad82..2a1da9a265 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts @@ -260,6 +260,18 @@ describe('StepsConfigParser hook construction', () => { expect(error).toBeInstanceOf(BuildConfigError); expect(error.message).toMatch(/nonexistent_function/); }); + + it('rejects a hook step using a local composite function with BuildConfigError, not an assertion crash', async () => { + const error = await getErrorAsync(async () => { + await parseWorkflowAsync({ + ctx, + steps: [{ uses: 'eas/install_node_modules' }], + hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup' }] }, + }); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch(/not supported in hooks/); + }); }); describe('constructHookEntriesAsync (public API)', () => { diff --git a/packages/steps/src/hooks.ts b/packages/steps/src/hooks.ts index 734958de1e..ef51a823c5 100644 --- a/packages/steps/src/hooks.ts +++ b/packages/steps/src/hooks.ts @@ -15,9 +15,10 @@ import { } from './BuildFunctionGroup'; import { BuildStep } from './BuildStep'; import { BuildStepGlobalContext } from './BuildStepContext'; -import { BuildStepOutput } from './BuildStepOutput'; import { collectAggregateStepErrors } from './BuildWorkflowValidator'; import { BuildConfigError, BuildWorkflowError } from './errors'; +import { isCompositeFunctionPath } from './utils/localCompositeFunctions'; +import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step'; /** * One entry per AUTHORED hook step — the unit the user wrote. The wrapper @@ -125,6 +126,11 @@ export function constructHookEntriesFromValidatedSteps( }); continue; } + if (isLocalCompositeFunctionPath(step.uses)) { + throw new BuildConfigError( + `Local composite function steps ("uses: ${step.uses}") are not supported in hooks.` + ); + } const maybeFunctionGroup = buildFunctionGroupById[step.uses]; if (maybeFunctionGroup !== undefined) { entries.push({ @@ -159,25 +165,11 @@ export function createBuildStepFromShellStep( ctx: BuildStepGlobalContext, step: ShellStep ): BuildStep { - const id = BuildStep.getNewId(step.id); - const displayName = - step.name ?? - step.id ?? - step.run - .split('\n') - .find(line => line.trim()) - ?.trim() ?? - step.run; - const outputs = step.outputs?.map( - entry => - new BuildStepOutput(ctx, { - id: entry.name, - stepDisplayName: displayName, - required: entry.required ?? true, - }) - ); + const displayName = getShellStepDisplayName(step); + const outputs = + step.outputs && createBuildStepOutputsFromDefinition(ctx, step.outputs, displayName); return new BuildStep(ctx, { - id, + id: BuildStep.getNewId(step.id), displayName, outputs, workingDirectory: step.working_directory, @@ -202,7 +194,7 @@ export function validateAllStepFunctionsExist( ): void { const calledFunctionsOrFunctionGroupsSet = new Set(); for (const step of steps) { - if (step.uses) { + if (step.uses && !isLocalCompositeFunctionPath(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 02f00d8a24..94ef043395 100644 --- a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts +++ b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts @@ -1,4 +1,4 @@ -import { isLocalCompositeFunctionPath } from '../localCompositeFunctions'; +import { isLocalCompositeFunctionPath, parseLocalCompositeFunctionPath } from '../localCompositeFunctions'; describe(isLocalCompositeFunctionPath, () => { it('recognizes relative paths as local composite function paths', () => { @@ -13,3 +13,69 @@ describe(isLocalCompositeFunctionPath, () => { expect(isLocalCompositeFunctionPath('..\\functions\\setup')).toBe(false); }); }); + +describe(parseLocalCompositeFunctionPath, () => { + it('parses local composite function paths', () => { + expect(parseLocalCompositeFunctionPath('./.eas/functions/setup')).toBe( + './.eas/functions/setup' + ); + expect(parseLocalCompositeFunctionPath('../../shared/functions/setup')).toBe( + '../../shared/functions/setup' + ); + }); + + it('normalizes local composite function paths', () => { + expect(parseLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe( + './.eas/functions/setup' + ); + }); + + it('collapses equivalent paths to the same canonical path', () => { + expect(parseLocalCompositeFunctionPath('././.eas/functions/setup')).toBe( + './.eas/functions/setup' + ); + expect(parseLocalCompositeFunctionPath('./.eas/functions/other/../setup')).toBe( + './.eas/functions/setup' + ); + expect(parseLocalCompositeFunctionPath('../shared/other/../functions/setup')).toBe( + '../shared/functions/setup' + ); + }); + + it('keeps the "./" prefix for under-root directories whose name starts with ".."', () => { + expect(parseLocalCompositeFunctionPath('./..functions/setup')).toBe('./..functions/setup'); + }); + + it('throws for degenerate paths with no path segment', () => { + expect(() => parseLocalCompositeFunctionPath('./')).toThrow( + /does not point to a composite function directory/ + ); + expect(() => parseLocalCompositeFunctionPath(' ./ ')).toThrow( + /does not point to a composite function directory/ + ); + expect(() => parseLocalCompositeFunctionPath('../')).toThrow( + /does not point to a composite function directory/ + ); + expect(() => parseLocalCompositeFunctionPath('./..')).toThrow( + /does not point to a composite function directory/ + ); + }); + + it('throws for backslash-based paths', () => { + expect(() => parseLocalCompositeFunctionPath('./functions\\setup')).toThrow( + /must not contain backslashes/ + ); + }); + + it('throws for interpolated local composite function paths', () => { + expect(() => parseLocalCompositeFunctionPath('./.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( + './.eas/functions/weird}}${{name' + ); + }); +}); diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index ab35e99004..15a1ee3dc6 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -1,7 +1,44 @@ +import path from 'path'; + +import { BuildConfigError } from '../errors'; + // Local composite 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 = /\$\{\{(.+?)\}\}/; + +function localCompositeFunctionPathIsInterpolated(uses: string): boolean { + return JOB_CONTEXT_INTERPOLATION_REGEXP.test(uses); +} + +export function parseLocalCompositeFunctionPath(uses: string): string { + const trimmed = uses.trim(); + // The composite function catalog is built before the workflow runs, so a local path must be + // known statically. + if (localCompositeFunctionPathIsInterpolated(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.` + ); + } + if (trimmed.includes('\\')) { + throw new BuildConfigError( + `Local composite function path "${trimmed}" must not contain backslashes. Use forward slashes as path separators.` + ); + } + const normalized = path.posix.normalize(trimmed.replace(/\/+$/, '')); + if (normalized === '.' || normalized === '..') { + throw new BuildConfigError( + `Local composite function path "${trimmed}" does not point to a composite function directory.` + ); + } + return normalized.startsWith('../') ? normalized : `./${normalized}`; +} + export function isLocalCompositeFunctionPath(uses: string): boolean { const trimmed = uses.trim(); return trimmed.startsWith('./') || trimmed.startsWith('../'); } + +export function getLocalCompositeFunctionCallWorkingDirectoryError(uses: string): string { + return `"working_directory" is not supported on a step that calls a local composite function ("uses: ${uses.trim()}"). Set "working_directory" on the steps inside the composite function instead.`; +} diff --git a/packages/steps/src/utils/step.ts b/packages/steps/src/utils/step.ts new file mode 100644 index 0000000000..5ed173e2c4 --- /dev/null +++ b/packages/steps/src/utils/step.ts @@ -0,0 +1,31 @@ +import { ShellStep } from '@expo/eas-build-job'; + +import { BuildStepGlobalContext } from '../BuildStepContext'; +import { BuildStepOutput } from '../BuildStepOutput'; + +export function getShellStepDisplayName(step: ShellStep): string { + return ( + step.name ?? + step.id ?? + step.run + .split('\n') + .find(line => line.trim()) + ?.trim() ?? + step.run + ); +} + +export function createBuildStepOutputsFromDefinition( + ctx: BuildStepGlobalContext, + stepOutputs: Required['outputs'], + stepDisplayName: string +): BuildStepOutput[] { + return stepOutputs.map( + entry => + new BuildStepOutput(ctx, { + id: entry.name, + stepDisplayName, + required: entry.required ?? true, + }) + ); +} From 4289617577cd019eb2e107542bc69c8b0b433a7a Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 17 Jul 2026 15:57:07 +0200 Subject: [PATCH 2/8] [steps] Never treat anchored functions inside composite functions as hook triggers --- packages/steps/src/BuildFunction.ts | 6 +- .../steps/src/CompositeFunctionExpander.ts | 6 +- .../__tests__/StepsConfigParser-hooks-test.ts | 62 ++++++++++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/steps/src/BuildFunction.ts b/packages/steps/src/BuildFunction.ts index e48c2ebab2..3eb8b51cb6 100644 --- a/packages/steps/src/BuildFunction.ts +++ b/packages/steps/src/BuildFunction.ts @@ -167,7 +167,11 @@ export class BuildFunction { isCompositeFunctionInternal, timeoutMs, __metricsId: this.__metricsId, - __hookId: this.__hookId, + // The declaration does not survive composite function expansion: hooks + // never fire around steps inside a composite function, so an expanded + // step must not carry an anchor mark for any discovery mechanism + // (parse-time or a future runtime scan) to find. + __hookId: compositeFunctionScope === undefined ? this.__hookId : undefined, }); } } diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts index 679ee2b8da..53e792a61e 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -96,7 +96,11 @@ export class CompositeFunctionExpander { const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath); const innerSteps = compositeFunction.runs.steps; - const { stepIdMap, newIds } = this.buildInnerStepIdMap(innerSteps, syntheticStepId, compositeFunctionPath); + const { stepIdMap, newIds } = this.buildInnerStepIdMap( + innerSteps, + syntheticStepId, + compositeFunctionPath + ); const nestedVisited = new Set(visited).add(compositeFunctionPath); diff --git a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts index 2a1da9a265..201d544ccc 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts @@ -1,5 +1,6 @@ -import { Hooks, Step } from '@expo/eas-build-job'; +import { CompositeFunctionCatalog, Hooks, Step } from '@expo/eas-build-job'; +import { makeCatalog } from './StepsConfigParser-composite-functions-test-utils'; import { createGlobalContextMock } from './utils/context'; import { getErrorAsync } from './utils/error'; import { BuildFunction } from '../BuildFunction'; @@ -38,12 +39,14 @@ async function parseWorkflowAsync({ hooks, externalFunctions, externalFunctionGroups, + compositeFunctionCatalog, }: { ctx: BuildStepGlobalContext; steps: Step[]; hooks: Hooks | undefined; externalFunctions?: BuildFunction[]; externalFunctionGroups?: BuildFunctionGroup[]; + compositeFunctionCatalog?: CompositeFunctionCatalog; }): Promise { const parser = new StepsConfigParser(ctx, { steps, @@ -53,6 +56,7 @@ async function parseWorkflowAsync({ createCheckoutFunction(), ], externalFunctionGroups, + compositeFunctionCatalog, }); return await parser.parseAsync(); } @@ -499,6 +503,62 @@ describe('StepsConfigParser hooks with function groups', () => { }); }); +describe('StepsConfigParser hooks with composite functions', () => { + let ctx: BuildStepGlobalContext; + + beforeEach(() => { + ctx = createGlobalContextMock(); + }); + + it('never treats an anchored function inside a composite function as a hook trigger', async () => { + const workflow = await parseWorkflowAsync({ + ctx, + steps: [{ uses: './.eas/functions/setup', id: 'setup' }], + hooks: { + before_install_node_modules: [{ run: 'echo never' }], + after_install_node_modules: [{ run: 'echo never' }], + }, + compositeFunctionCatalog: makeCatalog({ + './.eas/functions/setup': { + runs: { steps: [{ uses: 'eas/install_node_modules' }] }, + }, + }), + }); + expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']); + expect(workflow.hooksByAnchorStep.size).toBe(0); + // Structural invariant, not just parse-time behavior: the expanded step + // carries NO anchor mark, so a future runtime discovery mechanism (the + // native hook runner) cannot resolve it as an anchor occurrence either. + expect(workflow.buildSteps[0].__hookId).toBeUndefined(); + }); + + it('anchors only the job-level occurrence when the same function is also called inside a composite function', async () => { + const workflow = await parseWorkflowAsync({ + ctx, + steps: [ + { uses: './.eas/functions/setup', id: 'setup' }, + { uses: 'eas/install_node_modules' }, + ], + hooks: { + before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }], + after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }], + }, + compositeFunctionCatalog: makeCatalog({ + './.eas/functions/setup': { + runs: { steps: [{ uses: 'eas/install_node_modules' }] }, + }, + }), + }); + expect(workflow.hooksByAnchorStep.size).toBe(1); + expect(orderedDisplayNames(workflow)).toEqual([ + 'Install node modules', + 'before-hook', + 'Install node modules', + 'after-hook', + ]); + }); +}); + describe('StepsConfigParser hook validation view', () => { let ctx: BuildStepGlobalContext; From 27764d9f5ebd734e160bc4b88a12ccf133c73969 Mon Sep 17 00:00:00 2001 From: sswrk Date: Tue, 21 Jul 2026 16:17:39 +0200 Subject: [PATCH 3/8] reject hook anchors even in single step composite functions --- packages/steps/src/StepsConfigParser.ts | 16 ++++-- .../__tests__/StepsConfigParser-hooks-test.ts | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts index 30e0b6e4af..9c00d62217 100644 --- a/packages/steps/src/StepsConfigParser.ts +++ b/packages/steps/src/StepsConfigParser.ts @@ -145,17 +145,23 @@ export class StepsConfigParser extends AbstractConfigParser { ); 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)) { + throw new BuildConfigError( + 'Hook anchors are not supported on local composite function steps.' + ); + } const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps); const createdSteps = this.createBuildStepsFromNonGroupStepConfig( stepConfig, maps, compositeFunctionExpander ); - if (createdSteps.length !== 1) { - throw new BuildConfigError( - 'Hook anchors are not supported on local composite function steps that expand into multiple build steps.' - ); - } + assert( + createdSteps.length === 1, + 'a non-composite step config must create exactly one build step' + ); const anchorStep = createdSteps[0]; buildSteps.push(anchorStep); const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps); diff --git a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts index 201d544ccc..499a94d4e2 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts @@ -557,6 +557,55 @@ describe('StepsConfigParser hooks with composite functions', () => { 'after-hook', ]); }); + + it('rejects a REGISTERED stamp on a composite call even when it expands into a single step', async () => { + const error = await getErrorAsync(async () => { + await parseWorkflowAsync({ + ctx, + steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }], + hooks: { before_install_node_modules: [{ run: 'echo never' }] }, + compositeFunctionCatalog: makeCatalog({ + './.eas/functions/setup': { + runs: { steps: [{ run: 'echo setup' }] }, + }, + }), + }); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toBe('Hook anchors are not supported on local composite function steps.'); + }); + + it('rejects a REGISTERED stamp on a composite call that expands into multiple steps', async () => { + const error = await getErrorAsync(async () => { + await parseWorkflowAsync({ + ctx, + steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }], + hooks: { before_install_node_modules: [{ run: 'echo never' }] }, + compositeFunctionCatalog: makeCatalog({ + './.eas/functions/setup': { + runs: { steps: [{ run: 'echo one' }, { run: 'echo two' }] }, + }, + }), + }); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toBe('Hook anchors are not supported on local composite function steps.'); + }); + + it('treats an UNREGISTERED-stamped composite call as an inert ordinary step (skew tolerance)', async () => { + const workflow = await parseWorkflowAsync({ + ctx, + steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'some_future_anchor' }], + hooks: { before_install_node_modules: [{ run: 'echo never' }] }, + compositeFunctionCatalog: makeCatalog({ + './.eas/functions/setup': { + runs: { steps: [{ uses: 'eas/install_node_modules' }] }, + }, + }), + }); + expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']); + expect(workflow.hooksByAnchorStep.size).toBe(0); + }); }); describe('StepsConfigParser hook validation view', () => { From 60683b44b084c5351fe63205f750e79d52c1d872 Mon Sep 17 00:00:00 2001 From: sswrk Date: Tue, 21 Jul 2026 16:19:27 +0200 Subject: [PATCH 4/8] Simplify getLegacyStepOutputValue --- packages/steps/src/BuildStep.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index 5f411295be..01e2d844c3 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -28,7 +28,7 @@ import { evaluateIfCondition as evaluateIfConditionExpression } from './utils/js import { BIN_PATH } from './utils/shell/bin'; import { getShellCommandAndArgs } from './utils/shell/command'; import { spawnAsync } from './utils/shell/spawn'; -import { interpolateWithInputs, interpolateWithOutputs, parseOutputPath } from './utils/template'; +import { interpolateWithInputs, interpolateWithOutputs } from './utils/template'; export enum BuildStepStatus { NEW = 'new', @@ -518,8 +518,7 @@ export class BuildStep extends BuildStepOutputAccessor { } private getLegacyStepOutputValue(path: string): string { - const { stepId, outputId } = parseOutputPath(path); - return this.ctx.global.getStepOutputValue(`steps.${stepId}.${outputId}`) ?? ''; + return this.ctx.global.getStepOutputValue(path) ?? ''; } private async collectAndValidateOutputsAsync(outputsDir: string): Promise { From 85536eddbe33ccbb24913a5e6a40cf8b50c251ed Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 22 Jul 2026 15:21:51 +0200 Subject: [PATCH 5/8] [steps] Represent composite function calls as CompositeBuildStep nodes Parse-time tree owns children and flattens for execution; derive internal-ness from scope instead of a ctor flag. Suppress phantom step-section log markers on the composite node. --- packages/steps/src/BuildFunction.ts | 3 - packages/steps/src/BuildStep.ts | 59 +++++++++----- packages/steps/src/CompositeBuildStep.ts | 77 +++++++++++++++++++ .../steps/src/CompositeFunctionExpander.ts | 65 ++++++++-------- packages/steps/src/StepsConfigParser.ts | 12 +-- packages/steps/src/hooks.ts | 2 +- .../__tests__/localCompositeFunctions-test.ts | 5 +- 7 files changed, 164 insertions(+), 59 deletions(-) create mode 100644 packages/steps/src/CompositeBuildStep.ts diff --git a/packages/steps/src/BuildFunction.ts b/packages/steps/src/BuildFunction.ts index 3eb8b51cb6..b6d64c79ac 100644 --- a/packages/steps/src/BuildFunction.ts +++ b/packages/steps/src/BuildFunction.ts @@ -119,7 +119,6 @@ export class BuildFunction { env, ifCondition, compositeFunctionScope, - isCompositeFunctionInternal, timeoutMs, }: { id?: string; @@ -130,7 +129,6 @@ export class BuildFunction { env?: BuildStepEnv; ifCondition?: string; compositeFunctionScope?: BuildStepCompositeFunctionScope; - isCompositeFunctionInternal?: boolean; timeoutMs?: number; } = {} ): BuildStep { @@ -164,7 +162,6 @@ export class BuildFunction { env, ifCondition, compositeFunctionScope, - isCompositeFunctionInternal, timeoutMs, __metricsId: this.__metricsId, // The declaration does not survive composite function expansion: hooks diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index 01e2d844c3..d3fd8e48b7 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -136,8 +136,6 @@ export class BuildStep extends BuildStepOutputAccessor { public readonly stepEnvOverrides: BuildStepEnv; public readonly ifCondition?: string; public readonly compositeFunctionScope?: BuildStepCompositeFunctionScope; - /** Prefixed expansion steps; top-level composite function outputs stay public. */ - public readonly isCompositeFunctionInternal: boolean; public readonly timeoutMs?: number; public readonly __metricsId?: string; public readonly __hookId?: HookAnchorId; @@ -167,7 +165,6 @@ export class BuildStep extends BuildStepOutputAccessor { env, ifCondition, compositeFunctionScope, - isCompositeFunctionInternal, timeoutMs, __metricsId, __hookId, @@ -184,7 +181,6 @@ export class BuildStep extends BuildStepOutputAccessor { env?: BuildStepEnv; ifCondition?: string; compositeFunctionScope?: BuildStepCompositeFunctionScope; - isCompositeFunctionInternal?: boolean; timeoutMs?: number; __metricsId?: string; __hookId?: HookAnchorId; @@ -206,7 +202,6 @@ export class BuildStep extends BuildStepOutputAccessor { this.shell = shell ?? '/bin/bash -eo pipefail'; this.ifCondition = ifCondition; this.compositeFunctionScope = compositeFunctionScope; - this.isCompositeFunctionInternal = isCompositeFunctionInternal ?? false; this.timeoutMs = timeoutMs; this.__metricsId = __metricsId; this.__hookId = __hookId; @@ -222,15 +217,24 @@ export class BuildStep extends BuildStepOutputAccessor { this.outputsDir = getTemporaryOutputsDirPath(ctx, this.id); this.envsDir = getTemporaryEnvsDirPath(ctx, this.id); + this.registerSelf(ctx); + } + + /** + * Consulted by registerSelf() in the constructor; overrides may only read state + * assigned before that call (outputById, compositeFunctionScope). + */ + public get isCompositeFunctionInternal(): boolean { + return this.compositeFunctionScope !== undefined; + } + + protected registerSelf(ctx: BuildStepGlobalContext): void { ctx.registerStep(this); } public async executeAsync(): Promise { try { - this.ctx.logger.info( - { marker: BuildStepLogMarker.START_STEP }, - `Executing build step "${this.displayName}"` - ); + this.logStepStart(); this.status = BuildStepStatus.IN_PROGRESS; await fs.mkdir(this.outputsDir, { recursive: true }); @@ -279,10 +283,7 @@ export class BuildStep extends BuildStepOutputAccessor { await executionPromise; } - this.ctx.logger.info( - { marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS }, - `Finished build step "${this.displayName}" successfully` - ); + this.logStepSuccess(); this.status = BuildStepStatus.SUCCESS; } catch (err) { // Downstream error handling relies on real Errors; wrap non-Error @@ -293,11 +294,7 @@ export class BuildStep extends BuildStepOutputAccessor { : new BuildStepRuntimeError( `Build step "${this.displayName}" threw a non-Error value: ${util.inspect(err)}` ); - this.ctx.logger.error({ err: error }); - this.ctx.logger.error( - { marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL }, - `Build step "${this.displayName}" failed` - ); + this.logStepFailed(error); this.status = BuildStepStatus.FAIL; throw error; } finally { @@ -383,6 +380,32 @@ export class BuildStep extends BuildStepOutputAccessor { public skip(): void { this.status = BuildStepStatus.SKIPPED; + this.logStepSkipped(); + } + + protected logStepStart(): void { + this.ctx.logger.info( + { marker: BuildStepLogMarker.START_STEP }, + `Executing build step "${this.displayName}"` + ); + } + + protected logStepSuccess(): void { + this.ctx.logger.info( + { marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS }, + `Finished build step "${this.displayName}" successfully` + ); + } + + protected logStepFailed(error: Error): void { + this.ctx.logger.error({ err: error }); + this.ctx.logger.error( + { marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL }, + `Build step "${this.displayName}" failed` + ); + } + + protected logStepSkipped(): void { this.ctx.logger.info( { marker: BuildStepLogMarker.START_STEP }, 'Executing build step "${this.displayName}"' diff --git a/packages/steps/src/CompositeBuildStep.ts b/packages/steps/src/CompositeBuildStep.ts new file mode 100644 index 0000000000..91f38a82b3 --- /dev/null +++ b/packages/steps/src/CompositeBuildStep.ts @@ -0,0 +1,77 @@ +import { BuildStep } from './BuildStep'; +import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; +import { BuildStepGlobalContext } from './BuildStepContext'; + +/** + * Parse-time node for one composite function call (`uses: ./...`). + * Flattening contributes children (and this node only when it declares outputs). + */ +export class CompositeBuildStep extends BuildStep { + public readonly children: BuildStep[]; + + constructor( + ctx: BuildStepGlobalContext, + { + id, + displayName, + scope, + children, + }: { + id: string; + displayName: string; + scope: BuildStepCompositeFunctionScope; + children: BuildStep[]; + } + ) { + super(ctx, { + id, + displayName, + outputs: [], + fn: async () => {}, + ifCondition: '${{ always() }}', + compositeFunctionScope: scope, + }); + this.children = children; + } + + // Safe during super(): outputById is assigned before registerSelf() runs. + public get hasDeclaredOutputs(): boolean { + return this.outputs.length > 0; + } + + /** + * Nested calls stay hidden; a top-level call is public only when it declares outputs. + * Consulted by registerSelf() in the base constructor; may only read state assigned + * before that call (outputById, compositeFunctionScope), never fields like `children`. + */ + public override get isCompositeFunctionInternal(): boolean { + return this.compositeFunctionScope?.parent !== undefined || !this.hasDeclaredOutputs; + } + + // Skip the registry when there are no outputs so we do not shadow a public step that reuses this id. + protected override registerSelf(ctx: BuildStepGlobalContext): void { + if (this.hasDeclaredOutputs) { + super.registerSelf(ctx); + } + } + + public getFlattenedSteps(): BuildStep[] { + const flattened = this.children.flatMap(child => + child instanceof CompositeBuildStep ? child.getFlattenedSteps() : [child] + ); + return this.hasDeclaredOutputs ? [...flattened, this] : flattened; + } + + // Suppress the step-section log markers so log viewers do not render this bookkeeping node as a phantom step. + protected override logStepStart(): void {} + + protected override logStepSuccess(): void {} + + protected override logStepSkipped(): void {} + + // Open a step section on failure so error lines do not bleed into the previous step. + protected override logStepFailed(error: Error): void { + super.logStepStart(); + super.logStepFailed(error); + } +} diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts index 53e792a61e..7002c39d6f 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -1,11 +1,11 @@ /** - * Expands local composite functions (`uses: ./path/to/function`) into concrete {@link BuildStep}s - * at parse time. + * Expands local composite functions (`uses: ./path/to/function`) into a + * {@link CompositeBuildStep} tree at parse time. * - * A composite function call is not executed as a single step. Its `runs.steps` are flattened into the - * workflow with prefixed ids (`caller__inner`) so the existing step runner can execute them. - * Each expanded step carries a {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and - * `${{ inputs.* }}` inside the composite function resolve against composite-function-local names, not global ones. + * 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. */ import { CompositeFunctionCatalog, @@ -23,6 +23,7 @@ import { BuildStep } from './BuildStep'; import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; +import { CompositeBuildStep } from './CompositeBuildStep'; import { BuildConfigError } from './errors'; import { duplicates } from './utils/expodash/duplicates'; import { @@ -41,7 +42,7 @@ export type FunctionMaps = { type CompositeFunctionCall = { compositeFunctionPath: string; - /** Caller-assigned id used as prefix for all inner step ids and the composite function outputs step. */ + /** Caller-assigned id used as prefix for all inner step ids. */ syntheticStepId: string; /** Caller-provided input values, consumed when composite function inputs are interpolated. */ callWith?: Record; @@ -67,7 +68,7 @@ export class CompositeFunctionExpander { step: FunctionStep, compositeFunctionPath: string, syntheticStepId: string - ): BuildStep[] { + ): CompositeBuildStep { this.rejectCompositeFunctionCallWorkingDirectory(step); return this.expand( { @@ -89,11 +90,12 @@ export class CompositeFunctionExpander { } // `visited.size` is the current composite function nesting depth. - private expand(call: CompositeFunctionCall, visited: ReadonlySet): BuildStep[] { + private expand(call: CompositeFunctionCall, visited: ReadonlySet): CompositeBuildStep { const { compositeFunctionPath, syntheticStepId } = call; this.guardAgainstRunawayRecursion(compositeFunctionPath, visited); const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath); + const compositeFunctionDisplayName = compositeFunction.name ?? compositeFunctionPath; const innerSteps = compositeFunction.runs.steps; const { stepIdMap, newIds } = this.buildInnerStepIdMap( @@ -112,7 +114,7 @@ export class CompositeFunctionExpander { stepIdAliases: stepIdMap, }); - const buildSteps = innerSteps.flatMap((innerStep, index) => + const children = innerSteps.map((innerStep, index) => this.expandInnerStep(innerStep, newIds[index], { compositeFunctionPath, scope, @@ -120,7 +122,12 @@ export class CompositeFunctionExpander { }) ); - return buildSteps; + return new CompositeBuildStep(this.ctx, { + id: syntheticStepId, + displayName: compositeFunctionDisplayName, + scope, + children, + }); } private guardAgainstRunawayRecursion( @@ -162,7 +169,7 @@ export class CompositeFunctionExpander { scope: BuildStepCompositeFunctionScope; nestedVisited: ReadonlySet; } - ): BuildStep[] { + ): BuildStep { const overrides = this.resolveStepOverrides(innerStep); if (isStepFunctionStep(innerStep)) { @@ -176,7 +183,7 @@ export class CompositeFunctionExpander { visited: nestedVisited, }); } - return this.createExpandedFunctionSteps(innerStep, { + return this.createExpandedFunctionStep(innerStep, { newId, overrides, scope, @@ -184,7 +191,7 @@ export class CompositeFunctionExpander { }); } if (isStepShellStep(innerStep)) { - return [this.createExpandedShellStep(innerStep, { newId, overrides, scope })]; + return this.createExpandedShellStep(innerStep, { newId, overrides, scope }); } throw new BuildConfigError( `Invalid step configuration in composite function "${compositeFunctionPath}". Step must be a shell or function step.` @@ -214,7 +221,7 @@ export class CompositeFunctionExpander { scope: BuildStepCompositeFunctionScope; visited: ReadonlySet; } - ): BuildStep[] { + ): CompositeBuildStep { return this.expand( { compositeFunctionPath, @@ -254,12 +261,11 @@ export class CompositeFunctionExpander { env: overrides.env, ifCondition: overrides.ifCondition, compositeFunctionScope: scope, - isCompositeFunctionInternal: true, __metricsId: step.__metrics_id, }); } - private createExpandedFunctionSteps( + private createExpandedFunctionStep( step: FunctionStep, { newId, @@ -272,7 +278,7 @@ export class CompositeFunctionExpander { scope: BuildStepCompositeFunctionScope; compositeFunctionPath: string; } - ): BuildStep[] { + ): BuildStep { const functionId = step.uses; const { buildFunctionById, buildFunctionGroupById } = this.functionMaps; const maybeFunctionGroup = buildFunctionGroupById[functionId]; @@ -291,19 +297,16 @@ export class CompositeFunctionExpander { const callInputs = step.with as Record | undefined; - return [ - buildFunction.createBuildStepFromFunctionCall(this.ctx, { - id: newId, - name: step.name, - callInputs, - workingDirectory: overrides.workingDirectory, - shell: step.shell, - env: overrides.env, - ifCondition: overrides.ifCondition, - compositeFunctionScope: scope, - isCompositeFunctionInternal: true, - }), - ]; + return buildFunction.createBuildStepFromFunctionCall(this.ctx, { + id: newId, + name: step.name, + callInputs, + workingDirectory: overrides.workingDirectory, + shell: step.shell, + env: overrides.env, + ifCondition: overrides.ifCondition, + compositeFunctionScope: scope, + }); } /** diff --git a/packages/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts index 9c00d62217..617a3cbd3a 100644 --- a/packages/steps/src/StepsConfigParser.ts +++ b/packages/steps/src/StepsConfigParser.ts @@ -282,11 +282,13 @@ export class StepsConfigParser extends AbstractConfigParser { compositeFunctionExpander: CompositeFunctionExpander ): BuildStep[] { if (isLocalCompositeFunctionPath(step.uses)) { - return compositeFunctionExpander.expandCompositeFunctionStep( - step, - parseCompositeFunctionPath(step.uses), - BuildStep.getNewId(step.id) - ); + return compositeFunctionExpander + .expandCompositeFunctionStep( + step, + parseLocalCompositeFunctionPath(step.uses), + BuildStep.getNewId(step.id) + ) + .getFlattenedSteps(); } const buildFunction = buildFunctionById[step.uses]; diff --git a/packages/steps/src/hooks.ts b/packages/steps/src/hooks.ts index ef51a823c5..84cf80473d 100644 --- a/packages/steps/src/hooks.ts +++ b/packages/steps/src/hooks.ts @@ -17,7 +17,7 @@ import { BuildStep } from './BuildStep'; import { BuildStepGlobalContext } from './BuildStepContext'; import { collectAggregateStepErrors } from './BuildWorkflowValidator'; import { BuildConfigError, BuildWorkflowError } from './errors'; -import { isCompositeFunctionPath } from './utils/localCompositeFunctions'; +import { isLocalCompositeFunctionPath } from './utils/localCompositeFunctions'; import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step'; /** diff --git a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts index 94ef043395..b3ca0f692f 100644 --- a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts +++ b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts @@ -1,4 +1,7 @@ -import { isLocalCompositeFunctionPath, parseLocalCompositeFunctionPath } from '../localCompositeFunctions'; +import { + isLocalCompositeFunctionPath, + parseLocalCompositeFunctionPath, +} from '../localCompositeFunctions'; describe(isLocalCompositeFunctionPath, () => { it('recognizes relative paths as local composite function paths', () => { From 661e9d98862e255f1985ac4c1f0bd4d77eb5431a Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 24 Jul 2026 12:06:13 +0200 Subject: [PATCH 6/8] rename localCompositeFunctionPathIsInterpolated to doesLocalCompositeFunctionPathRequireInterpolation --- packages/steps/src/utils/localCompositeFunctions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index 15a1ee3dc6..ced5a30f2b 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -7,7 +7,7 @@ import { BuildConfigError } from '../errors'; const JOB_CONTEXT_INTERPOLATION_REGEXP = /\$\{\{(.+?)\}\}/; -function localCompositeFunctionPathIsInterpolated(uses: string): boolean { +function doesLocalCompositeFunctionPathRequireInterpolation(uses: string): boolean { return JOB_CONTEXT_INTERPOLATION_REGEXP.test(uses); } @@ -15,7 +15,7 @@ export function parseLocalCompositeFunctionPath(uses: string): string { const trimmed = uses.trim(); // The composite function catalog is built before the workflow runs, so a local path must be // known statically. - if (localCompositeFunctionPathIsInterpolated(trimmed)) { + if (doesLocalCompositeFunctionPathRequireInterpolation(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.` ); From 6e6db87e88fdea5d0259f19729290d016cd7c575 Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 24 Jul 2026 12:18:18 +0200 Subject: [PATCH 7/8] add a comment about ifCondition value in CompositeBuildStep constructor --- packages/steps/src/CompositeBuildStep.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/steps/src/CompositeBuildStep.ts b/packages/steps/src/CompositeBuildStep.ts index 91f38a82b3..f5aaa67587 100644 --- a/packages/steps/src/CompositeBuildStep.ts +++ b/packages/steps/src/CompositeBuildStep.ts @@ -28,6 +28,7 @@ export class CompositeBuildStep extends BuildStep { displayName, outputs: [], fn: async () => {}, + // `scope` already applies the call-site `if`, so `always()` disables the default skip-on-failure. ifCondition: '${{ always() }}', compositeFunctionScope: scope, }); From 8c0a57d788fba47e45eebf08401a8374c09e92e3 Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 24 Jul 2026 14:57:24 +0200 Subject: [PATCH 8/8] [steps] Allow local composite function paths that resolve to the project root or its parent --- ...rser-composite-functions-expansion-test.ts | 25 +++++++++++++++++++ .../__tests__/localCompositeFunctions-test.ts | 23 ++++++++--------- .../src/utils/localCompositeFunctions.ts | 8 +++--- 3 files changed, 38 insertions(+), 18 deletions(-) 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 b075344db1..ba1aa7cff4 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts @@ -111,6 +111,31 @@ describe('StepsConfigParser local composite functions', () => { expect(workflow.buildSteps.map(s => s.id)).toEqual(['top__mid__leaf']); }); + it('expands a composite function defined at the project root ("uses: ./")', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.': { + name: 'Root function', + runs: { steps: [{ id: 'root-step', run: 'echo root' }] }, + }, + }, + steps: [{ uses: './', id: 'root' }], + }); + expect(workflow.buildSteps.map(s => s.id)).toEqual(['root__root-step']); + }); + + it('expands a composite function defined at the project root parent ("uses: ../")', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + '..': { + runs: { steps: [{ id: 'parent-step', run: 'echo parent' }] }, + }, + }, + steps: [{ uses: '../', id: 'parent' }], + }); + expect(workflow.buildSteps.map(s => s.id)).toEqual(['parent__parent-step']); + }); + it('avoids collisions between generated inner step ids and declared inner step ids', async () => { const workflow = await parseCompositeFunctions({ catalog: { diff --git a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts index b3ca0f692f..305617eb50 100644 --- a/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts +++ b/packages/steps/src/utils/__tests__/localCompositeFunctions-test.ts @@ -49,19 +49,16 @@ describe(parseLocalCompositeFunctionPath, () => { expect(parseLocalCompositeFunctionPath('./..functions/setup')).toBe('./..functions/setup'); }); - it('throws for degenerate paths with no path segment', () => { - expect(() => parseLocalCompositeFunctionPath('./')).toThrow( - /does not point to a composite function directory/ - ); - expect(() => parseLocalCompositeFunctionPath(' ./ ')).toThrow( - /does not point to a composite function directory/ - ); - expect(() => parseLocalCompositeFunctionPath('../')).toThrow( - /does not point to a composite function directory/ - ); - expect(() => parseLocalCompositeFunctionPath('./..')).toThrow( - /does not point to a composite function directory/ - ); + it('canonicalizes paths pointing at the project root or its parent', () => { + expect(parseLocalCompositeFunctionPath('./')).toBe('./.'); + expect(parseLocalCompositeFunctionPath(' ./ ')).toBe('./.'); + expect(parseLocalCompositeFunctionPath('../')).toBe('..'); + expect(parseLocalCompositeFunctionPath('./..')).toBe('..'); + }); + + it('is stable when re-parsing its own canonical output', () => { + expect(parseLocalCompositeFunctionPath('./.')).toBe('./.'); + expect(parseLocalCompositeFunctionPath('../.')).toBe('..'); }); it('throws for backslash-based paths', () => { diff --git a/packages/steps/src/utils/localCompositeFunctions.ts b/packages/steps/src/utils/localCompositeFunctions.ts index ced5a30f2b..93b30923e6 100644 --- a/packages/steps/src/utils/localCompositeFunctions.ts +++ b/packages/steps/src/utils/localCompositeFunctions.ts @@ -26,12 +26,10 @@ export function parseLocalCompositeFunctionPath(uses: string): string { ); } const normalized = path.posix.normalize(trimmed.replace(/\/+$/, '')); - if (normalized === '.' || normalized === '..') { - throw new BuildConfigError( - `Local composite function path "${trimmed}" does not point to a composite function directory.` - ); + if (normalized === '..' || normalized.startsWith('../')) { + return normalized; } - return normalized.startsWith('../') ? normalized : `./${normalized}`; + return `./${normalized}`; } export function isLocalCompositeFunctionPath(uses: string): boolean {