Conditional Jobs and Path Filters in GitHub Actions

Most CI pipelines run everything on every push. That is fine for a five-file repo. It stops being fine the moment your build takes twenty minutes and a one-line docs edit triggers the full test matrix. GitHub Actions gives you three levers to run only what a change affects: the if: conditional, trigger-level path filters, and per-job path detection. Each solves a different problem, and mixing them up is how you end up with pull requests stuck forever 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.

yaml.yaml

jobs:
publish:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: ./scripts/publish.sh

A few things worth knowing. Inside if: you do not need the ${{ }} wrapper; GitHub evaluates the whole string as an expression. 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:

yaml.yaml

on:
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- '.github/workflows/**'
paths-ignore:
- 'docs/**'
- '**/*.md'

If a push touches only files matched by paths-ignore (or touches nothing under paths), the workflow does not run. No runner spins up, nothing shows in the checks list.

That last part is exactly the trap. If this workflow defines a job named test and you have made test a required status check on main, a docs-only pull request will never produce a test result. GitHub waits for a check that is never coming, and the PR sits unmergeable. Branch protection wants to see the check pass; a skipped workflow reports nothing, which is not the same as passing.

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.

yaml.yaml

jobs:
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
api: ${{ steps.filter.outputs.api }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
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.

The gate job that fixes required checks #

Now the subtle version of the earlier trap. Say you require the frontend job on main. On an api-only PR, frontend is skipped by its if:. A skipped job reports "skipped," and branch protection treats a required job that did not run as unsatisfied. You are back to the stuck PR.

The fix is a gate job: one job that is always required, depends on the real jobs, and passes as long as none of them failed. Because it uses if: always(), it runs even when its dependencies were skipped, and you evaluate their results explicitly.

yaml.yaml

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 required check instead of frontend and api directly. Skipped dependencies leave needs.*.result as skipped, which the contains check ignores, so the gate goes green. A genuine failure 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):

yaml.yaml

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').

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 required checks never get stuck on a skipped job. 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.