feat: add continue-on-error job-level support

This commit is contained in:
Marc 2024-08-19 23:35:33 -04:00
parent 76bd6eece1
commit 0bb9c3f127
Signed by: marc
GPG key ID: 048E042F22B5DC79
8 changed files with 167 additions and 16 deletions

View file

@ -27,7 +27,7 @@ syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for
- [ ] jobs.<job_id>.timeout-minutes - [ ] jobs.<job_id>.timeout-minutes
- [ ] jobs.<job_id>.strategy - [ ] jobs.<job_id>.strategy
- [ ] jobs.<job_id>.container - [ ] jobs.<job_id>.container
- [ ] jobs.<job_id>.continue-on-error - [x] jobs.<job_id>.continue-on-error
- [ ] jobs.<job_id>.services - [ ] jobs.<job_id>.services
- [ ] jobs.<job_id>.uses - [ ] jobs.<job_id>.uses
- [ ] jobs.<job_id>.with - [ ] jobs.<job_id>.with

View file

@ -38,10 +38,6 @@ func ExecuteWorkflow(configuration Configuration, workflowFile string) error {
} }
taskResult := runnerInstance.RunWorkflow(*workflow) taskResult := runnerInstance.RunWorkflow(*workflow)
if !taskResult.HasError() {
return nil
}
for _, job := range taskResult.Children { for _, job := range taskResult.Children {
if job.Status == "success" { if job.Status == "success" {
logger.Info(logger.Green("Job %s: %s"), job.TaskId, job.Status) logger.Info(logger.Green("Job %s: %s"), job.TaskId, job.Status)
@ -50,5 +46,9 @@ func ExecuteWorkflow(configuration Configuration, workflowFile string) error {
} }
} }
return fmt.Errorf("Task %s failed with at least 1 error.", taskResult.TaskId) if taskResult.Failed() {
return fmt.Errorf("Task %s failed with at least 1 error.", taskResult.TaskId)
}
return nil
} }

View file

@ -1,6 +1,7 @@
package runner package runner
import ( import (
"fmt"
"strings" "strings"
) )
@ -52,10 +53,19 @@ func (d *MockDriver) Exec(containerName string, command string, cwd string) Comm
if _, init := d.calls["Exec"]; !init { if _, init := d.calls["Exec"]; !init {
d.calls["Exec"] = []MockCall{} d.calls["Exec"] = []MockCall{}
} }
d.calls["Exec"] = append(d.calls["Exec"], MockCall{fname: "Exec", args: []string{containerName, command, cwd}})
if _, mocked := d.mockedCalls["Exec"][strings.Join([]string{containerName, command, cwd}, " ")]; mocked { args := []string{containerName, command, cwd}
return d.mockedCalls["Exec"][strings.Join([]string{containerName, command, cwd}, " ")] d.calls["Exec"] = append(d.calls["Exec"], MockCall{fname: "Exec", args: args})
mockKeys := []string{
fmt.Sprintf("nthcall::%d", len(d.calls["Exec"])),
fmt.Sprintf("withargs::%s", strings.Join(args, " ")),
}
for _, mockKey := range mockKeys {
if _, mocked := d.mockedCalls["Exec"][mockKey]; mocked {
return d.mockedCalls["Exec"][mockKey]
}
} }
return CommandResult{} return CommandResult{}
@ -65,7 +75,20 @@ func (d *MockDriver) Exec(containerName string, command string, cwd string) Comm
// //
// The mocked call is reused as long as it's defined within the mock driver. // The mocked call is reused as long as it's defined within the mock driver.
func (d *MockDriver) WithMockedCall(fn string, returnValue CommandResult, args ...string) { func (d *MockDriver) WithMockedCall(fn string, returnValue CommandResult, args ...string) {
mockKey := strings.Join(args, " ") mockKey := fmt.Sprintf("withargs::%s", strings.Join(args, " "))
if _, initialized := d.mockedCalls[fn]; !initialized {
d.mockedCalls[fn] = map[string]CommandResult{}
}
d.mockedCalls[fn][mockKey] = returnValue
}
// Mocks the nth call to <fn> to return <returnValue>.
//
// The mocked call is reused as long as it's defined within the mock driver.
func (d *MockDriver) WithNthMockedCall(fn string, callIndex int, returnValue CommandResult) {
mockKey := fmt.Sprintf("nthcall::%d", callIndex)
if _, initialized := d.mockedCalls[fn]; !initialized { if _, initialized := d.mockedCalls[fn]; !initialized {
d.mockedCalls[fn] = map[string]CommandResult{} d.mockedCalls[fn] = map[string]CommandResult{}

View file

@ -64,13 +64,21 @@ func (r *Runner) RunWorkflow(workflow workflow.Workflow) TaskTracker {
logger.Info("Using image %s (label: %s)", runnerImage, job.RunsOn) logger.Info("Using image %s (label: %s)", runnerImage, job.RunsOn)
if pullError := runner.Driver.Pull(runnerImage); pullError != nil { if pullError := runner.Driver.Pull(runnerImage); pullError != nil {
jobTracker.SetStatus("failed").SetError(pullError) jobTracker.SetError(pullError)
return
if !job.ContinueOnError {
jobTracker.SetStatus("failed")
return
}
} }
if runError := runner.RunJobInContainer(runnerImage, containerName, jobContext); runError != nil { if runError := runner.RunJobInContainer(runnerImage, containerName, jobContext); runError != nil {
jobTracker.SetStatus("failed").SetError(runError) jobTracker.SetError(runError)
return if !job.ContinueOnError {
jobTracker.SetStatus("failed")
return
}
} }
jobTracker.SetStatus("success") jobTracker.SetStatus("success")

View file

@ -7,6 +7,83 @@ import (
"testing" "testing"
) )
func TestJobErrorsWorkflowErrorsIfJobContinueOnErrorUndefined(t *testing.T) {
workflowSample := `
jobs:
jobA:
runs-on: test
steps:
- run: exit 1
`
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver()
runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow)
jobContext = context.WithValue(jobContext, "currentJob", workflow.Jobs["jobA"])
mockDriver.WithNthMockedCall("Exec", 1, CommandResult{Error: errors.New("exit 1!"), ExitCode: 1})
task := runner.RunWorkflow(*workflow)
if !task.Failed() {
t.Error("Expected task to fail, but it succeeded.")
}
}
func TestJobErrorsWorkflowErrorsIfJobContinueOnErrorFalsy(t *testing.T) {
workflowSample := `
jobs:
jobA:
runs-on: test
continue-on-error: false
steps:
- run: exit 1
`
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver()
runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow)
jobContext = context.WithValue(jobContext, "currentJob", workflow.Jobs["jobA"])
mockDriver.WithNthMockedCall("Exec", 1, CommandResult{Error: errors.New("exit 1!"), ExitCode: 1})
task := runner.RunWorkflow(*workflow)
if !task.Failed() {
t.Error("Expected task to fail, but it succeeded.")
}
}
func TestJobSkipsErrorWorkflowSucceedsIfContinueOnErrorTruthy(t *testing.T) {
workflowSample := `
jobs:
jobA:
runs-on: test
continue-on-error: true
steps:
- run: exit 1
`
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1")
runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow)
jobContext = context.WithValue(jobContext, "currentJob", workflow.Jobs["jobA"])
task := runner.RunWorkflow(*workflow)
if task.Failed() {
t.Error("Expected task to succeed, but it failed.")
}
}
func TestJobStepSkipsErrorIfContinueOnErrorTruthy(t *testing.T) { func TestJobStepSkipsErrorIfContinueOnErrorTruthy(t *testing.T) {
workflowSample := ` workflowSample := `
jobs: jobs:

View file

@ -52,3 +52,17 @@ func (t TaskTracker) HasError() bool {
return false return false
} }
func (t TaskTracker) Failed() bool {
if t.Status == "failed" {
return true
}
for _, child := range t.Children {
if child.Failed() {
return true
}
}
return false
}

View file

@ -27,3 +27,30 @@ func TestTaskHasErrorReturnsTrueIfAnyJobHasErrors(t *testing.T) {
t.Errorf("Expected true, got false.") t.Errorf("Expected true, got false.")
} }
} }
func TestTaskFailedReturnsTrueIfAnyChildHasFailed(t *testing.T) {
task := NewTaskTracker(nil)
NewTaskTracker(task).SetStatus("failed")
if !task.Failed() {
t.Error("Expected failed = true, got false.")
}
}
func TestTaskFailedReturnsTrueIfTaskFailed(t *testing.T) {
task := NewTaskTracker(nil).SetStatus("failed")
NewTaskTracker(task)
if !task.Failed() {
t.Error("Expected failed = true, got false.")
}
}
func TeskTaskFailedReturnsFalseIfNeitherSelfOrChildrenFailed(t *testing.T) {
task := NewTaskTracker(nil).SetStatus("success")
NewTaskTracker(task)
if task.Failed() {
t.Error("Expected failed = false, got true.")
}
}

View file

@ -9,8 +9,10 @@ type Job struct {
Steps []Step `yaml:"steps"` Steps []Step `yaml:"steps"`
Needs []string `yaml:"needs"` Needs []string `yaml:"needs"`
// Job name; this isn't guaranteed to be unique. // Job name; this isn't guaranteed to be unique.
Name string `yaml:"name"` Name string `yaml:"name"`
Defaults struct { // If truthy, job-level failure does not bubble up to the workflow.
ContinueOnError bool `yaml:"continue-on-error"`
Defaults struct {
Run struct { Run struct {
WorkingDirectory string `yaml:"working-directory"` WorkingDirectory string `yaml:"working-directory"`
} `yaml:"run"` } `yaml:"run"`