From d1daf2f28d8c2256e5712e3064ce6ac45fca2562 Mon Sep 17 00:00:00 2001
From: Alex Savchuk <a.u.savchuk@gmail.com>
Date: Thu, 8 Sep 2022 17:20:39 +0300
Subject: [PATCH] fix: support expression for step's continue-on-error field
 (#900) (#1331)

Co-authored-by: Markus Wolf <KnisterPeter@users.noreply.github.com>
---
 pkg/model/workflow.go   | 22 ++++++-------
 pkg/runner/step.go      | 25 ++++++++++++++-
 pkg/runner/step_test.go | 70 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 105 insertions(+), 12 deletions(-)

diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go
index 53b1cb2..86ec67f 100644
--- a/pkg/model/workflow.go
+++ b/pkg/model/workflow.go
@@ -350,17 +350,17 @@ type ContainerSpec struct {
 
 // Step is the structure of one step in a job
 type Step struct {
-	ID               string            `yaml:"id"`
-	If               yaml.Node         `yaml:"if"`
-	Name             string            `yaml:"name"`
-	Uses             string            `yaml:"uses"`
-	Run              string            `yaml:"run"`
-	WorkingDirectory string            `yaml:"working-directory"`
-	Shell            string            `yaml:"shell"`
-	Env              yaml.Node         `yaml:"env"`
-	With             map[string]string `yaml:"with"`
-	ContinueOnError  bool              `yaml:"continue-on-error"`
-	TimeoutMinutes   string            `yaml:"timeout-minutes"`
+	ID                 string            `yaml:"id"`
+	If                 yaml.Node         `yaml:"if"`
+	Name               string            `yaml:"name"`
+	Uses               string            `yaml:"uses"`
+	Run                string            `yaml:"run"`
+	WorkingDirectory   string            `yaml:"working-directory"`
+	Shell              string            `yaml:"shell"`
+	Env                yaml.Node         `yaml:"env"`
+	With               map[string]string `yaml:"with"`
+	RawContinueOnError string            `yaml:"continue-on-error"`
+	TimeoutMinutes     string            `yaml:"timeout-minutes"`
 }
 
 // String gets the name of step
diff --git a/pkg/runner/step.go b/pkg/runner/step.go
index cf403b6..5b37ce3 100644
--- a/pkg/runner/step.go
+++ b/pkg/runner/step.go
@@ -99,7 +99,14 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
 			logger.WithField("stepResult", rc.StepResults[rc.CurrentStep].Outcome).Infof("  \u2705  Success - %s %s", stage, stepString)
 		} else {
 			rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure
-			if stepModel.ContinueOnError {
+
+			continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
+			if parseErr != nil {
+				rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusFailure
+				return parseErr
+			}
+
+			if continueOnError {
 				logger.Infof("Failed but continue next step")
 				err = nil
 				rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusSuccess
@@ -183,6 +190,22 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
 	return runStep, nil
 }
 
+func isContinueOnError(ctx context.Context, expr string, step step, stage stepStage) (bool, error) {
+	// https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=true#L962
+	if len(strings.TrimSpace(expr)) == 0 {
+		return false, nil
+	}
+
+	rc := step.getRunContext()
+
+	continueOnError, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, exprparser.DefaultStatusCheckNone)
+	if err != nil {
+		return false, fmt.Errorf("  \u274C  Error in continue-on-error-expression: \"continue-on-error: %s\" (%s)", expr, err)
+	}
+
+	return continueOnError, nil
+}
+
 func mergeIntoMap(target *map[string]string, maps ...map[string]string) {
 	for _, m := range maps {
 		for k, v := range m {
diff --git a/pkg/runner/step_test.go b/pkg/runner/step_test.go
index cfce24e..621f873 100644
--- a/pkg/runner/step_test.go
+++ b/pkg/runner/step_test.go
@@ -276,3 +276,73 @@ func TestIsStepEnabled(t *testing.T) {
 	}
 	assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
 }
+
+func TestIsContinueOnError(t *testing.T) {
+	createTestStep := func(t *testing.T, input string) step {
+		var step *model.Step
+		err := yaml.Unmarshal([]byte(input), &step)
+		assert.NoError(t, err)
+
+		return &stepRun{
+			RunContext: &RunContext{
+				Config: &Config{
+					Workdir: ".",
+					Platforms: map[string]string{
+						"ubuntu-latest": "ubuntu-latest",
+					},
+				},
+				StepResults: map[string]*model.StepResult{},
+				Env:         map[string]string{},
+				Run: &model.Run{
+					JobID: "job1",
+					Workflow: &model.Workflow{
+						Name: "workflow1",
+						Jobs: map[string]*model.Job{
+							"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
+						},
+					},
+				},
+			},
+			Step: step,
+		}
+	}
+
+	log.SetLevel(log.DebugLevel)
+	assertObject := assert.New(t)
+
+	// absent
+	step := createTestStep(t, "name: test")
+	continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
+	assertObject.False(continueOnError)
+	assertObject.Nil(err)
+
+	// explcit true
+	step = createTestStep(t, "continue-on-error: true")
+	continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
+	assertObject.True(continueOnError)
+	assertObject.Nil(err)
+
+	// explicit false
+	step = createTestStep(t, "continue-on-error: false")
+	continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
+	assertObject.False(continueOnError)
+	assertObject.Nil(err)
+
+	// expression true
+	step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
+	continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
+	assertObject.True(continueOnError)
+	assertObject.Nil(err)
+
+	// expression false
+	step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
+	continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
+	assertObject.False(continueOnError)
+	assertObject.Nil(err)
+
+	// expression parse error
+	step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
+	continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
+	assertObject.False(continueOnError)
+	assertObject.NotNil(err)
+}