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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 224 | 224 | } | |
| 225 | 225 | 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 | + | } | |
| @@ -224,3 +224,42 @@ func (w *Workflow) JobLevels() [][]*Job { | |||||
| 224 | } | 224 | } | ||
| 225 | return levels | 225 | 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 @@ | |||
| 3 | 3 | package workflow | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "reflect" | |
| 6 | 7 | "strings" | |
| 7 | 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 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package workflow | 3 | 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 | + | } | |||