Fix: GitHub Actions if Condition Not Working (Steps and Jobs Being Skipped or Always Running)
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 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. 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 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.
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.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.shIn 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: falseCheck 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:
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.
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 Artifacts Not Working — Upload Fails, Download Empty, or Artifact Not Found
How to fix GitHub Actions artifact issues — upload-artifact path patterns, download-artifact across jobs, retention days, artifact name conflicts, and the v3 to v4 migration.
Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
How to fix GitHub Actions secrets that appear empty or undefined in workflows — secret scope, fork PR restrictions, environment protection rules, secret names, and OIDC alternatives.
Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing
How to fix GitHub Actions matrix strategy issues — matrix expansion, include/exclude patterns, failing fast, matrix variable access, and dependent jobs with matrix outputs.
Fix: GitHub Actions Docker Build and Push Failing
How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.