fix(planner): Drop a dangling optional: needs edge

What this MR does and why?

needs: optional: true tells GitLab to drop the dependency edge when the named job is not in the pipeline — excluded by its rules:, or never declared at all. The dependent job then runs immediately.

glci kept the edge. scheduler.New flattens job.Needs to a bare []string and discards Optional, so a need naming a job that is in neither done nor skipped can never satisfy depsComplete. The scheduler recognises the stall in its deadlock branch, flushes every pending job into skipped, and returns — and because pipelineFailed is only ever set in Complete(), the run reports PASSED and exits 0.

So a test suite guarded by an optional need does not execute, nothing in the terminal or ~/.glci/daemon.log says why, and the pipeline is green. Push it and GitLab runs the job for real. The word compile never appears in the output; the only trace is a and a 1 skipped count that reads like an intentional exclusion.

The cause was one comment in includeDeps:

effective, included := evalJobRules(job, filter, existsFiles)
if !included {
    // Rules evaluated to never — skip, scheduler will handle missing dep
    return
}

The scheduler does not handle it and cannot: Optional never crosses that boundary. It is read in exactly three places — PlanWithStats, ValidatePipeline and the TUI, which only renders the word.

pruneDanglingOptionalNeeds now resolves optional: where GitLab resolves it, when the plan is built. It runs after includeDeps, because the dependency walk can still pull a target back into the plan, and it treats a job in an undeclared stage as absent, because those are counted and then dropped rather than planned.

Two invariants the implementation holds deliberately:

  • A job whose every need was dropped keeps an empty, non-nil Needs. nil is a third state both layers below read: scheduler.New takes it for stage-based scheduling, and BuildDependencies for "download every preceding stage's artifacts". This matches GitLab, where Entry::Job fixes scheduling_type from whether the needs: key was written — needs_defined? ? :dag : :stage — and never from whether a target survived.
  • Nothing is written in place. evalJobRules returns the caller's own *JobConfig whenever it has nothing to change, so the planned slice aliases pipeline.Jobs; and even its clone is shallow, sharing the Needs backing array. Both the element and the slice are replaced.

A dangling need that is not optional is left exactly as it was. That case stalls the same way it does today, which is a different defect and out of scope here.

Because applyContextFilter rebuilds its pipeline from the execution plan, the dropped edge also disappears from glci jobs and glci show. GitLab's own pipeline graph drops the same link. The TUI renders the config as written rather than the plan, so it still lists the edge; that is pre-existing and left alone here.

Steps to reproduce

stages: [build, test]

compile:
  stage: build
  image: alpine:3.20
  script: echo compile
  rules:
    - if: '$COMPILE == "true"'      # not set, so compile is not in the pipeline

unit_tests:
  stage: test
  image: alpine:3.20
  script: echo tests
  needs:
    - job: compile
      optional: true

Before, on main:

$ glci jobs --json --context branch=main
[ { "name": "unit_tests", ..., "needs": [ "compile" ] } ]

$ glci run --context branch=main
  ⊘ unit_tests (skipped)

✓ Pipeline PASSED  2s
  1 skipped
$ echo $?
0

After:

$ glci jobs --json --context branch=main
[ { "name": "unit_tests", ... } ]

$ glci run --context branch=main
  ✓ unit_tests (2.3s)

✓ Pipeline PASSED  5s
  1 passed

GitLab runs unit_tests for this config — confirmed on a real pipeline before the fix was written.

Closes #169 (closed).

  • GitLab needs: documentation, optional.
  • make test, make test-e2e-parse and make test-e2e-golden pass. The golden fixtures compare against GitLab API ground truth and are unchanged, so no edge the API says should exist was removed.
  • TestE2E_OptionalNeedDroppedWhenTargetExcluded runs the config above through Docker; without the fix it reports expected: "passed", actual: "skipped" with an empty trace.

Merge request reports

Loading
Loading