diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b30dd53b3..e603fa4ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/eas-cli/src/commandUtils/workflow/__tests__/fixtures/workflow-schema.json b/packages/eas-cli/src/commandUtils/workflow/__tests__/fixtures/workflow-schema.json new file mode 100644 index 0000000000..3165cec4ce --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/__tests__/fixtures/workflow-schema.json @@ -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 + } +} diff --git a/packages/eas-cli/src/commandUtils/workflow/__tests__/validation-test.ts b/packages/eas-cli/src/commandUtils/workflow/__tests__/validation-test.ts new file mode 100644 index 0000000000..58278eefbb --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/__tests__/validation-test.ts @@ -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 { + 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/); + }); +}); diff --git a/packages/eas-cli/src/commandUtils/workflow/validation.ts b/packages/eas-cli/src/commandUtils/workflow/validation.ts index 19e95bafe0..66a75dd697 100644 --- a/packages/eas-cli/src/commandUtils/workflow/validation.ts +++ b/packages/eas-cli/src/commandUtils/workflow/validation.ts @@ -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'; @@ -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); @@ -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; + +/** + * 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 { + 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); }