Skip to content

Commit 69fbddb

Browse files
committed
[steps] Add local action discovery utilities
1 parent 581492e commit 69fbddb

5 files changed

Lines changed: 329 additions & 0 deletions

File tree

packages/steps/src/BuildConfig.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BuildRuntimePlatform } from './BuildRuntimePlatform';
88
import { BuildStepEnv } from './BuildStepEnv';
99
import { BuildStepInputValueType, BuildStepInputValueTypeName } from './BuildStepInput';
1010
import { BuildConfigError, BuildWorkflowError } from './errors';
11+
import { isActionReference } from './utils/localActions';
1112
import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX } from './utils/template';
1213

1314
export type BuildFunctions = Record<string, BuildFunctionConfig>;
@@ -438,6 +439,16 @@ export function validateAllFunctionsExist(
438439
}
439440
}
440441
const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet);
442+
const actionReferences = calledFunctionsOrFunctionGroup.filter(isActionReference);
443+
if (actionReferences.length > 0) {
444+
throw new BuildConfigError(
445+
`Local actions (${actionReferences
446+
.map(ref => `"${ref}"`)
447+
.join(
448+
', '
449+
)}) are not supported in ".eas/build/*.yml" custom builds. Local actions can only be used in EAS workflows (".eas/workflows/*.yml").`
450+
);
451+
}
441452
const externalFunctionIdsSet = new Set(externalFunctionIds);
442453
const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupsIds);
443454
const nonExistentFunctionsOrFunctionGroups = calledFunctionsOrFunctionGroup.filter(

packages/steps/src/__tests__/BuildConfig-test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,40 @@ describe(validateAllFunctionsExist, () => {
10421042
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
10431043
}).toThrowError(/Calling non-existent functions: "eas\/build"/);
10441044
});
1045+
test('rejects local action references with a dedicated error', () => {
1046+
const buildConfig: BuildConfig = {
1047+
build: {
1048+
steps: ['./.eas/actions/my-action'],
1049+
},
1050+
};
1051+
1052+
expect(() => {
1053+
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
1054+
}).toThrowError(
1055+
/Local actions \("\.\/\.eas\/actions\/my-action"\) are not supported in "\.eas\/build\/\*\.yml" custom builds\. Local actions can only be used in EAS workflows \("\.eas\/workflows\/\*\.yml"\)\./
1056+
);
1057+
});
1058+
test('rejects object-form local action references with a dedicated error', () => {
1059+
const buildConfig: BuildConfig = {
1060+
build: {
1061+
steps: [
1062+
{
1063+
'./.eas/actions/my-action': {
1064+
inputs: {
1065+
foo: 'bar',
1066+
},
1067+
},
1068+
},
1069+
],
1070+
},
1071+
};
1072+
1073+
expect(() => {
1074+
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
1075+
}).toThrow(
1076+
/Local actions \("\.\/\.eas\/actions\/my-action"\) are not supported in "\.eas\/build\/\*\.yml" custom builds\. Local actions can only be used in EAS workflows \("\.eas\/workflows\/\*\.yml"\)\./
1077+
);
1078+
});
10451079
test('non-existent namespaced functions with skipNamespacedFunctionsOrFunctionGroupsCheck = false', () => {
10461080
const buildConfig: BuildConfig = {
10471081
build: {

packages/steps/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export * from './interpolation';
1616
export * from './utils/shell/spawn';
1717
export * from './utils/jsepEval';
1818
export * from './utils/hashFiles';
19+
export * from './utils/localActions';
1920
export { StepMetric, StepMetricResult } from './StepMetrics';
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { ActionConfigZ } from '@expo/eas-build-job';
2+
import path from 'path';
3+
4+
import {
5+
buildActionCatalogFromStepsAsync,
6+
isActionReference,
7+
isLocalActionPathWithinProject,
8+
parseActionReference,
9+
resolveLocalActionPath,
10+
} from '../localActions';
11+
12+
describe(parseActionReference, () => {
13+
it('parses local action references', () => {
14+
expect(parseActionReference('./.eas/actions/setup')).toBe('./.eas/actions/setup');
15+
});
16+
17+
it('normalizes local action references', () => {
18+
expect(parseActionReference(' ./.eas/actions/setup/ ')).toBe('./.eas/actions/setup');
19+
});
20+
21+
it('returns null for built-in function ids', () => {
22+
expect(parseActionReference('eas/build')).toBeNull();
23+
});
24+
25+
it('returns null for degenerate refs with no path segment', () => {
26+
expect(parseActionReference('./')).toBeNull();
27+
expect(parseActionReference(' ./ ')).toBeNull();
28+
});
29+
});
30+
31+
describe(isActionReference, () => {
32+
it('returns true for local action references', () => {
33+
expect(isActionReference('./.eas/actions/setup')).toBe(true);
34+
});
35+
36+
it('returns false for built-in function ids', () => {
37+
expect(isActionReference('eas/build')).toBe(false);
38+
});
39+
40+
it('returns false for degenerate refs with no path segment', () => {
41+
expect(isActionReference('./')).toBe(false);
42+
});
43+
});
44+
45+
describe(buildActionCatalogFromStepsAsync, () => {
46+
it('loads referenced actions transitively', async () => {
47+
const catalog = await buildActionCatalogFromStepsAsync({
48+
rootSteps: [{ uses: './.eas/actions/outer', id: 'outer' }],
49+
loadAction: async ref => {
50+
if (ref === './.eas/actions/outer') {
51+
return ActionConfigZ.parse({
52+
runs: { steps: [{ uses: './.eas/actions/inner' }] },
53+
});
54+
}
55+
if (ref === './.eas/actions/inner') {
56+
return ActionConfigZ.parse({
57+
runs: { steps: [{ run: 'echo inner' }] },
58+
});
59+
}
60+
throw new Error(`missing ${ref}`);
61+
},
62+
});
63+
64+
expect(Object.keys(catalog).sort()).toEqual(['./.eas/actions/inner', './.eas/actions/outer']);
65+
});
66+
67+
it('detects cycles while loading', async () => {
68+
await expect(
69+
buildActionCatalogFromStepsAsync({
70+
rootSteps: [{ uses: './.eas/actions/a', id: 'a' }],
71+
loadAction: async ref => {
72+
if (ref === './.eas/actions/a') {
73+
return ActionConfigZ.parse({
74+
runs: { steps: [{ uses: './.eas/actions/b' }] },
75+
});
76+
}
77+
if (ref === './.eas/actions/b') {
78+
return ActionConfigZ.parse({
79+
runs: { steps: [{ uses: './.eas/actions/a' }] },
80+
});
81+
}
82+
throw new Error(`missing ${ref}`);
83+
},
84+
})
85+
).rejects.toThrow(/cycle/i);
86+
});
87+
88+
it('rejects action chains deeper than the maximum nesting depth at catalog build time', async () => {
89+
const chainLength = 12;
90+
const refs = Array.from({ length: chainLength }, (_, index) => `./.eas/actions/a${index}`);
91+
92+
await expect(
93+
buildActionCatalogFromStepsAsync({
94+
rootSteps: [{ uses: refs[0], id: 'root' }],
95+
loadAction: async ref => {
96+
const index = refs.indexOf(ref);
97+
if (index === -1) {
98+
throw new Error(`missing ${ref}`);
99+
}
100+
if (index === chainLength - 1) {
101+
return ActionConfigZ.parse({ runs: { steps: [{ run: 'echo leaf' }] } });
102+
}
103+
return ActionConfigZ.parse({
104+
runs: { steps: [{ uses: refs[index + 1] }] },
105+
});
106+
},
107+
})
108+
).rejects.toThrow(/Maximum action nesting depth \(10\) exceeded/);
109+
});
110+
111+
it('collects normalized action references from steps', async () => {
112+
const loadedRefs: string[] = [];
113+
await buildActionCatalogFromStepsAsync({
114+
rootSteps: [{ uses: './.eas/actions/setup/' }, { uses: 'eas/build' }, { run: 'echo hi' }],
115+
loadAction: async ref => {
116+
loadedRefs.push(ref);
117+
return ActionConfigZ.parse({ runs: { steps: [{ run: 'echo setup' }] } });
118+
},
119+
});
120+
expect(loadedRefs).toEqual(['./.eas/actions/setup']);
121+
});
122+
});
123+
124+
describe(resolveLocalActionPath, () => {
125+
const projectRoot = path.resolve('/tmp/project');
126+
127+
it('resolves a reference under the conventional .eas/actions directory', () => {
128+
expect(resolveLocalActionPath(projectRoot, './.eas/actions/setup')).toBe(
129+
path.join(projectRoot, '.eas', 'actions', 'setup')
130+
);
131+
});
132+
133+
it('resolves an arbitrary GitHub Actions style path within the project', () => {
134+
expect(resolveLocalActionPath(projectRoot, './internal-actions/deploy')).toBe(
135+
path.join(projectRoot, 'internal-actions', 'deploy')
136+
);
137+
});
138+
});
139+
140+
describe(isLocalActionPathWithinProject, () => {
141+
const projectRoot = path.resolve('/tmp/project');
142+
143+
it('returns true for paths within the project', () => {
144+
expect(
145+
isLocalActionPathWithinProject(
146+
projectRoot,
147+
resolveLocalActionPath(projectRoot, './.eas/actions/setup')
148+
)
149+
).toBe(true);
150+
});
151+
152+
it('returns false when the reference escapes the project root via ..', () => {
153+
expect(
154+
isLocalActionPathWithinProject(
155+
projectRoot,
156+
resolveLocalActionPath(projectRoot, './../secrets')
157+
)
158+
).toBe(false);
159+
});
160+
161+
it('returns false when the reference resolves to the project root itself', () => {
162+
expect(
163+
isLocalActionPathWithinProject(projectRoot, resolveLocalActionPath(projectRoot, './'))
164+
).toBe(false);
165+
});
166+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Utilities for discovering local composite actions referenced via `uses:` in EAS
3+
* workflows (`.eas/workflows/*.yml`) or inline job step definitions.
4+
*
5+
* Local actions are referenced by their path relative to the EAS project root, e.g.
6+
* `uses: ./.eas/actions/<name>`, which resolves the action config at
7+
* `<project-root>/.eas/actions/<name>/action.{yml,yaml}`. Any `./`-prefixed path is
8+
* supported (GitHub Actions style), so actions can be organized freely, e.g.
9+
* `uses: ./internal-actions/deploy`. The recommended convention is to keep them under
10+
* `.eas/actions/<name>`. They are not supported in `.eas/build/*.yml` custom build
11+
* config files.
12+
*/
13+
import { ActionCatalog, ActionConfig } from '@expo/eas-build-job';
14+
import path from 'path';
15+
16+
export const MAX_ACTION_NESTING_DEPTH = 10;
17+
18+
type ActionLoader = (ref: string) => Promise<ActionConfig>;
19+
20+
type BuildActionCatalogFromStepsOptions = {
21+
rootSteps: readonly unknown[];
22+
loadAction: ActionLoader;
23+
};
24+
25+
/** Recognizes `./…` local action references in a `uses` value, distinct from built-in functions like `eas/build`. */
26+
export function parseActionReference(uses: string): string | null {
27+
const trimmed = uses.trim();
28+
if (!trimmed.startsWith('./')) {
29+
return null;
30+
}
31+
let ref = trimmed;
32+
while (ref.length > 1 && ref.endsWith('/')) {
33+
ref = ref.slice(0, -1);
34+
}
35+
if (!ref.slice(2)) {
36+
return null;
37+
}
38+
return ref;
39+
}
40+
41+
/** @see parseActionReference */
42+
export function isActionReference(uses: string): boolean {
43+
return parseActionReference(uses) !== null;
44+
}
45+
46+
/**
47+
* Resolves every local action a workflow depends on, including actions referenced by other actions.
48+
*/
49+
export async function buildActionCatalogFromStepsAsync({
50+
rootSteps,
51+
loadAction,
52+
}: BuildActionCatalogFromStepsOptions): Promise<ActionCatalog> {
53+
const catalog: ActionCatalog = {};
54+
const loaded = new Set<string>();
55+
56+
const loadRecursiveAsync = async (ref: string, ancestry: string[]): Promise<void> => {
57+
// Must run before `loaded.has(ref)` — a cyclic ref is already in `loaded` when the cycle closes.
58+
if (ancestry.includes(ref)) {
59+
const cyclePath = [...ancestry, ref].join(' -> ');
60+
throw new Error(
61+
`Detected a cycle while expanding actions: ${cyclePath}. An action cannot reference itself, directly or indirectly.`
62+
);
63+
}
64+
if (ancestry.length > MAX_ACTION_NESTING_DEPTH) {
65+
throw new Error(
66+
`Maximum action nesting depth (${MAX_ACTION_NESTING_DEPTH}) exceeded while loading action "${ref}".`
67+
);
68+
}
69+
if (loaded.has(ref)) {
70+
return;
71+
}
72+
73+
const config = await loadAction(ref);
74+
catalog[ref] = config;
75+
loaded.add(ref);
76+
77+
for (const nestedRef of collectActionReferencesFromSteps(config.runs.steps)) {
78+
await loadRecursiveAsync(nestedRef, [...ancestry, ref]);
79+
}
80+
};
81+
82+
for (const ref of collectActionReferencesFromSteps(rootSteps)) {
83+
await loadRecursiveAsync(ref, []);
84+
}
85+
86+
return catalog;
87+
}
88+
89+
/** Locates an action's on-disk directory from its workflow reference. */
90+
export function resolveLocalActionPath(projectRoot: string, ref: string): string {
91+
return path.resolve(projectRoot, ref);
92+
}
93+
94+
/** Guards against local action paths that escape the project root via `..` traversal. */
95+
export function isLocalActionPathWithinProject(projectRoot: string, actionPath: string): boolean {
96+
const relative = path.relative(projectRoot, actionPath);
97+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
98+
}
99+
100+
function collectActionReferencesFromSteps(
101+
steps: readonly unknown[],
102+
into: Set<string> = new Set()
103+
): Set<string> {
104+
for (const step of steps) {
105+
if (!step || typeof step !== 'object') {
106+
continue;
107+
}
108+
const uses = (step as { uses?: unknown }).uses;
109+
if (typeof uses === 'string') {
110+
const parsed = parseActionReference(uses);
111+
if (parsed) {
112+
into.add(parsed);
113+
}
114+
}
115+
}
116+
return into;
117+
}

0 commit comments

Comments
 (0)