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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [eas-cli] Stop `eas workflow:validate` and `eas workflow:create` from rejecting a `${{ ... }}` expression where the schema asks for a URI, such as the Slack job's `webhook_url`. ([#4255](https://github.com/expo/eas-cli/pull/4255) by [@dennytosp](https://github.com/dennytosp))

### 🧹 Chores

## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"data": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"name": { "type": "string" },
"jobs": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "build" },
"params": {
"type": "object",
"properties": {
"platform": { "type": "string" },
"profile": { "type": "string" }
},
"required": ["platform"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "slack" },
"params": {
"anyOf": [
{
"type": "object",
"properties": {
"message": { "type": "string" },
"webhook_url": { "type": "string", "format": "uri" }
},
"required": ["message"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"payload": { "type": "object" },
"webhook_url": { "type": "string", "format": "uri" }
},
"required": ["payload"],
"additionalProperties": false
}
]
}
},
"required": ["type", "params"],
"additionalProperties": false
}
]
}
}
},
"required": ["jobs"],
"additionalProperties": false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import path from 'path';

import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import { validateWorkflowFileAsync } from '../validation';

jest.mock('../compositeFunctions', () => ({
validateWorkflowLocalCompositeFunctionsAsync: jest.fn(),
}));
jest.mock('../buildProfileUtils', () => ({
buildProfileNamesFromProjectAsync: jest.fn(async () => new Set(['production'])),
}));
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation', () => ({
WorkflowRevisionMutation: {
validateWorkflowYamlConfigAsync: jest.fn(),
},
}));

const SCHEMA_PATH = path.join(__dirname, 'fixtures', 'workflow-schema.json');

async function validateAsync(yamlConfig: string): Promise<void> {
await validateWorkflowFileAsync(
{ yamlConfig, filePath: '.eas/workflows/test.yml' },
'/project',
{} as ExpoGraphqlClient,
'projectId'
);
}

function slackWorkflow(webhookUrl: string): string {
return `
name: Notify
jobs:
notify:
name: Notify
type: slack
params:
message: Build finished
webhook_url: ${webhookUrl}
`;
}

describe(validateWorkflowFileAsync, () => {
const originalSchemaPath = process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH;

beforeAll(() => {
process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH = SCHEMA_PATH;
});

afterAll(() => {
process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH = originalSchemaPath;
});

it('accepts a value that only becomes a URI once the workflow runs', async () => {
await expect(
validateAsync(slackWorkflow('${{ env.SLACK_WEBHOOK_URL }}'))
).resolves.toBeUndefined();
});

it('accepts a URI written out in the file', async () => {
await expect(
validateAsync(slackWorkflow('https://hooks.slack.com/services/T000/B000/XXX'))
).resolves.toBeUndefined();
});

it('still rejects a value that is neither a URI nor an expression', async () => {
await expect(validateAsync(slackWorkflow('"not a webhook"'))).rejects.toThrow(/webhook_url/);
});
});
66 changes: 65 additions & 1 deletion packages/eas-cli/src/commandUtils/workflow/validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/errors';
import { CombinedError } from '@urql/core';
import Ajv, { FormatDefinition } from 'ajv';
import { promises as fs } from 'fs';
import path from 'path';
import * as YAML from 'yaml';
Expand Down Expand Up @@ -142,7 +143,7 @@ function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): voi
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
delete workflowJsonSchema['$schema'];

const ajv = createValidator();
const ajv = createWorkflowValidator();
const validate = ajv.compile(workflowJsonSchema);
const result = validate(parsedYaml);

Expand All @@ -167,6 +168,69 @@ function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): vo
}
}

// A `${{ ... }}` expression is only replaced with its value once the workflow runs.
const TEMPLATE_EXPRESSION = /\$\{\{[\s\S]*?\}\}/;

type AddedFormat = NonNullable<Ajv['formats'][string]>;

/**
* A validator that accepts an interpolated value for any `format` it is checked against. Until the
* workflow runs, what the file holds is not the string the format describes -- `${{ env.WEBHOOK }}`
* is not a URI -- so the format is the one thing that cannot be decided here, and the server checks
* it once the value is resolved. Letting it through matters beyond the message itself, because jobs
* are matched with `anyOf`: one failed format drops its branch and the job is then reported against
* every other job type at once.
*/
function createWorkflowValidator(): Ajv {
const validator = createValidator();
for (const [name, format] of Object.entries(validator.formats)) {
const validate = format && stringFormatValidate(format);
if (!validate) {
continue;
}
const relaxed = (value: string): boolean => TEMPLATE_EXPRESSION.test(value) || validate(value);
validator.addFormat(
name,
isStringFormatDefinition(format) ? { ...format, validate: relaxed } : relaxed
);
}
return validator;
}

function isStringFormatDefinition(format: AddedFormat): format is FormatDefinition<string> {
return (
typeof format === 'object' &&
!(format instanceof RegExp) &&
// A definition without a `type` describes a string, which is also AJV's default.
format.type !== 'number' &&
format.async !== true
);
}

/** How a format checks a string, in whichever of the shapes AJV accepts it was registered. */
function stringFormatValidate(format: AddedFormat): ((value: string) => boolean) | null {
if (format === true) {
// Every string is accepted as it is, so there is nothing left to relax.
return null;
}
if (format instanceof RegExp) {
return value => format.test(value);
}
if (typeof format === 'function') {
return format;
}
if (!isStringFormatDefinition(format)) {
// A number format, or an asynchronous one: neither can hold an expression to skip.
return null;
}
const { validate } = format;
if (typeof validate === 'function') {
return validate;
}
const pattern = validate instanceof RegExp ? validate : new RegExp(validate);
return value => pattern.test(value);
}

export function workflowContentsFromParsedYaml(parsedYaml: any): string {
return YAML.stringify(parsedYaml);
}
Expand Down
Loading