Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Add `ref` input to the `eas/checkout` step to check out a different git ref (branch, tag, or commit SHA) than the one that triggered the job. ([#4035](https://github.com/expo/eas-cli/pull/4035) by [@sswrk](https://github.com/sswrk))
- [build-tools] Load local composite function catalogs at build time so custom builds and generic jobs can resolve local composite functions referenced via `uses:`. ([#3930](https://github.com/expo/eas-cli/pull/3930) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Validate local composite functions referenced in workflow files during `eas workflow:validate`, checking that the YAML files exist and are well-formed. ([#3931](https://github.com/expo/eas-cli/pull/3931) by [@sswrk](https://github.com/sswrk))
- [build-tools] Load local composite function catalogs for hook steps, in both steps-based and native builds. ([#4063](https://github.com/expo/eas-cli/pull/4063) by [@sswrk](https://github.com/sswrk))

### 🐛 Bug fixes

Expand Down
2 changes: 1 addition & 1 deletion packages/build-tools/src/builders/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
hooks: ctx.job.hooks,
compositeFunctionCatalog: await buildCompositeFunctionCatalogAsync(
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
{ steps: ctx.job.steps, logger: ctx.logger }
{ steps: ctx.job.steps, hooks: ctx.job.hooks, logger: ctx.logger }
),
})
: new BuildConfigParser(globalContext, {
Expand Down
63 changes: 61 additions & 2 deletions packages/build-tools/src/common/__tests__/jobHooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';

import { BuildTrigger, ErrorCode, Hooks, Ios, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';

Expand All @@ -7,7 +11,10 @@ import { parseJobHooksAsync } from '../jobHooks';

const INSTALL: ['install_node_modules'] = ['install_node_modules'];

function createCtx(hooks: Hooks | undefined): {
function createCtx(
hooks: Hooks | undefined,
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
warn: jest.Mock;
} {
Expand All @@ -26,12 +33,24 @@ function createCtx(hooks: Hooks | undefined): {
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger,
uploadArtifact: jest.fn(),
workingdir: '/tmp/wd',
workingdir,
}
);
return { ctx, warn };
}

// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
async function makeWorkingdirWithCompositeFunctionAsync(
functionName: string,
contents: string
): Promise<string> {
const workingdir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-hooks-composite-'));
const functionDir = path.join(workingdir, 'build', '.eas', 'functions', functionName);
await fs.mkdir(functionDir, { recursive: true });
await fs.writeFile(path.join(functionDir, 'function.yml'), contents, 'utf-8');
return workingdir;
}

describe(parseJobHooksAsync, () => {
it('returns null when the job has no hooks', async () => {
const { ctx } = createCtx(undefined);
Expand Down Expand Up @@ -124,6 +143,46 @@ describe(parseJobHooksAsync, () => {
expect(after.steps[0].ctx.global).toBe(parsed!.globalContext);
});

it('parses a composite function under a wrapped key into ONE entry with the expansion inside', async () => {
const workingdir = await makeWorkingdirWithCompositeFunctionAsync(
'setup',
[
'runs:',
' steps:',
' - id: prepare',
' run: echo prepare',
' - run: echo hi',
].join('\n')
);
const { ctx } = createCtx(
{ before_install_node_modules: [{ uses: './.eas/functions/setup', id: 'setup' }] },
{ workingdir }
);
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_install_node_modules).toHaveLength(1);
expect(
parsed!.hookEntriesByKey.before_install_node_modules![0].steps.map(step => step.id)
).toEqual(['setup__prepare', 'setup__composite_function_step_1']);
});

it('fails with a hooks error when a wrapped key references a missing composite function', async () => {
const workingdir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-hooks-missing-'));
const { ctx } = createCtx(
{ before_install_node_modules: [{ uses: './.eas/functions/missing' }] },
{ workingdir }
);
const result = parseJobHooksAsync(ctx, INSTALL);
await expect(result).rejects.toThrow('no such composite function exists');
await expect(result).rejects.toMatchObject({ errorCode: ErrorCode.HOOKS_ERROR });
});

it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});

it('wraps a cross-key duplicate step id in the aggregate error', async () => {
const { ctx } = createCtx({
before_install_node_modules: [{ id: 'dup', run: 'echo a' }],
Expand Down
27 changes: 27 additions & 0 deletions packages/build-tools/src/common/jobHooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
BuildJob,
CompositeFunctionCatalog,
ErrorCode,
HookAnchorId,
HookKey,
Expand All @@ -17,6 +18,7 @@ import {

import { BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { buildCompositeFunctionCatalogAsync } from '../steps/compositeFunctions';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';

Expand Down Expand Up @@ -89,6 +91,30 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
}
}

// Only load composites for anchors this build actually wraps.
// Unwrapped registered keys warn-and-skip above.
const catalogRootSteps = wrappedAnchors.flatMap(anchor =>
(['before', 'after'] as const).flatMap(side => {
const steps = hooks[`${side}_${anchor}`];
return Array.isArray(steps) ? steps : [];
})
);
let compositeFunctionCatalog: CompositeFunctionCatalog;
try {
compositeFunctionCatalog = await buildCompositeFunctionCatalogAsync(
ctx.getReactNativeProjectDirectory(),
{ steps: catalogRootSteps, logger: ctx.logger }
);
} catch (err) {
throw new UserError(
ErrorCode.HOOKS_ERROR,
`Failed to load a local composite function referenced from the job's hooks: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}

// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
// before_ then after_) so generated step ids and output references follow the
// order steps actually run. All entries share one globalContext, so env and
Expand All @@ -107,6 +133,7 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
entries = await constructHookEntriesAsync(globalContext, steps, {
externalFunctions,
externalFunctionGroups,
compositeFunctionCatalog,
});
} catch (err) {
throw new UserError(
Expand Down
2 changes: 1 addition & 1 deletion packages/build-tools/src/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function runGenericJobAsync(
try {
const compositeFunctionCatalog = await buildCompositeFunctionCatalogAsync(
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
{ steps: ctx.job.steps, logger: ctx.logger }
{ steps: ctx.job.steps, hooks: ctx.job.hooks, logger: ctx.logger }
);

const parser = new StepsConfigParser(globalContext, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,35 @@ describe(buildCompositeFunctionCatalogAsync, () => {
);
});

it('loads composite functions referenced from registered hook keys', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'setup',
setupCompositeFunctionContents
);

const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ run: 'echo hi' }],
hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup' }] },
});

expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
});

it('ignores composite references under unregistered hook keys and non-array hook values', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-hooks-'));

const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ run: 'echo hi' }],
hooks: {
before_some_future_anchor: [{ uses: './.eas/functions/missing' }],
not_a_hook_key: [{ uses: './.eas/functions/missing' }],
before_install_node_modules: 'garbage' as never,
},
});

expect(catalog).toEqual({});
});

it('throws a clear error for a malformed referenced composite function config', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'broken',
Expand Down
15 changes: 13 additions & 2 deletions packages/build-tools/src/steps/compositeFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
CompositeFunctionCatalog,
CompositeFunctionConfig,
CompositeFunctionConfigZ,
Hooks,
Step,
parseHookKey,
} from '@expo/eas-build-job';
import {
buildCompositeFunctionCatalogFromStepsAsync,
Expand Down Expand Up @@ -76,11 +78,20 @@ async function loadLocalCompositeFunctionConfigAsync(

export async function buildCompositeFunctionCatalogAsync(
projectRoot: string,
{ steps, logger }: { steps: readonly Step[]; logger?: bunyan }
{ steps, hooks, logger }: { steps: readonly Step[]; hooks?: Hooks; logger?: bunyan }
): Promise<CompositeFunctionCatalog> {
return buildCompositeFunctionCatalogFromStepsAsync({
rootSteps: steps,
rootSteps: [...steps, ...hookCatalogRootSteps(hooks)],
loadCompositeFunction: compositeFunctionPath =>
loadLocalCompositeFunctionConfigAsync(projectRoot, compositeFunctionPath, { logger }),
});
}

function hookCatalogRootSteps(hooks: Hooks | undefined): Step[] {
if (!hooks) {
return [];
}
return Object.entries(hooks).flatMap(([key, steps]) =>
parseHookKey(key) !== null && Array.isArray(steps) ? steps : []
);
}
Loading