GitHub Actions expression evaluator
Evaluate a GitHub Actions ${{ }} expression against a context JSON and see every
type cast the runner applied, including the four casts where the runner disagrees with JavaScript.
Everything runs in this page. Nothing you type is uploaded, logged or sent anywhere. This is an independent tool. It is not affiliated with, endorsed by, or produced by GitHub.
Where the evaluator disagrees with JavaScript
The non-transitive results people quote are a lead-in, not the point. '' == 0,
0 == '0', '' == '0' and 'true' == true return exactly the same
answers in raw JavaScript, so knowing JavaScript is enough to predict them. The rows below are the ones
JavaScript knowledge gets wrong. String versus string is OrdinalIgnoreCase, so a branch
guard on a ref name or a label matches on any casing, and null coerces to 0
before the comparison is retried, so null equals both 0 and the empty string.
| expression | this evaluator (Actions semantics) | the same operator in JavaScript | why they differ |
|---|
| expression | this evaluator | JavaScript |
|---|
Evaluate an expression
Verdict
Trace
One row per node. For a comparison the row names the kind of each operand, the CoerceTypes branch that fired, and the branch that produced the verdict.
| # | node | left | right | cast applied | branch that produced the verdict | result |
|---|
Self test
The strip below runs on load. The full suite runs on the button. It contains one positive control, an assertion written to fail on purpose, so a suite that silently stops running is visible rather than green.
What this evaluator does not do
hashFilesis refused by name. It hashes files in the runner workspace, and this page has no workspace and reads no files.success,failure,alwaysandcancelledare refused by name. They read job status from the runner, which does not exist here.toJSONis refused by name. The runner writes JSON through its own writer and that writer was not reproduced and verified here, so the function returns a refusal rather than a guess.- The page evaluates one expression, not a whole template string with several
${{ }}segments interleaved with literal text.
Provenance
The coercion, comparison, truthiness and number parsing behaviour was reimplemented in JavaScript from GitHub's published runner sources, fetched and read on 2026-09-09. No code is reproduced here. The files read, with the byte sizes they had that day:
src/Sdk/DTExpressions2/Expressions2/EvaluationResult.cs, 17010 bytes.IsFalsyat lines 50 to 71,IsTruthyat line 75, the String equality branch at line 256, the reference equality branch at line 267,CoerceTypesat lines 360 to 397.src/Sdk/DTExpressions2/Expressions2/Sdk/ExpressionUtility.cs, 8581 bytes.ParseNumberat lines 193 to 257.src/Sdk/DTExpressions2/Expressions2/ExpressionConstants.cs, 2749 bytes.MaxDepth = 50at line 30,MaxLength = 21000at line 31,NumberFormat = "G15"at line 35.src/Sdk/DTExpressions2/Expressions2/Tokens/LexicalAnalyzer.cs, 20898 bytes. Number literals in the expression text go through the sameParseNumber, at line 131.
The rules were cross-checked against GitHub's own published port, the npm package
@actions/expressions version 0.3.61 (MIT, repository actions/languageservices),
read on the same day.
The comment sitting above the equality functions in EvaluationResult.cs, lines 78 and 79,
reads:
Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
The published documentation cast table is incomplete as a description of
==. It lists Array and Object casting to NaN, but CoerceTypes has no
Array or Object branch on either side, so a container compared with == never reaches a
NaN cast. It falls through to the kind check and returns false, and two containers
of the same kind are compared by reference. The NaN rows are unreachable from every
comparison operator, equality and relational alike.
The cast table's String row begins "Parsed from any legal JSON number format, otherwise NaN". The
runner's parser is wider than that. It accepts hexadecimal, octal, a leading plus, surrounding whitespace
and the literal Infinity in any casing, and not one of those is a legal JSON number. The
casing was measured on a hosted runner on 2026-09-21, job 35656787367. It also rejects binary
literals and the uppercase 0X and 0O prefixes that JavaScript's
Number() accepts, which is the table below.
Known divergence from GitHub's own JavaScript port
GitHub also publishes a JavaScript port of this language, the npm package
@actions/expressions, built in the repository actions/languageservices and MIT
licensed. In version 0.3.61 of that package, the string
to number cast is a plain JavaScript Number() call, in dist/data/string.js at line
12, so it disagrees with the C# runner on '0b101', '0X10', '0O17',
'0xFFFFFFFF' and '0x100000000'. This page follows the C# runner, because that is
the implementation that executes on hosted runners. All five are self test rows.
What was measured on a hosted runner, and what was not
Sixteen expressions were run in a workflow on a GitHub hosted ubuntu-latest runner on
2026-09-21 and their output read back verbatim, in jobs 35656695534 and
35656787367. This evaluator returns the same verdict on all sixteen. They are:
| expression | runner | this evaluator | job |
|---|---|---|---|
| '' == 0 | true | true | 35656695534 |
| 0 == '0' | true | true | 35656695534 |
| '' == '0' | false | false | 35656695534 |
| 'true' == true | false | false | 35656695534 |
| 'ABC' == 'abc' | true | true | 35656695534 |
| null == 0 | true | true | 35656695534 |
| fromJSON('[]') == 0 | false | false | 35656695534 |
| 'infinity' == 0 | false | false | 35656695534 |
| '0x10' == 16 | true | true | 35656695534 |
| '0o17' == 15 | true | true | 35656695534 |
| 'infinity' > 0 | true | true | 35656787367 |
| 'Infinity' > 0 | true | true | 35656787367 |
| 'INFINITY' > 0 | true | true | 35656787367 |
| 'NaN' > 0 | false | false | 35656787367 |
| '+5' == 5 | true | true | 35656787367 |
| ' 5 ' == 5 | true | true | 35656787367 |
The second job exists because equality cannot tell Infinity apart from NaN:
'infinity' == 0 and 'NaN' == 0 are both false, whichever one the string parsed to.
A relational comparison separates them, and it settled the open question. Lowercase
'infinity' does parse to a number on the runner, and the literal is matched case
insensitively, so this page now parses 'infinity', 'Infinity' and
'INFINITY' alike. 'NaN' is not a parse branch.
Every other result on this page comes from this evaluator, from raw JavaScript
running in your browser, or from the runner source files listed above, and was not read off a hosted
runner. That includes the whole G15 number format table, the hexadecimal and octal Int32 edge
cases beyond the two rows above, the OrdinalIgnoreCase folding rows, the truthiness rows and
every context lookup row. A hosted runner measurement is also narrower than it sounds: it is one runner
image on one date, it reports what that runner version did, and it is not a promise about a future runner.
Where a row matters to you, run it in a workflow and check.