rule:exists misses an existing directory depending on unrelated files

Summary

Since 18.2, rules:exists checks that a dir exists when the path ends in a /, but the lookup can fail silently depending on other files in the directory.

Steps to reproduce

  1. Create a project with these files:

    .gitlab-ci.yml  Cargo.toml  README.md  a.txt  b.txt  c.txt  docs/guide.md  src/main.rs  z.txt

    and this .gitlab-ci.yml:

    docs-dir:
      script: echo docs/ exists
      rules:
        - exists: [docs/]
    src-dir:
      script: echo src/ exists
      rules:
        - exists: [src/]
    docs-glob:
      script: echo docs/**/* exists
      rules:
        - exists: ["docs/**/*"]
    always:
      script: echo always
  2. Push. The pipeline has always, docs-glob and src-dir, but no docs-dir.

  3. Delete z.txt, which has nothing to do with docs/, and push. The pipeline now has all four jobs.

What is the current bug behavior?

exists: [docs/] is false even though docs/guide.md exists

What is the expected correct behavior?

exists: [docs/] is true whenever a file under docs/ exists, as it is for docs/**/*

Relevant logs and/or screenshots

The cause is in exact_matches?:

paths.bsearch { |path| path.start_with?(glob) }

With a boolean block, bsearch runs in find-minimum mode, which needs the block to be false for a leading part of the sorted array and true for the rest. start_with?(glob) is false before the directory, true inside it, and false again after it. For the files above, the search probes b.txt (false, go right), src/main.rs (false, go right) and z.txt (false), and returns nil without ever looking at docs/guide.md. Without z.txt, the second probe lands on docs/guide.md and the directory is found.

The existing spec passes because its repository is small enough that the probe lands inside the directory.

Output of checks

Reproduced on GitLab CE 19.4.1 (gitlab/gitlab-ce:latest), self-managed. Not tested on GitLab.com; the code is the same on master.

Possible fixes

Possible fixes

Look for the first path that is not less than the glob. Paths in the directory come first among those, so the block is monotonic and one start_with? decides:

paths.bsearch { |path| path >= glob }&.start_with?(glob)

On 200,000 random sorted path lists this agrees with paths.any? { |path| path.start_with?(glob) } every time; the current code disagrees on 14,593 of them. With the change, the reproduction above gives docs-dir in both pipelines.

Workaround until then: exists: ["docs/**/*"].

This issue was initially discovered, proven and drafted through the help of Claude Code.

Edited by 🤖 GitLab Bot 🤖