NAME
App::Actions::Expression - work out the value of a GitHub Actions ${{ }} expression
SYNOPSIS
Work out one expression:
use App::Actions::Expression;
my $expression = App::Actions::Expression->new;
my $context = { matrix => { perl => '5.40', os => 'ubuntu-latest' } };
my $value = $expression->evaluate(q{matrix.perl == '5.40'}, $context); # 1
Fill in the expressions in a text:
my $name = $expression->interpolate('Test on Perl ${{ matrix.perl }}', $context);
# "Test on Perl 5.40"
Decide whether a step runs (an if: value):
my $runs = $expression->condition(
q{${{ failure() && startsWith(matrix.os, 'ubuntu') }}},
$context,
{ success => 0, failure => 1, cancelled => 0 },
);
Refuse a value that does not exist yet:
my $image = eval { $expression->interpolate_strict('perl:${{ matrix.perl }}', {}) };
# dies: unable to resolve '${{ matrix.perl }}' from the values available
Fill in only what is known now, and the rest later:
my $half = $expression->interpolate_known('${{ env.A }}-${{ steps.x.outputs.y }}',
{ env => { A => 'a' } });
# "a-${{ steps.x.outputs.y }}"
DESCRIPTION
GitHub Actions workflows contain expressions: small formulas written as ${{ ... }}, for example ${{ matrix.perl }} or ${{ github.ref == 'refs/heads/main' }}. This module works out their values, so that workflows can run on your own computer.
It supports the part of the language that matters when you run a workflow locally:
Values: strings in single quotes (write two quotes for one quote inside:
'it''s'), numbers (42,1.5,1e3), andtrue,falseandnull.Looking up values:
matrix.perl,env['HOME'],steps.build.outputs.path,list[0]. Names are matched without regard to upper and lower case, as on GitHub. A name that does not exist givesnull; it is not an error (but see "interpolate_strict").Operators, from loosest to tightest:
||,&&,==and!=,<<=>>=,!, and brackets( ).||and&&return one of their operands, as on GitHub:a || 'x'isaif it is true, otherwise'x'.Comparisons: two numbers are compared as numbers. Anything else is compared as text, without regard to case, so
'ABC' == 'abc'is true.trueandfalsecount as 1 and 0 when compared with a number.Functions:
contains,startsWith,endsWith,format,join,toJSON,fromJSON, and the status functionssuccess,failure,cancelledandalways. Function names are not case sensitive.
Truth: null, false, the empty string, 0 and '0' are false. Everything else is true, including every array and object, even empty ones.
METHODS
new
my $expression = App::Actions::Expression->new;
Makes an evaluator. One evaluator can be used for any number of expressions, but not by two threads at once.
ARGUMENTS
None.
RETURNS
The new object.
SIDE EFFECTS
None.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'App::Actions::Expression' }
MESSAGES
None.
evaluate
my $value = $expression->evaluate($text, \%context, \%status);
Works out the value of one expression. The text is the part inside ${{ }}, without the braces.
ARGUMENTS
$text- the expression, for examplematrix.perl == '5.40'.\%context- optional. The values names look up: a hash reference such as{ matrix => {...}, env => {...}, github => {...} }.\%status- optional. The answers for the status functions:{ success => 1, failure => 0, cancelled => 0 }. Missing keys are false.always()is always true.
RETURNS
The value: a string, a number, 1 or 0 for a comparison, a reference for an array or object, or undef for null or a missing name.
SIDE EFFECTS
Remembers whether a name was missing, for "interpolate_strict" and "interpolate_known".
EXAMPLE
my $list = $expression->evaluate(q{fromJSON('[1, 2, 3]')}); # [1, 2, 3]
my $has = $expression->evaluate(q{contains(matrix.os, 'ubuntu')},
{ matrix => { os => 'Ubuntu-24.04' } }); # 1
API SPECIFICATION
INPUT
{
expression => { type => 'string', min => 1 },
context => { type => 'hashref', optional => 1 },
status => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'any', optional => 1 }
MESSAGES
All are thrown with croak. Fix the expression in the workflow.
+----------------------------------------------+------------------------------------------+
| Message | Meaning |
+----------------------------------------------+------------------------------------------+
| Cannot evaluate an undefined expression | $text was undef |
| Cannot evaluate an empty expression | $text was empty or only spaces |
| Unterminated string in expression 'T' | a ' has no closing ' |
| Unexpected character 'C' in expression 'T' | C is not part of the language (e.g. + *) |
| Unexpected 'X' in expression ['T'] | X is in the wrong place, or extra text |
| | follows a complete expression |
| Unexpected end of expression | the expression stops too early |
| Expected 'X' in expression | a ) or ] is missing |
| Expected a property name after dot | "a." with no name, or "a.1" |
| F() expects N argument(s) | wrong number of arguments to function F |
| join() first argument must be an array | join('a', ',') - use an array |
| fromJSON() failed: ERR | the text is not valid JSON |
| hashFiles() is not supported by local | this function is not implemented |
| action-run | |
| Unknown function 'F()' in expression | F is not one of the functions above |
+----------------------------------------------+------------------------------------------+
interpolate
my $result = $expression->interpolate($text, \%context, \%status);
Replaces every ${{ ... }} in a text with its value.
ARGUMENTS
$text- the text.undefand references are returned unchanged.\%context,\%status- as for "evaluate".
RETURNS
The new text. Values are written as GitHub writes them: null and missing names as the empty string, an array or object as JSON. Text that comes from a value is not searched again for ${{.
SIDE EFFECTS
None.
EXAMPLE
print $expression->interpolate('${{ format(\'{0}-{1}\', 1, 2) }}'); # 1-2
API SPECIFICATION
INPUT
{
text => { type => 'string', optional => 1 },
context => { type => 'hashref', optional => 1 },
status => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
+--------------------------------+---------------------------------+----------------------+
| Message (croak) | Meaning | What to do |
+--------------------------------+---------------------------------+----------------------+
| Unclosed expression in 'T' | a ${{ has no }} after it | add the }} |
| (any message of evaluate) | an expression inside is invalid | fix that expression |
+--------------------------------+---------------------------------+----------------------+
interpolate_strict
my $result = $expression->interpolate_strict($text, \%context, \%status);
Like "interpolate", but a name that does not exist is an error instead of an empty string. Use it when an empty value would be wrong, for example in an image name.
ARGUMENTS
As for "interpolate".
RETURNS
As for "interpolate".
SIDE EFFECTS
None.
EXAMPLE
$expression->interpolate_strict('node:${{ matrix.node }}', { matrix => { node => 20 } }); # node:20
API SPECIFICATION
INPUT
{
text => { type => 'string', optional => 1 },
context => { type => 'hashref', optional => 1 },
status => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
As for "interpolate", and:
+------------------------------------------------+-------------------------+--------------------------+
| Message (croak) | Meaning | What to do |
+------------------------------------------------+-------------------------+--------------------------+
| unable to resolve '${{ E }}' from the values | E names something that | give that value, or |
| available | does not exist | correct the name |
+------------------------------------------------+-------------------------+--------------------------+
A name that exists but whose value is empty is not an error.
interpolate_known
my $result = $expression->interpolate_known($text, \%context, \%status);
Like "interpolate", but an expression that names something missing is left in the text, exactly as written. Use it when you know some values now and the others later: call it again later with the full context.
ARGUMENTS
As for "interpolate".
RETURNS
The text with the known expressions replaced.
SIDE EFFECTS
None.
EXAMPLE
$expression->interpolate_known('${{ a }}/${{ b }}', { a => 1 }); # 1/${{ b }}
API SPECIFICATION
INPUT
{
text => { type => 'string', optional => 1 },
context => { type => 'hashref', optional => 1 },
status => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
As for "interpolate".
condition
my $runs = $expression->condition($if, \%context, \%status);
Works out a workflow's if: value: should this job or step run?
ARGUMENTS
$if- theif:value. It may be written with or without${{ }}around it.undefor empty means "no condition". A YAML boolean (if: false) is used as it is.\%context,\%status- as for "evaluate".
RETURNS
1 to run, 0 not to run.
SIDE EFFECTS
None.
EXAMPLE
$expression->condition("github.event_name == 'push'", { github => { event_name => 'push' } }); # 1
$expression->condition(undef); # 1
API SPECIFICATION
INPUT
{
condition => { type => 'string', optional => 1 },
context => { type => 'hashref', optional => 1 },
status => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'integer', memberof => [ 0, 1 ] }
MESSAGES
As for "evaluate".
COMMON PITFALLS
Strings use single quotes.
"main"is not a string; write'main'. Inside a string, write''for one quote.A missing name is not an error.
matrix.pearl(a typing mistake) quietly givesnull. Use "interpolate_strict" where that matters.Text comparison ignores case.
'Main' == 'main'is true, and so arecontains,startsWithandendsWithwith different case.'1.0' == 1is true,'1.0' == '1'is also true. Values that look like numbers are compared as numbers.||and&&do not always return true or false. They return an operand, so${{ a || 'default' }}is a common way to give a default.Booleans in text are not always
true/false. In this version,${{ true }}and${{ 1 == 1 }}are written as1, and${{ 1 == 2 }}as0, but${{ 1 != 1 }}asfalse. GitHub writestrueorfalsefor all of them. Use comparisons inif:, where they work the same, rather than in text.An empty array is true.
if: ${{ fromJSON('[]') }}runs.No arithmetic.
+,-,*and/are not part of the language; they are errors.Status functions need the status hash. Without it,
success()is false, soif: success()does not run.The context is not copied. Values from it, such as arrays, are returned as the same references. Do not change them.
ENCODING
Expressions, contexts and results are Perl character strings. String literals and values may contain any Unicode text, including non-ASCII and emoji, as long as the workflow was decoded as UTF-8 (App::Actions::Util does this). Names (matrix.perl) must be ASCII letters, digits, _ and -. Case-insensitive comparison uses Perl's lc, which handles non-ASCII letters too.
LIMITATIONS
hashFiles()is not supported.The object filter
*(as ingithub.event.*.name) is not supported.Booleans made by an expression are not written as
true/falsein text; see "COMMON PITFALLS".Arithmetic is not part of the GitHub Actions expression language and is not implemented.
AUTHOR
Nigel Horne, njh@nigelhorne.com
FORMAL SPECIFICATION
This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above.
How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. ΞEvaluator means the routine leaves the evaluator's visible state alone. The lines below the rule are always true when the routine returns.
[VALUE, TEXT]
CONTEXT == NAME ⇸ VALUE
STATUS == {success, failure, cancelled} ⇸ 𝔹
value : TEXT × CONTEXT × STATUS ⇸ VALUE the language's meaning
missing : TEXT × CONTEXT → 𝔹 some lookup found nothing
truthy : VALUE → 𝔹
truthy(v) ⇔ v ¬in; {⊥, false, ⟨⟩, 0, "0"}
show : VALUE → TEXT as App::Actions::Util::stringify
┌─ Evaluator ───────────────────────────────────────
│ unresolved : 𝔹
└───────────────────────────────────────────────────
new
┌─ New ─────────────────────────────────────────────
│ Evaluator'
├───────────────────────────────────────────────────
│ unresolved' = false
└───────────────────────────────────────────────────
evaluate
┌─ Evaluate ────────────────────────────────────────
│ ΔEvaluator
│ text? : TEXT
│ context? : CONTEXT
│ status? : STATUS
│ v! : VALUE
├───────────────────────────────────────────────────
│ text? = ⊥ ⇒ raises Undefined
│ text? is only white space ⇒ raises Empty
│ text? ¬in; dom value ⇒ raises Syntax
│ otherwise v! = value(text?, context?, status?)
│ ∧ unresolved' = missing(text?, context?)
└───────────────────────────────────────────────────
interpolate
┌─ Interpolate ─────────────────────────────────────
│ ΞEvaluator
│ text? : optional TEXT
│ r! : optional TEXT
├───────────────────────────────────────────────────
│ text? = ⊥ ∨ text? ∈ REF ⇒ r! = text?
│ text? = p₀ ⁀ "${{" ⁀ e₁ ⁀ "}}" ⁀ p₁ ⁀ … ⁀ "${{" ⁀ eₙ ⁀ "}}" ⁀ pₙ
│ where no pᵢ contains "${{" and no eᵢ contains "}}"
│ ⇒ r! = p₀ ⁀ show(value(e₁)) ⁀ p₁ ⁀ … ⁀ show(value(eₙ)) ⁀ pₙ
│ "${{" in pₙ with no "}}" after it ⇒ raises Unclosed
└───────────────────────────────────────────────────
interpolate_strict
┌─ InterpolateStrict ───────────────────────────────
│ Interpolate
├───────────────────────────────────────────────────
│ ∃ i • missing(eᵢ, context?) ⇒ raises Unresolved(eᵢ)
└───────────────────────────────────────────────────
interpolate_known
┌─ InterpolateKnown ────────────────────────────────
│ Interpolate
├───────────────────────────────────────────────────
│ r! = p₀ ⁀ k₁ ⁀ p₁ ⁀ … ⁀ kₙ ⁀ pₙ where
│ kᵢ = "${{" ⁀ eᵢ ⁀ "}}" if missing(eᵢ, context?)
│ kᵢ = show(value(eᵢ)) otherwise
└───────────────────────────────────────────────────
condition
┌─ Condition ───────────────────────────────────────
│ ΞEvaluator
│ if? : optional (TEXT ∪ BOOLEAN)
│ r! : {0, 1}
├───────────────────────────────────────────────────
│ if? = ⊥ ∨ trim(if?) = ⟨⟩ ⇒ r! = 1
│ if? ∈ BOOLEAN ⇒ r! = (if if? then 1 else 0)
│ trim(if?) = "${{" ⁀ e ⁀ "}}" ⇒ r! = truthy(value(trim(e))) as 0/1
│ otherwise r! = truthy(value(trim(if?))) as 0/1
└───────────────────────────────────────────────────
STATE DIAGRAM
The evaluator has one piece of state: whether the last lookup found nothing. Each call to "evaluate" goes through these steps:
evaluate($text, $context, $status)
|
v
+-------------------+ undef / empty
| CHECK INPUT |------------------> croak
+-------------------+
| unresolved := false
v
+-------------------+ bad character,
| TOKENIZE | open string
| (one pass, \G) |------------------> croak
+-------------------+
|
v
+-------------------+ unexpected token,
| PARSE + COMPUTE | bad function call
| || && == < ! () |------------------> croak
+-------------------+
| |
lookup found | | lookup found nothing
a value | v
| +-------------------+
| | unresolved := true|
| | value := undef |
| +-------------------+
| |
v v
+-------------------+
| DONE: value |
+-------------------+
|
interpolate ---------+--> insert show(value) into the text
interpolate_strict --+--> unresolved ? croak : insert
interpolate_known ---+--> unresolved ? keep "${{ ... }}" : insert
condition -----------+--> truthy(value) ? 1 : 0
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.