GitHub·7 min read·

Conditional Execution and Path Filtering in GitHub Actions

Use GitHub Actions if conditions and path filters to skip work that changes nothing, while keeping required checks honest, correct and your pipelines fast.

NB

Netbay Developer Relations

Netbay Engineering

On this page

The fastest pipeline is the one that does not run. GitHub Actions gives you two orthogonal ways to prune work: path filters at the trigger level, which decide whether a workflow runs at all, and if conditions, which decide whether an individual job or step runs. Done well, they keep CI focused and fast without hiding failures. Done badly, they silently skip things that mattered. This post shows the mechanics and the traps of both.

Path filtering at the trigger level

At the top of a workflow, on: push and on: pull_request accept paths and paths-ignore filters. A paths filter means the workflow runs only if the pushed or merged changes touch the listed paths. This is exactly right for "only run docs lint when docs change" or "only run backend tests when backend code changes."

There is a critical caveat: a push that changes only paths outside the filter, or a PR whose diff touches none of the filtered paths, will not trigger the workflow at all. If you rely on that workflow to run required status checks, GitHub will simply not run it, and you may find merges blocked on a check that never started. Path filters never fire for a PR opened with no matching changes. Plan accordingly, usually by acknowledging the risk in docs or by not path-filtering the workflow that enforces required checks.

yaml
name: Backend CI
on:
  push:
    paths: ['backend/**', '.github/workflows/**']
  pull_request:
    paths: ['backend/**']

jobs:
  test:
    runs-on: ubuntu-22.04
    steps:
      - run: echo backend tests

if conditions on steps and jobs

Within a workflow, the if keyword controls whether a job or step executes. The condition is a GitHub expression that must evaluate to a truthy value. If it is falsey, the job or step is skipped and shows as skipped in the run. if can live on a job (controlling the whole job) or on an individual step (skipping just that step). This is the workhorse of conditional execution.

Common conditions include comparing branches, checking event types, checking whether a previous step failed with failure(), or acting on the outcome of an earlier job with always() and success().

yaml
jobs:
  test:
    runs-on: ubuntu-22.04
    steps:
      - run: npm test
  deploy:
    runs-on: ubuntu-22.04
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - run: echo deploying to production
  notify:
    runs-on: ubuntu-22.04
    needs: [test, deploy]
    if: always() && (needs.test.result == 'failure' || needs.deploy.result == 'failure')
    steps:
      - run: echo "a job failed, page someone"

The status functions: success, failure, always

By default a job only runs if all its needs succeeded, and a step only runs if the previous step in the same job succeeded. Verbatim, the implicit condition is success(). To deviate, use the special functions: failure() runs the step only when a previous step or needed job failed, and always() runs regardless of the outcome. They are often combined with the needs context, as the notify job shows, to react to a specific job's result rather than the run overall.

Never assume always() is a free pass — it still must pass its own if expression; it just does not force success. And note that a step with failure() still requires the overall status check logic, so combine carefully: use success() || failure() when you want a job to run no matter what, then branch inside it with expressions.

Comparing branches and refs

Branch conditions are where beginners get bitten by ref format. In a push, github.ref is the full ref like refs/heads/main. In a pull request, github.ref points to a temporary merge branch, so compare against github.base_ref instead, or use github.event_name and github.head_ref. A common correct pattern is:

yaml
- if: github.event_name == 'pull_request' && github.base_ref == 'main'
  run: echo base is main

Realistic combined example

Here is a workflow that uses path filtering plus if to skip most work, keep required checks honest, and only deploy on main:

yaml
name: Web App
on:
  push:
    paths: ['app/**', 'tests/**', '.github/workflows/**']
  pull_request:
    paths: ['app/**', 'tests/**']

jobs:
  lint-and-test:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/lint.sh
      - run: ./scripts/test.sh
  deploy:
    runs-on: ubuntu-22.04
    needs: lint-and-test
    if: github.ref == 'refs/heads/main'
    steps:
      - run: ./scripts/deploy.sh
event push/PR paths filter no match - skip workflow ran if condition run step/job workflow skip (danger for required checks) if: github.ref , event_name, failure() set + filters status functions fast when it should be, honest when it matters

Takeaway

Path filters prune whole workflows; if conditions prune jobs and steps. Use paths to skip irrelevant work, but never put a required status check behind a path filter that might not fire. Use if with refs, event names, and the status functions to keep every remaining run meaningful. When a conditional deploy finally has the green light, deploy it to a fresh Ubuntu VPS on Netbay in under 60 seconds — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance