Skip to content

Commit 22be929

Browse files
committed
[steps] Expand legacy command/path local functions to a single build step
1 parent 891ef23 commit 22be929

13 files changed

Lines changed: 673 additions & 56 deletions

packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isLegacyFunctionConfig } from '@expo/eas-build-job';
2+
import assert from 'assert';
13
import { promises as fs } from 'fs';
24
import os from 'os';
35
import path from 'path';
@@ -43,6 +45,7 @@ describe(buildCompositeFunctionCatalogAsync, () => {
4345

4446
expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
4547
const action = catalog['./.eas/functions/setup'];
48+
assert(!isLegacyFunctionConfig(action));
4649
expect(action.name).toBe('Setup');
4750
expect(action.runs.steps).toHaveLength(1);
4851
expect(action.outputs?.version.value).toBe('${{ steps.read.outputs.version }}');

packages/eas-build-job/src/__tests__/compositeFunction.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,11 @@ describe('LegacyFunctionConfigZ', () => {
127127
});
128128

129129
it('rejects a config declaring both command and path', () => {
130-
expect(parseErrorMessages(LegacyFunctionConfigZ, { command: 'echo hi', path: './fn' })).toEqual([
131-
'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.',
132-
]);
130+
expect(parseErrorMessages(LegacyFunctionConfigZ, { command: 'echo hi', path: './fn' })).toEqual(
131+
[
132+
'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.',
133+
]
134+
);
133135
});
134136

135137
it('rejects a config declaring neither command nor path', () => {

packages/eas-build-job/src/compositeFunction.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,5 @@ export function isLegacyFunctionConfig(
288288
return config.runs === undefined;
289289
}
290290

291-
export type CompositeFunctionCatalog = Record<string, CompositeFunctionConfig>;
291+
/** Local functions of either shape, keyed by their normalized `uses:` path. */
292+
export type CompositeFunctionCatalog = Record<string, LocalFunctionConfig>;

packages/steps/src/CompositeFunctionExpander.ts

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
/**
2-
* Expands local composite functions (`uses: ./path/to/function`) into a
3-
* {@link CompositeBuildStep} tree at parse time.
2+
* Expands local functions (`uses: ./path/to/function`) into build steps at parse time.
43
*
5-
* Each call becomes a node with prefixed child ids (`caller__inner`); the parser
6-
* flattens the tree into the workflow. Expanded steps carry a
7-
* {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and `${{ inputs.* }}`
8-
* resolve against composite-function-local names.
4+
* A composite function call becomes a {@link CompositeBuildStep} node with prefixed child ids
5+
* (`caller__inner`); the parser flattens the tree into the workflow. Expanded steps carry a
6+
* {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and `${{ inputs.* }}` resolve
7+
* against composite-function-local names. A single-step (`command`/`path`) function call becomes
8+
* one ordinary build step keeping the caller's own id.
99
*/
1010
import {
1111
CompositeFunctionCatalog,
1212
CompositeFunctionConfig,
1313
FunctionStep,
14+
LegacyFunctionConfig,
15+
LocalFunctionConfig,
1416
ShellStep,
1517
Step,
18+
isLegacyFunctionConfig,
1619
isStepFunctionStep,
1720
isStepShellStep,
1821
} from '@expo/eas-build-job';
1922

20-
import { BuildFunctionById } from './BuildFunction';
23+
import { BuildFunction, BuildFunctionById } from './BuildFunction';
2124
import { BuildFunctionGroupById } from './BuildFunctionGroup';
2225
import { BuildStep, BuildStepOutputAccessor } from './BuildStep';
2326
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
@@ -31,6 +34,7 @@ import {
3134
import { CompositeBuildStep } from './CompositeBuildStep';
3235
import { BuildConfigError } from './errors';
3336
import { duplicates } from './utils/expodash/duplicates';
37+
import { createBuildFunctionFromLegacyFunctionConfig } from './utils/legacyFunction';
3438
import {
3539
getLocalCompositeFunctionCallWorkingDirectoryError,
3640
isLocalCompositeFunctionPath,
@@ -45,12 +49,16 @@ export type FunctionMaps = {
4549
buildFunctionGroupById: BuildFunctionGroupById;
4650
};
4751

48-
type CompositeFunctionCall = {
52+
export type FunctionMapsWithExpander = FunctionMaps & {
53+
compositeFunctionExpander: CompositeFunctionExpander;
54+
};
55+
56+
type LocalFunctionCall = {
4957
compositeFunctionPath: string;
50-
/** Caller-assigned id used as prefix for all inner step ids. */
58+
/** Caller-assigned id; for a composite function also the prefix of all inner step ids. */
5159
syntheticStepId: string;
5260
name?: string;
53-
/** Caller-provided input values, consumed when composite function inputs are interpolated. */
61+
/** Caller-provided input values, consumed when function inputs are interpolated. */
5462
callWith?: Record<string, unknown>;
5563
callIf?: string;
5664
parentScope?: BuildStepCompositeFunctionScope;
@@ -64,6 +72,9 @@ type StepOverrides = {
6472
};
6573

6674
export class CompositeFunctionExpander {
75+
/** Cached per path: a `BuildFunction` is stateless, and one path may be called many times. */
76+
private readonly legacyFunctionByPath = new Map<string, BuildFunction>();
77+
6778
constructor(
6879
private readonly ctx: BuildStepGlobalContext,
6980
private readonly compositeFunctionCatalog: CompositeFunctionCatalog,
@@ -78,15 +89,16 @@ export class CompositeFunctionExpander {
7889
return this.functionMaps.buildFunctionGroupById;
7990
}
8091

81-
public expandCompositeFunctionStep(
92+
/** Steps a local function call expands to: many for a composite function, one otherwise. */
93+
public expandLocalFunctionStep(
8294
step: FunctionStep,
83-
compositeFunctionPath: string,
95+
functionPath: string,
8496
syntheticStepId: string
85-
): CompositeBuildStep {
86-
this.rejectCompositeFunctionCallWorkingDirectory(step);
87-
return this.expand(
97+
): BuildStep[] {
98+
this.rejectLocalFunctionCallWorkingDirectory(step);
99+
const expanded = this.expandCall(
88100
{
89-
compositeFunctionPath,
101+
compositeFunctionPath: functionPath,
90102
syntheticStepId,
91103
name: step.name,
92104
callWith: step.with,
@@ -95,21 +107,58 @@ export class CompositeFunctionExpander {
95107
},
96108
new Set<string>()
97109
);
110+
return expanded instanceof CompositeBuildStep ? expanded.getFlattenedSteps() : [expanded];
98111
}
99112

100113
// The call step expands away; `working_directory` on it would never apply.
101-
private rejectCompositeFunctionCallWorkingDirectory(step: FunctionStep): void {
114+
private rejectLocalFunctionCallWorkingDirectory(step: FunctionStep): void {
102115
if (step.working_directory !== undefined) {
103116
throw new BuildConfigError(getLocalCompositeFunctionCallWorkingDirectoryError(step.uses));
104117
}
105118
}
106119

120+
private expandCall(call: LocalFunctionCall, visited: ReadonlySet<string>): BuildStep {
121+
const localFunction = this.lookupLocalFunction(call.compositeFunctionPath);
122+
if (isLegacyFunctionConfig(localFunction)) {
123+
return this.createLegacyFunctionStep(call, localFunction);
124+
}
125+
return this.expand(call, localFunction, visited);
126+
}
127+
128+
/**
129+
* A single-step function keeps the caller's id and takes the call-site `if` as its own
130+
* condition; there is no expansion scope to hang it on.
131+
*/
132+
private createLegacyFunctionStep(
133+
call: LocalFunctionCall,
134+
config: LegacyFunctionConfig
135+
): BuildStep {
136+
const { compositeFunctionPath } = call;
137+
let buildFunction = this.legacyFunctionByPath.get(compositeFunctionPath);
138+
if (!buildFunction) {
139+
buildFunction = createBuildFunctionFromLegacyFunctionConfig(compositeFunctionPath, config);
140+
this.legacyFunctionByPath.set(compositeFunctionPath, buildFunction);
141+
}
142+
return buildFunction.createBuildStepFromFunctionCall(this.ctx, {
143+
id: call.syntheticStepId,
144+
name: call.name,
145+
callInputs: call.callWith,
146+
shell: config.shell,
147+
env: call.inheritedEnv,
148+
ifCondition: call.callIf,
149+
compositeFunctionScope: call.parentScope,
150+
});
151+
}
152+
107153
// `visited.size` is the current composite function nesting depth.
108-
private expand(call: CompositeFunctionCall, visited: ReadonlySet<string>): CompositeBuildStep {
154+
private expand(
155+
call: LocalFunctionCall,
156+
compositeFunction: CompositeFunctionConfig,
157+
visited: ReadonlySet<string>
158+
): CompositeBuildStep {
109159
const { compositeFunctionPath, syntheticStepId } = call;
110160
this.guardAgainstRunawayRecursion(compositeFunctionPath, visited);
111161

112-
const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath);
113162
const compositeFunctionDisplayName =
114163
call.name ?? compositeFunction.name ?? compositeFunctionPath;
115164
const innerSteps = compositeFunction.runs.steps;
@@ -184,14 +233,14 @@ export class CompositeFunctionExpander {
184233
}
185234
}
186235

187-
private lookupCompositeFunction(compositeFunctionPath: string): CompositeFunctionConfig {
188-
const compositeFunction = this.compositeFunctionCatalog[compositeFunctionPath];
189-
if (!compositeFunction) {
236+
private lookupLocalFunction(compositeFunctionPath: string): LocalFunctionConfig {
237+
const localFunction = this.compositeFunctionCatalog[compositeFunctionPath];
238+
if (!localFunction) {
190239
throw new BuildConfigError(
191-
`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/<name>").`
240+
`Local function "${compositeFunctionPath}" does not exist. Expected a "function.yml" (or "function.yaml") file at "${compositeFunctionPath}" relative to the EAS project root (convention: ".eas/functions/<name>").`
192241
);
193242
}
194-
return compositeFunction;
243+
return localFunction;
195244
}
196245

197246
private expandInnerStep(
@@ -211,8 +260,8 @@ export class CompositeFunctionExpander {
211260

212261
if (isStepFunctionStep(innerStep)) {
213262
if (isLocalCompositeFunctionPath(innerStep.uses)) {
214-
this.rejectCompositeFunctionCallWorkingDirectory(innerStep);
215-
return this.expandNestedCompositeFunctionCall(innerStep, {
263+
this.rejectLocalFunctionCallWorkingDirectory(innerStep);
264+
return this.expandNestedLocalFunctionCall(innerStep, {
216265
compositeFunctionPath: parseLocalCompositeFunctionPath(innerStep.uses),
217266
newId,
218267
overrides,
@@ -243,7 +292,7 @@ export class CompositeFunctionExpander {
243292
};
244293
}
245294

246-
private expandNestedCompositeFunctionCall(
295+
private expandNestedLocalFunctionCall(
247296
innerStep: FunctionStep,
248297
{
249298
compositeFunctionPath,
@@ -258,8 +307,8 @@ export class CompositeFunctionExpander {
258307
scope: BuildStepCompositeFunctionScope;
259308
visited: ReadonlySet<string>;
260309
}
261-
): CompositeBuildStep {
262-
return this.expand(
310+
): BuildStep {
311+
return this.expandCall(
263312
{
264313
compositeFunctionPath,
265314
syntheticStepId: newId,

packages/steps/src/StepsConfigParser.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -285,13 +285,11 @@ export class StepsConfigParser extends AbstractConfigParser {
285285
compositeFunctionExpander: CompositeFunctionExpander
286286
): BuildStep[] {
287287
if (isLocalCompositeFunctionPath(step.uses)) {
288-
return compositeFunctionExpander
289-
.expandCompositeFunctionStep(
290-
step,
291-
parseLocalCompositeFunctionPath(step.uses),
292-
BuildStep.getNewId(step.id)
293-
)
294-
.getFlattenedSteps();
288+
return compositeFunctionExpander.expandLocalFunctionStep(
289+
step,
290+
parseLocalCompositeFunctionPath(step.uses),
291+
BuildStep.getNewId(step.id)
292+
);
295293
}
296294

297295
const buildFunction = compositeFunctionExpander.buildFunctionById[step.uses];

packages/steps/src/__tests__/StepsConfigParser-composite-functions-expansion-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ describe('StepsConfigParser local composite functions', () => {
163163
parseCompositeFunctions({
164164
steps: [{ uses: './.eas/functions/missing', id: 'x' }],
165165
})
166-
).rejects.toThrow(/Local composite function ".\/.eas\/functions\/missing"/);
166+
).rejects.toThrow(/Local function ".\/.eas\/functions\/missing" does not exist/);
167167
});
168168

169169
it.each([

packages/steps/src/__tests__/StepsConfigParser-composite-functions-test-utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/eas-build-job';
1+
import { CompositeFunctionCatalog, LocalFunctionConfigZ, Step } from '@expo/eas-build-job';
22

33
import { createGlobalContextMock } from './utils/context';
44
import { BuildFunction } from '../BuildFunction';
@@ -13,7 +13,7 @@ export const SETUP = './.eas/functions/setup';
1313
export function makeCatalog(entries: Record<string, unknown>): CompositeFunctionCatalog {
1414
const catalog: CompositeFunctionCatalog = {};
1515
for (const [compositeFunctionPath, raw] of Object.entries(entries)) {
16-
catalog[compositeFunctionPath] = CompositeFunctionConfigZ.parse(raw);
16+
catalog[compositeFunctionPath] = LocalFunctionConfigZ.parse(raw);
1717
}
1818
return catalog;
1919
}

packages/steps/src/__tests__/StepsConfigParser-hooks-test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,31 @@ describe('StepsConfigParser hooks with composite functions', () => {
633633
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']);
634634
});
635635

636+
it('parses a single-step function hook step into one entry with one step', async () => {
637+
const workflow = await parseWorkflowAsync({
638+
ctx,
639+
steps: [{ uses: 'eas/install_node_modules' }],
640+
hooks: {
641+
before_install_node_modules: [
642+
{ uses: './.eas/functions/say-hi', id: 'greet', if: '${{ always() }}' },
643+
],
644+
},
645+
compositeFunctionCatalog: makeCatalog({
646+
'./.eas/functions/say-hi': { name: 'Say hi', command: 'echo hi' },
647+
}),
648+
});
649+
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
650+
expect(anchorHooks.before).toHaveLength(1);
651+
const [hookStep] = anchorHooks.before[0].steps;
652+
expect(anchorHooks.before[0].steps).toHaveLength(1);
653+
expect(hookStep.id).toBe('greet');
654+
expect(hookStep.displayName).toBe('Say hi');
655+
expect(hookStep.command).toBe('echo hi');
656+
// Unlike a composite call, the authored if: lands on the step itself.
657+
expect(hookStep.ifCondition).toBe('${{ always() }}');
658+
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']);
659+
});
660+
636661
it('does not copy the if condition of a composite hook step onto the entry', async () => {
637662
// The authored if: is applied inside the expansion scope, not on the entry.
638663
const workflow = await parseWorkflowAsync({

0 commit comments

Comments
 (0)