diff --git a/packages/steps/src/ActionExpander.ts b/packages/steps/src/ActionExpander.ts index a488f7d270..b5fe0096e4 100644 --- a/packages/steps/src/ActionExpander.ts +++ b/packages/steps/src/ActionExpander.ts @@ -26,7 +26,11 @@ import { BuildStepEnv } from './BuildStepEnv'; import { BuildConfigError } from './errors'; import { duplicates } from './utils/expodash/duplicates'; import { isActionPath, parseActionPath } from './utils/localActions'; -import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step'; +import { + createBuildStepOutputsFromDefinition, + getShellStepDisplayName, + mergeEnv, +} from './utils/step'; const MAX_ACTION_NESTING_DEPTH = 10; @@ -328,10 +332,3 @@ export class ActionExpander { return { stepIdMap, newIds }; } } - -function mergeEnv(base?: BuildStepEnv, overrides?: BuildStepEnv): BuildStepEnv | undefined { - if (!base && !overrides) { - return undefined; - } - return { ...base, ...overrides }; -} diff --git a/packages/steps/src/utils/__tests__/actionInterpolation-test.ts b/packages/steps/src/utils/__tests__/actionInterpolation-test.ts new file mode 100644 index 0000000000..d7a69c7ccc --- /dev/null +++ b/packages/steps/src/utils/__tests__/actionInterpolation-test.ts @@ -0,0 +1,93 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { createGlobalContextMock } from '../../__tests__/utils/context'; +import { + containsUnresolvedTemplateReference, + resolveInterpolatedTarget, + stringifyInterpolatedResult, +} from '../actionInterpolation'; + +function contextWith({ + steps = {}, + env = {}, +}: { + steps?: JobInterpolationContext['steps']; + env?: Record; +} = {}): JobInterpolationContext { + const globalCtx = createGlobalContextMock(); + return { ...globalCtx.getInterpolationContext(), steps, env } as JobInterpolationContext; +} + +describe(resolveInterpolatedTarget, () => { + const neverResolve = (): string => ''; + + it('passes literal targets through unchanged', () => { + expect(resolveInterpolatedTarget('literal', contextWith(), neverResolve)).toBe('literal'); + }); + + it('resolves ${{ }} expressions against the context', () => { + expect( + resolveInterpolatedTarget( + '${{ env.FOO }}', + contextWith({ env: { FOO: 'bar' } }), + neverResolve + ) + ).toBe('bar'); + }); + + it('routes legacy ${ steps.* } references through the provided resolver', () => { + const resolver = (path: string): string => (path === 'steps.read.version' ? '1.2.3' : ''); + expect(resolveInterpolatedTarget('v${ steps.read.version }', contextWith(), resolver)).toBe( + 'v1.2.3' + ); + }); + + it('returns non-string results as-is, without touching the legacy resolver', () => { + const context = contextWith(); + const resolver = jest.fn(); + expect(resolveInterpolatedTarget('${{ fromJSON(\'{"a":1}\') }}', context, resolver)).toEqual({ + a: 1, + }); + expect(resolver).not.toHaveBeenCalled(); + }); +}); + +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('recognizes legacy ${ } step output references', () => { + expect(containsUnresolvedTemplateReference('${ steps.a.outputs.v }')).toBe(true); + expect(containsUnresolvedTemplateReference('${ steps.a.v }')).toBe(true); + expect(containsUnresolvedTemplateReference('a-${ steps.a.outputs.v }-b')).toBe(true); + }); + + it('does not recognize non-step legacy ${ } spans', () => { + 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/actionInterpolation.ts b/packages/steps/src/utils/actionInterpolation.ts new file mode 100644 index 0000000000..0649596cc0 --- /dev/null +++ b/packages/steps/src/utils/actionInterpolation.ts @@ -0,0 +1,42 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { BUILD_STEP_OUTPUT_EXPRESSION_REGEXP, interpolateWithOutputs } from './template'; +import { interpolateJobContext } from '../interpolation'; + +// Non-string results skip the legacy resolver: `${ steps.* }` only appears inside strings. +export function resolveInterpolatedTarget( + target: unknown, + context: JobInterpolationContext, + legacyResolver: (path: string) => string +): unknown { + const resolved = interpolateJobContext({ target, context }); + if (typeof resolved === 'string') { + return interpolateWithOutputs(resolved, legacyResolver); + } + return resolved; +} + +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 `${{ }}` or legacy `${ steps.* }` only; shell `${FOO:-bar}` / `${ inputs.* }` are excluded. +export function containsUnresolvedTemplateReference(value: unknown): boolean { + return ( + typeof value === 'string' && + (value.includes('${{') || BUILD_STEP_OUTPUT_EXPRESSION_REGEXP.test(value)) + ); +} diff --git a/packages/steps/src/utils/step.ts b/packages/steps/src/utils/step.ts index 5ed173e2c4..42aaf10aa6 100644 --- a/packages/steps/src/utils/step.ts +++ b/packages/steps/src/utils/step.ts @@ -1,8 +1,16 @@ import { ShellStep } from '@expo/eas-build-job'; import { BuildStepGlobalContext } from '../BuildStepContext'; +import { BuildStepEnv } from '../BuildStepEnv'; import { BuildStepOutput } from '../BuildStepOutput'; +export function mergeEnv(base?: BuildStepEnv, overrides?: BuildStepEnv): BuildStepEnv | undefined { + if (!base && !overrides) { + return undefined; + } + return { ...base, ...overrides }; +} + export function getShellStepDisplayName(step: ShellStep): string { return ( step.name ??