Skip to content

Expressions if Conditional


Everything inside ${{ }} you’ve written so far has just been reading a value out of a context. Expressions can do more than that — comparisons, boolean logic, function calls — and if: is where they earn their keep, deciding whether a step or an entire job runs at all. This is the mechanism that turns a workflow from “runs the same steps every time” into something that actually reacts to its situation.

Expression Basics

An expression lives inside ${{ }} and supports operators you’d recognize from most programming languages: ==, !=, <, >, &&, ||, !.

name: Expressions Demo
on: workflow_dispatch

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "1: ${{ 1 == 1 }}"
          echo "2: ${{ 'main' == github.ref_name }}"
          echo "3: ${{ github.actor != '' && true }}"

Try it:

mkdir gha-expr-demo && cd gha-expr-demo
git init
mkdir -p .github/workflows
cat > .github/workflows/expressions.yml << 'EOF'
name: Expressions Demo
on: workflow_dispatch

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "1: ${{ 1 == 1 }}"
          echo "2: ${{ 'main' == github.ref_name }}"
          echo "3: ${{ github.actor != '' && true }}"
EOF
git add .
git commit -m "Add expressions demo workflow"
git branch -M main

Push and trigger it manually — each line prints true, confirming the comparisons resolve the way you’d expect.

The if: Conditional

if: can appear on a step or an entire job, and it’s evaluated before that step or job would otherwise run. If it resolves to false, GitHub skips it entirely — the step or job shows as “Skipped” in the UI, not “Failed.”

name: If Conditional Demo
on:
  push:
    branches: [main, develop]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Always runs"
      - if: github.ref_name == 'main'
        run: echo "Only runs on main"
      - if: github.ref_name != 'main'
        run: echo "Only runs on non-main branches"

Warning

Whether you write the expression with ${{ }} or without it, the string literal should use single quotes.

# invalid because of double quote
# Github Actions parsing Error will occur as: Unexpected symbol: '"Windows"'. 
# Located at position 21 within expression: vars.required_os != "Windows"
if: vars.required_os != "Windows"

# valid because sinlge quoted
if: vars.required_os != 'Windows'

Add and push it:

cat > .github/workflows/if-conditional.yml << 'EOF'
name: If Conditional Demo
on:
  push:
    branches: [main, develop]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Always runs"
      - if: github.ref_name == 'main'
        run: echo "Only runs on main"
      - if: github.ref_name != 'main'
        run: echo "Only runs on non-main branches"
EOF
git add .
git commit -m "Add if conditional demo"

Push to main and check the run: the second step runs, the third is skipped. Push the same content to a develop branch and it flips.

Note

Inside an if: condition specifically, the ${{ }} wrapper is optional — GitHub always treats the entire if: value as an expression, so if: github.ref_name == 'main' and if: ${{ github.ref_name == 'main' }} behave identically. Most style guides drop the braces here since they’re redundant, but including them isn’t wrong.

Only following Context and Special Functions are available for job level if: -> jobs.<job_id>.steps.if:

ContextSpecial Functions
github, needs, strategy, matrix, job, runner, env, vars, steps, inputsalways, cancelled, success, failure, hashFiles

Job-Level if:

The same syntax works at the job level, skipping the entire job — including every step in it — rather than just one step.

jobs:
  deploy:
    if: github.ref_name == 'main'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying"

This is the most common real-world use: a deploy job that only makes sense on the default branch, guarded at the job level so you don’t have to repeat the same if: on every single step inside it.

Only following Context and Special Functions are available for job level if: -> jobs.<job_id>.if:

ContextSpecial Functions
github, needs, vars, inputsalways, cancelled, success, failure

Status Check Functions

By default, a step only runs if every previous step in the job succeeded — this is an implicit condition baked into GitHub Actions, and it’s why a failed step normally halts everything after it. Four functions let you override that default behavior explicitly:

steps:
  - run: exit 1
    continue-on-error: true

  - if: success()
    run: echo "Runs only if everything before succeeded"

  - if: failure()
    run: echo "Runs only if something before failed"

  - if: always()
    run: echo "Runs no matter what happened before"

  - if: cancelled()
    run: echo "Runs only if the workflow was cancelled"

always() is the one you’ll reach for constantly — it’s the standard way to guarantee a cleanup or notification step runs regardless of whether the build succeeded or crashed.

Warning

always() genuinely means always — including after a step failure that would normally stop the job. If you use always() on a step that assumes earlier steps succeeded (like uploading a build artifact that was never created because the build failed), that step will still attempt to run and will likely fail itself. Pair always() with your own explicit checks, like if: always() && steps.build.outcome == 'success', when you need “run regardless of failure, but only if this specific prerequisite happened.”

Combining Conditions

Conditions combine the same way you’d expect from any boolean logic.

- if: github.event_name == 'push' && github.ref_name == 'main'
  run: echo "Push to main specifically"

- if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
  run: echo "Manually or automatically triggered, not by a push or PR"

Common Comparison Gotchas

Boolean-looking input values are actually strings, as covered earlier — comparing inputs.skip-tests == true (unquoted) works because GitHub’s expression engine does a loose type coercion here, but comparing against a string literal like 'true' also works and is what some engineers prefer for clarity. Either is valid; consistency within your own workflows matters more than which one you pick.

Empty-string checks are also a common trap: github.event.pull_request.body might be null rather than an empty string if a PR has no description, so == '' won’t always match — safer is often checking truthiness directly, like if: github.event.pull_request.body.

How Real DevOps Engineers Use This

if: conditionals are what let a single workflow file replace what would otherwise be three or four near-duplicate files. A typical CI/deploy pipeline uses job-level if: to gate deployment behind branch checks (github.ref_name == 'main'), uses always() on a final notification step so a Slack or email alert fires whether the build passed or failed, and uses success() combined with an environment input to decide whether a production deploy proceeds after a staging deploy completes cleanly. Getting comfortable reading and writing these expressions is one of the highest-leverage skills in the whole platform — nearly every nontrivial workflow leans on if: somewhere.

Clean up:

cd .. && rm -rf gha-expr-demo
Last updated on