Skip to content
Français
CI/CD & Automatisation medium

Conditions and if in GitHub Actions

35 min de lecture

Read this page in French

Conditions (if) let you control when a job or a step runs. You can run code only on certain branches, only after a failure, or according to your own criteria.

What you will learn

  • Place an if condition at the right level, job or step, for the granularity you want
  • Write conditional expressions: comparisons, logical operators, escaping
  • Use the status functions success(), failure(), always() and cancelled()
  • Condition on the context: branch, event, actor, pull request
  • React to the outputs and results of earlier steps and jobs
  • Avoid the three classic traps of if conditions

This guide assumes you know the structure of a workflow. If not, start with Contexts and expressions.

Basic syntax

An if condition sits either on a whole job or on an individual step. The principle is identical, only the scope changes.

Condition on a job

Placed at job level, the condition decides whether all the steps run or whether the whole job is skipped.

jobs:
deploy:
runs-on: ubuntu-24.04
# This job only runs on the main branch
if: github.ref == 'refs/heads/main'
steps:
- run: ./deploy.sh

Condition on a step

Placed at step level, the condition only affects that step; the other steps of the job carry on normally.

jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run tests
run: npm test
# This step only runs when the previous one failed
- name: Upload logs on failure
if: failure()
run: ./upload-logs.sh

Conditional expressions

Conditions use GitHub Actions expressions with the ${{ }} syntax, which is optional inside if.

Comparisons

Comparison operators test the equality, the inequality or the order of two values.

# Equality
if: github.ref == 'refs/heads/main'
if: github.event_name == 'push'
if: matrix.os == 'ubuntu-24.04'
# Inequality
if: github.ref != 'refs/heads/main'
# Numeric comparisons
if: matrix.node >= 20
if: github.run_attempt > 1

Logical operators

The &&, || and ! operators combine several conditions into one expression.

# Logical AND
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# Logical OR
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
# Negation
if: "!contains(github.event.head_commit.message, '[skip ci]')"
# Combinations
if: |
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))

Escaping expressions

Expressions starting with ! must be quoted:

# ❌ YAML error: ! is a special character
if: !contains(...)
# ✅ Correct: quoted
if: "!contains(...)"

Status functions

These functions evaluate the result of earlier steps or jobs.

success() (the default)

success() returns true as long as no earlier step has failed; it is the implicit condition of every step.

steps:
- run: npm test
# Equivalent to if: success()
- name: Deploy (if the tests passed)
run: ./deploy.sh
# Explicit
- name: Notify success
if: success()
run: echo "All good!"

success() is the default behaviour: a step only runs when every earlier step succeeded.

failure()

failure() only fires when an earlier step has failed: ideal for collecting diagnostic artifacts or alerting a team.

steps:
- name: Run tests
run: npm test
- name: Upload test artifacts on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-results
path: ./test-results/
- name: Notify the team on failure
if: failure()
run: |
curl -X POST "$SLACK_WEBHOOK" \
-d "{\"text\": \"Tests failed on $REPOSITORY\"}"
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
REPOSITORY: ${{ github.repository }}

always()

always() forces execution whatever happens: success, failure or cancellation. Reserve it for cleanup and log collection.

steps:
- name: Run tests
run: npm test
# ALWAYS runs, even when a step failed or the job was cancelled
- name: Cleanup
if: always()
run: ./cleanup.sh
- name: Upload coverage
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage
path: ./coverage/

cancelled()

cancelled() targets the case where the workflow was interrupted, manually or by a new run replacing the previous one.

steps:
- name: Long running task
run: ./process.sh
- name: Notify cancellation
if: cancelled()
run: echo "Job was cancelled"

Combining the functions

Combining these functions expresses finer cases, such as "always except when cancelled".

# Runs on success OR failure (but not when cancelled)
if: success() || failure()
# Runs always EXCEPT when cancelled
if: "!cancelled()"
# Runs only when the previous job failed
if: always() && needs.build.result == 'failure'

Conditions on contexts

Most conditions lean on the github context to react to the branch, the event or the actor that triggered the workflow.

Branches and tags

The github.ref property distinguishes branches from tags; the startsWith() function recognises families of references.

# The main branch only
if: github.ref == 'refs/heads/main'
# Branches starting with "release/"
if: startsWith(github.ref, 'refs/heads/release/')
# Tags only
if: startsWith(github.ref, 'refs/tags/')
# Tags matching a semver pattern
if: startsWith(github.ref, 'refs/tags/v')

Events

github.event_name says which trigger started the workflow, which helps when one workflow reacts to several events.

# Push only
if: github.event_name == 'push'
# Pull request only
if: github.event_name == 'pull_request'
# Manual trigger
if: github.event_name == 'workflow_dispatch'
# Scheduled (cron)
if: github.event_name == 'schedule'
# Several events
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'

Actors

github.actor identifies the account behind the run, which lets you treat bots differently from human contributors.

# A specific user
if: github.actor == 'admin-user'
# Bots (Dependabot, Renovate)
if: github.actor == 'dependabot[bot]'
if: github.actor == 'renovate[bot]'
# Excluding bots
if: "!contains(github.actor, '[bot]')"

Pull requests

Conditions on pull requests filter by target branch, by origin (fork or not) or by the PR labels.

# PRs targeting main
if: github.event_name == 'pull_request' && github.base_ref == 'main'
# PRs coming from a fork
if: github.event.pull_request.head.repo.fork == true
# Non-draft PRs
if: github.event.pull_request.draft == false
# PRs carrying a specific label
if: contains(github.event.pull_request.labels.*.name, 'deploy')

Conditions on outputs

A condition can also depend on a value computed earlier in the workflow, through the outputs of a step or a job.

Step outputs

A step writes a value to $GITHUB_OUTPUT, and a later step reads it in its if condition.

steps:
- name: Check changes
id: changes
run: |
if git diff --name-only HEAD~1 | grep -q "^src/"; then
echo "src_changed=true" >> $GITHUB_OUTPUT
else
echo "src_changed=false" >> $GITHUB_OUTPUT
fi
- name: Build
if: steps.changes.outputs.src_changed == 'true'
run: npm run build

Job outputs (needs)

A job exposes an output; a dependent job reads it through needs to decide whether it should run.

jobs:
check:
runs-on: ubuntu-24.04
outputs:
should_deploy: ${{ steps.check.outputs.deploy }}
steps:
- id: check
env:
REF: ${{ github.ref }}
run: |
if [ "$REF" == "refs/heads/main" ]; then
echo "deploy=true" >> $GITHUB_OUTPUT
else
echo "deploy=false" >> $GITHUB_OUTPUT
fi
deploy:
needs: check
if: needs.check.outputs.should_deploy == 'true'
runs-on: ubuntu-24.04
steps:
- run: ./deploy.sh

Job results

needs.<job>.result gives the verdict of an earlier job: success, failure, skipped or cancelled, provided you depend on that job.

jobs:
build:
runs-on: ubuntu-24.04
steps:
- run: npm run build
test:
runs-on: ubuntu-24.04
steps:
- run: npm test
notify:
needs: [build, test]
if: always()
runs-on: ubuntu-24.04
steps:
- name: Notify success
if: needs.build.result == 'success' && needs.test.result == 'success'
run: echo "All good!"
- name: Notify failure
if: needs.build.result == 'failure' || needs.test.result == 'failure'
run: echo "Something failed!"

Common patterns

Here are the conditions you find in almost every real pipeline, to copy then adapt to your context.

Skip CI

This pattern avoids rerunning the pipeline when the commit message carries the [skip ci] marker.

jobs:
build:
# Do not run when the commit message contains [skip ci]
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-24.04
steps:
- run: npm test

Conditional deployment

Each environment gets its own job, triggered by the matching branch.

jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
environment: staging
runs-on: ubuntu-24.04
steps:
- run: ./deploy.sh staging
deploy-production:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
runs-on: ubuntu-24.04
steps:
- run: ./deploy.sh production

Schedule versus push

One job adapts its behaviour depending on whether it runs on a push or on a scheduled run, a quick scan against a full scan for instance.

jobs:
scan:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Quick scan on push
- name: Quick scan
if: github.event_name == 'push'
run: ./scan.sh --quick
# Full scan on schedule
- name: Full scan
if: github.event_name == 'schedule'
run: ./scan.sh --full

Ignoring bots

This condition avoids rerunning the whole CI on every automatic dependency update pushed by Dependabot or Renovate.

jobs:
test:
# Do not trigger the tests for bot commits
if: |
github.actor != 'dependabot[bot]' &&
github.actor != 'renovate[bot]'
runs-on: ubuntu-24.04
steps:
- run: npm test

Dry-run versus real execution

On a pull request, the deployment runs as a simulation; it only becomes real on the main branch.

jobs:
deploy:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Dry-run on PRs
- name: Deploy (dry-run)
if: github.event_name == 'pull_request'
run: ./deploy.sh --dry-run
# Real deployment on main
- name: Deploy (real)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh

Matrix with conditions

Inside a matrix, a condition on matrix.* reserves certain steps for a given system or version.

jobs:
test:
strategy:
matrix:
os: [ubuntu-24.04, windows-2025, macos-15]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# A Windows-specific step
- name: Setup (Windows)
if: matrix.os == 'windows-2025'
run: choco install nodejs
# A step for older versions only
- name: Legacy compatibility check
if: matrix.node < 20
run: npm run test:legacy

Common mistakes

Three traps come up systematically with if conditions. Knowing them saves hours of debugging a job that never fires, or always does.

A condition that is always true or always false

The most frequent mistake: comparing github.ref to a bare branch name, when that property carries the refs/heads/ prefix.

# ❌ Always false: github.ref includes "refs/heads/"
if: github.ref == 'main'
# ✅ Correct
if: github.ref == 'refs/heads/main'
if: github.ref_name == 'main'

Forgetting the quotes

An expression starting with ! breaks the YAML parser unless it is quoted.

# ❌ YAML error
if: !contains(...)
# ✅ Correct
if: "!contains(...)"

A condition at the wrong level

An if: false on a job makes all of its steps unreachable; put the condition at the right level of granularity.

# ❌ The job is skipped, the steps never run
jobs:
deploy:
if: false
steps:
- if: true # Never reached
run: echo "Never runs"
# ✅ A condition at step level, for finer granularity
jobs:
deploy:
steps:
- if: github.ref == 'refs/heads/main'
run: ./deploy.sh

Key points

  • An if condition sits on a job (all or nothing) or on a step (fine granularity).
  • Without if, every step carries an implicit success() condition: it is skipped as soon as an earlier step fails.
  • failure(), always() and cancelled() unlock steps even after a failure, which is essential for diagnosis and cleanup.
  • Always compare github.ref to the full reference (refs/heads/main), or use github.ref_name.
  • An expression starting with ! must be quoted, otherwise the YAML breaks.
  • To react to an earlier job's verdict, read needs.<job>.result from a job that depends on it.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn