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
ifcondition 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()andcancelled() - 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
ifconditions
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.shCondition 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.shConditional 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.
# Equalityif: github.ref == 'refs/heads/main'if: github.event_name == 'push'if: matrix.os == 'ubuntu-24.04'
# Inequalityif: github.ref != 'refs/heads/main'
# Numeric comparisonsif: matrix.node >= 20if: github.run_attempt > 1Logical operators
The &&, || and ! operators combine several conditions into one
expression.
# Logical ANDif: github.ref == 'refs/heads/main' && github.event_name == 'push'
# Logical ORif: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
# Negationif: "!contains(github.event.head_commit.message, '[skip ci]')"
# Combinationsif: | 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 characterif: !contains(...)
# ✅ Correct: quotedif: "!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 cancelledif: "!cancelled()"
# Runs only when the previous job failedif: 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 onlyif: github.ref == 'refs/heads/main'
# Branches starting with "release/"if: startsWith(github.ref, 'refs/heads/release/')
# Tags onlyif: startsWith(github.ref, 'refs/tags/')
# Tags matching a semver patternif: 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 onlyif: github.event_name == 'push'
# Pull request onlyif: github.event_name == 'pull_request'
# Manual triggerif: github.event_name == 'workflow_dispatch'
# Scheduled (cron)if: github.event_name == 'schedule'
# Several eventsif: 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 userif: github.actor == 'admin-user'
# Bots (Dependabot, Renovate)if: github.actor == 'dependabot[bot]'if: github.actor == 'renovate[bot]'
# Excluding botsif: "!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 mainif: github.event_name == 'pull_request' && github.base_ref == 'main'
# PRs coming from a forkif: github.event.pull_request.head.repo.fork == true
# Non-draft PRsif: github.event.pull_request.draft == false
# PRs carrying a specific labelif: 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 buildJob 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.shJob 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 testConditional 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 productionSchedule 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 --fullIgnoring 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 testDry-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.shMatrix 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:legacyCommon 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'
# ✅ Correctif: 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 errorif: !contains(...)
# ✅ Correctif: "!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 runjobs: deploy: if: false steps: - if: true # Never reached run: echo "Never runs"
# ✅ A condition at step level, for finer granularityjobs: deploy: steps: - if: github.ref == 'refs/heads/main' run: ./deploy.shKey points
- An
ifcondition sits on a job (all or nothing) or on a step (fine granularity). - Without
if, every step carries an implicitsuccess()condition: it is skipped as soon as an earlier step fails. failure(),always()andcancelled()unlock steps even after a failure, which is essential for diagnosis and cleanup.- Always compare
github.refto the full reference (refs/heads/main), or usegithub.ref_name. - An expression starting with
!must be quoted, otherwise the YAML breaks. - To react to an earlier job's verdict, read
needs.<job>.resultfrom a job that depends on it.
Next steps
- Reusable workflows: factoring out the jobs whose execution you have just made conditional.
- Composite actions: grouping a sequence of conditional steps into a single reusable action.
- Securing GitHub Actions: why a condition on
github.actorprotects nothing at all.