Skip to content

CI: use expression-based job filters so path-filtered PR jobs are never scheduled #34782

Description

@cacieprins

What would you like?

Path-filtered PR jobs are currently disabled, not skipped: every filtered job is still scheduled, its container spins up, and halt-if-skipped ends it as the first step. Replace that with CircleCI expression-based job filtersfilters: pipeline.parameters.run-driver-tests == true on the job entry — so a filtered job is never scheduled at all.

This is the mechanism CircleCI provides for exactly this: a filtered-out job is not scheduled (zero credits), and per CircleCI's documented behavior it is dropped from downstream requires lists automatically — "if a job is not run due to branch or tag filters, it will not be required by downstream jobs." So the all-jobs-passed fan-in keeps working unmodified, and the packed config stays one static, validated artifact. We already use this filter syntax in the develop workflow (&mainBuildFilters combines branch matches with a pipeline-parameter comparison), so there is in-repo precedent for both halves.

Why is this needed?

The halt pattern costs an estimated ~0.15M credits/month, and it is already optimal as a halt pattern: halt-if-skipped is the first step in all 32 conditional jobs, so the waste per skipped job is bare container spin-up. Eliminating it requires the job never to be scheduled. The two other routes there are both bad: generating a pruned config per-pipeline (verified: omitting a job that a requires list still names is a server-side compilation error, so pruning means transitive YAML surgery on every pipeline), or splitting conditional jobs into separate when:-gated workflows (breaks the single all-jobs-passed fan-in, since requires cannot span workflows). Job-level expression filters get the same result as pruning with none of the surgery.

The develop-filtering work will roughly double the population of skipped jobs, so the saving grows with it.

Other

This is action 4 of the config-level cost work in the internal CI cost report (2026-08-31, "The CI Wedge").

One semantic difference from the halt pattern, accepted with eyes open: a halted job appears in the UI as "considered and skipped"; a filter-excluded job does not appear in the workflow at all. The audit trail for "why didn't X run" moves to the launch-primary-workflow step output, which already prints the generated parameters for every pipeline.

One behavior to verify before relying on it (it is documented for branch/tag filters; expression filters share the filters: key but the interaction deserves a live test): that a job excluded by an expression filter is treated as absent by requires — downstream jobs proceed — rather than blocking them. The rollout below tests this on a real pipeline before any halt logic is removed.

Attn Agents:

Mechanism. Today: halt-if-skipped command at .circleci/src/pipeline/@pipeline.yml:1754-1766, first step at all 32 usage sites, guarded by run-* pipeline parameters computed in generate-pipeline-parameters.sh and passed at continuation (.circleci/config.yml:179-191). Fan-ins: all-jobs-passed (pull-request.yml:439-505, the single required GitHub status check) and percy-finalize both requires: conditional jobs by name. Target state: each conditional job entry in pull-request.yml gains filters: pipeline.parameters.run-<x>-tests == true (expression-based job filter — bare expression syntax, no << >> interpolation; see the in-repo precedent filters: &mainBuildFilters pipeline.git.branch == "develop" or ... or pipeline.parameters.force-persist-artifacts == true at @main.yml:365).

Evidence. ~0.15M credits/mo (per-job spin-up × halted-job counts, 30-day Insights window ending 2026-08-31); flat overhead. Negative results that rule out rival designs: (a) graph pruning — verified 2026-09-03 via circleci config validate (server-side compilation): removing cli-visual-tests from the packed pull-request workflow while requires lists still name it fails compilation (Job 'percy-finalize' requires 'cli-visual-tests', which is the name of 0 other jobs in workflow 'pull-request'); two fan-ins reference that one job, so pruning means transitive rewriting of a per-pipeline generated config. (b) per-parameter when: workflows — requires cannot cross workflows, so all-jobs-passed cannot fan in. (c) cheaper halting — none left; halt is already first-step everywhere. Filters semantics: CircleCI support article "Required job did not run when using branch filters": "When using filters, if a job is not run due to branch or tag filters, it will not be required by downstream jobs" — filtered jobs are dropped from downstream requires, dependents proceed.

Fix design. (1) Add filters: pipeline.parameters.run-<x>-tests == true to each conditional job entry in pull-request.yml, using the same parameter each job's halt-if-skipped guard uses today (scrape the guard mapping from the packed YAML to build the list; some jobs pass guard: as a job parameter — e.g. driver/system jobs at @pipeline.yml:784-791 — the filter goes on the workflow job entry, which is where the guard value is wired in). (2) Keep halt-if-skipped in place during rollout as belt-and-braces; the filter prevents scheduling, the halt catches any filter that fails to exclude. (3) Remove the halt steps only after the rollout verification passes. (4) Jobs like percy-finalize whose entire upstream set can be filtered: confirm it behaves sensibly when all its required jobs are dropped (it runs; today it already handles halted upstreams that uploaded nothing — same input state). all-jobs-passed needs no change.

Constraints. (1) all-jobs-passed must run and report on every PR, including one where every conditional job is filtered — its requires list also contains always-run jobs (linux-lint, check-ts, etc., pull-request.yml:443-449), so it always has live upstreams. (2) Do not touch approve-contributor-pr or the external-fork gating (pull-request.yml:38-57). (3) run-all-jobs=true, develop/release branches, and scheduled pipelines emit all-true parameters, which makes every filter expression true — no special-casing needed, but verify. (4) Expression filters coexist with the old map-style filters: branches: on some job entries — a job can't have both forms on one entry; the conditional PR jobs use branch filters only in the internal/external-PR build split (pull-request.yml:23-57), which are not conditional jobs, so no collisions expected — but check each entry when adding.

Fallback. Keep the halt pattern (status quo): already minimal, ~0.15M/mo accepted as the price of skip visibility. Graph pruning at continuation time remains possible but is strictly worse than filters unless the live test in verification falsifies the documented requires behavior for expression filters.

Verification plan. Staged, on a test branch with a scratch parameter before touching real jobs: (a) push a PR where one filtered job's parameter is false — the job must not appear in the workflow, must bill zero credits, and all-jobs-passed must run and pass with it absent; (b) same PR with the parameter true — job runs, fan-in waits for it; (c) a GUI-filtered PR — percy-finalize completes sanely with all Percy upstreams absent; (d) run-all-jobs=true — full graph; (e) external-fork PR — approval gate intact. Then apply to all 32 jobs, keep halts one release cycle, compare halted-job spin-up credits in Insights (expect → 0), then delete the halt steps and, once no job uses it, the halt-if-skipped command.

Interim mitigation. None needed — the halt pattern is safe and already minimal.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CIGeneral issues involving running in a CI providertype: choreWork is required w/ no deliverable to end user

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions