Skip to content
Draft
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 @@ -16,6 +16,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [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))
- [eas-cli] Validate local composite functions referenced from workflow job hooks during `eas workflow:validate`. ([#4064](https://github.com/expo/eas-cli/pull/4064) by [@sswrk](https://github.com/sswrk))
- [build-tools] Load local functions declaring `command` or `path`, so custom build functions moved from `.eas/build` configs into `.eas/functions` can be called from workflows. ([#4097](https://github.com/expo/eas-cli/pull/4097) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Validate local functions declaring `command` or `path` referenced from workflows, including that the module directory of a `path` function exists. ([#4098](https://github.com/expo/eas-cli/pull/4098) by [@sswrk](https://github.com/sswrk))

### 🐛 Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,104 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
).resolves.toBeUndefined();
});

it('validates a referenced single-step command function', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-command-'));
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'say-hi',
['inputs:', ' - name', 'command: echo "Hi, ${ inputs.name }!"'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/say-hi', with: { name: 'World' } }],
},
},
};

await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).resolves.toBeUndefined();
});

it('validates a referenced single-step path function whose module exists', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-path-'));
await makeProjectWithCompositeFunctionAsync(projectRoot, 'say-hi', 'path: ./my-function');
const moduleDir = path.join(projectRoot, '.eas', 'functions', 'say-hi', 'my-function');
await fs.mkdir(moduleDir, { recursive: true });
await fs.writeFile(path.join(moduleDir, 'package.json'), '{}', 'utf-8');
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/say-hi' }],
},
},
};

await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).resolves.toBeUndefined();
});

it('throws when the module of a referenced path function is missing', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-path-'));
await makeProjectWithCompositeFunctionAsync(projectRoot, 'say-hi', 'path: ./my-function');
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/say-hi' }],
},
},
};

await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).rejects.toThrow(
/Local function "\.\/\.eas\/functions\/say-hi" declares "path: \.\/my-function", but there is no such directory at .*my-function\./
);
});

it('throws when the module of a referenced path function has no package.json', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-path-'));
await makeProjectWithCompositeFunctionAsync(projectRoot, 'say-hi', 'path: ./my-function');
await fs.mkdir(path.join(projectRoot, '.eas', 'functions', 'say-hi', 'my-function'), {
recursive: true,
});
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/say-hi' }],
},
},
};

await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).rejects.toThrow(/does not contain a package.json file/);
});

it('throws when a referenced function mixes the composite and single-step shapes', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-mixed-'));
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'broken',
['command: echo hi', 'runs:', ' steps:', ' - run: echo hi'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/broken' }],
},
},
};

await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).rejects.toThrow(
'Invalid local function "./.eas/functions/broken": ✖ A local function must declare either "runs.steps" (a composite function) or "command"/"path" (a single-step function), not both.'
);
});

it('throws when a referenced local composite function is malformed', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-'));
await makeProjectWithCompositeFunctionAsync(
Expand Down
50 changes: 45 additions & 5 deletions packages/eas-cli/src/commandUtils/workflow/compositeFunctions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { CompositeFunctionConfig, CompositeFunctionConfigZ } from '@expo/eas-build-job';
import {
LocalFunctionConfig,
LocalFunctionConfigZ,
isLegacyFunctionConfig,
} from '@expo/eas-build-job';
import {
buildCompositeFunctionCatalogFromStepsAsync,
resolveLegacyFunctionModulePath,
resolveLocalCompositeFunctionPath,
} from '@expo/steps';
import { promises as fs } from 'fs';
Expand All @@ -26,7 +31,7 @@ export async function validateWorkflowLocalCompositeFunctionsAsync(
async function loadLocalCompositeFunctionConfigAsync(
projectDir: string,
compositeFunctionPath: string
): Promise<CompositeFunctionConfig> {
): Promise<LocalFunctionConfig> {
const resolvedPath = resolveLocalCompositeFunctionPath(projectDir, compositeFunctionPath);

for (const ext of COMPOSITE_FUNCTION_FILE_EXTENSIONS) {
Expand All @@ -35,6 +40,9 @@ async function loadLocalCompositeFunctionConfigAsync(
path.join(resolvedPath, `function.${ext}`)
);
if (config) {
if (isLegacyFunctionConfig(config) && config.path !== undefined) {
await validateFunctionModulePathAsync(projectDir, compositeFunctionPath, config.path);
}
Log.debug(`Validated local composite function "${compositeFunctionPath}"`);
return config;
}
Expand All @@ -45,10 +53,42 @@ async function loadLocalCompositeFunctionConfigAsync(
);
}

/** A `path` function ships a prebuilt JavaScript module; a missing one must not reach the build. */
async function validateFunctionModulePathAsync(
projectDir: string,
functionPath: string,
modulePath: string
): Promise<void> {
const resolvedModulePath = resolveLegacyFunctionModulePath({
projectRoot: projectDir,
functionPath,
modulePath,
});
if (!(await pathExistsAsync(resolvedModulePath))) {
throw new Error(
`Local function "${functionPath}" declares "path: ${modulePath}", but there is no such directory at ${resolvedModulePath}.`
);
}
if (!(await pathExistsAsync(path.join(resolvedModulePath, 'package.json')))) {
throw new Error(
`Local function "${functionPath}" declares "path: ${modulePath}", but the module directory ${resolvedModulePath} does not contain a package.json file.`
);
}
}

async function pathExistsAsync(target: string): Promise<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}

async function readCompositeFunctionConfigFileAsync(
compositeFunctionPath: string,
absolutePath: string
): Promise<CompositeFunctionConfig | null> {
): Promise<LocalFunctionConfig | null> {
let rawContents: string;
try {
rawContents = await fs.readFile(absolutePath, 'utf-8');
Expand Down Expand Up @@ -76,10 +116,10 @@ async function readCompositeFunctionConfigFileAsync(
);
}

const result = CompositeFunctionConfigZ.safeParse(parsed);
const result = LocalFunctionConfigZ.safeParse(parsed);
if (!result.success) {
throw new Error(
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(result.error)}`
`Invalid local function "${compositeFunctionPath}": ${z.prettifyError(result.error)}`
);
}

Expand Down
Loading