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.nilis a third state both layers below read:scheduler.Newtakes it for stage-based scheduling, andBuildDependenciesfor "download every preceding stage's artifacts". This matches GitLab, whereEntry::Jobfixesscheduling_typefrom whether theneeds:key was written —needs_defined? ? :dag : :stage— and never from whether a target survived. - Nothing is written in place.
evalJobRulesreturns the caller's own*JobConfigwhenever it has nothing to change, so the planned slice aliasespipeline.Jobs; and even its clone is shallow, sharing theNeedsbacking 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: trueBefore, 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 $?
0After:
$ glci jobs --json --context branch=main
[ { "name": "unit_tests", ... } ]
$ glci run --context branch=main
✓ unit_tests (2.3s)
✓ Pipeline PASSED 5s
1 passedGitLab runs unit_tests for this config — confirmed on a real pipeline before the fix was written.
Relevant issues and other links
Closes #169 (closed).
- GitLab
needs:documentation,optional. make test,make test-e2e-parseandmake test-e2e-goldenpass. The golden fixtures compare against GitLab API ground truth and are unchanged, so no edge the API says should exist was removed.TestE2E_OptionalNeedDroppedWhenTargetExcludedruns the config above through Docker; without the fix it reportsexpected: "passed", actual: "skipped"with an empty trace.