Compare commits

...
Author SHA1 Message Date
Brent Vatne 39623f4694 Update CHANGELOG with PR number 2026-04-28 17:24:04 -07:00
Brent Vatne e70c52dc19 Fix interpolation dispatcher misclassifying multi-expression values
`interpolateJobContext` chooses between two branches based on whether the
value `startsWith('${{')` and `endsWith('}}')`. If both are true it
treats the value as a single expression, slices off the outer delimiters,
and hands the rest to jsep as one expression — preserving the result's
type for cases like `${{ inputs.flags }}` evaluating to an object.

The check does not verify those delimiters are a matched pair. A value
like `${{ a }}: ${{ b }}` also satisfies both conditions but is two
expressions joined by literal text. The dispatcher takes the wrong
branch and feeds jsep `a }}: ${{ b`, which produces the cryptic
`Unexpected "}" at character N` error.

Add a third condition: there must be no further `${{` after the opening
one. Multi-expression values then fall through to the existing
`replace(/\$\{\{(.+?)\}\}/g, ...)` branch which handles them correctly.
2026-04-28 17:23:23 -07:00
3 changed files with 98 additions and 3 deletions

No files matched your search

+2
View File
@@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- Fix EAS Workflows interpolation throwing `Unexpected "}" at character N` when a single value contains multiple `${{ ... }}` expressions (for example, `changelog: ${{ a }}: ${{ b }}`). ([#3643](https://github.com/expo/eas-cli/pull/3643) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
@@ -0,0 +1,83 @@
import { JobInterpolationContext } from '@expo/eas-build-job';
import { interpolateJobContext } from '../interpolation';
const context = {
inputs: {
profile: 'production',
flags: { quiet: true, retries: 3 },
},
github: {
event: {
pull_request: {
html_url: 'https://github.com/foo/bar/pull/42',
title: 'Add cool feature',
},
},
},
} as unknown as JobInterpolationContext;
describe(interpolateJobContext, () => {
test('single expression value preserves the underlying type', () => {
const result = interpolateJobContext({
target: '${{ inputs.flags }}',
context,
});
expect(result).toEqual({ quiet: true, retries: 3 });
});
test('single expression value resolves to a string', () => {
const result = interpolateJobContext({
target: '${{ inputs.profile }}',
context,
});
expect(result).toBe('production');
});
test('multiple expressions in one value are each replaced with their string result', () => {
const result = interpolateJobContext({
target:
'${{ github.event.pull_request.html_url }}: ${{ github.event.pull_request.title }}',
context,
});
expect(result).toBe('https://github.com/foo/bar/pull/42: Add cool feature');
});
test('plain text is returned unchanged', () => {
const result = interpolateJobContext({ target: 'just text', context });
expect(result).toBe('just text');
});
test('expression with text around it is interpolated as a string', () => {
const result = interpolateJobContext({
target: 'profile=${{ inputs.profile }}',
context,
});
expect(result).toBe('profile=production');
});
test('object values recurse', () => {
const result = interpolateJobContext({
target: { profile: '${{ inputs.profile }}', literal: 'x' },
context,
});
expect(result).toEqual({ profile: 'production', literal: 'x' });
});
test('array values recurse', () => {
const result = interpolateJobContext({
target: ['${{ inputs.profile }}', 'literal'],
context,
});
expect(result).toEqual(['production', 'literal']);
});
test('string concatenation inside a single expression', () => {
const result = interpolateJobContext({
target:
"${{ github.event.pull_request.html_url + ': ' + github.event.pull_request.title }}",
context,
});
expect(result).toBe('https://github.com/foo/bar/pull/42: Add cool feature');
});
});
+13 -3
View File
@@ -10,9 +10,19 @@ export function interpolateJobContext({
context: JobInterpolationContext;
}): unknown {
if (typeof target === 'string') {
// If the value is e.g. `build: ${{ inputs.build }}`, we will interpolate the value
// without changing `inputs.build` type, i.e. if it is an object it'll be like `build: {...inputs.build}`.
if (target.startsWith('${{') && target.endsWith('}}')) {
// If the value is exactly one `${{ ... }}` expression, we interpolate it without
// changing the result's type, i.e. if `inputs.build` is an object then
// `build: ${{ inputs.build }}` becomes `build: {...inputs.build}`.
//
// `startsWith('${{') && endsWith('}}')` alone is not enough: a value like
// `${{ a }}: ${{ b }}` also starts with `${{` and ends with `}}`, but those
// delimiters are not a matched pair. We must also confirm there's no second
// `${{` after the opening one.
if (
target.startsWith('${{') &&
target.endsWith('}}') &&
target.indexOf('${{', 3) === -1
) {
return jsepEval(target.slice(3, -2), context);
}