detached 27d907d

fix(ci): make the default success() of a job look at its whole chain of needs

commit
27d907d01ecbf918cbb6e1a6492de273a907644b
author
brice · · 2026-10-13 03:21 UTC
parent
e893110
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +24 -2
M internal/usecase/ci_plan.go +24 -2
@@ -133,9 +133,31 @@ func result(insts []domain.CIJob) string {
133133
134134 // needsOf computes the needs context and the status functions of a job from
135135 // the jobs it needs, and whether they all have ended.
136-func (rs *runState) needsOf(j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
136+//
137+// The needs context holds the direct needs only, but the status functions look
138+// at the whole chain (wf.Ancestors): a job whose need ran through always()
139+// after a failure further up is skipped by its default success() (recorded:
140+// needs-failed-dependency, chain-after-always), and failure() is true when an
141+// ancestor failed (documented, not recorded). An ancestor beyond the direct
142+// needs only changes success() and failure(): cancelled() is not recorded for
143+// them, and a skipped ancestor behind a need that did run is not either.
144+func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
137145 needs = expr.NewDictionary()
138146 st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}
147+ for _, n := range wf.Ancestors(j.ID) {
148+ var insts []domain.CIJob
149+ for _, i := range rs.instances(n) {
150+ if rs.jobs[i].Ended() {
151+ insts = append(insts, rs.jobs[i])
152+ }
153+ }
154+ switch result(insts) {
155+ case "failure":
156+ st.Failure, st.Success = true, false
157+ case "cancelled":
158+ st.Success = false
159+ }
160+ }
139161 for _, n := range j.Needs {
140162 var insts []domain.CIJob
141163 for _, i := range rs.instances(n) {
@@ -305,7 +327,7 @@ func (rs *runState) advance(ctx context.Context) error {
305327 if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
306328 continue
307329 }
308- needs, st, ready := rs.needsOf(wj)
330+ needs, st, ready := rs.needsOf(wf, wj)
309331 if !ready {
310332 continue
311333 }
@@ -133,9 +133,31 @@ func result(insts []domain.CIJob) string {
133 133
134 // needsOf computes the needs context and the status functions of a job from134 // needsOf computes the needs context and the status functions of a job from
135 // the jobs it needs, and whether they all have ended.135 // the jobs it needs, and whether they all have ended.
136-func (rs *runState) needsOf(j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {136+//
137+// The needs context holds the direct needs only, but the status functions look
138+// at the whole chain (wf.Ancestors): a job whose need ran through always()
139+// after a failure further up is skipped by its default success() (recorded:
140+// needs-failed-dependency, chain-after-always), and failure() is true when an
141+// ancestor failed (documented, not recorded). An ancestor beyond the direct
142+// needs only changes success() and failure(): cancelled() is not recorded for
143+// them, and a skipped ancestor behind a need that did run is not either.
144+func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
137 needs = expr.NewDictionary()145 needs = expr.NewDictionary()
138 st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}146 st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}
147+ for _, n := range wf.Ancestors(j.ID) {
148+ var insts []domain.CIJob
149+ for _, i := range rs.instances(n) {
150+ if rs.jobs[i].Ended() {
151+ insts = append(insts, rs.jobs[i])
152+ }
153+ }
154+ switch result(insts) {
155+ case "failure":
156+ st.Failure, st.Success = true, false
157+ case "cancelled":
158+ st.Success = false
159+ }
160+ }
139 for _, n := range j.Needs {161 for _, n := range j.Needs {
140 var insts []domain.CIJob162 var insts []domain.CIJob
141 for _, i := range rs.instances(n) {163 for _, i := range rs.instances(n) {
@@ -305,7 +327,7 @@ func (rs *runState) advance(ctx context.Context) error {
305 if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {327 if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
306 continue328 continue
307 }329 }
308- needs, st, ready := rs.needsOf(wj)330+ needs, st, ready := rs.needsOf(wf, wj)
309 if !ready {331 if !ready {
310 continue332 continue
311 }333 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close