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
pathsorpaths-ignorefilters 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.
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 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 … 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.
Related Jump to heading
- Merge Queues & Required Checks — the parent topic.
- Choosing Required Status Checks That Actually Gate — which checks deserve to be required.
- Detecting Affected Projects from git diff — computing the changes job’s outputs in a monorepo.
- Setting Up a GitHub Merge Queue — where merge_group events come from.