# Conditional Jobs and Path Filters in GitHub Actions

URL: https://www.devopsness.com/blog/github-actions-conditional-jobs
Author: Kiril Urbonas
Published: 2026-07-24
Updated: 2026-10-03
Categories: DevOps
Tags: CI/CD, GitHub Actions, Monorepo, DevOps, Automation

Summary: Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.

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

```yaml
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`:

```yaml
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.

```yaml
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.

```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 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):

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

## 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](/blog/github-actions-recipes) pillar, and if runtime is your real problem, [how to speed up a slow CI pipeline](/blog/speed-up-ci-pipeline) goes deeper on caching and parallelism.

## Sources

- [GitHub Docs: Using conditions to control job execution](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-jobs-with-conditions)
- [GitHub Docs: Workflow syntax, path filters, and skipped-workflow checks](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Docs: Expressions and status check functions](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions)
- [dorny/paths-filter releases](https://github.com/dorny/paths-filter/releases)

