Conditional Jobs and Path Filters in GitHub Actions
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
Key takeaways
- Add an if: expression to a job or step, for example if: github.ref == 'refs/heads/main', and GitHub Actions skips it when the expression is false.
- Use trigger-level paths filters only for workflows that are not required checks, because a workflow skipped by a path filter leaves required checks pending and blocks the merge.
On this page
To run a GitHub Actions job conditionally, add an if: expression to the job or step, such as if: github.ref == 'refs/heads/main'; when it evaluates false, the job is skipped. For change-based CI, filter with paths: on the trigger or dorny/paths-filter inside the workflow. Mixing those up is how pull requests get stuck on a check that never runs.
The if: conditional
Every job and every step accepts an if: key. It takes an expression, and when the expression is false the job or step is skipped. The expression has access to contexts like github, env, and needs, so you can branch on almost anything about the event that triggered the run.
The two you reach for most are github.event_name (was this a push, a pull_request, a schedule?) and github.ref (which branch or tag?). A common shape: build on every event, but only publish when someone pushes to the default branch.
jobs:
publish:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v7
- run: ./scripts/publish.sh
Inside if: you can omit the ${{ }} wrapper, because GitHub evaluates the whole string as an expression. The exception: an expression that starts with ! must be wrapped (if: ${{ !cancelled() }}), since ! is reserved YAML notation. You get helper functions too. contains(github.event.head_commit.message, '[skip-e2e]') lets you honor a commit-message convention. startsWith(github.ref, 'refs/tags/') gates a job to tag pushes, which is how you wire release-only work.
Step-level if: also unlocks the status functions: success(), failure(), and always(). By default a step only runs if every previous step succeeded, which is an implicit if: success(). Add if: failure() to a step to run it only when something upstream broke (post a Slack alert, upload a debug artifact), or if: always() to run it no matter what (tear down infra, publish test reports even on failure).
Path filters at the trigger level
The cheapest filter is to not start the workflow at all. Trigger-level path filters live under on.push and on.pull_request:
on:
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- '.github/workflows/**'
- '!src/**/*.md'
If a push touches nothing that matches paths, the workflow does not run. No runner spins up. You cannot combine paths and paths-ignore on the same event, so exclusions go inside paths as ! patterns, after at least one positive pattern.
That is exactly the trap. If this workflow defines a job named test and test is a required status check on main, a docs-only pull request never produces a test result. GitHub's docs are explicit: checks from a workflow skipped by path, branch, or commit-message filtering stay "Pending", and the PR is blocked from merging.
So the rule of thumb: trigger-level path filters are great for workflows whose jobs are not required checks. For anything gating a merge, filter inside the workflow instead, so the workflow always runs and always reports.
Per-job path detection with dorny/paths-filter
In a monorepo you want finer control than "run the workflow or don't." You want the frontend job to run when apps/web/** changed and the api job to run when services/api/** changed, all inside one workflow that always starts. The dorny/paths-filter action does the diffing and exposes boolean outputs you consume in downstream if: conditions.
jobs:
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
api: ${{ steps.filter.outputs.api }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
frontend:
- 'apps/web/**'
api:
- 'services/api/**'
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "build and test the web app"
api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "build and test the api"
The changes job always runs and always reports, so it can safely be required. The frontend and api jobs only do real work when their paths moved. Note the string comparison: paths-filter outputs the string 'true', not a boolean, so compare against 'true' quoted. As of October 2026 the current majors are actions/checkout@v7 and dorny/paths-filter@v4 (latest v4.0.3), which moved the action to the Node 24 runtime.
The gate job that makes required checks honest
Job-level skips behave differently from workflow-level skips. A job skipped by its if: reports its status as "Success", so it never blocks a merge, even when required. That fixes the stuck PR, and opens the opposite hole: a required job that was skipped is indistinguishable from one that passed, and requiring every job by name breaks each time you rename or add one.
The fix is a gate job: one always-required job that depends on the real jobs and evaluates their results explicitly. With if: always() it runs whatever happened upstream.
ci-gate:
needs: [frontend, api]
if: always()
runs-on: ubuntu-latest
steps:
- name: Fail if any dependency failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
Make ci-gate the only required check. Skipped dependencies leave needs.*.result as skipped, which the contains check ignores, so the gate goes green. A genuine failure or cancellation flips it red. One stable check to require, no matter which paths a PR touches.
Gating deploys on branch and environment
Deploy jobs deserve the strictest conditions. Combine the ref check with a GitHub Environment so you also get environment protection rules (required reviewers, wait timers, environment secrets):
deploy-prod:
needs: ci-gate
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
The if: keeps the job off pull requests and feature branches entirely, and the environment: production layer adds a human gate on top. Tag-driven releases follow the same pattern with startsWith(github.ref, 'refs/tags/v').
Questions people ask
How do I run a GitHub Actions job only on the main branch?
Put if: github.ref == 'refs/heads/main' on the job, and add && github.event_name == 'push' if pull requests targeting main should not run it. On a pull_request event github.ref is refs/pull/<number>/merge, so the comparison is false and the job is skipped. For deploys, add environment: production so reviewers and environment secrets apply too.
Does a skipped job block a pull request?
No. A job skipped because its if: was false reports "Success" and does not block merging, even if it is a required check. A whole workflow that never started because of a paths, branches, or commit-message filter is different: its checks stay "Pending" and a PR requiring them cannot merge. That difference is why we filter inside the workflow for required checks.
How do I run a step only when an earlier step failed?
Use if: failure() on the step. Steps carry an implicit if: success(), so they stop running after the first failure; failure() flips that, which suits alert, log-upload, and debug steps. Use if: always() for cleanup that must run even when the job is cancelled, and if: ${{ !cancelled() }} to run after failures but not after a cancel.
The call we'd make
Use trigger-level paths only for workflows that are not required checks; they are the cheapest win for docs and tooling changes. For anything gating a merge, keep the workflow always running and do the filtering inside with dorny/paths-filter, then put a single if: always() gate job in front of branch protection so there is exactly one required check and it only goes green when nothing upstream failed. Reserve github.ref and environment conditions for deploys. That combination gives you fast PRs without the "waiting for a check that never runs" support tickets.
For more patterns in this vein, see our GitHub Actions recipes pillar, and if runtime is your real problem, how to speed up a slow CI pipeline goes deeper on caching and parallelism.
Sources
Using Secrets in GitHub Actions Safely
A practical guide to scoping GitHub Actions secrets correctly, avoiding leaks in logs, blocking fork-PR theft, and preferring OIDC over stored keys.
Best AI Agent Frameworks in 2026 — Compared
A practitioner's comparison of the leading LLM agent frameworks, matching LangGraph, CrewAI, AutoGen and more to real use cases.
More from DevOps
Explore more articles in this category
GitHub Copilot CLI Sandboxing Is GA: What Actually Changed
GitHub made local sandboxing generally available for Copilot CLI on October 7, so the default now restricts the agent instead of trusting it.
The Tensorlake npm Compromise Targeted Your AI Tool Configs
A compromised npm release went after cloud credentials as expected, and local AI coding-assistant config files, which most teams never treated as secrets.
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
You might have missed
Evergreen posts worth revisiting.