Skip to content

Commit 506eea1

Browse files
committed
[build-tools] Load local action catalog at build runtime
1 parent b5be21a commit 506eea1

6 files changed

Lines changed: 308 additions & 23 deletions

File tree

packages/build-tools/src/__tests__/generic.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe(runGenericJobAsync, () => {
5252
child: jest.fn().mockReturnThis(),
5353
},
5454
runBuildPhase: jest.fn(async (_phase: BuildPhase, fn: () => Promise<any>) => fn()),
55+
getReactNativeProjectDirectory: jest.fn(() => '/tmp/src'),
5556
};
5657

5758
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);

packages/build-tools/src/builders/__tests__/custom.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,71 @@ describe(runCustomBuildAsync, () => {
163163
drainSpy.mockRestore();
164164
executeSpy.mockRestore();
165165
});
166+
167+
describe('with inline job steps (StepsConfigParser path)', () => {
168+
let executeSpy: jest.SpyInstance;
169+
170+
function createStepsCtx(steps: unknown[]): BuildContext<BuildJob> {
171+
const job = createTestIosJob();
172+
return new BuildContext(
173+
{
174+
...job,
175+
steps,
176+
} as unknown as BuildJob,
177+
{
178+
workingdir: '/workingdir',
179+
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
180+
logger: createMockLogger(),
181+
env: {
182+
__API_SERVER_URL: 'http://api.expo.test',
183+
},
184+
uploadArtifact: jest.fn(),
185+
}
186+
);
187+
}
188+
189+
beforeEach(() => {
190+
executeSpy = jest.spyOn(BuildWorkflow.prototype, 'executeAsync').mockResolvedValue(undefined);
191+
});
192+
193+
afterEach(() => {
194+
executeSpy.mockRestore();
195+
});
196+
197+
it('builds the action catalog and resolves a local action referenced by a step', async () => {
198+
jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => {
199+
vol.mkdirSync('/workingdir/env', { recursive: true });
200+
vol.fromJSON(
201+
{
202+
'.eas/actions/hello/action.yml': `
203+
name: Hello
204+
runs:
205+
steps:
206+
- run: echo "hello from action"
207+
`,
208+
},
209+
'/workingdir/temporary-custom-build'
210+
);
211+
return { handled: true };
212+
});
213+
214+
const stepsCtx = createStepsCtx([{ uses: './.eas/actions/hello', id: 'hello' }]);
215+
216+
await expect(runCustomBuildAsync(stepsCtx)).resolves.toBeDefined();
217+
expect(executeSpy).toHaveBeenCalledTimes(1);
218+
});
219+
220+
it('fails to parse when a referenced local action does not exist', async () => {
221+
jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => {
222+
vol.mkdirSync('/workingdir/env', { recursive: true });
223+
vol.mkdirSync('/workingdir/temporary-custom-build', { recursive: true });
224+
return { handled: true };
225+
});
226+
227+
const stepsCtx = createStepsCtx([{ uses: './.eas/actions/missing', id: 'missing' }]);
228+
229+
await expect(runCustomBuildAsync(stepsCtx)).rejects.toThrow();
230+
expect(executeSpy).not.toHaveBeenCalled();
231+
});
232+
});
166233
});

packages/build-tools/src/builders/custom.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Artifacts, BuildContext } from '../context';
1717
import { CustomBuildContext } from '../customBuildContext';
1818
import { Datadog } from '../datadog';
1919
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
20+
import { buildActionCatalogAsync } from '../steps/actions';
2021
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
2122
import { getEasFunctions } from '../steps/easFunctions';
2223
import { retryAsync } from '../utils/retry';
@@ -58,25 +59,29 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
5859
const globalContext = new BuildStepGlobalContext(customBuildCtx, false);
5960
const easFunctions = getEasFunctions(customBuildCtx);
6061
const easFunctionGroups = getEasFunctionGroups(customBuildCtx);
61-
const parser = ctx.job.steps
62-
? new StepsConfigParser(globalContext, {
63-
externalFunctions: easFunctions,
64-
externalFunctionGroups: easFunctionGroups,
65-
steps: ctx.job.steps,
66-
})
67-
: new BuildConfigParser(globalContext, {
68-
externalFunctions: easFunctions,
69-
externalFunctionGroups: easFunctionGroups,
70-
configPath: path.join(
71-
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
72-
nullthrows(
73-
ctx.job.customBuildConfig?.path,
74-
'Steps or custom build config path are required in custom jobs'
75-
)
76-
),
77-
});
7862
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
7963
try {
64+
const parser = ctx.job.steps
65+
? new StepsConfigParser(globalContext, {
66+
externalFunctions: easFunctions,
67+
externalFunctionGroups: easFunctionGroups,
68+
steps: ctx.job.steps,
69+
actionCatalog: await buildActionCatalogAsync(
70+
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
71+
{ steps: ctx.job.steps, logger: ctx.logger }
72+
),
73+
})
74+
: new BuildConfigParser(globalContext, {
75+
externalFunctions: easFunctions,
76+
externalFunctionGroups: easFunctionGroups,
77+
configPath: path.join(
78+
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
79+
nullthrows(
80+
ctx.job.customBuildConfig?.path,
81+
'Steps or custom build config path are required in custom jobs'
82+
)
83+
),
84+
});
8085
return await parser.parseAsync();
8186
} catch (parseError: any) {
8287
ctx.logger.error('Failed to parse the custom build config file.');

packages/build-tools/src/generic.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import nullthrows from 'nullthrows';
77
import { prepareProjectSourcesAsync } from './common/projectSources';
88
import { BuildContext } from './context';
99
import { CustomBuildContext } from './customBuildContext';
10+
import { buildActionCatalogAsync } from './steps/actions';
1011
import { getEasFunctionGroups } from './steps/easFunctionGroups';
1112
import { getEasFunctions } from './steps/easFunctions';
1213
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
@@ -43,14 +44,20 @@ export async function runGenericJobAsync(
4344

4445
const globalContext = new BuildStepGlobalContext(customBuildCtx, false);
4546

46-
const parser = new StepsConfigParser(globalContext, {
47-
externalFunctions: getEasFunctions(customBuildCtx),
48-
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
49-
steps: ctx.job.steps,
50-
});
51-
5247
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
5348
try {
49+
const actionCatalog = await buildActionCatalogAsync(
50+
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
51+
{ steps: ctx.job.steps, logger: ctx.logger }
52+
);
53+
54+
const parser = new StepsConfigParser(globalContext, {
55+
externalFunctions: getEasFunctions(customBuildCtx),
56+
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
57+
steps: ctx.job.steps,
58+
actionCatalog,
59+
});
60+
5461
return await parser.parseAsync();
5562
} catch (parseError: any) {
5663
ctx.logger.error('Failed to parse the job definition file.');
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { promises as fs } from 'fs';
2+
import os from 'os';
3+
import path from 'path';
4+
5+
import { buildActionCatalogAsync } from '../actions';
6+
7+
async function makeProjectWithActionAsync(actionName: string, contents: string): Promise<string> {
8+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-actions-test-'));
9+
const actionDir = path.join(projectRoot, '.eas', 'actions', actionName);
10+
await fs.mkdir(actionDir, { recursive: true });
11+
await fs.writeFile(path.join(actionDir, 'action.yml'), contents, 'utf-8');
12+
return projectRoot;
13+
}
14+
15+
const setupActionContents = [
16+
'name: Setup',
17+
'inputs:',
18+
' - name: greeting',
19+
' type: string',
20+
' default_value: hello',
21+
'outputs:',
22+
' version:',
23+
' value: ${{ steps.read.outputs.version }}',
24+
'runs:',
25+
' steps:',
26+
' - id: read',
27+
' run: set-output version "1.0.0"',
28+
].join('\n');
29+
30+
describe(buildActionCatalogAsync, () => {
31+
it('discovers, reads and validates referenced local actions keyed by normalized ref', async () => {
32+
const projectRoot = await makeProjectWithActionAsync('setup', setupActionContents);
33+
34+
const catalog = await buildActionCatalogAsync(projectRoot, {
35+
steps: [{ uses: './.eas/actions/setup', id: 'setup' }],
36+
});
37+
38+
expect(Object.keys(catalog)).toEqual(['./.eas/actions/setup']);
39+
const action = catalog['./.eas/actions/setup'];
40+
expect(action.name).toBe('Setup');
41+
expect(action.runs.steps).toHaveLength(1);
42+
expect(action.outputs?.version.value).toBe('${{ steps.read.outputs.version }}');
43+
});
44+
45+
it('loads nested actions transitively referenced by other actions', async () => {
46+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-actions-nested-'));
47+
const innerDir = path.join(projectRoot, '.eas', 'actions', 'inner');
48+
const outerDir = path.join(projectRoot, '.eas', 'actions', 'outer');
49+
await fs.mkdir(innerDir, { recursive: true });
50+
await fs.mkdir(outerDir, { recursive: true });
51+
await fs.writeFile(
52+
path.join(innerDir, 'action.yml'),
53+
['runs:', ' steps:', ' - run: echo inner'].join('\n'),
54+
'utf-8'
55+
);
56+
await fs.writeFile(
57+
path.join(outerDir, 'action.yml'),
58+
['runs:', ' steps:', ' - uses: ./.eas/actions/inner'].join('\n'),
59+
'utf-8'
60+
);
61+
62+
const catalog = await buildActionCatalogAsync(projectRoot, {
63+
steps: [{ uses: './.eas/actions/outer', id: 'outer' }],
64+
});
65+
66+
expect(Object.keys(catalog).sort()).toEqual(['./.eas/actions/inner', './.eas/actions/outer']);
67+
});
68+
69+
it('returns an empty catalog when there are no referenced actions', async () => {
70+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-actions-empty-'));
71+
const catalog = await buildActionCatalogAsync(projectRoot, { steps: [{ run: 'echo hi' }] });
72+
expect(catalog).toEqual({});
73+
});
74+
75+
it('ignores unreferenced malformed actions on disk', async () => {
76+
const projectRoot = await makeProjectWithActionAsync(
77+
'broken',
78+
['name: Broken', 'runs:', ' steps: []'].join('\n')
79+
);
80+
81+
const catalog = await buildActionCatalogAsync(projectRoot, { steps: [{ run: 'echo hi' }] });
82+
expect(catalog).toEqual({});
83+
});
84+
85+
it('resolves actions referenced by an arbitrary path (GitHub Actions style)', async () => {
86+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-actions-arbitrary-'));
87+
const actionDir = path.join(projectRoot, 'internal-actions', 'deploy');
88+
await fs.mkdir(actionDir, { recursive: true });
89+
await fs.writeFile(
90+
path.join(actionDir, 'action.yml'),
91+
['runs:', ' steps:', ' - run: echo deploy'].join('\n'),
92+
'utf-8'
93+
);
94+
95+
const catalog = await buildActionCatalogAsync(projectRoot, {
96+
steps: [{ uses: './internal-actions/deploy', id: 'deploy' }],
97+
});
98+
99+
expect(Object.keys(catalog)).toEqual(['./internal-actions/deploy']);
100+
});
101+
102+
it('resolves actions defined with an action.yaml extension', async () => {
103+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-actions-yaml-'));
104+
const actionDir = path.join(projectRoot, '.eas', 'actions', 'setup');
105+
await fs.mkdir(actionDir, { recursive: true });
106+
await fs.writeFile(
107+
path.join(actionDir, 'action.yaml'),
108+
['runs:', ' steps:', ' - run: echo hi'].join('\n'),
109+
'utf-8'
110+
);
111+
112+
const catalog = await buildActionCatalogAsync(projectRoot, {
113+
steps: [{ uses: './.eas/actions/setup', id: 'setup' }],
114+
});
115+
116+
expect(Object.keys(catalog)).toEqual(['./.eas/actions/setup']);
117+
});
118+
119+
it('throws when a referenced action escapes the project directory', async () => {
120+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-actions-traversal-'));
121+
await expect(
122+
buildActionCatalogAsync(projectRoot, {
123+
steps: [{ uses: './../secrets', id: 'evil' }],
124+
})
125+
).rejects.toThrow(/resolves to a path outside of the EAS project directory/);
126+
});
127+
128+
it('throws a clear error for a referenced action that does not exist', async () => {
129+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-actions-missing-'));
130+
await expect(
131+
buildActionCatalogAsync(projectRoot, {
132+
steps: [{ uses: './.eas/actions/missing', id: 'missing' }],
133+
})
134+
).rejects.toThrow(
135+
/Local action "\.\/\.eas\/actions\/missing" was referenced by a step but no such action exists/
136+
);
137+
});
138+
139+
it('throws a clear error for a malformed referenced action config', async () => {
140+
const projectRoot = await makeProjectWithActionAsync(
141+
'broken',
142+
['name: Broken', 'runs:', ' steps: []'].join('\n')
143+
);
144+
await expect(
145+
buildActionCatalogAsync(projectRoot, {
146+
steps: [{ uses: './.eas/actions/broken', id: 'broken' }],
147+
})
148+
).rejects.toThrow(/must declare at least one step under "runs.steps"/);
149+
});
150+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { bunyan } from '@expo/logger';
2+
import { ActionCatalog, ActionConfig, validateActionConfig } from '@expo/eas-build-job';
3+
import {
4+
buildActionCatalogFromStepsAsync,
5+
isLocalActionPathWithinProject,
6+
resolveLocalActionPath,
7+
} from '@expo/steps';
8+
import fs from 'fs/promises';
9+
import path from 'path';
10+
import YAML from 'yaml';
11+
12+
async function loadLocalActionConfigAsync(
13+
projectRoot: string,
14+
ref: string,
15+
{ logger }: { logger?: bunyan } = {}
16+
): Promise<ActionConfig> {
17+
const actionPath = resolveLocalActionPath(projectRoot, ref);
18+
if (!isLocalActionPathWithinProject(projectRoot, actionPath)) {
19+
throw new Error(
20+
`Local action "${ref}" resolves to a path outside of the EAS project directory. Local actions must be referenced by a path relative to the EAS project root (the directory containing eas.json) and cannot escape it.`
21+
);
22+
}
23+
24+
for (const ext of ['yml', 'yaml'] as const) {
25+
const absolutePath = path.join(actionPath, `action.${ext}`);
26+
let rawContents: string;
27+
try {
28+
rawContents = await fs.readFile(absolutePath, 'utf-8');
29+
} catch {
30+
continue;
31+
}
32+
const parsed = YAML.parse(rawContents);
33+
const config = validateActionConfig(parsed, { actionReference: ref });
34+
logger?.debug(`Loaded local action "${ref}" from ${path.relative(projectRoot, absolutePath)}`);
35+
return config;
36+
}
37+
38+
throw new Error(
39+
`Local action "${ref}" was referenced by a step but no such action exists. A local action is resolved from an "action.yml" (or "action.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${ref}" resolves "${ref}/action.yml"). The recommended convention is to keep actions under ".eas/actions/<name>".`
40+
);
41+
}
42+
43+
export async function buildActionCatalogAsync(
44+
projectRoot: string,
45+
{ steps, logger }: { steps: readonly unknown[]; logger?: bunyan }
46+
): Promise<ActionCatalog> {
47+
return buildActionCatalogFromStepsAsync({
48+
rootSteps: steps,
49+
loadAction: ref => loadLocalActionConfigAsync(projectRoot, ref, { logger }),
50+
onCycleDetected: cyclePath =>
51+
new Error(
52+
`Detected a cycle while expanding actions: ${cyclePath.join(' -> ')}. An action cannot reference itself, directly or indirectly.`
53+
),
54+
});
55+
}

0 commit comments

Comments
 (0)