Required checks for path-filtered workflows Jump to heading

Path filters make CI efficient: the backend tests run only when backend files change, the docs build only when docs change. Required checks make CI enforceable: a pull request cannot merge until named checks pass. Combine the two naively and they collide. A workflow skipped because no matching path changed never starts, so its check never reports, and the forge waits for it indefinitely — the pull request shows “Expected — Waiting for status to be reported” and cannot merge. Teams then either remove the check from the required list, losing enforcement, or drop the path filter, losing efficiency. Neither is necessary. This page shows two patterns that keep both, within merge queues and required checks.

When to use this approach Jump to heading

  • A required check sometimes shows “Expected — Waiting for status to be reported” forever.
  • You use paths or paths-ignore filters on workflows that produce required checks.
  • In a monorepo, each project has its own workflow and its own required check.
  • Your path filters are already in place, as in optimizing CI triggers for path-specific changes.

Step 1 — See why the check never reports Jump to heading

A required check is a named status the forge expects. Workflow-level path filters decide whether the workflow runs at all; a workflow that does not run produces no jobs and no statuses. Job-level conditions are different: a job skipped by if: still reports, as “skipped”, which forges treat as passing.

Workflow-level filters against job-level conditionsA workflow-level path filter stops the whole workflow from starting, so no check is ever reported and a required check waits forever. A job-level condition starts the workflow and skips the job, which reports a skipped status that branch protection accepts as passing.on.pull_request.pathsjob if: + changed-files stepworkflow startsnoyescheck reportedneverskipped or resultrequired check satisfiedwaits foreveryescost when skippedzeroa few secondsmoving the filter from the trigger into the job is the whole fix

Step 2 — Pattern A: one always-running gate job Jump to heading

Keep one workflow that always runs. A first job decides which areas changed; area jobs run conditionally; a final gate job depends on all of them and is the only required check. The gate passes if every needed job passed or was skipped.

on: pull_request
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      backend: ${{ steps.f.outputs.backend }}
      frontend: ${{ steps.f.outputs.frontend }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: f
        run: |
          changed=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD")
          echo "backend=$(echo "$changed" | grep -qE '^(services|libs)/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
          echo "frontend=$(echo "$changed" | grep -qE '^web/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
  backend:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps: [ { uses: actions/checkout@v4 }, { run: make -C services test } ]
  frontend:
    needs: changes
    if: needs.changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps: [ { uses: actions/checkout@v4 }, { run: npm --prefix web test } ]
  ci-gate:
    needs: [backend, frontend]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo '${{ toJSON(needs) }}' | jq -e 'all(.[]; .result == "success" or .result == "skipped")'

Mark only ci-gate as required. Adding a new area job means adding it to needs, not editing branch protection.

The always-running gateA changes job works out which areas a pull request touched. Area jobs run only for touched areas and report skipped otherwise. The gate job always runs after them and passes only if every area job succeeded or was skipped, so it is the single required check.changesdiff vs basebackendif touchedfrontendif touchedci-gateif: always()Requiredonly ci-gatethe gate must check results itself — if: always() alone would pass on failures

The jq -e line is essential: if: always() makes the gate run even when a dependency failed, so it must fail explicitly when any dependency’s result is failure or cancelled.

Step 3 — Pattern B: report success for skipped workflows Jump to heading

When separate workflows must stay separate — owned by different teams, triggered differently — add a lightweight companion workflow with the inverse path filter that reports the same job name as successful. GitHub’s documentation describes this pattern for exactly this problem.

# .github/workflows/backend-skip.yml — runs when backend paths did NOT change
name: backend
on:
  pull_request:
    paths-ignore: ["services/**", "libs/**"]
jobs:
  test:                      # same workflow name and job name as the real check
    runs-on: ubuntu-latest
    steps: [ { run: echo "No backend changes — skipping" } ]

The check name must match the real one exactly (backend / test). This pattern is easy to get subtly wrong — a pull request touching both filtered and unfiltered paths runs both workflows — so prefer Pattern A where possible.

Step 4 — Account for merge queues Jump to heading

In a merge queue, checks run on merge_group events, which have no pull request diff to filter on. Either run everything in the queue — simplest and safest — or compute changed paths against the queue’s base.

on:
  pull_request:
  merge_group:
jobs:
  changes:
    steps:
      - id: f
        run: |
          base=${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{0}', github.base_ref) }}
          changed=$(git diff --name-only "$base"...HEAD)
          # … same outputs as before

Forgetting merge_group in the trigger is the queue’s version of the stuck-check problem: the queue waits for a check that never starts. See debugging a stuck merge queue.

Step 5 — Verify with a pull request that touches nothing filtered Jump to heading

Open a pull request that changes only an unfiltered file — a README — and confirm the required check reports and the pull request is mergeable.

gh pr checks "$PR" --required
# ci-gate   pass   …
Which pattern to useIf all path-filtered jobs can live in one workflow, use an always-running gate job and require only the gate. If workflows must stay separate, add companion workflows that report success on the inverse paths. In a merge queue, include merge_group triggers either way.Can the filtered jobs share one workflow?yesGate jobrequire only ci-gateno, separate ownersCompanion workflowsinverse paths, same namemerge queue in useAdd merge_groupto every patternthe gate job is simpler to keep correct as areas are added

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not just make the path-filtered checks non-required? Jump to heading

Then a failing backend test does not block merging, which defeats the purpose of having it. Required checks plus correct reporting give both efficiency and enforcement.

Does a skipped job really count as passing? Jump to heading

Branch protection and rulesets treat a skipped check as successful. That is why the gate job must look at its dependencies’ results rather than relying on its own status alone.

Does GitLab have the same problem? Jump to heading

Yes, in a different form: a pipeline with no jobs may not be created, leaving “pipeline must succeed” unsatisfied. An always-running job fixes it, as described in mapping GitLab CI rules to branch and path changes.