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.

  • 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 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.

  • 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, 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. 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.

  • Booleans in text are not always true/false. In this version, ${{ true }} and ${{ 1 == 1 }} are written as 1, and ${{ 1 == 2 }} as 0, but ${{ 1 != 1 }} as false. GitHub writes true or false for all of them. Use comparisons in if:, 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, 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.

  • Booleans made by an expression are not written as true/false in 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.