Skip to content

Fix: GitHub Actions if Condition Not Working (Steps and Jobs Being Skipped or Always Running)

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix GitHub Actions if conditions that don't evaluate correctly — why steps are skipped or always run, how to use context expressions, fix boolean checks, and handle job outputs in conditions.

The Error

A step or job with an if: condition does not behave as expected:

- name: Deploy to production
  if: github.ref == 'refs/heads/main'
  run: ./deploy.sh
# Step is always skipped, or always runs regardless of branch

Or a condition on a job output doesn’t work:

jobs:
  check:
    outputs:
      changed: ${{ steps.check.outputs.changed }}

  deploy:
    needs: check
    if: needs.check.outputs.changed == 'true'  # Never evaluates to true
    runs-on: ubuntu-latest

Or success(), failure(), or always() conditions are not behaving as documented:

- name: Notify on failure
  if: failure()  # Never runs even when previous step fails
  run: ./notify.sh

Why This Happens

GitHub Actions if: expressions use a specific syntax that differs from typical programming languages. They look like Bash, behave like a templating language, and silently fall back to false when something is wrong. That silent fallback is the root of most “my step is skipped and I don’t know why” reports: instead of throwing a parse error, an unresolvable context reference evaluates to an empty string, and an empty string compared with a non-empty string returns false.

The common mistakes:

  • Forgetting the ${{ }} expression syntaxif: evaluates the expression directly without wrapping it. Unlike run: or env:, the if: field interprets its value as an expression automatically. Sometimes wrapping in ${{ }} is needed, sometimes it is not.
  • String comparison vs booleangithub.event_name == 'push' is correct, but github.event.pull_request.draft == false may not work as expected because false is a boolean in YAML but github.event.pull_request.draft is a JSON boolean.
  • Incorrect context accessneeds.job-name.outputs.key requires the exact job ID, not the display name.
  • Status check functionssuccess(), failure(), always() only work at the step level when used with if: and only reflect the status of the overall job up to that point.
  • Job output not set — a job output must be explicitly set with echo "key=value" >> $GITHUB_OUTPUT for it to be available in downstream jobs.

A second class of confusion comes from operator semantics. GitHub’s expression language treats string comparisons as case-insensitive, so 'Push' == 'push' is true. But it does not coerce types between strings and booleans the way JavaScript does. 'true' == true is false, because the left side is a string and the right side is a boolean. Every job output is a string by the time you read it in needs.<job>.outputs.<key>, so comparisons must use the quoted string form: == 'true', never == true. Wrap with fromJSON() if you genuinely need the boolean.

Fix 1: Correct if: Syntax for Common Conditions

# Branch check
- name: Deploy
  if: github.ref == 'refs/heads/main'

# Event type check
- name: Run on push only
  if: github.event_name == 'push'

# Multiple conditions (AND)
- name: Deploy to prod
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'

# Multiple conditions (OR)
- name: Run on main or release
  if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')

# Negation
- name: Skip on forks
  if: github.repository_owner == 'myorg'

# Check if a variable is not empty
- name: Run if secret exists
  if: secrets.DEPLOY_KEY != ''
  # Note: secrets cannot be directly evaluated in if conditions for security
  # Use an env variable workaround:
  env:
    HAS_SECRET: ${{ secrets.DEPLOY_KEY != '' }}
  if: env.HAS_SECRET == 'true'

The correct way to check secrets in conditions:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Check if deploy key exists
        id: check-secret
        run: |
          if [ -n "${{ secrets.DEPLOY_KEY }}" ]; then
            echo "has_key=true" >> $GITHUB_OUTPUT
          else
            echo "has_key=false" >> $GITHUB_OUTPUT
          fi

      - name: Deploy
        if: steps.check-secret.outputs.has_key == 'true'
        run: ./deploy.sh

Fix 2: Fix Step Status Conditions

Use status functions to run steps conditionally based on prior step results:

steps:
  - name: Build
    id: build
    run: npm run build  # This might fail

  # Runs only if ALL previous steps succeeded (default behavior)
  - name: Test
    run: npm test

  # Runs only if the 'build' step failed
  - name: Notify build failure
    if: failure() && steps.build.conclusion == 'failure'
    run: ./notify-failure.sh

  # Always runs regardless of previous step status
  - name: Upload logs
    if: always()
    uses: actions/upload-artifact@v4
    with:
      name: build-logs
      path: logs/

  # Runs if the overall job failed (any step failed)
  - name: Send failure alert
    if: failure()
    run: curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d '{"text":"Build failed!"}'

  # Run on failure BUT only on the main branch
  - name: Alert on main failure
    if: failure() && github.ref == 'refs/heads/main'
    run: ./alert.sh

Step conclusion vs outcome:

- name: Risky step
  id: risky
  continue-on-error: true  # Don't fail the job if this step fails
  run: ./risky-command.sh

# steps.risky.outcome = 'success' | 'failure' | 'cancelled' | 'skipped'
# steps.risky.conclusion = same values, but reflects continue-on-error

- name: Handle risky failure
  # Use outcome when continue-on-error is set — conclusion is always 'success' with continue-on-error
  if: steps.risky.outcome == 'failure'
  run: echo "Risky step failed but we continue"

Fix 3: Fix Job-Level if Conditions

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

  deploy:
    needs: build
    runs-on: ubuntu-latest

    # Job-level conditions use job context
    if: |
      github.ref == 'refs/heads/main' &&
      github.event_name == 'push' &&
      needs.build.result == 'success'

    steps:
      - run: ./deploy.sh

Job result values: success, failure, cancelled, skipped

  notify:
    needs: [build, deploy]
    runs-on: ubuntu-latest
    if: always()  # Always run this job

    steps:
      - name: Report status
        run: |
          echo "Build: ${{ needs.build.result }}"
          echo "Deploy: ${{ needs.deploy.result }}"

Fix 4: Fix Job Output Not Available in if Conditions

Job outputs must be explicitly defined and set:

jobs:
  check-changes:
    runs-on: ubuntu-latest
    outputs:
      # Declare the output at the job level
      has_changes: ${{ steps.diff.outputs.has_changes }}
      changed_files: ${{ steps.diff.outputs.changed_files }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Need at least 2 commits to diff

      - name: Check for changes
        id: diff
        run: |
          CHANGED=$(git diff --name-only HEAD~1 HEAD -- src/)
          if [ -n "$CHANGED" ]; then
            echo "has_changes=true" >> $GITHUB_OUTPUT
            echo "changed_files<<EOF" >> $GITHUB_OUTPUT
            echo "$CHANGED" >> $GITHUB_OUTPUT
            echo "EOF" >> $GITHUB_OUTPUT
          else
            echo "has_changes=false" >> $GITHUB_OUTPUT
          fi

  deploy:
    needs: check-changes
    runs-on: ubuntu-latest

    # Access job output with: needs.<job-id>.outputs.<output-name>
    if: needs.check-changes.outputs.has_changes == 'true'

    steps:
      - name: Deploy
        run: echo "Deploying because src/ changed"

Common mistake — comparing boolean to string:

# Wrong — 'true' (string) != true (YAML boolean)
if: needs.check.outputs.has_changes == true

# Correct — compare to string 'true'
if: needs.check.outputs.has_changes == 'true'

# Also correct — use the expression to coerce
if: fromJSON(needs.check.outputs.has_changes) == true

Fix 5: Fix Environment and Variable Conditions

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Requires approval if configured

    steps:
      - name: Set environment flag
        id: env-check
        run: |
          echo "env_name=${{ github.event.deployment.environment }}" >> $GITHUB_OUTPUT

      # Check environment variable set in a previous step
      - name: Production only step
        if: steps.env-check.outputs.env_name == 'production'
        run: ./production-only.sh

      # Check vars context (repository/org variables)
      - name: Use variable
        if: vars.FEATURE_FLAG == 'enabled'
        run: echo "Feature is enabled"

Using env context in conditions (set in the workflow):

env:
  DEPLOY_ENV: production

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      # env context from workflow-level env
      - name: Deploy to prod
        if: env.DEPLOY_ENV == 'production'
        run: ./deploy-prod.sh

Fix 6: Fix Pull Request and Push Event Conditions

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Only on PRs to main
      - name: PR checks
        if: github.event_name == 'pull_request' && github.base_ref == 'main'
        run: ./pr-checks.sh

      # Only on direct pushes (not PRs)
      - name: Push-only step
        if: github.event_name == 'push'
        run: ./push-only.sh

      # Only when PR is not a draft
      - name: Full test suite
        if: |
          github.event_name == 'push' ||
          (github.event_name == 'pull_request' && !github.event.pull_request.draft)
        run: npm run test:all

      # Check if PR is from a fork (fork PRs don't have access to secrets)
      - name: Deploy preview
        if: |
          github.event_name == 'pull_request' &&
          github.event.pull_request.head.repo.full_name == github.repository
        run: ./deploy-preview.sh

Fix 7: Debug if Conditions with Verbose Logging

steps:
  - name: Debug context values
    run: |
      echo "github.ref: ${{ github.ref }}"
      echo "github.event_name: ${{ github.event_name }}"
      echo "github.repository: ${{ github.repository }}"
      echo "github.actor: ${{ github.actor }}"
      echo "github.base_ref: ${{ github.base_ref }}"

  - name: Debug entire context (careful with secrets)
    run: echo '${{ toJSON(github) }}'

  - name: Debug needs context
    run: echo '${{ toJSON(needs) }}'

  - name: Conditional with debug
    id: my-condition
    run: |
      # Evaluate condition manually for debugging
      REF="${{ github.ref }}"
      EVENT="${{ github.event_name }}"
      echo "ref=${REF}, event=${EVENT}"
      if [ "$REF" = "refs/heads/main" ] && [ "$EVENT" = "push" ]; then
        echo "should_deploy=true" >> $GITHUB_OUTPUT
      else
        echo "should_deploy=false" >> $GITHUB_OUTPUT
        echo "Condition not met: ref=${REF}, event=${EVENT}"
      fi

  - name: Deploy
    if: steps.my-condition.outputs.should_deploy == 'true'
    run: ./deploy.sh

In Production: Incident Lens

Broken if: conditions show up in two opposite shapes, both with a meaningful blast radius. Shape one: a deploy job has an if: that silently evaluates to false, so production deploys stop running on merge to main. The repo looks green, the team thinks shipping is healthy, and the gap between “merged” and “deployed” grows for days until someone notices traffic is on a stale revision. Shape two: a deploy job has an if: intended to gate on the main branch, but a typo makes it always-true. Now every pull request triggers a production deploy, burning CI minutes, racking up cloud spend, and — worst case — pushing untested code live.

Detection. Watch two metrics. First, the count of successful deploy runs per day against main; a sudden drop to zero while merges continue is a silent-skip incident. Second, the count of deploy runs against non-main refs; any non-zero value is a silent always-run incident. Configure a workflow_run alert that pings on-call when the production deploy workflow has not completed within N hours of a merge to main. Pair with actionlint in CI to catch the typos that cause both shapes before they merge.

Recovery. When if: is broken, edit it on a branch with a known-good comparison and force a deploy via workflow_dispatch to confirm the fixed expression evaluates correctly. Never patch the condition directly in main without verifying — a second silent skip means another release window lost. For an always-run incident, immediately add a guard at the top of the affected job (if: github.ref == 'refs/heads/main' && github.event_name == 'push') and revoke any deploy credentials the workflow used, in case a PR-triggered run already reached production.

Prevention. Lint workflows with actionlint as a required CI check. It catches unknown contexts, type mismatches, and most expression typos at PR time. Wrap any production-affecting if: in workflow_dispatch first and exercise both branches (should-deploy and should-not-deploy) with a manual run. Add a Debug condition step that echoes the resolved values of every context the production gate depends on — when something goes wrong months later, the log already contains the answer.

Still Not Working?

Check expression syntax carefully. GitHub Actions expressions use == (not ===), &&, ||, !. Function calls use startsWith(), endsWith(), contains(), fromJSON(), toJSON():

# String functions
if: startsWith(github.ref, 'refs/tags/')
if: endsWith(github.ref, '/main')
if: contains(github.event.pull_request.labels.*.name, 'ready-to-deploy')

Check for YAML quoting issues. Conditions with && or || need quoting in some YAML contexts:

# May need quoting
if: "github.ref == 'refs/heads/main' && github.event_name == 'push'"

# Or use block scalar
if: |
  github.ref == 'refs/heads/main' &&
  github.event_name == 'push'

Add workflow_dispatch to test manually:

on:
  push:
  workflow_dispatch:  # Allows manual trigger from GitHub UI for testing
    inputs:
      debug:
        type: boolean
        default: false

Check for reusable-workflow context loss. When a job calls a reusable workflow via uses: ./.github/workflows/deploy.yml, the caller’s github context is passed but needs and steps contexts are not. A condition like if: needs.build.result == 'success' inside the reusable workflow always evaluates to false because needs.build does not exist in that scope. Pass the value explicitly via inputs: and compare against inputs.upstream_result.

Check matrix job condition scoping. if: on a matrix job evaluates per matrix combination, but needs.matrix-job.result collapses to a single value: success only if every matrix combination succeeded. If you need per-combination gating in a downstream job, emit per-combination outputs from each matrix run and read them by index in the dependent job.

Use the GitHub UI’s expression evaluator. On a failed run, expand the skipped step and look for the line “Evaluating:→ false.” The UI shows exactly which sub-expression collapsed to a falsy value, including contexts that resolved to empty strings.

For related GitHub Actions issues, see Fix: GitHub Actions Cache Not Working, Fix: GitHub Actions Permission Denied, Fix: GitHub Actions env Var Between Steps, and Fix: GitHub Actions Secret Not Available.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles