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), and true, false and null. A }} inside a string is part of the string, so ${{ '}}' }} is the text }}.

  • Looking up values: matrix.perl, env['HOME'], steps.build.outputs.path, list[0], list[-1] (the last). Names are matched without regard to upper and lower case, as on GitHub. A name that does not exist, or an index past either end of a list, gives null; 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' is a if 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. true and false count as 1 and 0 when compared with a number. A comparison, !, true, false and the functions that answer yes or no give a boolean, which is written as true or false in text, as on GitHub.

  • Functions: contains, startsWith, endsWith, format, join, toJSON, fromJSON, and the status functions success, failure, cancelled and always. 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 example matrix.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, a boolean (JSON::PP::true or JSON::PP::false, which count as 1 and 0) for a comparison, a reference for an array or object, or undef for null or a missing name.

SIDE EFFECTS

None. The object holds the text being parsed only while the call runs, so a lookup that itself evaluates an expression with the same object (a tied hash in the context, say) is safe, and the object does not keep the context alive once the call returns.

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. undef and 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 - the if: value. It may be written with or without ${{ }} around it. undef or 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 gives null. Use "interpolate_strict" where that matters.

  • Text comparison ignores case. 'Main' == 'main' is true, and so are contains, startsWith and endsWith with different case.

  • '1.0' == 1 is 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.

  • A boolean in text is true or false, not 1 or 0. echo ${{ 1 == 1 }} prints true, as on GitHub. Perl code that reads the value of "evaluate" gets a JSON::PP boolean, which is 1 or 0 in a number or a comparison but true or false when it goes through "stringify" in App::Actions::Util.

  • 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, so if: 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 in github.event.*.name) is not supported.

  • 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 state alone: no routine changes it, because each keeps the expression it is parsing only while it runs. 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 ───────────────────────────────────────
│ context : CONTEXT
│ status  : STATUS
└───────────────────────────────────────────────────

new

┌─ New ─────────────────────────────────────────────
│ Evaluator'
├───────────────────────────────────────────────────
│ context' = ∅ ∧ status' = ∅
└───────────────────────────────────────────────────

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?)
│ (a lookup may itself call Evaluate on the same evaluator:
│  the outer call's result is unchanged)
└───────────────────────────────────────────────────

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 "}}" outside a quoted string
│     ⇒ 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 keeps nothing between calls. While a call runs it holds the expression, its position and whether a lookup found nothing; these are put back when the call returns, so a call made from inside a lookup does not disturb the one that made it. Each call 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.