Skip to content

GitHub Actions Expression Tester · CI/CD

Test your if: conditions and triggers before you push.

Evaluate ${{ }} expressions against an editable mock context, catch the if: that is silently always true, and simulate which jobs run for a push, PR or tag — instantly, in your browser. No commit-push-pray loop.

Runs in your browser — nothing you paste leaves this page. How we prove that

Runs in your browser No signup Exact GitHub semantics Updated Jul 29, 2026

GitHub Actions expression and trigger playground

if-condition
context (editable mock)

Results update as you type — press ⌘/Ctrl + Enter to run now. Press Esc to release the editor. Your input never leaves the browser.

Result

Load an example or write an if: condition, then Evaluate to see whether it is true, the returned value, and a token-by-token breakdown.

.github/workflows/ci.yml
Event scenario

Results update as you type — press ⌘/Ctrl + Enter to run now. Press Esc to release the editor. Nothing is uploaded.

Decision

Load an example or paste a workflow, set an event, then Simulate to see which jobs run or are skipped — and why.

100% in your browser · no signup

The Gap

Stop debugging by pushing.

Everyone has done it: add an if: to a step, push, watch it run when it shouldn’t, tweak, push again — twenty dummy commits later you find the condition was a literal string the whole time. GitHub only evaluates what is inside ${{ }}; anything outside is literal text, and a non-empty string is always truthy.

The trigger side is just as opaque: a workflow that doesn’t run produces no output at all — the decision happens in GitHub’s event filtering before any runner starts. Branch and path filters combine with AND, tag pushes are excluded when you set branches but not tags, and ** behaves differently from *.

This tool evaluates both — a single expression, or a whole workflow against an event — using GitHub’s exact rules, so you get the answer in the browser. See GitHub’s expressions and workflow-trigger docs, and the always-true footgun issue.

Jump to the cheat-sheet or the live playground above.

The Pipeline

How it works.

Five deterministic steps run end-to-end on every evaluation — all inside your browser tab, every time.

  1. Pick a tab.

    Evaluate a single if: / expression, or switch to the trigger simulator to test a whole workflow against an event.

  2. Set the context.

    Edit the mock github/env/matrix/steps/needs JSON (or describe the event: type, ref, changed files) so it matches the run you care about.

  3. Parse with GitHub’s grammar.

    The expression is tokenised and parsed with the same operator precedence and function set the runner uses — no shell-out, no network.

  4. Apply the exact semantics.

    Coercion, case-insensitive equality, operand-returning && / ||, glob filters and the branch+path AND-rule are replicated against a versioned conformance corpus.

  5. Show the verdict + why.

    You get the truthy/falsy result and returned value, the always-true footgun warning where it applies, and a per-job RUNS/SKIPPED table with the deciding reason.

Cheat-sheet

The rules that trip people up.

The footgun, the coercion surprises, the functions, and the trigger globs — each with a runnable example you can paste into the playground.

The “always true” footgun

Most common

GitHub only evaluates what is inside ${{ }}. Operators left outside become literal text after substitution — a non-empty string, which is always truthy. The evaluator flags this (actions/runner#1173).

Always true

.github/workflows/ci.yml
# ALWAYS TRUE — operators sit OUTSIDE ${{ }}
jobs:
  deploy:
    if: ${{ github.ref }} == 'refs/heads/main'
    # after substitution this is the literal string
    #   refs/heads/main == 'refs/heads/main'
    # a non-empty string => truthy => runs on EVERY branch

Fixed

.github/workflows/ci.yml
# CORRECT — wrap the WHOLE condition in one ${{ }}
jobs:
  deploy:
    if: ${{ github.ref == 'refs/heads/main' }}
    # now GitHub evaluates the comparison, not a literal string

Operators & coercion

YAML
# GitHub's coercion is JS-like and case-insensitive:
${{ 'ABC' == 'abc' }}      # true  (strings compare case-insensitively)
${{ null == 0 }}           # true  (both coerce to the number 0)
${{ '' || 'fallback' }}    # 'fallback'  (|| returns an OPERAND, not a bool)
${{ github.x && 'yes' }}   # 'yes' when github.x is truthy
${{ 'true' == true }}      # false (string 'true' coerces to NaN)

Functions

YAML
${{ contains('refs/heads/main', 'main') }}     # true (substring, case-insensitive)
${{ startsWith(github.ref, 'refs/tags/') }}    # is this a tag push?
${{ format('{0}-{1}', 'app', github.sha) }}    # 'app-d6cd1e2…'
${{ fromJSON('["a","b"]') }}                   # a real array
${{ always() }}  ${{ success() }}  ${{ failure() }}  # status checks

Triggers, branches & paths

YAML
on:
  push:
    branches: [main]      # AND
    paths: ['src/**']     # — both must match to trigger
# feature/* matches feature/a but NOT feature/a/b
# feature/** matches both (** crosses '/')

Fidelity & scope

This is a semantics playground, not a runner. It replicates GitHub’s documented expression and trigger behaviour against a versioned conformance corpus (shown as the semantics: label in the tool), but it does not execute jobs, fetch the live contents of referenced actions, or compute a real hashFiles() — that needs files only the runner can see, so it is shown as a placeholder. The full github.event payload is modelled as an editable mock you control. Treat a clean result as strong pre-push confidence, and still pair it with actionlint for syntax.

FAQ

Questions, answered.

Tap a question to expand the answer.

It has two tabs. The Expression Evaluator runs your ${{ }} expressions against an editable mock context (github, env, matrix, steps, needs) using GitHub Actions' exact semantics — the operators, the case-insensitive string ==, the JS-like coercion rules, and documented functions like contains, startsWith, endsWith, format, join, toJSON, fromJSON, success(), failure(), always() and cancelled(). The Trigger Simulator lets you describe a push, pull_request or tag event and shows a per-job RUN or SKIPPED table that explains exactly which on: branches, tags, paths or paths-ignore filter decided the outcome. Together they answer "will this if: be true?" and "will this workflow even trigger?" before you push.

Almost always because the if: contains literal text outside of ${{ }} — for example if: "${{ github.event_name }}" == 'push' or if: always-deploy. GitHub does not evaluate the whole line as one expression; the literal characters make the condition a non-empty string, and a non-empty string is truthy, so the step runs every time. Wrap the entire condition in a single ${{ }} (e.g. if: ${{ github.event_name == 'push' }}) and the Expression Evaluator will flag the footgun (actions/runner#1173) and show you the corrected, truthy-or-falsy result.

Paste the expression into the Expression Evaluator tab and edit the mock github/env/matrix/steps/needs context to match the run you care about — no commit, no push, no waiting on a runner. You get the evaluated value instantly, plus a warning if the syntax would silently coerce to always-true. It is the fastest way to verify an if: condition or a ${{ }} interpolation before it ever reaches GitHub Actions.

These are status check functions you use in an if:. success() is true only when every prior step or needed job succeeded (it is the implicit default the moment you write no if:), failure() is true when any of them failed, cancelled() is true when the workflow was cancelled, and always() forces the step to run regardless of prior status — including on cancellation. The catch is that writing any custom if: removes the implicit success() guard, so if: env.DEPLOY == 'true' will run even after a previous step failed unless you add && success(). The evaluator lets you toggle the mock status and see each function resolve.

The usual causes are a ref that does not match your on: branches or tags glob, the workflow file not existing on the target branch yet, or a paths filter excluding every changed file. A subtle one: when you set both branches and paths under the same event, they combine with AND — the event must match both, not either. Describe your event in the Trigger Simulator and it replays the on: filters and tells you the deciding reason for each job.

Within a single event, branches (or tags) and paths are ANDed: a push must be on a matching branch and touch a matching path for the workflow to run. Inside one filter the patterns are ORed — any one branch glob or any one changed path matching is enough. The Trigger Simulator makes this explicit by showing both the branch decision and the path decision separately, then the combined RUN or SKIPPED verdict.

* matches any characters except the path separator /, while ** matches across separators including /, so feature/** matches feature/a/b but feature/* matches only feature/a. The glob engine also honors +, ?, ! negation, and \ escaping the same way GitHub does. The Trigger Simulator uses a faithful re-implementation of these rules so you can test a pattern like 'release/**' or '!**/*.md' against a real ref or file list and see what matches.

In the Trigger Simulator you provide the event type, the ref name, and the list of changed files; the tool then evaluates each job's on: filters and any job-level if: against that simulated event and renders a RUN or SKIPPED row with the deciding reason — "branch matched but path did not", "no paths filter", "if: evaluated false", and so on. It mirrors GitHub Actions' decision order rather than guessing, so the verdict matches what the real runner would do for that event.

actionlint is a static linter for workflow syntax and expression typing, and act actually executes your jobs in local Docker containers — both are great and worth using. This tool does neither: it does not run your steps and it is not a type checker. It is a semantics playground that evaluates a single ${{ }} expression or simulates trigger filtering against a context you control, in the browser, so you can reason about why an if: is truthy or why a workflow did or did not trigger without spinning up containers or pushing commits.

Anything that depends on real runner state. hashFiles() needs the actual files on disk, so the evaluator treats it as an opaque placeholder rather than computing a true hash; the full webhook payload is far larger than the mock github context we expose; and live action contents, secrets, and runner labels are not resolved. Treat a clean result as strong pre-push confidence about expression and trigger logic — not a guarantee about file hashing or the complete event payload.

No. Both tabs run 100% client-side. The expressions, mock context, workflow filters, and event payloads you enter are evaluated inside your browser tab — nothing is uploaded, there is no account, and there is no signup. You can safely paste internal or proprietary workflows, including secret names, private runner labels, and real branch and path lists.

No. This is an independent, community tool and is not affiliated with, endorsed by, or sponsored by GitHub, Inc. "GitHub" and "GitHub Actions" are used here only descriptively, to identify the expression and workflow-trigger format the tool evaluates.

More free, private DevOps tools.

The GitHub Actions Expression Tester is one tool in OpsCanopy — a growing canopy of browser-based validators, converters and testers that never touch a server.

39 free tools, every one offline-capable — opscanopy.com works with no signup and nothing uploaded.

Related tools: the GitHub Actions Validator, the GitLab CI Validator, and AlertLint, the Loki rule tester. Since on: schedule triggers use cron syntax, try the Cron Expression Tester too. Browse the full tools directory.

Not affiliated with, endorsed by, or sponsored by GitHub, Inc. GitHub and GitHub Actions are trademarks of GitHub, Inc., used here only descriptively to identify the expression and workflow-trigger format this tool evaluates.