The CI Steps Expression Language
Summary
This document proposes extending the CI Steps Expression Language to include string manipulation, arithmetic, comparisons, logic, property access, and function calls. It proposes to build the language in-house, and explains why it differs from the CI Components Expression Language.
The document then formally specifies the CI Steps Expression language.
Terms
- CI Steps Expression Language: The syntax used by Step authors and users to reference variables within Steps. These expressions use
${{ }}
delimiters. Examples include:- Accessing an input:
${{ inputs.address }}
- Referencing a job variable:
${{ job.GITLAB_USER_NAME }}
- Getting a previous step’s output:
${{ step.my_step.output.duration }}
- Accessing an input:
- CI Components Expression Language: The syntax used to reference CI Pipeline and CI Component inputs. These expressions use
$[[ ]]
delimiters. For example, you can create a component with a dynamically named job:$[[ inputs.job-prefix ]]-scan-website:
- Evaluation: The process of resolving an expression to its actual value. For instance, the expression
${{ inputs.speed_of_light_ms }}
evaluates to299792458
when that value is provided in the inputs.
Motivation
A differentiator of CI Steps compared with other CI solutions is that individual job tasks (steps) may be reused. Users create a Steps CI job by stitching together purpose-built steps when performing a custom task (e.g. email my stakeholders), and steps from an external source when performing a common task (e.g. a compile Go step hosted in a steps library).
Steps are stitched together by using expressions ${{ [expression] }}
as the step input. The expression is evaluated during job execution, so has access to job variables or outputs of a previously run step.
For example, if a Docker build
step builds a Docker image, a subsequent release
step can use the image reference output from the build step to ensure the correct image is released.
build-and-release:
run:
- name: build
step: steps/library/docker/build # this step has an output called 'image_ref', the repository and tag of the Docker image
inputs:
work_dir: .
Dockerfile: ./Dockerfile
- name: release
step: steps/library/docker/promote
inputs:
from_image: ${{ steps.build.outputs.image_ref }} # expression evaluates to the name of the image created in the 'build' step
to_image: registry.gitlab.com/my-product:1.0.1
The above example is simple, the image_ref
output is passed directly into the from_image
input.
Many real-world situations are more complex, so the user needs tools to craft input values from variables and step outputs. Examples include:
- String manipulation:
build_image: ${{ job.CI_REGISTRY }}/my-build:${{ job.CI_PIPELINE_IID }}
- Arithmetic:
version: ${{ steps.versions.most_recent.major + 1 }}
- Comparisons:
log_when: ${{ inputs.LOGLEVEL > 4 }}
- Logic:
user: ${{ inputs.username || "default_username" }}
- Property access:
address_line2: ${{ user.address.street[2] }}
- Function calls:
commit: ${{ replace(job.CI_COMMIT_DESCRIPTION, "\n", " ") }}
If these features are not added to the expression language, then the burden of this variable/step output to step input conversion is passed on to the step author. This:
- Reduces the productivity of the step author, and
- Reduces the reusability of the step (if you don’t have the right input format, it can’t be used)
Goal
Summary
Extend the CI Steps Expression Language to allow string manipulation, arithmetic, comparisons, logic, property access, and function calls.
The extension to the expression language:
- MUST be backwards compatible with already used property lookups, e.g.
${{ inputs.my_variable }}
so it can replace the current implementation - MUST be specified as a context-free grammar using Extended Backus–Naur form (EBNF) so the language can be reviewed before it is implemented
- MUST be extensible. Additional functions, operations, and mutations are possible future extensions
- SHOULD be easy to use, and feel somewhat familiar to engineers. More powerful than JSON, less complex than JavaScript
Built in-house
Expressions will be built in-house at GitLab using a recursive-descent parser.
A review of existing expression languages and libraries didn’t find anything suitable for CI Steps’ needs. The main issues were:
- They offer too many features, are too complex, or are considered to be unfamiliar to GitLab users
- They do not support passing metadata, so Steps can’t determine if evaluated expressions are derived from sensitive values
Out-of-scope
This proposal does not:
- Outline which functions should be defined, only that functions can be called
- Outline which namespaces/scopes/contexts/variables should be defined, only that they can be used
- Change the CI Components Expression Language
- Propose ways to implement control flow
Example use-cases
Version management
# Extract and increment semantic versions
- name: bump-major-version
step: ./steps/bump
inputs:
new_version: ${{ major_version(steps.get_current.outputs.version) + 1 }}.0.0
tag_name: v${{ steps.get_current.outputs.version }}
Environment-Specific Configuration
# Different registry URLs based on branch
- name: deploy
step: ./steps/deploy
inputs:
registry: '${{ (job.CI_COMMIT_REF_NAME == "main" && "prod.registry.com") || "staging.registry.com" }}'
replicas: '${{ (job.CI_COMMIT_REF_NAME == "main" && 5) || 2 }}'
Conditional Execution Logic
# Skip steps based on file changes or conditions
- name: run-tests
step: ./steps/run-tests
inputs:
skip_integration: '${{ !contains(steps.changes.outputs.files, "integration/") }}'
test_command: '${{ (inputs.test_type == "full" && "npm run test:all") || "npm run test:unit" }}'
Security & Compliance
# Validate and transform security scan results
- name: security_gate
step: ./security_gate
inputs:
proceed: ${{ steps.scan.outputs.critical_vulnerabilities == 0 && steps.scan.outputs.high_vulnerabilities < 5 }}
Deviation from CI Components
As is - CI Components
The CI Components Expression Language:
- Is expressed surrounded by
$[[
and]]
- Is evaluated during pipeline creation
- Supports property access using
.
, for example,inputs.rust_version
- Supports types
array
,boolean
,number
andstring
- Supports string templating, for example,
echo $[[inputs.message]]
- Supports function calls with pipes
|
, for example,$[[ inputs.test | expand_vars | truncate(5,8) ]]
- Only three piped functions are supported
- Only supports predefined functions, of which there are two,
expand_vars
andtruncate
To be - CI Steps
The CI Steps Expression Language MUST:
- Support types
array
,boolean
,number
,string
ANDstruct
- Be evaluated at the last possible moment, during job execution
- Support powerful ways to manipulate variables/job inputs/step outputs into step inputs, using arithmetic, string manipulation, logic, and property access
- Support comparisons for control flow
- Support complex function composition, for example,
max(15, major_version(extract_version("postgres:13.4.1")))
- Support string templating
- Support property access using
.
Moving forward
The Steps and CI Components expression languages are different because they are evaluated at different times using different contexts.
- Steps cannot use CI Components expressions, it does not provide rich enough tools for the user to craft step input values.
- CI Components cannot use Steps expressions, step context such as
env
,output_file
,work_dir
, andexport_file
are not available during pipeline creation.
While differences between the expression languages remain, effort should be made to minimize differences where possible. Going forward, Steps expressions will:
- Be surrounded by
${{
and}}
to communicate to a user both the expression language used, and that evaluation happens during job execution - Conform to CI Components expressions where possible, for example:
- Property access
- Types
array
,boolean
,number
andstring
- Deviate from CI Components expressions where necessary, for example:
- Support
struct
- Support arithmetic, string manipulation, logic, comparisons
- The way functions are called, limits on number of functions used, the functions available to call
- Support
See https://gitlab.com/groups/gitlab-org/-/epics/18519+ to follow the effort for unifying the CI Component and Steps expression languages.
Example
The following example is of a CI component containing jobs that run steps. Users are required to know two expression syntaxes.
spec:
inputs:
echo_version:
type: string
---
build-job:
run:
- name: echo_step
step: gitlab.com/steps/echo@$[[inputs.echo_version]] # CI Component expression, evaluated when the pipeline is created
inputs:
message: 'Hello, ${{ remove_new_lines(jobs.CI_RUNNER_DESCRIPTION) }}' # CI Steps expression, evaluated during job execution
Specification
Context-free grammar
The CI Steps Expression Language defined as an Extended Backus-Naur Form (EBNF) grammar.
// Lexical elements
unicode_char = /* an arbitrary Unicode code point */ .
unicode_letter = /* a Unicode code point categorized as "Letter" */ .
unicode_digit = /* a Unicode code point categorized as "Number, decimal digit" */ .
letter = unicode_letter | "_" .
digit = "0" … "9" .
// String escape sequences
escaped_single = `\` ( `\` | "'" ) .
escaped_double = `\` ( "a" | "b" | "f" | "n" | "r" | "t" | "v" | `\` | `"` ) .
// Template expressions
template = "${{" Expression "}}" .
// Tokens (lexical rules)
identifier = letter { letter | unicode_digit } . /* except reserved */
int_lit = digit { digit } .
float_lit = digit { digit } "." digit { digit } [ exponent ] .
exponent = ( "e" | "E" ) [ "+" | "-" ] digit { digit } .
number = int_lit | float_lit .
string_lit = single_quoted | double_quoted .
single_quoted = "'" { unicode_char | escaped_single } "'" .
double_quoted = `"` { unicode_char | escaped_double | template } `"` .
string = string_lit .
// Operators
binary_op = "||" | "&&" | rel_op | add_op | mul_op .
unary_op = "+" | "-" | "!" .
rel_op = "==" | "!=" | "<" | "<=" | ">" | ">=" .
add_op = "+" | "-" .
mul_op = "*" | "/" .
// Reserved words
reserved = "array" | "as" | "break" | "case" | "const" | "continue" |
"default" | "else" | "fallthrough" | "float" | "for" | "func" |
"function" | "goto" | "if" | "import" | "in" | "int" | "let" | "loop" |
"map" | "namespace" | "number" | "object" | "package" | "range" |
"return" | "string" | "struct" | "switch" | "type" | "var" | "void" |
"while" .
// CI Steps Expression Language
Expression = OrExpression .
OrExpression = AndExpression { "||" AndExpression } .
AndExpression = ComparisonExpression { "&&" ComparisonExpression } .
ComparisonExpression = AdditiveExpression { rel_op AdditiveExpression } .
AdditiveExpression = MultiplicativeExpression { add_op MultiplicativeExpression } .
MultiplicativeExpression = UnaryExpression { mul_op UnaryExpression } .
UnaryExpression = unary_op UnaryExpression
| PostfixExpression .
PostfixExpression = PrimaryExpression
{ "." identifier
| "[" Expression "]"
| Call
} .
PrimaryExpression = Literal
| identifier
| "(" Expression ")"
| Array
| Object .
Literal = "null"
| "true"
| "false"
| string
| number .
Array = "[" [ ArrayElements ] "]" .
ArrayElements = Expression { "," Expression } [ "," ] .
Object = "{" [ ObjectElements ] "}" .
ObjectElements = ObjectElement { "," ObjectElement } [ "," ] .
ObjectElement = Expression ":" Expression .
Call = "(" [ Expression { "," Expression } ] ")" .
Draft implementation
- Expression playground
- Draft: Add specification
- Draft: Implement expression lexer
- Draft: Implement expression value/type system
- Draft: Implement expression parser
- Draft: Implement expression evaluator
Breakdown and examples
Source code representation
Source code is Unicode text encoded in UTF-8. The text is not canonicalized, so a single accented code point is distinct from the same character constructed from combining an accent and a letter.
Characters
unicode_char = /* an arbitrary Unicode code point */ .
unicode_letter = /* a Unicode code point categorized as "Letter" */ .
unicode_digit = /* a Unicode code point categorized as "Number, decimal digit" */ .
Letters and digits
letter = unicode_letter | "_" .
digit = "0" … "9" .
Lexical elements
Comments
The language does not currently support comments.
Tokens
Tokens form the vocabulary of the language. There are four classes: identifiers, keywords, operators and punctuation, and literals.
Identifiers
Identifiers name variables and functions.
identifier = letter { letter | unicode_digit } .
Identifiers must not be keywords. Identifiers are case-sensitive: foo
, Foo
, and FOO
are three different identifiers.
Keywords
The following keywords are reserved and may not be used as identifiers:
array as break case const
continue default else fallthrough float
for func function goto if
import in int let loop
map namespace number object package
range return string struct switch
type var void while
Additionally, the following literal keywords are recognized:
false null true
Operators and punctuation
The following character sequences represent operators and punctuation:
+ && == != ( )
- || < <= [ ]
* ! > >= { }
/ . , :
Integer literals
Integer literals are sequences of digits. Leading zeros are permitted.
int_lit = digit { digit } .
Floating-point literals
Floating-point literals consist of an integer part, a decimal point, a fractional part, and an optional exponent part.
float_lit = digit { digit } "." digit { digit } [ exponent ] .
exponent = ( "e" | "E" ) [ "+" | "-" ] digit { digit } .
Number literals
A number literal is either an integer or a floating-point literal.
number = int_lit | float_lit .
String literals
String literals represent strings constants and come in two forms: single-quoted and double-quoted. Both allow multiple characters, but differ in how they handle escape sequences and template expressions.
Single-quoted strings are raw string literals. Use single quotes when you want a string with minimal interpretation.
- They do not support template expressions or most escape sequences
- Supports minimal escape sequences
\\
- backslash\'
- single quote
Double-quoted strings support:
- Template expressions using
${{ ... }}
syntax. The expression inside a template must evaluate to a string. See Template Expressions for details - A full set of escape sequences
\a
- alert or bell\b
- backspace\f
- form feed\n
- newline\r
- carriage return\t
- horizontal tab\v
- vertical tab\\
- backslash\"
- double quote\$
- single dollar sign
string_lit = single_quoted | double_quoted .
single_quoted = "'" { unicode_char | single_escape } "'" .
double_quoted = `"` { unicode_char | double_escape | template } `"` .
single_escape = `\` ( `\` | "'" ) .
double_escape = `\` ( "a" | "b" | "f" | "n" | "r" | "t" | "v" | `\` | `"` | "$" ) .
template = "${{" Expression "}}" .
Examples:
// Single-quoted strings
'Hello, world!'
'It\'s a beautiful day' // Escaped single quote
'Path: C:\\Users\\Alice' // Escaped backslashes
'${{ "hello" }}' // Treated literally, does not evaluate
// Double-quoted strings
"Hello, world!"
"She said, \"Hello!\"" // Escaped double quotes
"Line 1\nLine 2\nLine 3" // Newline characters
"Name:\tJohn\nAge:\t30" // Tab and newline
"Alert\a\tBackspace\b" // Special characters
// Template expressions
"Hello, ${{ name }}!" // Simple variable interpolation
"Path: ${{ dir }}/${{ file }}" // Multiple templates
Types
The language supports the following types:
Boolean
Boolean values are represented by the predeclared constants true
and false
.
Null
The null value is represented by the predeclared constant null
.
Number
Numbers are high-precision decimal floating-point values with 53 bits of precision.
String
Strings are immutable sequences of Unicode code points.
Array
Arrays are ordered sequences of values. Elements can be of any type and types can be mixed within an array.
Object
Objects are unordered collections of key-value pairs. Keys must be strings (either string literals or expressions that evaluate to strings). Values can be of any type.
Type Operations
This section describes which operations are valid between different types and their behavior.
Type compatibility table
Operation | Valid Types | Result Type | Notes |
---|---|---|---|
+ (binary) |
number + number | number | Addition |
+ (binary) |
string + string | string | Concatenation |
- (binary) |
number - number | number | Subtraction |
* |
number * number | number | Multiplication |
/ |
number / number | number | Division (error on divide by zero) |
+ (unary) |
number | number | Identity (returns unchanged) |
- (unary) |
number | number | Negation |
! |
any | boolean | Logical NOT based on truthiness |
== |
any == any | boolean | Equality comparison |
!= |
any != any | boolean | Inequality comparison |
< |
any < any | boolean | Less than (see comparison semantics) |
<= |
any <= any | boolean | Less than or equal |
> |
any > any | boolean | Greater than |
>= |
any >= any | boolean | Greater than or equal |
&& |
any && any | any | Returns first falsy or last value |
|| |
any || any | any | Returns first truthy or last value |
. |
object/array | any | Property/method access |
[] |
object[string] | any | Object property access |
[] |
array[number] | any | Array element access |
() |
function | any | Function call |
Equality semantics
The ==
and !=
operators compare values as follows:
- null: Only equals
null
- boolean: Only equals boolean with same value
- number: Equals numbers with same numeric value
- string: Equals strings with identical UTF-8 byte sequences
- array: Equals arrays with same length and equal elements (deep comparison)
- object: Equals objects with same keys and equal values (deep comparison, key order irrelevant)
Comparison semantics
The comparison operators <
, <=
, >
, and >=
can compare any types. When comparing values:
- Same type comparisons:
- numbers: Numeric comparison
- strings: Lexicographic comparison (UTF-8 byte order)
- booleans:
false < true
- arrays: Unsupported
- objects: Unsupported
- null: Unsupported
Short-circuit evaluation
The logical operators &&
and ||
use short-circuit evaluation:
&&
: If left operand is falsy, right operand is not evaluated||
: If left operand is truthy, right operand is not evaluated
Expressions
Primary expressions
Primary expressions are the operands for unary and binary expressions.
PrimaryExpression = Literal | identifier | "(" Expression ")" | Array | Object .
Literal = "null" | "true" | "false" | string_lit | number .
Parentheses can be used to group expressions and override operator precedence:
2 + 3 * 4 // evaluates to 14
(2 + 3) * 4 // evaluates to 20
Array literals
Array literals construct array values.
Array = "[" [ ArrayElements ] "]" .
ArrayElements = Expression { "," Expression } [ "," ] .
Example:
[1, 2, 3]
["a", 1, true, null]
[1, 2, 3,] // trailing comma allowed
Object literals
Object literals construct object values. Keys can be string literals or expressions that evaluate to strings.
Object = "{" [ ObjectElements ] "}" .
ObjectElements = ObjectElement { "," ObjectElement } [ "," ] .
ObjectElement = Expression ":" Expression .
Example:
{"name": "John", "age": 30}
{"key": value}
{computed_key: value} // computed_key must evaluate to string
{"prefix" + "_suffix": value} // expressions that produce strings
{obj.prop: value,} // trailing comma allowed
Note: Object keys must evaluate to strings at runtime. Non-string keys will result in a runtime error.
Selectors
Selectors access properties or elements of a value.
Selector = "." identifier | "[" Expression "]" .
Property access with .
requires an identifier. Computed property access with []
accepts any expression.
Example:
obj.property
obj["property"]
my_array[0]
my_array[index]
Function calls
Function calls invoke a function with zero or more arguments.
Call = "(" [ Expression { "," Expression } ] ")" .
Example:
my_func()
my_func(1, 2, 3)
obj.method()
my_array[0]() // if my_array[0] contains a function
Unary operators
Unary operators have the highest precedence.
UnaryExpression = unary_op UnaryExpression | PostfixExpression .
unary_op = "+" | "-" | "!" .
Operator | Name | Types | Description |
---|---|---|---|
+ |
unary plus | number | numeric identity |
- |
unary minus | number | numeric negation |
! |
logical NOT | any | logical negation (based on truthiness) |
Binary operators
Binary operators are left-associative and follow standard precedence rules.
Precedence | Operators | Associativity |
---|---|---|
5 | * / |
left |
4 | + - |
left |
3 | == != < <= > >= |
left |
2 | && |
left |
1 | || |
left |
Arithmetic operators
Operator | Name | Types | Result |
---|---|---|---|
+ |
addition | number + number | number |
+ |
concatenation | string + string | string |
- |
subtraction | number - number | number |
* |
multiplication | number * number | number |
/ |
division | number / number | number |
Note: Division by zero results in a runtime error. The +
operator performs addition for numbers and concatenation for strings. No implicit type conversion occurs - "hello" + 42
is an error.
Comparison operators
Operator | Name | Types | Result |
---|---|---|---|
== |
equal | any == any | boolean |
!= |
not equal | any != any | boolean |
< |
less than | any < any | boolean |
<= |
less than or equal | any <= any | boolean |
> |
greater than | any > any | boolean |
>= |
greater than or equal | any >= any | boolean |
Note: Comparison operators can compare values of any type. See Comparison Semantics for details on how different types are compared.
Logical operators
Operator | Name | Description |
---|---|---|
&& |
logical AND | returns right operand if left is truthy, else left |
|| |
logical OR | returns left operand if truthy, else right |
Note: Logical operators use short-circuit evaluation and return the actual operand value, not a boolean.
Special ||
behavior: When the left operand results in a property-not-found or index-out-of-bounds error, ||
treats this as a falsy value and evaluates the right operand instead of propagating the error.
Examples:
"foo" && "bar" // "bar" (returns right when left is truthy)
null && "bar" // null (returns left when left is falsy)
"foo" || "bar" // "foo" (returns left when left is truthy)
false || "default" // "default" (returns right when left is falsy)
// Special || error handling
obj.missing || "default" // "default" (missing property treated as falsy)
array[999] || "fallback" // "fallback" (out of bounds treated as falsy)
obj.exists || "default" // obj.exists value
Template expressions
Template expressions allow embedding expressions within string literals using the ${{ }}
syntax. Only double-quoted strings support templates.
template = "${{" Expression "}}" .
The expression inside the template must evaluate to a string at runtime. Non-string values will result in a runtime error. \${{
can be used to escape template expressions.
Examples:
// Simple variable interpolation
"Hello, ${{ name }}!" // "Hello, Alice!"
'Welcome ${{ user }}' // "Welcome Bob"
// Expressions with operators
"Full name: ${{ firstName + " " + lastName }}"
"Path: ${{ dir }}/${{ file }}"
// Multiple templates in one string
"${{ greeting }}, ${{ name }}! Today is ${{ day }}."
// Complex expressions
"User: ${{ user.firstName }} (${{ user.role }})"
"Items: ${{ items[0] }}, ${{ items[1] }}"
// Escape template
"Hello, \${{ \"world!\" }}" // "Hello, ${{ \"world!\" }}"
// Errors - expression must return string
"Count: ${{ 42 }}" // Error: number not string
"Total: ${{ price + tax }}" // Error: number not string
Truthiness
The following values are considered “falsy”:
false
null
0
(number zero)""
(empty string)[]
(empty array){}
(empty object)
All other values are considered “truthy”.
Operator precedence
The precedence of operators is reflected in the grammar. From lowest to highest:
||
(logical OR)&&
(logical AND)==
,!=
,<
,<=
,>
,>=
(comparison)+
,-
(addition, subtraction)*
,/
(multiplication, division)+
,-
,!
(unary operators).
,[]
,()
(postfix operators)
a4643367
)