Fix: GitHub Actions if Condition Not Working (Steps and Jobs Being Skipped or Always Running)
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 branchOr 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-latestOr 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.shWhy This Happens
GitHub Actions if: expressions use a specific syntax that differs from typical programming languages. Common mistakes:
- Forgetting the
${{ }}expression syntax —if:evaluates the expression directly without wrapping it. Unlikerun:orenv:, theif:field interprets its value as an expression automatically. Sometimes wrapping in${{ }}is needed, sometimes it is not. - String comparison vs boolean —
github.event_name == 'push'is correct, butgithub.event.pull_request.draft == falsemay not work as expected becausefalseis a boolean in YAML butgithub.event.pull_request.draftis a JSON boolean. - Incorrect context access —
needs.job-name.outputs.keyrequires the exact job ID, not the display name. - Status check functions —
success(),failure(),always()only work at the step level when used withif: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_OUTPUTfor it to be available in downstream jobs.
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.shFix 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.shStep 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.shJob 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) == trueFix 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.shFix 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.shFix 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.shStill 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: falseFor related GitHub Actions issues, see Fix: GitHub Actions Cache Not Working and Fix: GitHub Actions Permission Denied.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GitHub Actions Environment Variables Not Available Between Steps
How to fix GitHub Actions env vars and outputs not persisting between steps — GITHUB_ENV, GITHUB_OUTPUT, job outputs, and why echo >> $GITHUB_ENV is required.
Fix: GitHub Actions Cache Not Working (Cache Miss on Every Run)
How to fix GitHub Actions cache not restoring — why actions/cache always misses, how to construct correct cache keys, debug cache hits and misses, and optimize caching for npm, pip, and Gradle.
Fix: AWS ECR Authentication Failed (docker login and push Errors)
How to fix AWS ECR authentication errors — no basic auth credentials, token expired, permission denied on push, and how to authenticate correctly from CI/CD pipelines and local development.
Fix: GitHub Actions permission denied (EACCES, 403, or Permission to X denied)
How to fix GitHub Actions permission denied errors caused by GITHUB_TOKEN permissions, checkout issues, artifact access, npm/pip cache, and Docker socket access.