From 45a0dcc68f21fbc2e8a10d7e9b4e0489b217370a Mon Sep 17 00:00:00 2001 From: sswrk Date: Mon, 13 Jul 2026 22:25:59 +0200 Subject: [PATCH 1/6] [steps] Interpolate composite function inputs and scope caller context --- packages/steps/src/BuildStep.ts | 56 +- .../src/BuildStepCompositeFunctionScope.ts | 107 +- packages/steps/src/BuildStepContext.ts | 12 +- packages/steps/src/BuildStepInput.ts | 19 + packages/steps/src/BuildWorkflowValidator.ts | 19 +- .../steps/src/CompositeFunctionExpander.ts | 94 +- .../steps/src/__tests__/BuildStep-test.ts | 3 + .../BuildStepCompositeFunctionScope-test.ts | 59 + ...gParser-composite-functions-inputs-test.ts | 206 +++ ...Parser-composite-functions-scoping-test.ts | 1226 +++++++++++++++++ ...igParser-composite-functions-test-utils.ts | 75 + .../compositeFunctionInterpolation-test.ts | 80 ++ .../utils/compositeFunctionInterpolation.ts | 33 + 13 files changed, 1965 insertions(+), 24 deletions(-) create mode 100644 packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts create mode 100644 packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts create mode 100644 packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts create mode 100644 packages/steps/src/utils/__tests__/compositeFunctionInterpolation-test.ts create mode 100644 packages/steps/src/utils/compositeFunctionInterpolation.ts diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index d3fd8e48b7..9c0dac2e9c 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -24,6 +24,12 @@ import { } from './BuildTemporaryFiles'; import { BuildStepRuntimeError } from './errors'; import { interpolateJobContext } from './interpolation'; +import { + containsUnresolvedTemplateReference, + resolveInterpolatedTarget, + stringifyInterpolatedResult, + stringifyOptionalInterpolatedResult, +} from './utils/compositeFunctionInterpolation'; import { evaluateIfCondition as evaluateIfConditionExpression } from './utils/jsepEval'; import { BIN_PATH } from './utils/shell/bin'; import { getShellCommandAndArgs } from './utils/shell/command'; @@ -234,6 +240,8 @@ export class BuildStep extends BuildStepOutputAccessor { public async executeAsync(): Promise { try { + this.resolveTemplatedWorkingDirectoryIfNeeded(); + this.logStepStart(); this.status = BuildStepStatus.IN_PROGRESS; @@ -506,7 +514,7 @@ export class BuildStep extends BuildStepOutputAccessor { template: string, inputs?: BuildStepInput[] ): string { - // Actions support only `${{ }}`; a literal `${ steps.x.y }` reaching bash is the accepted + // Composite functions 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) { @@ -597,8 +605,32 @@ export class BuildStep extends BuildStepOutputAccessor { }); } + private getInterpolatedEnvOverrides(): BuildStepEnv { + const ownOverrides = this.stepEnvOverrides; + if (!this.compositeFunctionScope) { + return ownOverrides; + } + // Use global env here to avoid recursing through getScriptEnv. + const base: JobInterpolationContext = { + ...this.ctx.global.getInterpolationContext(), + env: this.ctx.global.env, + }; + // Call-site env uses caller scope; step env uses action scope. + const inheritedEnv = this.compositeFunctionScope.resolveInheritedEnv(base); + const scoped = this.compositeFunctionScope.getScopedInterpolationContext(base); + const ownEnv = Object.fromEntries( + Object.entries(ownOverrides).map(([key, value]) => { + if (typeof value !== 'string' || !containsUnresolvedTemplateReference(value)) { + return [key, value]; + } + return [key, stringifyInterpolatedResult(resolveInterpolatedTarget(value, scoped))]; + }) + ); + return { ...inheritedEnv, ...ownEnv }; + } + private getScriptEnv(): Record { - const effectiveEnv = { ...this.ctx.global.env, ...this.stepEnvOverrides }; + const effectiveEnv = { ...this.ctx.global.env, ...this.getInterpolatedEnvOverrides() }; const currentPath = effectiveEnv.PATH ?? process.env.PATH; const newPath = currentPath ? `${BIN_PATH}:${currentPath}` : BIN_PATH; return { @@ -609,4 +641,24 @@ export class BuildStep extends BuildStepOutputAccessor { PATH: newPath, }; } + + private resolveTemplatedWorkingDirectoryIfNeeded(): void { + const scope = this.compositeFunctionScope; + if (!scope) { + return; + } + const relativeWorkingDirectory = this.ctx.relativeWorkingDirectory; + if ( + relativeWorkingDirectory === undefined || + !containsUnresolvedTemplateReference(relativeWorkingDirectory) + ) { + return; + } + const context = scope.getScopedInterpolationContext({ + ...this.ctx.global.getInterpolationContext(), + env: this.getScriptEnv(), + }); + const resolved = resolveInterpolatedTarget(relativeWorkingDirectory, context); + this.ctx.updateRelativeWorkingDirectory(stringifyOptionalInterpolatedResult(resolved)); + } } diff --git a/packages/steps/src/BuildStepCompositeFunctionScope.ts b/packages/steps/src/BuildStepCompositeFunctionScope.ts index f4e0b113b7..7e34d0e276 100644 --- a/packages/steps/src/BuildStepCompositeFunctionScope.ts +++ b/packages/steps/src/BuildStepCompositeFunctionScope.ts @@ -10,37 +10,59 @@ import { JobInterpolationContext } from '@expo/eas-build-job'; import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; +import { BuildStepInput } from './BuildStepInput'; +import { BuildStepRuntimeError } from './errors'; +import { + resolveInterpolatedTarget, + stringifyInterpolatedResult, +} from './utils/compositeFunctionInterpolation'; export type EvaluateIfExpression = ( expression: string, context: JobInterpolationContext ) => boolean; +type ScopedInterpolationContext = JobInterpolationContext & { inputs: Record }; + export class BuildStepCompositeFunctionScope { public readonly parent?: BuildStepCompositeFunctionScope; public readonly env?: BuildStepEnv; private readonly ctx: BuildStepGlobalContext; private readonly ifCondition?: string; + private readonly compositeFunctionPath: string; + private readonly inputs: Map; + private readonly providedInputKeys: ReadonlySet; private readonly stepIdAliases: Map; private cachedIsActive?: boolean; + // Detects cycles while resolving input default values. + private readonly resolvingInputs = new Set(); constructor({ ctx, parent, ifCondition, env, + compositeFunctionPath, + inputs, + providedInputKeys, stepIdAliases, }: { ctx: BuildStepGlobalContext; parent?: BuildStepCompositeFunctionScope; ifCondition?: string; env?: BuildStepEnv; + compositeFunctionPath: string; + inputs: Map; + providedInputKeys: ReadonlySet; stepIdAliases: Map; }) { this.ctx = ctx; this.parent = parent; this.ifCondition = ifCondition; this.env = env; + this.compositeFunctionPath = compositeFunctionPath; + this.inputs = inputs; + this.providedInputKeys = providedInputKeys; this.stepIdAliases = stepIdAliases; } @@ -57,13 +79,16 @@ export class BuildStepCompositeFunctionScope { 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. + // Call-site if uses caller env/inputs/steps, not expanded inner steps. private evaluateCallIfCondition(evaluate: EvaluateIfExpression): boolean { if (!this.ifCondition) { return !this.ctx.hasAnyPreviousStepFailed; } - const env = { ...this.ctx.env, ...(this.env ?? {}) }; + const callerBase: JobInterpolationContext = { + ...this.ctx.getInterpolationContext(), + env: this.ctx.env, + }; + const env = { ...this.ctx.env, ...this.resolveInheritedEnv(callerBase) }; const baseContext = this.ctx.getIfConditionContext({ inputs: {}, env, @@ -79,10 +104,38 @@ export class BuildStepCompositeFunctionScope { } public getScopedInterpolationContext(base: JobInterpolationContext): JobInterpolationContext { - return { + // Spread `base` into a new object; never spread the returned context, as that would eagerly + // resolve every lazy input getter. + const scoped: ScopedInterpolationContext = { ...base, steps: this.buildStepsView(), + inputs: this.buildInputsObject(base), }; + return scoped; + } + + /** Caller scope for `with:` values and call-site env. */ + public getCallerInterpolationContext(base: JobInterpolationContext): JobInterpolationContext { + return this.parent ? this.parent.getScopedInterpolationContext(base) : base; + } + + public resolveScopeEnv(base: JobInterpolationContext): BuildStepEnv { + if (!this.env) { + return {}; + } + const callerContext = this.getCallerInterpolationContext(base); + return Object.fromEntries( + Object.entries(this.env).map(([key, value]) => [ + key, + stringifyInterpolatedResult(resolveInterpolatedTarget(value, callerContext)), + ]) + ); + } + + /** Merge call-site env from each scope level, outer to inner; inner keys win. */ + public resolveInheritedEnv(base: JobInterpolationContext): BuildStepEnv { + const parentEnv = this.parent?.resolveInheritedEnv(base) ?? {}; + return { ...parentEnv, ...this.resolveScopeEnv(base) }; } // Workflow hides prefixed ids; re-expose them under short aliases. @@ -97,4 +150,50 @@ export class BuildStepCompositeFunctionScope { } return view; } + + private buildInputsObject(base: JobInterpolationContext): Record { + const inputs: Record = {}; + for (const [name, input] of this.inputs) { + Object.defineProperty(inputs, name, { + enumerable: true, + configurable: true, + get: () => this.resolveInputValue(name, input, base), + }); + } + return inputs; + } + + private resolveInputValue( + name: string, + input: BuildStepInput, + base: JobInterpolationContext + ): unknown { + if (this.resolvingInputs.has(name)) { + throw new BuildStepRuntimeError( + `Composite function "${this.compositeFunctionPath}" input "${name}" references itself, directly or indirectly, through its default value.` + ); + } + this.resolvingInputs.add(name); + try { + const isProvided = this.providedInputKeys.has(name); + // Env binds at the call boundary so inner step `env:` overrides do not leak into inputs. + const boundBase: JobInterpolationContext = { + ...base, + env: this.resolveCompositeFunctionBoundaryEnv(base), + }; + // Caller-provided values resolve in the caller's scope; defaults resolve in this composite function's. + const interpolationContext = isProvided + ? this.getCallerInterpolationContext(boundBase) + : this.getScopedInterpolationContext(boundBase); + return input.getValue({ interpolationContext, skipLegacyOutputInterpolation: true }); + } finally { + this.resolvingInputs.delete(name); + } + } + + // Global + inherited call-site env; inputs ignore inner overrides. + public resolveCompositeFunctionBoundaryEnv(base: JobInterpolationContext): BuildStepEnv { + const boundaryBase: JobInterpolationContext = { ...base, env: this.ctx.env }; + return { ...this.ctx.env, ...this.resolveInheritedEnv(boundaryBase) }; + } } diff --git a/packages/steps/src/BuildStepContext.ts b/packages/steps/src/BuildStepContext.ts index 09dd435d9d..e4d0b4caba 100644 --- a/packages/steps/src/BuildStepContext.ts +++ b/packages/steps/src/BuildStepContext.ts @@ -328,7 +328,7 @@ export interface SerializedBuildStepContext { export class BuildStepContext { public readonly logger: bunyan; - public readonly relativeWorkingDirectory?: string; + private _relativeWorkingDirectory?: string; constructor( private readonly ctx: BuildStepGlobalContext, @@ -341,7 +341,15 @@ export class BuildStepContext { } ) { this.logger = logger ?? ctx.baseLogger; - this.relativeWorkingDirectory = relativeWorkingDirectory; + this._relativeWorkingDirectory = relativeWorkingDirectory; + } + + public get relativeWorkingDirectory(): string | undefined { + return this._relativeWorkingDirectory; + } + + public updateRelativeWorkingDirectory(value: string | undefined): void { + this._relativeWorkingDirectory = value; } public get global(): BuildStepGlobalContext { diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts index afe5e56a63..2b7d61f505 100644 --- a/packages/steps/src/BuildStepInput.ts +++ b/packages/steps/src/BuildStepInput.ts @@ -236,6 +236,25 @@ export class BuildStepInput< } } +/** Raw allowed-values check; skips step/context refs that only resolve at runtime. */ +export function getDisallowedInputValueError( + input: BuildStepInput, + stepDisplayName: string +): string | undefined { + if (input.rawValue === undefined) { + return undefined; + } + if (input.isRawValueStepOrContextReference() || input.isRawValueOneOfAllowedValues()) { + return undefined; + } + const rendered = + typeof input.rawValue === 'object' ? JSON.stringify(input.rawValue) : String(input.rawValue); + const allowedValues = (input.allowedValues ?? []) + .map(value => (typeof value === 'object' ? JSON.stringify(value) : `"${value}"`)) + .join(', '); + return `Input parameter "${input.id}" for step "${stepDisplayName}" is set to "${rendered}" which is not one of the allowed values: ${allowedValues}.`; +} + export function makeBuildStepInputByIdMap(inputs?: BuildStepInput[]): BuildStepInputById { if (inputs === undefined) { return {}; diff --git a/packages/steps/src/BuildWorkflowValidator.ts b/packages/steps/src/BuildWorkflowValidator.ts index cea992fdef..4b4405ed01 100644 --- a/packages/steps/src/BuildWorkflowValidator.ts +++ b/packages/steps/src/BuildWorkflowValidator.ts @@ -3,7 +3,7 @@ import path from 'path'; import { BuildRuntimePlatform } from './BuildRuntimePlatform'; import { BuildStep } from './BuildStep'; -import { BuildStepInputValueTypeName } from './BuildStepInput'; +import { BuildStepInputValueTypeName, getDisallowedInputValueError } from './BuildStepInput'; // Type-only to keep the runtime module graph acyclic (BuildWorkflow imports // hooks.ts, which imports this module for the shared aggregate checks). import type { BuildWorkflow } from './BuildWorkflow'; @@ -128,17 +128,12 @@ function validateInputs(steps: readonly BuildStep[]): BuildConfigError[] { if (currentStepInput.defaultValue === undefined) { continue; } - if (!currentStepInput.isRawValueOneOfAllowedValues()) { - const error = new BuildConfigError( - `Input parameter "${currentStepInput.id}" for step "${ - currentStep.displayName - }" is set to "${ - currentStepInput.rawValue - }" which is not one of the allowed values: ${nullthrows(currentStepInput.allowedValues) - .map(i => `"${i}"`) - .join(', ')}.` - ); - errors.push(error); + const disallowedValueError = getDisallowedInputValueError( + currentStepInput, + currentStep.displayName + ); + if (disallowedValueError) { + errors.push(new BuildConfigError(disallowedValueError)); } const paths = typeof currentStepInput.defaultValue === 'string' diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts index 7002c39d6f..d7c3a460f1 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -23,6 +23,11 @@ import { BuildStep } from './BuildStep'; import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; +import { + BuildStepInput, + BuildStepInputValueTypeName, + getDisallowedInputValueError, +} from './BuildStepInput'; import { CompositeBuildStep } from './CompositeBuildStep'; import { BuildConfigError } from './errors'; import { duplicates } from './utils/expodash/duplicates'; @@ -44,6 +49,7 @@ type CompositeFunctionCall = { compositeFunctionPath: string; /** Caller-assigned id used as prefix for all inner step ids. */ syntheticStepId: string; + name?: string; /** Caller-provided input values, consumed when composite function inputs are interpolated. */ callWith?: Record; callIf?: string; @@ -95,7 +101,8 @@ export class CompositeFunctionExpander { this.guardAgainstRunawayRecursion(compositeFunctionPath, visited); const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath); - const compositeFunctionDisplayName = compositeFunction.name ?? compositeFunctionPath; + const compositeFunctionDisplayName = + call.name ?? compositeFunction.name ?? compositeFunctionPath; const innerSteps = compositeFunction.runs.steps; const { stepIdMap, newIds } = this.buildInnerStepIdMap( @@ -103,6 +110,11 @@ export class CompositeFunctionExpander { syntheticStepId, compositeFunctionPath ); + const { inputs, providedInputKeys } = this.buildActionInputs( + compositeFunction, + compositeFunctionPath, + call.callWith + ); const nestedVisited = new Set(visited).add(compositeFunctionPath); @@ -111,6 +123,9 @@ export class CompositeFunctionExpander { parent: call.parentScope, ifCondition: call.callIf, env: call.inheritedEnv, + compositeFunctionPath, + inputs, + providedInputKeys, stepIdAliases: stepIdMap, }); @@ -148,13 +163,13 @@ export class CompositeFunctionExpander { } private lookupCompositeFunction(compositeFunctionPath: string): CompositeFunctionConfig { - const compositeFunction = this.compositeFunctionCatalog[compositeFunctionPath]; - if (!compositeFunction) { + const action = this.compositeFunctionCatalog[compositeFunctionPath]; + if (!action) { 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; + return action; } private expandInnerStep( @@ -344,4 +359,75 @@ export class CompositeFunctionExpander { } return { stepIdMap, newIds }; } + + // Nullish `with` values are treated as absent so defaults resolve in the composite function's own scope. + private buildActionInputs( + action: CompositeFunctionConfig, + compositeFunctionPath: string, + callWith: Record | undefined + ): { inputs: Map; providedInputKeys: Set } { + const inputs = new Map(); + const providedInputKeys = new Set(); + for (const declared of action.inputs ?? []) { + const definition = + typeof declared === 'string' + ? { + name: declared, + type: 'string', + required: false, + allowedValues: undefined as unknown[] | undefined, + defaultValue: undefined as unknown, + } + : { + name: declared.name, + type: declared.type, + required: declared.required ?? false, + allowedValues: declared.allowed_values, + defaultValue: declared.default_value, + }; + const input = new BuildStepInput(this.ctx, { + id: definition.name, + stepDisplayName: compositeFunctionPath, + defaultValue: definition.defaultValue, + required: definition.required, + allowedValues: definition.allowedValues, + allowedValueTypeName: this.toInputValueTypeName( + definition.type, + compositeFunctionPath, + definition.name + ), + }); + if (callWith && Object.prototype.hasOwnProperty.call(callWith, definition.name)) { + const value = callWith[definition.name]; + if (value !== undefined) { + input.set(value); + } + if (value != null) { + providedInputKeys.add(definition.name); + } + } + const disallowedValueError = getDisallowedInputValueError(input, compositeFunctionPath); + if (disallowedValueError) { + throw new BuildConfigError(disallowedValueError); + } + inputs.set(definition.name, input); + } + return { inputs, providedInputKeys }; + } + + private toInputValueTypeName( + type: string, + compositeFunctionPath: string, + inputName: string + ): BuildStepInputValueTypeName { + const supported = Object.values(BuildStepInputValueTypeName) as string[]; + if (!supported.includes(type)) { + throw new BuildConfigError( + `Composite function "${compositeFunctionPath}" input "${inputName}" has unsupported type "${type}". Supported types: ${supported.join( + ', ' + )}.` + ); + } + return type as BuildStepInputValueTypeName; + } } diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index 8f80df3e57..b8c85df269 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -636,6 +636,9 @@ describe(BuildStep, () => { 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, + compositeFunctionPath: 'test-action', + inputs: new Map(), + providedInputKeys: new Set(), stepIdAliases: new Map(), }); diff --git a/packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts b/packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts new file mode 100644 index 0000000000..5fa8c474da --- /dev/null +++ b/packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts @@ -0,0 +1,59 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; +import { instance, mock, when } from 'ts-mockito'; + +import { createGlobalContextMock } from './utils/context'; +import { BuildStep } from '../BuildStep'; +import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput'; +import { BuildStepOutput } from '../BuildStepOutput'; +import { interpolateJobContext } from '../interpolation'; + +describe(BuildStepCompositeFunctionScope, () => { + const baseContext = {} as unknown as JobInterpolationContext; + + function makeScope(): BuildStepCompositeFunctionScope { + const ctx = createGlobalContextMock(); + + // Aliases resolve against registered global steps, not base. + const versionOutput = mock(); + when(versionOutput.id).thenReturn('version'); + when(versionOutput.rawValue).thenReturn('1.0.0'); + const innerStep = mock(); + when(innerStep.id).thenReturn('setup__build'); + when(innerStep.outputs).thenReturn([instance(versionOutput)]); + ctx.registerStep(instance(innerStep)); + + const greeting = new BuildStepInput(ctx, { + id: 'greeting', + stepDisplayName: 'test-action', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + greeting.set('hello'); + return new BuildStepCompositeFunctionScope({ + ctx, + compositeFunctionPath: 'test-action', + inputs: new Map([['greeting', greeting]]), + providedInputKeys: new Set(['greeting']), + stepIdAliases: new Map([['build', 'setup__build']]), + }); + } + + it('exposes declared inputs and composite-function-local step aliases in the interpolation context', () => { + const scope = makeScope(); + const context = scope.getScopedInterpolationContext(baseContext); + expect(interpolateJobContext({ target: '${{ inputs.greeting }}', context })).toBe('hello'); + expect(interpolateJobContext({ target: '${{ steps.build.outputs.version }}', context })).toBe( + '1.0.0' + ); + }); + + it('returns undefined for references outside the composite function scope', () => { + const scope = makeScope(); + const context = scope.getScopedInterpolationContext(baseContext); + expect(interpolateJobContext({ target: '${{ inputs.gretting }}', context })).toBeUndefined(); + expect( + interpolateJobContext({ target: '${{ steps.checkout.outputs.sha }}', context }) + ).toBeUndefined(); + }); +}); diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts new file mode 100644 index 0000000000..f173178af6 --- /dev/null +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts @@ -0,0 +1,206 @@ +import { + SETUP, + actionReadingInput, + echoInputAction, + parseCompositeFunctions, + passThroughFunction, +} from './StepsConfigParser-composite-functions-test-utils'; +import { getErrorAsync } from './utils/error'; +import { BuildConfigError, BuildStepRuntimeError } from '../errors'; + +describe('StepsConfigParser local composite functions', () => { + describe('input handling', () => { + it('uses the composite function input default when the caller omits the value', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: echoInputAction('greeting', { + name: 'greeting', + type: 'string', + default_value: 'hello', + }), + }, + steps: [{ uses: SETUP, id: 'setup' }], + }); + expect(workflow.buildSteps[0].command).toBe('echo "${{ inputs.greeting }}"'); + }); + + it.each([ + [ + 'boolean', + { name: 'enabled', type: 'boolean' }, + { enabled: true }, + 'echo "${{ inputs.enabled }}"', + ], + ['number', { name: 'count', type: 'number' }, { count: 3 }, 'echo "${{ inputs.count }}"'], + ])( + 'accepts an explicit %s value, leaving the command raw', + async (_typeLabel, inputDef, withInputs, expectedCommand) => { + const workflow = await parseCompositeFunctions({ + catalog: { [SETUP]: echoInputAction(inputDef.name, inputDef) }, + steps: [{ uses: SETUP, id: 'setup', with: withInputs }], + }); + expect(workflow.buildSteps[0].command).toBe(expectedCommand); + } + ); + + it('ignores unknown caller inputs, matching function-step behavior', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { [SETUP]: actionReadingInput({ name: 'greeting', type: 'string' }) }, + steps: [{ uses: SETUP, id: 'setup', with: { greetng: 'hi' } }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out') + ).toBe(''); + }); + + it('does not error for a missing required input that is never referenced', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'token', type: 'string', required: true }], + runs: { steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: 'static' } }] }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out') + ).toBe('static'); + }); + + it.each([ + [ + 'a required input is missing but referenced', + actionReadingInput({ name: 'token', type: 'string', required: true }), + [{ uses: SETUP, id: 'setup' }], + /Input parameter "token" for step ".+" is required but it was not set/, + ], + [ + 'a provided input has the wrong type', + actionReadingInput({ name: 'count', type: 'number' }), + [{ uses: SETUP, id: 'setup', with: { count: 'two' } }], + /Input parameter "count" for step ".+" must be of type "number"/, + ], + [ + 'a string does not parse as JSON for a json input', + actionReadingInput({ name: 'config', type: 'json' }), + [{ uses: SETUP, id: 'setup', with: { config: 'literal' } }], + /Input parameter "config" for step ".+" must be of type "json"/, + ], + ])('errors at runtime when %s', async (_, actionConfig, steps, message) => { + const workflow = await parseCompositeFunctions({ + catalog: { [SETUP]: actionConfig }, + steps, + externalFunctions: [passThroughFunction()], + }); + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(message); + }); + + it('coerces a provided string value to the declared number type', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'count', type: 'number' }], + runs: { + steps: [ + { + id: 'inner', + uses: 'test/passthrough', + // Without number coercion, `'2' + 1` would render as `21`. + with: { value: 'next-${{ inputs.count + 1 }}' }, + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { count: '2' } }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out') + ).toBe('next-3'); + }); + + it('falls back to the default resolved in the composite function scope when the caller passes null', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: actionReadingInput({ name: 'greeting', type: 'string', default_value: 'hello' }), + }, + steps: [{ uses: SETUP, id: 'setup', with: { greeting: null } }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out') + ).toBe('hello'); + }); + + it('accepts a provided literal value that is one of allowed_values', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: actionReadingInput({ + name: 'greeting', + type: 'string', + allowed_values: ['hi', 'hello'], + }), + }, + steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hello' } }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out') + ).toBe('hello'); + }); + + it('rejects at parse time a provided literal value outside allowed_values', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { + [SETUP]: actionReadingInput({ + name: 'greeting', + type: 'string', + allowed_values: ['hi', 'hello'], + }), + }, + steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'bye' } }], + externalFunctions: [passThroughFunction()], + }) + ); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch(/not one of the allowed values/); + }); + + it('resolves an undeclared input reference to an empty value', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }], + runs: { + steps: [ + { + id: 'inner', + uses: 'test/passthrough', + with: { value: '${{ inputs.gretting }}' }, + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out') + ).toBe(''); + }); + }); +}); diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts new file mode 100644 index 0000000000..69d8a3ff6f --- /dev/null +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts @@ -0,0 +1,1226 @@ +import { + SETUP, + captureEnvFunction, + echoFunction, + failingFunction, + makeCatalog, + parseCompositeFunctions, + passThroughFunction, + setVersionFunction, +} from './StepsConfigParser-composite-functions-test-utils'; +import { createGlobalContextMock } from './utils/context'; +import { getErrorAsync } from './utils/error'; +import { BuildFunction } from '../BuildFunction'; +import { StepsConfigParser } from '../StepsConfigParser'; +import { BuildStepStatus } from '../BuildStep'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput'; +import { BuildStepOutput } from '../BuildStepOutput'; +import { BuildWorkflow } from '../BuildWorkflow'; +import { BuildConfigError, BuildStepRuntimeError } from '../errors'; + +describe('StepsConfigParser local composite functions', () => { + describe('step reference scoping', () => { + it("resolves inner steps.* references against the composite function's own steps, isolating two callers", async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/pair': { + inputs: [{ name: 'tag', type: 'string', required: true }], + runs: { + steps: [ + { id: 'a', uses: 'test/passthrough', with: { value: '${{ inputs.tag }}' } }, + { + id: 'b', + uses: 'test/passthrough', + with: { value: '${{ steps.a.outputs.out }}' }, + }, + ], + }, + }, + }, + steps: [ + { uses: './.eas/functions/pair', id: 'first', with: { tag: 'first-tag' } }, + { uses: './.eas/functions/pair', id: 'second', with: { tag: 'second-tag' } }, + ], + externalFunctions: [passThroughFunction()], + }); + + const ids = workflow.buildSteps.map(s => s.id); + expect(ids).toEqual(['first__a', 'first__b', 'second__a', 'second__b']); + + await workflow.executeAsync(); + + expect(workflow.buildSteps.find(s => s.id === 'first__b')?.getOutputValueByName('out')).toBe( + 'first-tag' + ); + expect(workflow.buildSteps.find(s => s.id === 'second__b')?.getOutputValueByName('out')).toBe( + 'second-tag' + ); + }); + + it('rejects a legacy workflow-realm reference to a flattened inner step id', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + outputs: { version: { value: '${{ steps.read.outputs.version }}' } }, + runs: { + steps: [{ id: 'read', uses: 'test/set-version' }], + }, + }, + }, + steps: [ + { uses: SETUP, id: 'setup' }, + { + id: 'leak-legacy', + uses: 'test/passthrough', + with: { value: '${ steps.setup__read.version }' }, + }, + ], + externalFunctions: [setVersionFunction(), passThroughFunction()], + }); + + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(/Step "setup__read" does not exist/); + }); + + it('leaves step references raw, untouched inside longer identifiers', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/pair': { + runs: { + steps: [ + { id: 'a', run: 'set-output v "x"' }, + { id: 'b', run: 'echo "mysteps.a.outputs.v ${{ steps.a.outputs.v }}"' }, + ], + }, + }, + }, + steps: [{ uses: './.eas/functions/pair', id: 'caller' }], + }); + + const bStep = workflow.buildSteps.find(s => s.id === 'caller__b'); + expect(bStep?.command).toBe('echo "mysteps.a.outputs.v ${{ steps.a.outputs.v }}"'); + }); + + it('resolves references to steps whose ids share a prefix', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/pair': { + runs: { + steps: [ + { id: 'a', uses: 'test/passthrough', with: { value: 'short' } }, + { id: 'ab', uses: 'test/passthrough', with: { value: 'long' } }, + { + id: 'c', + uses: 'test/passthrough', + with: { value: '${{ steps.a.outputs.out }}-${{ steps.ab.outputs.out }}' }, + }, + ], + }, + }, + }, + steps: [{ uses: './.eas/functions/pair', id: 'caller' }], + externalFunctions: [passThroughFunction()], + }); + + await workflow.executeAsync(); + expect(workflow.buildSteps.find(s => s.id === 'caller__c')?.getOutputValueByName('out')).toBe( + 'short-long' + ); + }); + + it('resolves caller step references passed through composite function inputs against the caller scope', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/notify': { + inputs: [{ name: 'msg', type: 'string', required: true }], + runs: { + steps: [ + { id: 'build', uses: 'test/passthrough', with: { value: 'inner build' } }, + { id: 'report', uses: 'test/passthrough', with: { value: '${{ inputs.msg }}' } }, + ], + }, + }, + }, + steps: [ + { id: 'build', uses: 'test/set-version' }, + { + uses: './.eas/functions/notify', + id: 'notify', + with: { msg: '${{ steps.build.outputs.version }}' }, + }, + ], + externalFunctions: [passThroughFunction(), setVersionFunction()], + }); + + await workflow.executeAsync(); + + expect( + workflow.buildSteps.find(s => s.id === 'notify__report')?.getOutputValueByName('out') + ).toBe('$(echo injected)'); + }); + + it("resolves inner step references in action input defaults against the composite function's own steps", async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/notify': { + inputs: [ + { name: 'msg', type: 'string', default_value: '${{ steps.build.outputs.version }}' }, + ], + runs: { + steps: [ + { id: 'build', uses: 'test/set-version' }, + { id: 'report', uses: 'test/passthrough', with: { value: '${{ inputs.msg }}' } }, + ], + }, + }, + }, + steps: [{ uses: './.eas/functions/notify', id: 'notify' }], + externalFunctions: [passThroughFunction(), setVersionFunction()], + }); + + await workflow.executeAsync(); + + expect( + workflow.buildSteps.find(s => s.id === 'notify__report')?.getOutputValueByName('out') + ).toBe('$(echo injected)'); + }); + + it('resolves an out-of-scope step reference to an empty value', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/notify': { + runs: { + steps: [ + { + id: 'report', + uses: 'test/passthrough', + with: { value: '${{ steps.checkout.outputs.sha }}' }, + }, + ], + }, + }, + }, + steps: [{ uses: './.eas/functions/notify', id: 'notify' }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'notify__report')?.getOutputValueByName('out') + ).toBe(''); + }); + + it('leaves a legacy step reference in a composite function-scoped value uninterpolated', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/notify': { + runs: { + steps: [ + { + id: 'report', + uses: 'test/passthrough', + with: { value: '${ steps.checkout.sha }' }, + }, + ], + }, + }, + }, + steps: [{ uses: './.eas/functions/notify', id: 'notify' }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'notify__report')?.getOutputValueByName('out') + ).toBe('${ steps.checkout.sha }'); + }); + + it('resolves a reference to a nested composite function call with no outputs to an empty value', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/inner': { + runs: { + steps: [{ id: 'noop', uses: 'test/passthrough', with: { value: 'x' } }], + }, + }, + './.eas/functions/caller': { + runs: { + steps: [ + { uses: './.eas/functions/inner', id: 'nested' }, + { + id: 'report', + uses: 'test/passthrough', + with: { value: '${{ steps.nested.outputs.foo }}' }, + }, + ], + }, + }, + }, + steps: [{ uses: './.eas/functions/caller', id: 'caller' }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'caller__report')?.getOutputValueByName('out') + ).toBe(''); + }); + }); + describe('caller context propagation', () => { + it('binds composite function inputs into inner function step inputs', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/wrap': { + inputs: [{ name: 'msg', type: 'string', required: true }], + runs: { + steps: [ + { id: 'inner', uses: 'test/passthrough', with: { value: '${{ inputs.msg }}' } }, + ], + }, + }, + }, + steps: [{ uses: './.eas/functions/wrap', id: 'wrap', with: { msg: 'bound-value' } }], + externalFunctions: [passThroughFunction()], + }); + const innerStep = workflow.buildSteps[0]; + expect(innerStep.inputs?.[0].id).toBe('value'); + expect(innerStep.inputs?.[0].rawValue).toBe('${{ inputs.msg }}'); + + await workflow.executeAsync(); + expect(innerStep.getOutputValueByName('out')).toBe('bound-value'); + }); + + it('propagates caller env to expanded inner steps and keeps only their own if condition', async () => { + let capturedEnv: Record = {}; + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [{ id: 'inner', uses: 'test/capture-env', if: '${{ success() }}' }], + }, + }, + }, + steps: [ + { + uses: SETUP, + id: 'setup', + env: { CALLER: 'value' }, + if: '${{ always() }}', + }, + ], + externalFunctions: [captureEnvFunction(env => (capturedEnv = env))], + }); + const inner = workflow.buildSteps[0]; + expect(inner.ifCondition).toBe('${{ success() }}'); + expect(inner.compositeFunctionScope).toBeDefined(); + await workflow.executeAsync(); + expect(capturedEnv.CALLER).toBe('value'); + }); + + it('skips an inner always() step when the caller if condition is false', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { steps: [{ id: 'inner', uses: 'eas/echo', if: '${{ always() }}' }] }, + }, + }, + steps: [ + { id: 'fail', uses: 'test/fail' }, + { uses: SETUP, id: 'setup', if: '${{ success() }}' }, + ], + externalFunctions: [failingFunction(), echoFunction()], + }); + + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toBe('inner failed'); + + const innerStep = workflow.buildSteps.find(s => s.id === 'setup__inner'); + expect(innerStep?.status).toBe(BuildStepStatus.SKIPPED); + }); + + it('runs a failure()-gated inner step but skips a default-gated one after an earlier workflow failure', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { + id: 'first', + uses: 'eas/echo', + with: { value: 'cleanup' }, + if: '${{ failure() }}', + }, + { id: 'second', uses: 'eas/echo', with: { value: 'notify' } }, + ], + }, + }, + }, + steps: [ + { id: 'fail', uses: 'test/fail' }, + { uses: SETUP, id: 'cleanup', if: '${{ failure() }}' }, + ], + externalFunctions: [failingFunction(), echoFunction()], + }); + + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toBe('inner failed'); + + const firstStep = workflow.buildSteps.find(s => s.id === 'cleanup__first'); + const secondStep = workflow.buildSteps.find(s => s.id === 'cleanup__second'); + expect(firstStep?.status).toBe(BuildStepStatus.SUCCESS); + expect(secondStep?.status).toBe(BuildStepStatus.SKIPPED); + }); + + it('skips inner steps of a failure()-gated action when no previous step failed', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { steps: [{ id: 'inner', uses: 'eas/echo', with: { value: 'cleanup' } }] }, + }, + }, + steps: [ + { id: 'ok', uses: 'eas/echo', with: { value: 'ok' } }, + { uses: SETUP, id: 'cleanup', if: '${{ failure() }}' }, + ], + externalFunctions: [echoFunction()], + }); + + await workflow.executeAsync(); + + const innerStep = workflow.buildSteps.find(s => s.id === 'cleanup__inner'); + expect(innerStep?.status).toBe(BuildStepStatus.SKIPPED); + }); + + it('stops after an inner failure inside a failure()-gated action, but runs inner failure() cleanup', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { id: 'breaks', uses: 'test/fail', if: '${{ always() }}' }, + { id: 'after', uses: 'eas/echo', with: { value: 'should not run' } }, + { + id: 'inner_cleanup', + uses: 'eas/echo', + with: { value: 'cleanup' }, + if: '${{ failure() }}', + }, + ], + }, + }, + }, + steps: [ + { id: 'fail', uses: 'test/fail' }, + { uses: SETUP, id: 'cleanup', if: '${{ failure() }}' }, + ], + externalFunctions: [failingFunction(), echoFunction()], + }); + + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toBe('inner failed'); + + const breaksStep = workflow.buildSteps.find(s => s.id === 'cleanup__breaks'); + const afterStep = workflow.buildSteps.find(s => s.id === 'cleanup__after'); + const innerCleanupStep = workflow.buildSteps.find(s => s.id === 'cleanup__inner_cleanup'); + expect(breaksStep?.status).toBe(BuildStepStatus.FAIL); + expect(afterStep?.status).toBe(BuildStepStatus.SKIPPED); + expect(innerCleanupStep?.status).toBe(BuildStepStatus.SUCCESS); + }); + + it('propagates a nested action failure to subsequent steps of the outer action', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + runs: { + steps: [ + { uses: './.eas/functions/inner', id: 'nested' }, + { id: 'after', uses: 'eas/echo', with: { value: 'should not run' } }, + ], + }, + }, + './.eas/functions/inner': { + runs: { steps: [{ id: 'breaks', uses: 'test/fail' }] }, + }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'top', if: '${{ always() }}' }], + externalFunctions: [failingFunction(), echoFunction()], + }); + + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toBe('inner failed'); + + const afterStep = workflow.buildSteps.find(s => s.id === 'top__after'); + expect(afterStep?.status).toBe(BuildStepStatus.SKIPPED); + }); + + it('runs an always()-gated inner step of an always()-gated action after earlier workflow failure', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { + id: 'cleanup', + uses: 'eas/echo', + with: { value: 'cleanup' }, + if: '${{ always() }}', + }, + ], + }, + }, + }, + steps: [ + { id: 'fail', uses: 'test/fail' }, + { uses: SETUP, id: 'setup', if: '${{ always() }}' }, + ], + externalFunctions: [failingFunction(), echoFunction()], + }); + + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toBe('inner failed'); + + const cleanupStep = workflow.buildSteps.find(s => s.id === 'setup__cleanup'); + expect(cleanupStep?.status).toBe(BuildStepStatus.SUCCESS); + }); + + it('rejects working_directory on a step that calls a local composite function', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { steps: [{ id: 'inner', run: 'echo hi' }] }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', working_directory: 'packages/app' }], + }) + ); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toContain('"working_directory" is not supported on a step that calls'); + }); + + it('resolves a templated inner step working_directory in the composite function scope', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'dir', type: 'string', default_value: 'inner/dir' }], + runs: { + steps: [ + { + id: 'inner', + uses: 'test/passthrough', + with: { value: 'ok' }, + working_directory: '${{ inputs.dir }}', + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { dir: 'overridden/dir' } }], + externalFunctions: [passThroughFunction()], + }); + expect(workflow.buildSteps[0].ctx.relativeWorkingDirectory).toBe('${{ inputs.dir }}'); + await workflow.executeAsync(); + expect(workflow.buildSteps[0].ctx.relativeWorkingDirectory).toBe('overridden/dir'); + }); + + it('preserves the raw inner step if expression through expansion', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'flag', type: 'string', required: true }], + runs: { + steps: [{ id: 'inner', run: 'echo hi', if: '${{ inputs.flag == "true" }}' }], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { flag: 'true' } }], + }); + expect(workflow.buildSteps[0].ifCondition).toBe('${{ inputs.flag == "true" }}'); + }); + + it.each([ + ['true', BuildStepStatus.SUCCESS], + ['false', BuildStepStatus.SKIPPED], + ])( + 'gates an inner step on a composite function input in its if expression (flag=%s)', + async (flag, expectedStatus) => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'flag', type: 'string', required: true }], + runs: { + steps: [ + { + id: 'inner', + uses: 'test/passthrough', + with: { value: 'ran' }, + if: '${{ inputs.flag == "true" }}', + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { flag } }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect(workflow.buildSteps.find(s => s.id === 'setup__inner')?.status).toBe(expectedStatus); + } + ); + + // Action inputs.* are the composite function's inputs (GHA composite), not the step's with. + it('does not expose an inner function step own with value to its if expression', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { + id: 'inner', + uses: 'test/passthrough', + with: { value: 'true' }, + if: '${{ inputs.value == "true" }}', + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect(workflow.buildSteps.find(s => s.id === 'setup__inner')?.status).toBe( + BuildStepStatus.SKIPPED + ); + }); + }); + describe('scoped runtime evaluation', () => { + it("evaluates the caller if condition against the caller's env overrides", async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { steps: [{ id: 'inner', uses: 'eas/echo' }] }, + }, + }, + steps: [ + { + uses: SETUP, + id: 'setup', + env: { DEPLOY: 'true' }, + if: "${{ env.DEPLOY == 'true' }}", + }, + ], + externalFunctions: [echoFunction()], + }); + await workflow.executeAsync(); + expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SUCCESS); + }); + + it("evaluates the caller if condition against the caller env, not an inner step's env overrides", async () => { + const ctx = createGlobalContextMock(); + ctx.updateEnv({ DEPLOY: 'true' }); + const parser = new StepsConfigParser(ctx, { + steps: [{ uses: SETUP, id: 'setup', if: "${{ env.DEPLOY == 'true' }}" }], + hooks: undefined, + compositeFunctionCatalog: makeCatalog({ + [SETUP]: { + runs: { steps: [{ id: 'inner', uses: 'eas/echo', env: { DEPLOY: 'false' } }] }, + }, + }), + externalFunctions: [echoFunction()], + }); + const workflow = await parser.parseAsync(); + await workflow.executeAsync(); + expect(workflow.buildSteps.find(s => s.id === 'setup__inner')?.status).toBe( + BuildStepStatus.SUCCESS + ); + }); + + it('resolves an expression-valued input used inside an inner if condition at runtime', async () => { + const okFunction = new BuildFunction({ + namespace: 'test', + id: 'ok', + fn: (_ctx, { outputs }) => { + outputs.ok.set('true'); + }, + outputProviders: [BuildStepOutput.createProvider({ id: 'ok', required: true })], + }); + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'flag', type: 'string' }], + runs: { + steps: [{ id: 'gated', uses: 'eas/echo', if: '${{ inputs.flag == "true" }}' }], + }, + }, + }, + steps: [ + { id: 'prev', uses: 'test/ok' }, + { uses: SETUP, id: 'setup', with: { flag: '${{ steps.prev.outputs.ok }}' } }, + ], + externalFunctions: [okFunction, echoFunction()], + }); + await workflow.executeAsync(); + expect(workflow.buildSteps.find(s => s.id === 'setup__gated')?.status).toBe( + BuildStepStatus.SUCCESS + ); + }); + + it('uses global success() in input interpolation, matching if condition semantics', async () => { + let captured: unknown; + const captureFunction = new BuildFunction({ + namespace: 'test', + id: 'capture', + fn: (_ctx, { inputs }) => { + captured = inputs.value.value; + }, + inputProviders: [ + BuildStepInput.createProvider({ + id: 'value', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + }); + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { + id: 'check', + uses: 'test/capture', + with: { value: 'ok-${{ success() }}' }, + if: '${{ always() }}', + }, + ], + }, + }, + }, + steps: [ + { id: 'fail', uses: 'test/fail' }, + { uses: SETUP, id: 'setup', if: '${{ always() }}' }, + ], + externalFunctions: [failingFunction(), captureFunction], + }); + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toBe('inner failed'); + expect(workflow.buildSteps.find(s => s.id === 'setup__check')?.status).toBe( + BuildStepStatus.SUCCESS + ); + // Global success() is already false after the earlier failure. + expect(captured).toBe('ok-false'); + }); + + it('resolves success() in a provided with: value against the caller, not the composite function scope', async () => { + let captured: unknown; + const captureFunction = new BuildFunction({ + namespace: 'test', + id: 'capture', + fn: (_ctx, { inputs }) => { + captured = inputs.value.value; + }, + inputProviders: [ + BuildStepInput.createProvider({ + id: 'value', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + }); + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'flag', type: 'string', required: false }], + runs: { + steps: [ + { + id: 'check', + uses: 'test/capture', + with: { value: '${{ inputs.flag }}' }, + if: '${{ always() }}', + }, + ], + }, + }, + }, + steps: [ + { id: 'fail', uses: 'test/fail' }, + { + uses: SETUP, + id: 'setup', + if: '${{ always() }}', + with: { flag: 'caller-${{ success() }}' }, + }, + ], + externalFunctions: [failingFunction(), captureFunction], + }); + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toBe('inner failed'); + // success() in with: reflects the caller (fail step), not inner action steps. + expect(captured).toBe('caller-false'); + }); + + it("resolves env in a provided with: value against the caller, not an inner step's env overrides", async () => { + let captured: unknown; + const captureFunction = new BuildFunction({ + namespace: 'test', + id: 'capture', + fn: (_ctx, { inputs }) => { + captured = inputs.value.value; + }, + inputProviders: [ + BuildStepInput.createProvider({ + id: 'value', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + }); + const ctx = createGlobalContextMock(); + ctx.updateEnv({ SECRET: 'from-caller' }); + const parser = new StepsConfigParser(ctx, { + steps: [{ uses: SETUP, id: 'setup', with: { msg: '${{ env.SECRET }}' } }], + hooks: undefined, + compositeFunctionCatalog: makeCatalog({ + [SETUP]: { + inputs: [{ name: 'msg', type: 'string', required: false }], + runs: { + steps: [ + { + id: 'check', + uses: 'test/capture', + env: { SECRET: 'from-inner' }, + with: { value: '${{ inputs.msg }}' }, + }, + ], + }, + }, + }), + externalFunctions: [captureFunction], + }); + const workflow = await parser.parseAsync(); + await workflow.executeAsync(); + expect(captured).toBe('from-caller'); + }); + + it('resolves a default value env against the composite function boundary, stable across inner steps that override the key', async () => { + const captured: unknown[] = []; + const captureFunction = new BuildFunction({ + namespace: 'test', + id: 'capture', + fn: (_ctx, { inputs }) => { + captured.push(inputs.value.value); + }, + inputProviders: [ + BuildStepInput.createProvider({ + id: 'value', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + }); + const ctx = createGlobalContextMock(); + ctx.updateEnv({ NAME: 'boundary' }); + const parser = new StepsConfigParser(ctx, { + steps: [{ uses: SETUP, id: 'setup' }], + hooks: undefined, + compositeFunctionCatalog: makeCatalog({ + [SETUP]: { + inputs: [{ name: 'label', type: 'string', default_value: '${{ env.NAME }}' }], + runs: { + steps: [ + { + id: 'first', + uses: 'test/capture', + env: { NAME: 'alice' }, + with: { value: '${{ inputs.label }}' }, + }, + { + id: 'second', + uses: 'test/capture', + env: { NAME: 'bob' }, + with: { value: '${{ inputs.label }}' }, + }, + ], + }, + }, + }), + externalFunctions: [captureFunction], + }); + const workflow = await parser.parseAsync(); + await workflow.executeAsync(); + expect(captured).toEqual(['boundary', 'boundary']); + }); + }); + describe('bare if conditions', () => { + it('substitutes composite function inputs in bare inner if expressions', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'flag', type: 'string', required: true }], + runs: { + steps: [{ id: 'inner', uses: 'eas/echo', if: 'inputs.flag == "true"' }], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { flag: 'true' } }], + externalFunctions: [echoFunction()], + }); + expect(workflow.buildSteps[0].ifCondition).toBe('inputs.flag == "true"'); + + await workflow.executeAsync(); + expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SUCCESS); + }); + + it('resolves inner step references in bare if expressions at runtime', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { id: 'read', uses: 'test/set-version' }, + { + id: 'gated', + uses: 'eas/echo', + if: "steps.read.outputs.version == '$(echo injected)'", + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + externalFunctions: [setVersionFunction(), echoFunction()], + }); + expect(workflow.buildSteps[1].ifCondition).toBe( + "steps.read.outputs.version == '$(echo injected)'" + ); + + await workflow.executeAsync(); + expect(workflow.buildSteps[1].status).toBe(BuildStepStatus.SUCCESS); + }); + + it('skips an inner step when a bare if references an out-of-scope step', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { + steps: [ + { + id: 'inner', + uses: 'test/passthrough', + with: { value: 'ok' }, + if: "steps.checkout.outputs.sha == 'x'", + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + expect(workflow.buildSteps.find(s => s.id === 'setup__inner')?.status).toBe( + BuildStepStatus.SKIPPED + ); + }); + }); + describe('lifted restrictions and runtime resolution', () => { + it('resolves an input whose value mixes text with an expression, used inside an inner expression', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'target', type: 'string' }], + runs: { + steps: [ + { + id: 'gate', + uses: 'test/passthrough', + with: { value: 'ran' }, + if: '${{ inputs.target == "prod-1" }}', + }, + ], + }, + }, + }, + steps: [ + { id: 'prev', uses: 'test/passthrough', with: { value: '1' } }, + { uses: SETUP, id: 'setup', with: { target: 'prod-${{ steps.prev.outputs.out }}' } }, + ], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + + const gate = workflow.buildSteps.find(s => s.id === 'setup__gate'); + expect(gate?.status).toBe(BuildStepStatus.SUCCESS); + expect(gate?.getOutputValueByName('out')).toBe('ran'); + }); + + it('supports property access on a json input at runtime', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'config', type: 'json' }], + runs: { + steps: [ + { + id: 'show', + uses: 'test/passthrough', + with: { value: 'retries=${{ inputs.config.retries }}' }, + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { config: { retries: 3 } } }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + + expect( + workflow.buildSteps.find(s => s.id === 'setup__show')?.getOutputValueByName('out') + ).toBe('retries=3'); + }); + + it('resolves a hyphenated input referenced with bracket syntax', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'app-name', type: 'string' }], + runs: { + steps: [ + { + id: 'show', + uses: 'test/passthrough', + with: { value: `\${{ inputs['app-name'] }}` }, + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { 'app-name': 'my-app' } }], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + + expect( + workflow.buildSteps.find(s => s.id === 'setup__show')?.getOutputValueByName('out') + ).toBe('my-app'); + }); + + it('does not resolve a hyphenated input referenced with dot syntax', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'app-name', type: 'string' }], + runs: { + steps: [ + { + id: 'inner', + uses: 'test/passthrough', + with: { value: '${{ inputs.app-name }}' }, + }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { 'app-name': 'my-app' } }], + externalFunctions: [passThroughFunction()], + }); + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toMatch(/Invalid identifier "name"/); + }); + + it('skips the allowed_values check for a reference-valued constrained input', async () => { + const makeWorkflow = (provided: string): Promise => + parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'env', type: 'string', allowed_values: ['dev', 'prod'] }], + runs: { + steps: [ + { id: 'show', uses: 'test/passthrough', with: { value: '${{ inputs.env }}' } }, + ], + }, + }, + }, + steps: [ + { id: 'pick', uses: 'test/passthrough', with: { value: provided } }, + { uses: SETUP, id: 'setup', with: { env: '${{ steps.pick.outputs.out }}' } }, + ], + externalFunctions: [passThroughFunction()], + }); + + for (const provided of ['prod', 'staging']) { + const workflow = await makeWorkflow(provided); + await workflow.executeAsync(); + expect( + workflow.buildSteps.find(s => s.id === 'setup__show')?.getOutputValueByName('out') + ).toBe(provided); + } + }); + + it('resolves an expression-valued env override into the process env', async () => { + let capturedEnv: Record = {}; + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [{ name: 'token', type: 'string' }], + runs: { + steps: [ + { id: 'read', uses: 'test/capture-env', env: { MY_TOKEN: '${{ inputs.token }}' } }, + ], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup', with: { token: 'secret' } }], + externalFunctions: [captureEnvFunction(env => (capturedEnv = env))], + }); + await workflow.executeAsync(); + + expect(capturedEnv.MY_TOKEN).toBe('secret'); + }); + + it('resolves a call-site env template against the caller scope, not the composite function scope', async () => { + let capturedEnv: Record = {}; + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + runs: { steps: [{ id: 'read', uses: 'test/capture-env' }] }, + }, + }, + steps: [ + { id: 'prev', uses: 'test/passthrough', with: { value: 'from-caller' } }, + { + uses: SETUP, + id: 'setup', + env: { TOKEN: '${{ steps.prev.outputs.out }}' }, + }, + ], + externalFunctions: [passThroughFunction(), captureEnvFunction(env => (capturedEnv = env))], + }); + await workflow.executeAsync(); + + expect(capturedEnv.TOKEN).toBe('from-caller'); + }); + + it('rejects working_directory on a nested step that calls another action', async () => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + runs: { + steps: [ + { + uses: './.eas/functions/inner', + id: 'inner', + working_directory: 'packages/app', + }, + ], + }, + }, + './.eas/functions/inner': { + runs: { steps: [{ id: 'read', uses: 'test/passthrough', with: { value: 'ok' } }] }, + }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'outer' }], + externalFunctions: [passThroughFunction()], + }) + ); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toContain('"working_directory" is not supported on a step that calls'); + }); + + it('resolves a nested call-site env template against the outer composite function inputs', async () => { + let capturedEnv: Record = {}; + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + inputs: [{ name: 'token', type: 'string' }], + runs: { + steps: [ + { + uses: './.eas/functions/inner', + id: 'inner', + env: { TOKEN: '${{ inputs.token }}' }, + }, + ], + }, + }, + './.eas/functions/inner': { + runs: { steps: [{ id: 'read', uses: 'test/capture-env' }] }, + }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'outer', with: { token: 'secret' } }], + externalFunctions: [captureEnvFunction(env => (capturedEnv = env))], + }); + await workflow.executeAsync(); + + expect(capturedEnv.TOKEN).toBe('secret'); + }); + + it('throws a runtime error when an input default references itself indirectly', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + [SETUP]: { + inputs: [ + { name: 'a', type: 'string', default_value: '${{ inputs.b }}' }, + { name: 'b', type: 'string', default_value: '${{ inputs.a }}' }, + ], + runs: { + steps: [{ id: 'show', uses: 'test/passthrough', with: { value: '${{ inputs.a }}' } }], + }, + }, + }, + steps: [{ uses: SETUP, id: 'setup' }], + externalFunctions: [passThroughFunction()], + }); + + const error = await getErrorAsync(() => workflow.executeAsync()); + expect(error.message).toMatch(/input "a" references itself/); + }); + + it('resolves nested action if and with against the outer composite function inputs at runtime', async () => { + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/wrap': { + inputs: [ + { name: 'flag', type: 'string' }, + { name: 'msg', type: 'string' }, + ], + runs: { + steps: [ + { + uses: './.eas/functions/inner', + id: 'inner', + if: '${{ inputs.flag == "go" }}', + with: { text: '${{ inputs.msg }}' }, + }, + ], + }, + }, + './.eas/functions/inner': { + inputs: [{ name: 'text', type: 'string' }], + runs: { + steps: [ + { id: 'show', uses: 'test/passthrough', with: { value: '${{ inputs.text }}' } }, + ], + }, + }, + }, + steps: [ + { id: 'prev', uses: 'test/passthrough', with: { value: 'hello' } }, + { + uses: './.eas/functions/wrap', + id: 'wrap', + with: { flag: 'go', msg: '${{ steps.prev.outputs.out }}' }, + }, + ], + externalFunctions: [passThroughFunction()], + }); + await workflow.executeAsync(); + + const show = workflow.buildSteps.find(s => s.id === 'wrap__inner__show'); + expect(show?.status).toBe(BuildStepStatus.SUCCESS); + expect(show?.getOutputValueByName('out')).toBe('hello'); + }); + }); +}); diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts index 48b2f0a616..828f7ced71 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts @@ -4,6 +4,7 @@ import { createGlobalContextMock } from './utils/context'; import { BuildFunction } from '../BuildFunction'; import { BuildFunctionGroup } from '../BuildFunctionGroup'; import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput'; +import { BuildStepOutput } from '../BuildStepOutput'; import { BuildWorkflow } from '../BuildWorkflow'; import { StepsConfigParser } from '../StepsConfigParser'; @@ -48,3 +49,77 @@ export function echoFunction(): BuildFunction { ], }); } + +export function setVersionFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'test', + id: 'set-version', + fn: (_ctx, { outputs }) => { + outputs.version.set('$(echo injected)'); + }, + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'version', + required: true, + }), + ], + }); +} + +export function failingFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'test', + id: 'fail', + fn: () => { + throw new Error('inner failed'); + }, + }); +} + +export function passThroughFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'test', + id: 'passthrough', + fn: (_ctx, { inputs, outputs }) => { + outputs.out.set(String(inputs.value.value ?? '')); + }, + inputProviders: [ + BuildStepInput.createProvider({ + id: 'value', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + outputProviders: [BuildStepOutput.createProvider({ id: 'out', required: true })], + }); +} + +export function captureEnvFunction( + sink: (env: Record) => void +): BuildFunction { + return new BuildFunction({ + namespace: 'test', + id: 'capture-env', + fn: (_ctx, { env }) => { + sink(env); + }, + }); +} + +export function echoInputAction(inputName: string, input: Record) { + return { + inputs: [input], + runs: { steps: [{ run: `echo "\${{ inputs.${inputName} }}"` }] }, + }; +} + +export function actionReadingInput(input: Record) { + return { + inputs: [input], + runs: { + steps: [ + { id: 'inner', uses: 'test/passthrough', with: { value: `\${{ inputs.${input.name} }}` } }, + ], + }, + }; +} diff --git a/packages/steps/src/utils/__tests__/compositeFunctionInterpolation-test.ts b/packages/steps/src/utils/__tests__/compositeFunctionInterpolation-test.ts new file mode 100644 index 0000000000..54e512f588 --- /dev/null +++ b/packages/steps/src/utils/__tests__/compositeFunctionInterpolation-test.ts @@ -0,0 +1,80 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { createGlobalContextMock } from '../../__tests__/utils/context'; +import { + containsUnresolvedTemplateReference, + resolveInterpolatedTarget, + stringifyInterpolatedResult, +} from '../compositeFunctionInterpolation'; + +function contextWith({ + steps = {}, + env = {}, +}: { + steps?: JobInterpolationContext['steps']; + env?: Record; +} = {}): JobInterpolationContext { + const globalCtx = createGlobalContextMock(); + return { ...globalCtx.getInterpolationContext(), steps, env } as JobInterpolationContext; +} + +describe(resolveInterpolatedTarget, () => { + it('passes literal targets through unchanged', () => { + expect(resolveInterpolatedTarget('literal', contextWith())).toBe('literal'); + }); + + it('resolves ${{ }} expressions against the context', () => { + expect(resolveInterpolatedTarget('${{ env.FOO }}', contextWith({ env: { FOO: 'bar' } }))).toBe( + 'bar' + ); + }); + + it('leaves legacy ${ steps.* } references untouched', () => { + expect(resolveInterpolatedTarget('v${ steps.read.version }', contextWith())).toBe( + 'v${ steps.read.version }' + ); + }); + + it('returns non-string results as-is', () => { + expect(resolveInterpolatedTarget('${{ fromJSON(\'{"a":1}\') }}', contextWith())).toEqual({ + a: 1, + }); + }); +}); + +describe(stringifyInterpolatedResult, () => { + it('renders nullish values as an empty string and objects as JSON', () => { + expect(stringifyInterpolatedResult(undefined)).toBe(''); + expect(stringifyInterpolatedResult(null)).toBe(''); + expect(stringifyInterpolatedResult({ a: 1 })).toBe('{"a":1}'); + expect(stringifyInterpolatedResult(3)).toBe('3'); + }); +}); + +describe(containsUnresolvedTemplateReference, () => { + it('recognizes ${{ }} template references', () => { + expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}')).toBe(true); + expect(containsUnresolvedTemplateReference(' ${{ steps.a.outputs.v }} ')).toBe(true); + expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}\n')).toBe(true); + }); + + it('recognizes ${{ }} template references embedded in a larger string', () => { + expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}px')).toBe(true); + expect(containsUnresolvedTemplateReference('${{ inputs.p }}-variant')).toBe(true); + expect(containsUnresolvedTemplateReference('a-${{ env.FOO }}-b')).toBe(true); + }); + + it('does not recognize legacy ${ } spans, which are unsupported inside actions', () => { + expect(containsUnresolvedTemplateReference('${ steps.a.outputs.v }')).toBe(false); + expect(containsUnresolvedTemplateReference('${ steps.a.v }')).toBe(false); + expect(containsUnresolvedTemplateReference('a-${ steps.a.outputs.v }-b')).toBe(false); + expect(containsUnresolvedTemplateReference('${ inputs.count }')).toBe(false); + expect(containsUnresolvedTemplateReference('echo ${FOO:-bar}')).toBe(false); + }); + + it('returns false for plain strings and non-strings', () => { + expect(containsUnresolvedTemplateReference('plain')).toBe(false); + expect(containsUnresolvedTemplateReference(5)).toBe(false); + expect(containsUnresolvedTemplateReference(undefined)).toBe(false); + }); +}); diff --git a/packages/steps/src/utils/compositeFunctionInterpolation.ts b/packages/steps/src/utils/compositeFunctionInterpolation.ts new file mode 100644 index 0000000000..cd97f81386 --- /dev/null +++ b/packages/steps/src/utils/compositeFunctionInterpolation.ts @@ -0,0 +1,33 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { interpolateJobContext } from '../interpolation'; + +// Composite functions support only `${{ }}`; legacy `${ steps.* }` is intentionally left uninterpolated here. +export function resolveInterpolatedTarget( + target: unknown, + context: JobInterpolationContext +): unknown { + return interpolateJobContext({ target, context }); +} + +export function stringifyInterpolatedResult(value: unknown): string { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} + +export function stringifyOptionalInterpolatedResult(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return stringifyInterpolatedResult(value); +} + +// Matches `${{ }}` only; legacy `${ steps.* }` is unsupported inside actions. +export function containsUnresolvedTemplateReference(value: unknown): boolean { + return typeof value === 'string' && value.includes('${{'); +} From 4dbb19c5d33ce43e268ddd73e5240ec920c7c826 Mon Sep 17 00:00:00 2001 From: sswrk Date: Tue, 21 Jul 2026 16:36:42 +0200 Subject: [PATCH 2/6] correct naming: action -> composite function --- packages/steps/src/CompositeFunctionExpander.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts index d7c3a460f1..6a9d7598ff 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -110,7 +110,7 @@ export class CompositeFunctionExpander { syntheticStepId, compositeFunctionPath ); - const { inputs, providedInputKeys } = this.buildActionInputs( + const { inputs, providedInputKeys } = this.buildCompositeFunctionInputs( compositeFunction, compositeFunctionPath, call.callWith @@ -163,13 +163,13 @@ export class CompositeFunctionExpander { } private lookupCompositeFunction(compositeFunctionPath: string): CompositeFunctionConfig { - const action = this.compositeFunctionCatalog[compositeFunctionPath]; - if (!action) { + 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 action; + return compositeFunction; } private expandInnerStep( @@ -361,14 +361,14 @@ export class CompositeFunctionExpander { } // Nullish `with` values are treated as absent so defaults resolve in the composite function's own scope. - private buildActionInputs( - action: CompositeFunctionConfig, + private buildCompositeFunctionInputs( + compositeFunction: CompositeFunctionConfig, compositeFunctionPath: string, callWith: Record | undefined ): { inputs: Map; providedInputKeys: Set } { const inputs = new Map(); const providedInputKeys = new Set(); - for (const declared of action.inputs ?? []) { + for (const declared of compositeFunction.inputs ?? []) { const definition = typeof declared === 'string' ? { From 8dc5582557bbf2693d4c7a9a59a2ec8c58e9030d Mon Sep 17 00:00:00 2001 From: sswrk Date: Wed, 22 Jul 2026 15:28:06 +0200 Subject: [PATCH 3/6] [steps] Resolve composite function step refs from child nodes Replace the short-id alias map with childrenByLocalId so the scope reads outputs from the call's own children instead of a hidden global steps view. --- .../src/BuildStepCompositeFunctionScope.ts | 28 +++++------ packages/steps/src/BuildStepContext.ts | 15 ++---- .../steps/src/CompositeFunctionExpander.ts | 47 ++++++++++--------- .../steps/src/__tests__/BuildStep-test.ts | 2 +- .../BuildStepCompositeFunctionScope-test.ts | 5 +- 5 files changed, 42 insertions(+), 55 deletions(-) diff --git a/packages/steps/src/BuildStepCompositeFunctionScope.ts b/packages/steps/src/BuildStepCompositeFunctionScope.ts index 7e34d0e276..d9ba3d2c7b 100644 --- a/packages/steps/src/BuildStepCompositeFunctionScope.ts +++ b/packages/steps/src/BuildStepCompositeFunctionScope.ts @@ -3,11 +3,12 @@ * * 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. + * This class overlays a `steps` view built from the call's own children 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 type { BuildStepOutputAccessor } from './BuildStep'; import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; import { BuildStepInput } from './BuildStepInput'; @@ -32,7 +33,8 @@ export class BuildStepCompositeFunctionScope { private readonly compositeFunctionPath: string; private readonly inputs: Map; private readonly providedInputKeys: ReadonlySet; - private readonly stepIdAliases: Map; + // Filled by the expander after construction; children and scope need each other. + private readonly childrenByLocalId: Map; private cachedIsActive?: boolean; // Detects cycles while resolving input default values. private readonly resolvingInputs = new Set(); @@ -45,7 +47,7 @@ export class BuildStepCompositeFunctionScope { compositeFunctionPath, inputs, providedInputKeys, - stepIdAliases, + childrenByLocalId, }: { ctx: BuildStepGlobalContext; parent?: BuildStepCompositeFunctionScope; @@ -54,7 +56,7 @@ export class BuildStepCompositeFunctionScope { compositeFunctionPath: string; inputs: Map; providedInputKeys: ReadonlySet; - stepIdAliases: Map; + childrenByLocalId: Map; }) { this.ctx = ctx; this.parent = parent; @@ -63,7 +65,7 @@ export class BuildStepCompositeFunctionScope { this.compositeFunctionPath = compositeFunctionPath; this.inputs = inputs; this.providedInputKeys = providedInputKeys; - this.stepIdAliases = stepIdAliases; + this.childrenByLocalId = childrenByLocalId; } /** @@ -99,10 +101,6 @@ export class BuildStepCompositeFunctionScope { return evaluate(this.ifCondition, context); } - public resolveStepId(shortId: string): string | undefined { - return this.stepIdAliases.get(shortId); - } - public getScopedInterpolationContext(base: JobInterpolationContext): JobInterpolationContext { // Spread `base` into a new object; never spread the returned context, as that would eagerly // resolve every lazy input getter. @@ -138,15 +136,13 @@ export class BuildStepCompositeFunctionScope { return { ...parentEnv, ...this.resolveScopeEnv(base) }; } - // Workflow hides prefixed ids; re-expose them under short aliases. + // Workflow hides prefixed ids; re-expose the call's children under their local ids. 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; - } + for (const [localId, child] of this.childrenByLocalId) { + view[localId] = { + outputs: Object.fromEntries(child.outputs.map(output => [output.id, output.rawValue])), + }; } return view; } diff --git a/packages/steps/src/BuildStepContext.ts b/packages/steps/src/BuildStepContext.ts index e4d0b4caba..7ad80edfaa 100644 --- a/packages/steps/src/BuildStepContext.ts +++ b/packages/steps/src/BuildStepContext.ts @@ -95,23 +95,14 @@ export class BuildStepGlobalContext { public get staticContext(): StaticJobInterpolationContext { return { ...this.provider.staticContext(), - steps: this.buildStepsInterpolationMap({ includeInternal: false }), + steps: this.buildStepsInterpolationMap(), }; } - /** 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'] { + private buildStepsInterpolationMap(): StaticJobInterpolationContext['steps'] { return Object.fromEntries( Object.values(this.stepById) - .filter(step => includeInternal || !this.internalStepIds.has(step.id)) + .filter(step => !this.internalStepIds.has(step.id)) .map(step => [ step.id, { diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts index 6a9d7598ff..a33e7a7441 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -19,7 +19,7 @@ import { import { BuildFunctionById } from './BuildFunction'; import { BuildFunctionGroupById } from './BuildFunctionGroup'; -import { BuildStep } from './BuildStep'; +import { BuildStep, BuildStepOutputAccessor } from './BuildStep'; import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; @@ -105,7 +105,7 @@ export class CompositeFunctionExpander { call.name ?? compositeFunction.name ?? compositeFunctionPath; const innerSteps = compositeFunction.runs.steps; - const { stepIdMap, newIds } = this.buildInnerStepIdMap( + const innerStepIds = this.buildInnerStepIdMap( innerSteps, syntheticStepId, compositeFunctionPath @@ -118,6 +118,7 @@ export class CompositeFunctionExpander { const nestedVisited = new Set(visited).add(compositeFunctionPath); + const childrenByLocalId = new Map(); const scope = new BuildStepCompositeFunctionScope({ ctx: this.ctx, parent: call.parentScope, @@ -126,16 +127,23 @@ export class CompositeFunctionExpander { compositeFunctionPath, inputs, providedInputKeys, - stepIdAliases: stepIdMap, + childrenByLocalId, }); - const children = innerSteps.map((innerStep, index) => - this.expandInnerStep(innerStep, newIds[index], { + const children = innerSteps.map((innerStep, index) => { + const { localId, newId } = innerStepIds[index]; + const child = this.expandInnerStep(innerStep, newId, { compositeFunctionPath, scope, nestedVisited, - }) - ); + }); + // Omit output-less nested nodes: `${{ steps.mid }}` is undefined today, + // and `{ outputs: {} }` would make it truthy. + if (!(child instanceof CompositeBuildStep) || child.hasDeclaredOutputs) { + childrenByLocalId.set(localId, child); + } + return child; + }); return new CompositeBuildStep(this.ctx, { id: syntheticStepId, @@ -325,16 +333,14 @@ export class CompositeFunctionExpander { } /** - * 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. + * Assigns globally unique ids to inner steps. Without namespacing, two + * composite function calls with inner step `read` would collide. */ private buildInnerStepIdMap( innerSteps: Step[], syntheticStepId: string, compositeFunctionPath: string - ): { stepIdMap: Map; newIds: string[] } { - const stepIdMap = new Map(); - const newIds: string[] = []; + ): Array<{ localId: string; newId: string }> { const declaredIdList = innerSteps.map(step => step.id).filter((id): id is string => !!id); const duplicatedIds = duplicates(declaredIdList); if (duplicatedIds.length > 0) { @@ -346,18 +352,15 @@ export class CompositeFunctionExpander { } const declaredIds = new Set(declaredIdList); let generatedStepIdCounter = 0; - for (const innerStep of innerSteps) { - let sourceId = innerStep.id; - if (!sourceId) { + return innerSteps.map(innerStep => { + let localId = innerStep.id; + if (!localId) { do { - sourceId = `composite_function_step_${++generatedStepIdCounter}`; - } while (declaredIds.has(sourceId)); + localId = `composite_function_step_${++generatedStepIdCounter}`; + } while (declaredIds.has(localId)); } - const newId = `${syntheticStepId}__${sourceId}`; - stepIdMap.set(sourceId, newId); - newIds.push(newId); - } - return { stepIdMap, newIds }; + return { localId, newId: `${syntheticStepId}__${localId}` }; + }); } // Nullish `with` values are treated as absent so defaults resolve in the composite function's own scope. diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index b8c85df269..75bf0df5e8 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -639,7 +639,7 @@ describe(BuildStep, () => { compositeFunctionPath: 'test-action', inputs: new Map(), providedInputKeys: new Set(), - stepIdAliases: new Map(), + childrenByLocalId: new Map(), }); const firstStep = new BuildStep(baseStepCtx, { diff --git a/packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts b/packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts index 5fa8c474da..54055e9431 100644 --- a/packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts +++ b/packages/steps/src/__tests__/BuildStepCompositeFunctionScope-test.ts @@ -14,14 +14,11 @@ describe(BuildStepCompositeFunctionScope, () => { function makeScope(): BuildStepCompositeFunctionScope { const ctx = createGlobalContextMock(); - // Aliases resolve against registered global steps, not base. const versionOutput = mock(); when(versionOutput.id).thenReturn('version'); when(versionOutput.rawValue).thenReturn('1.0.0'); const innerStep = mock(); - when(innerStep.id).thenReturn('setup__build'); when(innerStep.outputs).thenReturn([instance(versionOutput)]); - ctx.registerStep(instance(innerStep)); const greeting = new BuildStepInput(ctx, { id: 'greeting', @@ -35,7 +32,7 @@ describe(BuildStepCompositeFunctionScope, () => { compositeFunctionPath: 'test-action', inputs: new Map([['greeting', greeting]]), providedInputKeys: new Set(['greeting']), - stepIdAliases: new Map([['build', 'setup__build']]), + childrenByLocalId: new Map([['build', instance(innerStep)]]), }); } From b69fd99f80bd8834f322d18093690b203705a89f Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 24 Jul 2026 12:30:42 +0200 Subject: [PATCH 4/6] make outer callsite env visible to nested call env templates --- .../src/BuildStepCompositeFunctionScope.ts | 7 +++-- ...Parser-composite-functions-scoping-test.ts | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/steps/src/BuildStepCompositeFunctionScope.ts b/packages/steps/src/BuildStepCompositeFunctionScope.ts index d9ba3d2c7b..0ebc905617 100644 --- a/packages/steps/src/BuildStepCompositeFunctionScope.ts +++ b/packages/steps/src/BuildStepCompositeFunctionScope.ts @@ -117,11 +117,12 @@ export class BuildStepCompositeFunctionScope { return this.parent ? this.parent.getScopedInterpolationContext(base) : base; } - public resolveScopeEnv(base: JobInterpolationContext): BuildStepEnv { + private resolveScopeEnv(base: JobInterpolationContext, parentEnv: BuildStepEnv): BuildStepEnv { if (!this.env) { return {}; } - const callerContext = this.getCallerInterpolationContext(base); + const callerBase: JobInterpolationContext = { ...base, env: { ...base.env, ...parentEnv } }; + const callerContext = this.getCallerInterpolationContext(callerBase); return Object.fromEntries( Object.entries(this.env).map(([key, value]) => [ key, @@ -133,7 +134,7 @@ export class BuildStepCompositeFunctionScope { /** Merge call-site env from each scope level, outer to inner; inner keys win. */ public resolveInheritedEnv(base: JobInterpolationContext): BuildStepEnv { const parentEnv = this.parent?.resolveInheritedEnv(base) ?? {}; - return { ...parentEnv, ...this.resolveScopeEnv(base) }; + return { ...parentEnv, ...this.resolveScopeEnv(base, parentEnv) }; } // Workflow hides prefixed ids; re-expose the call's children under their local ids. diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts index 69d8a3ff6f..18493bc9b2 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts @@ -1157,6 +1157,34 @@ describe('StepsConfigParser local composite functions', () => { expect(capturedEnv.TOKEN).toBe('secret'); }); + it('resolves a nested call-site env template against the outer call-site env', async () => { + let capturedEnv: Record = {}; + const workflow = await parseCompositeFunctions({ + catalog: { + './.eas/functions/outer': { + runs: { + steps: [ + { + uses: './.eas/functions/inner', + id: 'inner', + env: { COPIED: '${{ env.OUTER }}' }, + }, + ], + }, + }, + './.eas/functions/inner': { + runs: { steps: [{ id: 'read', uses: 'test/capture-env' }] }, + }, + }, + steps: [{ uses: './.eas/functions/outer', id: 'outer', env: { OUTER: 'visible' } }], + externalFunctions: [captureEnvFunction(env => (capturedEnv = env))], + }); + await workflow.executeAsync(); + + expect(capturedEnv.COPIED).toBe('visible'); + expect(capturedEnv.OUTER).toBe('visible'); + }); + it('throws a runtime error when an input default references itself indirectly', async () => { const workflow = await parseCompositeFunctions({ catalog: { From 8af38e15ed97a9ebe6d99367dee74523346b8e72 Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 24 Jul 2026 13:21:25 +0200 Subject: [PATCH 5/6] make a simple required: true check --- .../steps/src/CompositeFunctionExpander.ts | 5 +++ ...gParser-composite-functions-inputs-test.ts | 42 +++++++++++++------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/steps/src/CompositeFunctionExpander.ts b/packages/steps/src/CompositeFunctionExpander.ts index a33e7a7441..5604d6ff9e 100644 --- a/packages/steps/src/CompositeFunctionExpander.ts +++ b/packages/steps/src/CompositeFunctionExpander.ts @@ -409,6 +409,11 @@ export class CompositeFunctionExpander { providedInputKeys.add(definition.name); } } + if (definition.required && input.rawValue === undefined) { + throw new BuildConfigError( + `Input parameter "${definition.name}" for step "${compositeFunctionPath}" is required but it was not set.` + ); + } const disallowedValueError = getDisallowedInputValueError(input, compositeFunctionPath); if (disallowedValueError) { throw new BuildConfigError(disallowedValueError); diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts index f173178af6..7b6e3bc995 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-inputs-test.ts @@ -55,30 +55,46 @@ describe('StepsConfigParser local composite functions', () => { ).toBe(''); }); - it('does not error for a missing required input that is never referenced', async () => { + it.each([ + [ + 'never referenced', + { + inputs: [{ name: 'token', type: 'string', required: true }], + runs: { steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: 'static' } }] }, + }, + ], + ['referenced', actionReadingInput({ name: 'token', type: 'string', required: true })], + ])('rejects at parse time a missing required input that is %s', async (_, actionConfig) => { + const error = await getErrorAsync(() => + parseCompositeFunctions({ + catalog: { [SETUP]: actionConfig }, + steps: [{ uses: SETUP, id: 'setup' }], + externalFunctions: [passThroughFunction()], + }) + ); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch( + /Input parameter "token" for step ".+" is required but it was not set/ + ); + }); + + // Required-ness is a static presence check, like for regular function steps. + // Whether a provided interpolation resolves to a value is not validated. + it('treats a required input set to an interpolation as provided, even one resolving to undefined', async () => { const workflow = await parseCompositeFunctions({ catalog: { - [SETUP]: { - inputs: [{ name: 'token', type: 'string', required: true }], - runs: { steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: 'static' } }] }, - }, + [SETUP]: actionReadingInput({ name: 'token', type: 'string', required: true }), }, - steps: [{ uses: SETUP, id: 'setup' }], + steps: [{ uses: SETUP, id: 'setup', with: { token: '${{ env.UNSET_VAR }}' } }], externalFunctions: [passThroughFunction()], }); await workflow.executeAsync(); expect( workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out') - ).toBe('static'); + ).toBe(''); }); it.each([ - [ - 'a required input is missing but referenced', - actionReadingInput({ name: 'token', type: 'string', required: true }), - [{ uses: SETUP, id: 'setup' }], - /Input parameter "token" for step ".+" is required but it was not set/, - ], [ 'a provided input has the wrong type', actionReadingInput({ name: 'count', type: 'number' }), From 6c9c8dc5f675c2f337a4bfd9e55bd01db034f608 Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 24 Jul 2026 13:49:29 +0200 Subject: [PATCH 6/6] add comment above the skips the allowed_values check test --- .../StepsConfigParser-composite-functions-scoping-test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts index 18493bc9b2..8909654221 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-scoping-test.ts @@ -1029,6 +1029,7 @@ describe('StepsConfigParser local composite functions', () => { expect(error.message).toMatch(/Invalid identifier "name"/); }); + // allowed_values is checked eagerly on raw values, so references are skipped it('skips the allowed_values check for a reference-valued constrained input', async () => { const makeWorkflow = (provided: string): Promise => parseCompositeFunctions({