detached 8e90c04

feat(workflow): add Workflow.Ancestors for the transitive needs of success()

commit
8e90c04e478603fd7b403b3c06e54b4eace7cdf0
author
brice · · 2026-10-13 02:33 UTC
committer
brice · · 2026-10-18 14:59 UTC
parent
c18027d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +93 -0
M workflow/job_eval.go +39 -0
@@ -224,3 +224,42 @@ func (w *Workflow) JobLevels() [][]*Job {
224224 }
225225 return levels
226226 }
227+
228+// Ancestors returns the ids of every job the job with the given id depends on,
229+// directly or through other jobs, in the order of the file. It is empty for a
230+// job without needs and nil for an unknown id.
231+//
232+// GitHub's default condition of a job, success(), looks at this whole chain
233+// and not only at the direct needs: a job that needs a job which ran through
234+// always() after a failure is skipped, although its only direct need
235+// succeeded. Recorded (needs-failed-dependency): chain-after-always needs
236+// with-always, which needs broken; broken failed, with-always (if: always())
237+// succeeded, and chain-after-always was skipped. Likewise failure() is true
238+// when any ancestor failed, as documented.
239+func (w *Workflow) Ancestors(id string) []string {
240+ start := w.Job(id)
241+ if start == nil {
242+ return nil
243+ }
244+ seen := map[string]bool{id: true}
245+ stack := slices.Clone(start.Needs)
246+ for len(stack) > 0 {
247+ n := stack[len(stack)-1]
248+ stack = stack[:len(stack)-1]
249+ if seen[n] {
250+ continue
251+ }
252+ seen[n] = true
253+ if j := w.Job(n); j != nil {
254+ stack = append(stack, j.Needs...)
255+ }
256+ }
257+ delete(seen, id)
258+ out := []string{}
259+ for _, j := range w.Jobs {
260+ if seen[j.ID] {
261+ out = append(out, j.ID)
262+ }
263+ }
264+ return out
265+}
@@ -224,3 +224,42 @@ func (w *Workflow) JobLevels() [][]*Job {
224 }224 }
225 return levels225 return levels
226 }226 }
227+
228+// Ancestors returns the ids of every job the job with the given id depends on,
229+// directly or through other jobs, in the order of the file. It is empty for a
230+// job without needs and nil for an unknown id.
231+//
232+// GitHub's default condition of a job, success(), looks at this whole chain
233+// and not only at the direct needs: a job that needs a job which ran through
234+// always() after a failure is skipped, although its only direct need
235+// succeeded. Recorded (needs-failed-dependency): chain-after-always needs
236+// with-always, which needs broken; broken failed, with-always (if: always())
237+// succeeded, and chain-after-always was skipped. Likewise failure() is true
238+// when any ancestor failed, as documented.
239+func (w *Workflow) Ancestors(id string) []string {
240+ start := w.Job(id)
241+ if start == nil {
242+ return nil
243+ }
244+ seen := map[string]bool{id: true}
245+ stack := slices.Clone(start.Needs)
246+ for len(stack) > 0 {
247+ n := stack[len(stack)-1]
248+ stack = stack[:len(stack)-1]
249+ if seen[n] {
250+ continue
251+ }
252+ seen[n] = true
253+ if j := w.Job(n); j != nil {
254+ stack = append(stack, j.Needs...)
255+ }
256+ }
257+ delete(seen, id)
258+ out := []string{}
259+ for _, j := range w.Jobs {
260+ if seen[j.ID] {
261+ out = append(out, j.ID)
262+ }
263+ }
264+ return out
265+}
M workflow/job_eval_test.go +54 -0
@@ -3,6 +3,7 @@
33 package workflow
44
55 import (
6+ "reflect"
67 "strings"
78 "testing"
89 )
@@ -200,3 +201,56 @@ jobs:
200201 }
201202 }
202203 }
204+
205+// needsFailedDependency is the workflow of the recorded scenario of that name.
206+const needsFailedDependency = `on:
207+ push:
208+ branches: ['scn/**']
209+jobs:
210+ broken:
211+ runs-on: ubuntu-24.04
212+ steps:
213+ - run: exit 1
214+ plain:
215+ needs: broken
216+ runs-on: ubuntu-24.04
217+ steps:
218+ - run: echo SHOULD-NOT-RUN
219+ with-always:
220+ needs: broken
221+ if: always()
222+ runs-on: ubuntu-24.04
223+ steps:
224+ - run: echo
225+ chain-after-always:
226+ needs: with-always
227+ runs-on: ubuntu-24.04
228+ steps:
229+ - run: echo chain-after-always-runs-because-with-always-succeeded
230+ diamond:
231+ needs: [chain-after-always, plain, with-always]
232+ runs-on: ubuntu-24.04
233+ steps:
234+ - run: echo
235+`
236+
237+// TestAncestors checks the transitive needs that GitHub's default success()
238+// looks at (recorded: needs-failed-dependency, chain-after-always was skipped).
239+func TestAncestors(t *testing.T) {
240+ w, errs := Parse("w.yml", []byte(needsFailedDependency))
241+ if len(errs) > 0 {
242+ t.Fatal(errs)
243+ }
244+ for id, want := range map[string][]string{
245+ "broken": {},
246+ "plain": {"broken"},
247+ "with-always": {"broken"},
248+ "chain-after-always": {"broken", "with-always"},
249+ "diamond": {"broken", "plain", "with-always", "chain-after-always"},
250+ "nowhere": nil,
251+ } {
252+ if got := w.Ancestors(id); !reflect.DeepEqual(got, want) {
253+ t.Errorf("Ancestors(%q) = %q, want %q", id, got, want)
254+ }
255+ }
256+}
@@ -3,6 +3,7 @@
3 package workflow3 package workflow
4 4
5 import (5 import (
6+ "reflect"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 )9 )
@@ -200,3 +201,56 @@ jobs:
200 }201 }
201 }202 }
202 }203 }
204+
205+// needsFailedDependency is the workflow of the recorded scenario of that name.
206+const needsFailedDependency = `on:
207+ push:
208+ branches: ['scn/**']
209+jobs:
210+ broken:
211+ runs-on: ubuntu-24.04
212+ steps:
213+ - run: exit 1
214+ plain:
215+ needs: broken
216+ runs-on: ubuntu-24.04
217+ steps:
218+ - run: echo SHOULD-NOT-RUN
219+ with-always:
220+ needs: broken
221+ if: always()
222+ runs-on: ubuntu-24.04
223+ steps:
224+ - run: echo
225+ chain-after-always:
226+ needs: with-always
227+ runs-on: ubuntu-24.04
228+ steps:
229+ - run: echo chain-after-always-runs-because-with-always-succeeded
230+ diamond:
231+ needs: [chain-after-always, plain, with-always]
232+ runs-on: ubuntu-24.04
233+ steps:
234+ - run: echo
235+`
236+
237+// TestAncestors checks the transitive needs that GitHub's default success()
238+// looks at (recorded: needs-failed-dependency, chain-after-always was skipped).
239+func TestAncestors(t *testing.T) {
240+ w, errs := Parse("w.yml", []byte(needsFailedDependency))
241+ if len(errs) > 0 {
242+ t.Fatal(errs)
243+ }
244+ for id, want := range map[string][]string{
245+ "broken": {},
246+ "plain": {"broken"},
247+ "with-always": {"broken"},
248+ "chain-after-always": {"broken", "with-always"},
249+ "diamond": {"broken", "plain", "with-always", "chain-after-always"},
250+ "nowhere": nil,
251+ } {
252+ if got := w.Ancestors(id); !reflect.DeepEqual(got, want) {
253+ t.Errorf("Ancestors(%q) = %q, want %q", id, got, want)
254+ }
255+ }
256+}

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 1

Tags 3

↑↓ moveenter openesc close