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 filters — filters: 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.
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-skippedends it as the first step. Replace that with CircleCI expression-based job filters —filters: pipeline.parameters.run-driver-tests == trueon 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
requireslists automatically — "if a job is not run due to branch or tag filters, it will not be required by downstream jobs." So theall-jobs-passedfan-in keeps working unmodified, and the packed config stays one static, validated artifact. We already use this filter syntax in the develop workflow (&mainBuildFilterscombines 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-skippedis 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 arequireslist still names is a server-side compilation error, so pruning means transitive YAML surgery on every pipeline), or splitting conditional jobs into separatewhen:-gated workflows (breaks the singleall-jobs-passedfan-in, sincerequirescannot 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-workflowstep 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 byrequires— 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-skippedcommand at.circleci/src/pipeline/@pipeline.yml:1754-1766, first step at all 32 usage sites, guarded byrun-*pipeline parameters computed ingenerate-pipeline-parameters.shand passed at continuation (.circleci/config.yml:179-191). Fan-ins:all-jobs-passed(pull-request.yml:439-505, the single required GitHub status check) andpercy-finalizebothrequires:conditional jobs by name. Target state: each conditional job entry inpull-request.ymlgainsfilters: pipeline.parameters.run-<x>-tests == true(expression-based job filter — bare expression syntax, no<< >>interpolation; see the in-repo precedentfilters: &mainBuildFilters pipeline.git.branch == "develop" or ... or pipeline.parameters.force-persist-artifacts == trueat@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): removingcli-visual-testsfrom the packedpull-requestworkflow whilerequireslists 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-parameterwhen:workflows —requirescannot cross workflows, soall-jobs-passedcannot 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 downstreamrequires, dependents proceed.Fix design. (1) Add
filters: pipeline.parameters.run-<x>-tests == trueto each conditional job entry inpull-request.yml, using the same parameter each job'shalt-if-skippedguard uses today (scrape the guard mapping from the packed YAML to build the list; some jobs passguard: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) Keephalt-if-skippedin 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 likepercy-finalizewhose 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-passedneeds no change.Constraints. (1)
all-jobs-passedmust 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 touchapprove-contributor-pror 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-stylefilters: 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-passedmust 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-finalizecompletes 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, thehalt-if-skippedcommand.Interim mitigation. None needed — the halt pattern is safe and already minimal.