In a GitHub Actions monorepo, how would you design paths filters without blocking merges?
answer
- A run that never happens reports nothing
- Pending forever is not the same as failing
- A skipped job is not a skipped workflow
- Two files that must agree, or one gate that cannot drift
- Keep the required name stable
basics
~20 sA workflow skipped by a paths filter reports no status, so a required check stays pending and the pull request cannot merge. Either pair it with a same-named stub workflow on the inverse filter, or run always and skip inside with job conditions.
solid answer
~50 sPath filters are the cheapest way to stop a monorepo running every suite on every commit, but they interact badly with required status checks: a run that never starts reports nothing, so the required check sits in **pending** forever and the pull request is blocked. There are two workable designs. The first keeps `paths` on the trigger and adds a companion workflow with the **same workflow and job names** triggered on the inverse `paths-ignore`, whose only step exits successfully — the check reports green without doing work. The second removes path filtering from the trigger entirely: the workflow always runs, a cheap change-detection job computes which areas changed, and the expensive jobs carry an `if:` on its outputs. That works because a job skipped by a conditional is reported as successful for required-check purposes, unlike a workflow that never ran. I prefer the second at scale, fronted by a single aggregate gate job.
code
yaml · 24 lines# ci-api.yml — the real work, path-filtered
on:
pull_request:
paths:
- 'services/api/**'
- '.github/workflows/ci-api.yml'
jobs:
api-tests: # <- the required check name
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./gradlew :api:test
# ci-api-skip.yml — same workflow name, same job name, inverse filter
on:
pull_request:
paths-ignore:
- 'services/api/**'
- '.github/workflows/ci-api.yml'
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- run: echo "no api changes"go deeper
Know that paths and paths-ignore limit which file changes trigger a GitHub Actions workflow, and that the two forms cannot be used together on the same event.
Explain the asymmetry precisely: a workflow filtered out by paths creates no run and therefore no check, while a job skipped by an if: reports success — and why that difference decides whether a pull request can merge.
Diagnose the blocked pull request whose required check sits pending forever, and implement a fix: either the same-named stub workflow on the inverse filter, or always-run with conditional jobs behind a gate.
Own the repository-wide policy — which checks are required, whether merge policy points at one stable gate name or many job names, and the tradeoff between saved runner minutes and the incident cost of a merge gate that silently never reports.
## The tension A monorepo wants two things that pull against each other. Cost and latency say: only run the API suite when the API changed. Merge policy says: this check must pass before anything merges. GitHub Actions resolves them only if you understand one asymmetry. **A workflow that never runs reports nothing.** If `on: pull_request: paths: ['services/api/**']` excludes the diff, GitHub creates no run, so no check named after that workflow's jobs ever appears. A required check that never reports stays *pending*, and the pull request is blocked indefinitely — a merge failure caused by a *saving* measure. **A job skipped by a conditional reports success.** If the workflow runs and a job's `if:` evaluates false, the job is skipped, and skipped jobs are treated as successful for required-status-check purposes. The check appears, resolves, and merging proceeds. Every viable design is built on that distinction. ## Design A: the stub companion workflow Keep the path filter on the real workflow and add a second workflow file with the **same workflow name and the same job names**, triggered on the complementary `paths-ignore`, doing nothing: ``` # ci-api.yml on: pull_request: paths: ['services/api/**'] # ci-api-skip.yml on: pull_request: paths-ignore: ['services/api/**'] ``` Exactly one of the two runs for any diff, and either way a check with the required name reports success or the real result. Strengths: the expensive workflow genuinely never starts, so no runner minutes at all; the mechanism is explicit and easy to explain. Weaknesses: the two files must be kept in lockstep — every job-name change has to be mirrored, and a drifted stub silently reintroduces the blocked-merge failure. With a dozen components you are maintaining two dozen files and the filters must partition the diff space exactly. ## Design B: always run, decide inside Drop `paths` from the trigger. One cheap job computes what changed and exposes it as outputs; expensive jobs gate on those outputs: ``` jobs: changes: outputs: api: ${{ steps.filter.outputs.api }} api-tests: needs: changes if: needs.changes.outputs.api == 'true' ``` Change detection can be a `git diff` against the base ref in a `run:` step, or a dedicated change-filter action (`dorny/paths-filter` is the widely used one) — pinned by SHA like any third-party action. The cost is one short job per pull request; the benefit is that every check reports, filter logic lives in one place, and it can express things trigger filters cannot: "run the integration suite if either the API *or* the shared library changed", "always run everything for a release branch", "run everything when the workflow file itself changed". At scale, front the whole thing with one **aggregate gate job** that is the only required check: ``` ci-gate: needs: [api-tests, web-tests, lint] if: always() steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 ``` `if: always()` makes it run even when a dependency was skipped or failed, and `needs.*.result` lets it fail when any real job failed while tolerating skips. Now the merge policy references exactly one stable check name, and you can add, split or rename underlying jobs without touching branch policy — which in a large org is worth more than the runner minutes, because renaming a required check is an administrative change, not a code change. ## Choosing between them A handful of components with genuinely expensive suites and stable job names: Design A is fine and gives the best cost profile. A repository with many components, frequent job churn, or cross-component dependencies: Design B, because correctness of the merge gate matters more than the seconds spent on a detection job, and because "why can't I merge" incidents are expensive in engineer time. A hybrid is common and reasonable: path-filter the workflows nobody requires (nightly perf, docs preview, artifact publishing), and use always-run-plus-gate for the ones merge policy depends on. The rule of thumb worth stating: **if a check is required, do not filter it at the trigger.** ## Details that bite Path filters accept the same glob vocabulary as branch filters, `*` not crossing `/` while `**` does, and `paths` cannot be paired with `paths-ignore` on the same event — use a `!` negation inside the positive list. `paths` is available on `push` and `pull_request` only; `schedule` and `workflow_dispatch` have no diff to filter, so those workflows always run in full. On `push`, the filter examines the files changed in the pushed commits; on `pull_request`, the pull request's whole diff — so a stack of commits can match on a pull request while an individual push did not. Finally: whichever design you choose, the workflow files themselves should be inside the filter set. A change to `.github/workflows/**` that alters what CI does must trigger CI, or you can merge a workflow edit that no run ever exercised.
- Why does a skipped job satisfy a required check while a path-filtered workflow does not?A conditional skip happens *inside* a run that exists, so GitHub has a check to report on and marks it successful. A path-filtered workflow produces no run at all, so no check with that name is ever created and the required check stays pending — there is nothing to resolve it. Existence of the run, not the work, is what the merge gate observes.
- What makes an aggregate gate job worth the extra indirection in a large monorepo?It decouples merge policy from job topology. Branch rules reference one stable check name, so teams can add, split or rename underlying jobs without an administrative change to protection settings — and without the window where a renamed required check silently never reports. It also gives one place to encode "skipped is fine, failed and cancelled are not", via `if: always()` and `needs.*.result`.
- What is the maintenance hazard of the stub-workflow approach?The stub must mirror the real workflow's names exactly and its `paths-ignore` must be the precise complement of the real `paths`. Rename a job, add a component directory, or let the two filters overlap or leave a gap, and you get either a duplicate check or the original blocked-merge failure — with no error message, because nothing is wrong from the workflow file's point of view.
- Should .github/workflows itself be inside the path filter set?Yes. If a change to workflow files does not trigger the workflows it changes, you can merge an edit that no run ever exercised — including one that weakens the checks. Include `.github/workflows/**` in the filter or in the change-detection rules, and treat a change there as "run everything" rather than trying to map workflow edits to components.
saying these in an interview costs you the question
- Assumes a filtered-out workflow reports success
- Confuses a skipped job with a skipped workflow
- Path-filters the workflows that merge policy requires
- Lets the stub workflow's job names drift from the real one
- Excludes .github/workflows from the filter set