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>.strategy
- [ ] jobs.<job_id>.container
- [ ] jobs.<job_id>.continue-on-error
- [x] jobs.<job_id>.continue-on-error
- [ ] jobs.<job_id>.services
- [ ] jobs.<job_id>.uses
- [ ] jobs.<job_id>.with

View file

@ -38,10 +38,6 @@ func ExecuteWorkflow(configuration Configuration, workflowFile string) error {
}
taskResult := runnerInstance.RunWorkflow(*workflow)
if !taskResult.HasError() {
return nil
}
for _, job := range taskResult.Children {
if job.Status == "success" {
logger.Info(logger.Green("Job %s: %s"), job.TaskId, job.Status)
@ -50,5 +46,9 @@ func ExecuteWorkflow(configuration Configuration, workflowFile string) error {
}
}
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
import (
"fmt"
"strings"
)
@ -52,10 +53,19 @@ func (d *MockDriver) Exec(containerName string, command string, cwd string) Comm
if _, init := d.calls["Exec"]; !init {
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 {
return d.mockedCalls["Exec"][strings.Join([]string{containerName, command, cwd}, " ")]
args := []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{}
@ -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.
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 {
d.mockedCalls[fn] = map[string]CommandResult{}

View file

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

View file

@ -7,6 +7,83 @@ import (
"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) {
workflowSample := `
jobs:

View file

@ -52,3 +52,17 @@ func (t TaskTracker) HasError() bool {
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.")
}
}
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

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