diff --git a/CHANGELOG.md b/CHANGELOG.md index 1475002553..33e1cd582e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/build-tools/src/builders/custom.ts b/packages/build-tools/src/builders/custom.ts index 7391c8e071..73c3776504 100644 --- a/packages/build-tools/src/builders/custom.ts +++ b/packages/build-tools/src/builders/custom.ts @@ -69,7 +69,7 @@ export async function runCustomBuildAsync(ctx: BuildContext): 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, { diff --git a/packages/build-tools/src/common/__tests__/jobHooks.test.ts b/packages/build-tools/src/common/__tests__/jobHooks.test.ts index 76df51812c..e4bd9bdf53 100644 --- a/packages/build-tools/src/common/__tests__/jobHooks.test.ts +++ b/packages/build-tools/src/common/__tests__/jobHooks.test.ts @@ -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'; @@ -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; warn: jest.Mock; } { @@ -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 /build (job projectRootDirectory is '.'). +async function makeWorkingdirWithCompositeFunctionAsync( + functionName: string, + contents: string +): Promise { + 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); @@ -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' }], diff --git a/packages/build-tools/src/common/jobHooks.ts b/packages/build-tools/src/common/jobHooks.ts index cf7071a029..e3a200f616 100644 --- a/packages/build-tools/src/common/jobHooks.ts +++ b/packages/build-tools/src/common/jobHooks.ts @@ -1,5 +1,6 @@ import { BuildJob, + CompositeFunctionCatalog, ErrorCode, HookAnchorId, HookKey, @@ -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'; @@ -89,6 +91,30 @@ export async function parseJobHooksAsync( } } + // 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 @@ -107,6 +133,7 @@ export async function parseJobHooksAsync( entries = await constructHookEntriesAsync(globalContext, steps, { externalFunctions, externalFunctionGroups, + compositeFunctionCatalog, }); } catch (err) { throw new UserError( diff --git a/packages/build-tools/src/generic.ts b/packages/build-tools/src/generic.ts index 2800ef2601..abca477fe1 100644 --- a/packages/build-tools/src/generic.ts +++ b/packages/build-tools/src/generic.ts @@ -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, { diff --git a/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts b/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts index d095851ba8..071170536b 100644 --- a/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts +++ b/packages/build-tools/src/steps/__tests__/compositeFunctions.test.ts @@ -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', diff --git a/packages/build-tools/src/steps/compositeFunctions.ts b/packages/build-tools/src/steps/compositeFunctions.ts index 599b3dabb3..9c68902c5d 100644 --- a/packages/build-tools/src/steps/compositeFunctions.ts +++ b/packages/build-tools/src/steps/compositeFunctions.ts @@ -7,7 +7,9 @@ import { CompositeFunctionCatalog, CompositeFunctionConfig, CompositeFunctionConfigZ, + Hooks, Step, + parseHookKey, } from '@expo/eas-build-job'; import { buildCompositeFunctionCatalogFromStepsAsync, @@ -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 { 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 : [] + ); +}