feat: add continue-on-error job-level support
This commit is contained in:
parent
76bd6eece1
commit
0bb9c3f127
8 changed files with 167 additions and 16 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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{}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
Loading…
Reference in a new issue