Skip empty pipelines when include:rules filter out all includes

What does this MR do and why?

Fixes a spurious failed pipeline (red X on the MR) that appears when include:rules: filter out all includes, leaving a config with zero visible jobs.

This is an alternative approach to !246380 (closed). Both fix the same issue (#591780); this one keys the decision on include filtering rather than deferring the error to workflow:rules.

Root cause

When every include: of a file is dropped by its include:rules: and no inline jobs exist, the config resolves to 0 visible jobs. Config::Process reports "jobs config should contain at least one visible job" as a config_error — a persistable failure reason — so a failed pipeline is saved and becomes the MR's head_pipeline (red X). But the emptiness is a filtering outcome, not a configuration mistake: the includes are conditionally applied by design and simply didn't match this pipeline.

Fix

  1. External::Context: Add a shared mutable filter_state holder, copied by reference through #mutate so state recorded while processing a nested include is visible on the top-level context (the same mechanism expandset uses for cross-nesting visibility). It is protected; callers use mark_includes_filtered_by_rules! / includes_filtered_by_rules?.
  2. External::Mapper::Filter: When a file's includes are all dropped by their include:rules: (kept.empty? && locations.any?), call context.mark_includes_filtered_by_rules!.
  3. Config / YamlProcessor::Result: Expose includes_filtered_by_rules?, and only_no_visible_jobs_error? so we reclassify only when emptiness is the sole error.
  4. Config::Process: When the only error is "no visible jobs" and includes were filtered by rules, report the non-persistable filtered_by_rules reason instead of config_error. No failed pipeline is persisted.

On matching the "no visible jobs" error

only_no_visible_jobs_error? must identify one specific validation error, but CI config errors reach Result as flattened, runtime-composed strings (no typed identity survives). To avoid brittle coupling, the error message fragment lives in a single constant Jobs::NO_VISIBLE_JOBS_MESSAGE, used both when the error is added and when it is matched (via end_with?), so a reword updates both sides. An entry-level spec guards that the composed message still ends with that fragment, so any change to the composition fails loudly instead of silently reverting the fix.

Scope of the reclassification

The reclassification is deliberately narrow — it fires only when:

  • the sole config error is "no visible jobs", and
  • a file had include: entries that were entirely removed by include:rules:.

If some includes survive (even if the surviving ones contribute only hidden jobs), or the config genuinely defines no jobs and no includes, the original config_error is preserved. This avoids masking real "you defined no jobs" mistakes, and also leaves unrelated errors (e.g. the nested empty-include syntax error in #607152 (closed)) untouched.

Behaviour matrix

Scenario Before After
All includes of a file filtered by rules → 0 jobs persisted config_error (red X) not persisted (filtered_by_rules)
Some includes survive, but still 0 visible jobs persisted config_error unchanged
Config genuinely defines no jobs and no includes persisted config_error unchanged
Syntax / other config errors persisted config_error unchanged
Valid config normal unchanged

Nested includes are covered: if a nested file's includes are all filtered out, the shared filter_state surfaces the flag at the top level.

How to reproduce

  1. Project .gitlab-ci.yml:

    workflow:
      rules:
        - if: $CI_PIPELINE_SOURCE == 'push' && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH
          when: never
        - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
    
    include:
      - component: $CI_SERVER_FQDN/group/component/template@main
        rules:
          - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
  2. Push to a non-default branch

  3. Before: push pipeline created with 0 jobs, config_error, persisted as failed (red X)

  4. After: no pipeline persisted

Reproduced on GitLab.com: https://gitlab.com/oyakovenko-group/broken-pipeline-591780/-/pipelines/2685242385

References

MR acceptance checklist

  • Unit tests: Filter (all/partial/none/empty), Context mutation propagation, Config::Process, YamlProcessor::Result, and an entry-spec drift guard for the error fragment
  • Integration tests: top-level filtered-to-empty, nested filtered-to-empty, surviving-include-with-no-visible-jobs, genuinely-empty
  • All existing specs pass
  • RuboCop clean
Edited by Oleg Yakovenko

Merge request reports

Loading