Repository: go-task/task
Pull request #205 (2019-05-17 20:45:05)

Add Preconditions to Tasks
## What changes does this PR introduce?

This adds a `preconditions` stanza to the `task` stanza.  A precondition is _basically_ the inverse of a `status` in that it's a condition that _must_ succeed before the task can run.

## Any background context you want to provide?

Preconditions on file statuses doesn't make much sense, so preconditions are limited to `sh` interpreter invocations.  Furthermore - preconditions often require the user to do something in order to proceed, so they have a `msg` field that can be used to report next steps to the user.

I've added an "ignore_error" feature to preconditions, which will ignore errors in preconditions if the task is being executed as either a dependency or as a sub-task.  I'm not 100% this isn't a misfeature, but it was requested by our team, so it's there.   Normally a failure in a precondition will fail the task that it's on, and thus any "upstream" tasks as well.

## Where should the reviewer start?

`TestPrecondition` enumerates all of the situations I could think of that encounter a conditional - it's a pretty good place to see how to use `preconditions` - they are executed in the same context as `status` commands in `areTaskPreconditionsMet`

## Has this been manually tested? How?

We run our own fork of go-task with this feature for our automation.  So far it's working well.




Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 6adf4e27..00000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,1 +0,0 @@
-open_collective: task
diff --git a/.gitignore b/.gitignore
index d5340eb2..c5de0bc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,6 @@ dist/
 
 # intellij idea/goland
 .idea/
+
+# exuberant ctags
+tags
diff --git a/docs/taskfile_versions.md b/docs/taskfile_versions.md
index 82c80463..a318f12a 100644
--- a/docs/taskfile_versions.md
+++ b/docs/taskfile_versions.md
@@ -141,6 +141,21 @@ includes:
   docker: ./DockerTasks.yml
 ```
 
+## Version 2.6
+
+Version 2.6 comes with `preconditions` stanza in tasks.
+
+```yaml
+version: '2'
+
+tasks:
+  upload_environment:
+    preconditions:
+      - test -f .env
+    cmds:
+      - aws s3 cp .env s3://myenvironment
+```
+
 Please check the [documentation][includes]
 
 [output]: usage.md#output-syntax
diff --git a/docs/usage.md b/docs/usage.md
index 40c3dd4a..e70cbc5a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -344,6 +344,55 @@ up-to-date.
 Also, `task --status [tasks]...` will exit with a non-zero exit code if any of
 the tasks are not up-to-date.
 
+If you need a certain set of conditions to be _true_ you can use the
+`preconditions` stanza.  `preconditions` are very similar to `status`
+lines except they support `sh` expansion and they SHOULD all return 0.
+
+```yaml
+version: '2'
+
+tasks:
+  generate-files:
+    cmds:
+      - mkdir directory
+      - touch directory/file1.txt
+      - touch directory/file2.txt
+    # test existence of files
+    preconditions:
+      - test -f .env
+      - sh: "[ 1 = 0 ]"
+        msg: "One doesn't equal Zero, Halting"
+```
+
+Preconditions can set specific failure messages that can tell
+a user what steps to take using the `msg` field.
+
+If a task has a dependency on a sub-task with a precondition, and that
+precondition is not met - the calling task will fail.  Note that a task
+executed with a failing precondition will not run unless `--force` is
+given.
+
+Unlike `status` which will skip a task if it is up to date, and continue
+executing tasks that depend on it, a `precondition` will fail a task, along
+with any other tasks that depend on it.
+
+```yaml
+version: '2'
+tasks:
+  task_will_fail:
+    preconditions:
+      - sh: "exit 1"
+
+  task_will_also_fail:
+    deps:
+      - task_will_fail
+
+  task_will_still_fail:
+    cmds:
+      - task: task_will_fail
+      - echo "I will not run"
+```
+
 ## Variables
 
 When doing interpolation of variables, Task will look for the below.
diff --git a/internal/taskfile/precondition.go b/internal/taskfile/precondition.go
new file mode 100644
index 00000000..04c1e532
--- /dev/null
+++ b/internal/taskfile/precondition.go
@@ -0,0 +1,45 @@
+package taskfile
+
+import (
+	"errors"
+	"fmt"
+)
+
+var (
+	// ErrCantUnmarshalPrecondition is returned for invalid precond YAML.
+	ErrCantUnmarshalPrecondition = errors.New("task: Can't unmarshal precondition value")
+)
+
+// Precondition represents a precondition necessary for a task to run
+type Precondition struct {
+	Sh  string
+	Msg string
+}
+
+// UnmarshalYAML implements yaml.Unmarshaler interface.
+func (p *Precondition) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	var cmd string
+
+	if err := unmarshal(&cmd); err == nil {
+		p.Sh = cmd
+		p.Msg = fmt.Sprintf("`%s` failed", cmd)
+		return nil
+	}
+
+	var sh struct {
+		Sh  string
+		Msg string
+	}
+
+	if err := unmarshal(&sh); err != nil {
+		return err
+	}
+
+	p.Sh = sh.Sh
+	p.Msg = sh.Msg
+	if p.Msg == "" {
+		p.Msg = fmt.Sprintf("%s failed", sh.Sh)
+	}
+
+	return nil
+}
diff --git a/internal/taskfile/task.go b/internal/taskfile/task.go
index 1afcbfa3..bc4bf981 100644
--- a/internal/taskfile/task.go
+++ b/internal/taskfile/task.go
@@ -5,19 +5,20 @@ type Tasks map[string]*Task
 
 // Task represents a task
 type Task struct {
-	Task        string
-	Cmds        []*Cmd
-	Deps        []*Dep
-	Desc        string
-	Summary     string
-	Sources     []string
-	Generates   []string
-	Status      []string
-	Dir         string
-	Vars        Vars
-	Env         Vars
-	Silent      bool
-	Method      string
-	Prefix      string
-	IgnoreError bool `yaml:"ignore_error"`
+	Task         string
+	Cmds         []*Cmd
+	Deps         []*Dep
+	Desc         string
+	Summary      string
+	Sources      []string
+	Generates    []string
+	Status       []string
+	Preconditions []*Precondition
+	Dir          string
+	Vars         Vars
+	Env          Vars
+	Silent       bool
+	Method       string
+	Prefix       string
+	IgnoreError  bool `yaml:"ignore_error"`
 }
diff --git a/internal/taskfile/version/version.go b/internal/taskfile/version/version.go
index bb2176e4..b2776c31 100644
--- a/internal/taskfile/version/version.go
+++ b/internal/taskfile/version/version.go
@@ -10,6 +10,8 @@ var (
 	v21 = mustVersion("2.1")
 	v22 = mustVersion("2.2")
 	v23 = mustVersion("2.3")
+	v24 = mustVersion("2.4")
+	v25 = mustVersion("2.5")
 )
 
 // IsV1 returns if is a given Taskfile version is version 1
@@ -37,6 +39,16 @@ func IsV23(v *semver.Constraints) bool {
 	return v.Check(v23)
 }
 
+// IsV24 returns if is a given Taskfile version is at least version 2.4
+func IsV24(v *semver.Constraints) bool {
+	return v.Check(v24)
+}
+
+// IsV25 returns if is a given Taskfile version is at least version 2.5
+func IsV25(v *semver.Constraints) bool {
+	return v.Check(v25)
+}
+
 func mustVersion(s string) *semver.Version {
 	v, err := semver.NewVersion(s)
 	if err != nil {
diff --git a/precondition.go b/precondition.go
new file mode 100644
index 00000000..eff123a0
--- /dev/null
+++ b/precondition.go
@@ -0,0 +1,31 @@
+package task
+
+import (
+	"context"
+	"errors"
+
+	"github.com/go-task/task/v2/internal/execext"
+	"github.com/go-task/task/v2/internal/taskfile"
+)
+
+var (
+	// ErrPreconditionFailed is returned when a precondition fails
+	ErrPreconditionFailed = errors.New("task: precondition not met")
+)
+
+func (e *Executor) areTaskPreconditionsMet(ctx context.Context, t *taskfile.Task) (bool, error) {
+	for _, p := range t.Preconditions {
+		err := execext.RunCommand(ctx, &execext.RunCommandOptions{
+			Command: p.Sh,
+			Dir:     t.Dir,
+			Env:     getEnviron(t),
+		})
+
+		if err != nil {
+			e.Logger.Errf("task: %s", p.Msg)
+			return false, ErrPreconditionFailed
+		}
+	}
+
+	return true, nil
+}
diff --git a/status.go b/status.go
index 173be34b..7ebb5f84 100644
--- a/status.go
+++ b/status.go
@@ -78,8 +78,10 @@ func (e *Executor) isTaskUpToDateStatus(ctx context.Context, t *taskfile.Task) (
 			Env:     getEnviron(t),
 		})
 		if err != nil {
+			e.Logger.VerboseOutf("task: status command %s exited non-zero: %s", s, err)
 			return false, nil
 		}
+		e.Logger.VerboseOutf("task: status command %s exited zero", s)
 	}
 	return true, nil
 }
diff --git a/task.go b/task.go
index 2df08c2c..154166aa 100644
--- a/task.go
+++ b/task.go
@@ -119,7 +119,7 @@ func (e *Executor) Setup() error {
 			Vars:   e.taskvars,
 			Logger: e.Logger,
 		}
-	case version.IsV2(v), version.IsV21(v), version.IsV22(v):
+	case version.IsV2(v), version.IsV21(v), version.IsV22(v), version.IsV23(v):
 		e.Compiler = &compilerv2.CompilerV2{
 			Dir:          e.Dir,
 			Taskvars:     e.taskvars,
@@ -127,8 +127,9 @@ func (e *Executor) Setup() error {
 			Expansions:   e.Taskfile.Expansions,
 			Logger:       e.Logger,
 		}
-	case version.IsV23(v):
-		return fmt.Errorf(`task: Taskfile versions greater than v2.3 not implemented in the version of Task`)
+
+	case version.IsV24(v):
+		return fmt.Errorf(`task: Taskfile versions greater than v2.4 not implemented in the version of Task`)
 	}
 
 	if !version.IsV21(v) && e.Taskfile.Output != "" {
@@ -188,11 +189,17 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error {
 	}
 
 	if !e.Force {
+		preCondMet, err := e.areTaskPreconditionsMet(ctx, t)
+		if err != nil {
+			return err
+		}
+
 		upToDate, err := e.isTaskUpToDate(ctx, t)
 		if err != nil {
 			return err
 		}
-		if upToDate {
+
+		if upToDate && preCondMet {
 			if !e.Silent {
 				e.Logger.Errf(`task: Task "%s" is up to date`, t.Task)
 			}
@@ -224,7 +231,11 @@ func (e *Executor) runDeps(ctx context.Context, t *taskfile.Task) error {
 		d := d
 
 		g.Go(func() error {
-			return e.RunTask(ctx, taskfile.Call{Task: d.Task, Vars: d.Vars})
+			err := e.RunTask(ctx, taskfile.Call{Task: d.Task, Vars: d.Vars})
+			if err != nil {
+				return err
+			}
+			return nil
 		})
 	}
 
@@ -236,7 +247,11 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi
 
 	switch {
 	case cmd.Task != "":
-		return e.RunTask(ctx, taskfile.Call{Task: cmd.Task, Vars: cmd.Vars})
+		err := e.RunTask(ctx, taskfile.Call{Task: cmd.Task, Vars: cmd.Vars})
+		if err != nil {
+			return err
+		}
+		return nil
 	case cmd.Cmd != "":
 		if e.Verbose || (!cmd.Silent && !t.Silent && !e.Silent) {
 			e.Logger.Errf(cmd.Cmd)
diff --git a/testdata/precondition/foo.txt b/testdata/precondition/foo.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/variables.go b/variables.go
index 8e360073..9c227372 100644
--- a/variables.go
+++ b/variables.go
@@ -84,5 +84,15 @@ func (e *Executor) CompiledTask(call taskfile.Call) (*taskfile.Task, error) {
 		}
 	}
 
+	if len(origTask.Preconditions) > 0 {
+		new.Preconditions = make([]*taskfile.Precondition, len(origTask.Preconditions))
+		for i, precond := range origTask.Preconditions {
+			new.Preconditions[i] = &taskfile.Precondition{
+				Sh:  r.Replace(precond.Sh),
+				Msg: r.Replace(precond.Msg),
+			}
+		}
+	}
+
 	return &new, r.Err()
 }


Test patch:
diff --git a/internal/taskfile/precondition_test.go b/internal/taskfile/precondition_test.go
new file mode 100644
index 00000000..799e9ac4
--- /dev/null
+++ b/internal/taskfile/precondition_test.go
@@ -0,0 +1,48 @@
+package taskfile_test
+
+import (
+	"testing"
+
+	"github.com/go-task/task/v2/internal/taskfile"
+
+	"github.com/stretchr/testify/assert"
+	"gopkg.in/yaml.v2"
+)
+
+func TestPreconditionParse(t *testing.T) {
+	tests := []struct {
+		content  string
+		v        interface{}
+		expected interface{}
+	}{
+		{
+			"test -f foo.txt",
+			&taskfile.Precondition{},
+			&taskfile.Precondition{Sh: `test -f foo.txt`, Msg: "`test -f foo.txt` failed"},
+		},
+		{
+			"sh: '[ 1 = 0 ]'",
+			&taskfile.Precondition{},
+			&taskfile.Precondition{Sh: "[ 1 = 0 ]", Msg: "[ 1 = 0 ] failed"},
+		},
+		{`
+sh: "[ 1 = 2 ]"
+msg: "1 is not 2"
+`,
+			&taskfile.Precondition{},
+			&taskfile.Precondition{Sh: "[ 1 = 2 ]", Msg: "1 is not 2"},
+		},
+		{`
+sh: "[ 1 = 2 ]"
+msg: "1 is not 2"
+`,
+			&taskfile.Precondition{},
+			&taskfile.Precondition{Sh: "[ 1 = 2 ]", Msg: "1 is not 2"},
+		},
+	}
+	for _, test := range tests {
+		err := yaml.Unmarshal([]byte(test.content), test.v)
+		assert.NoError(t, err)
+		assert.Equal(t, test.expected, test.v)
+	}
+}
diff --git a/task_test.go b/task_test.go
index e2278dc5..d42325c2 100644
--- a/task_test.go
+++ b/task_test.go
@@ -273,6 +273,47 @@ func TestStatus(t *testing.T) {
 	}
 }
 
+func TestPrecondition(t *testing.T) {
+	const dir = "testdata/precondition"
+
+	var buff bytes.Buffer
+	e := &task.Executor{
+		Dir:    dir,
+		Stdout: &buff,
+		Stderr: &buff,
+	}
+
+	// A precondition that has been met
+	assert.NoError(t, e.Setup())
+	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "foo"}))
+	if buff.String() != "" {
+		t.Errorf("Got Output when none was expected: %s", buff.String())
+	}
+
+	// A precondition that was not met
+	assert.Error(t, e.Run(context.Background(), taskfile.Call{Task: "impossible"}))
+
+	if buff.String() != "task: 1 != 0 obviously!\n" {
+		t.Errorf("Wrong output message: %s", buff.String())
+	}
+	buff.Reset()
+
+	// Calling a task with a precondition in a dependency fails the task
+	assert.Error(t, e.Run(context.Background(), taskfile.Call{Task: "depends_on_imposssible"}))
+
+	if buff.String() != "task: 1 != 0 obviously!\n" {
+		t.Errorf("Wrong output message: %s", buff.String())
+	}
+	buff.Reset()
+
+	// Calling a task with a precondition in a cmd fails the task
+	assert.Error(t, e.Run(context.Background(), taskfile.Call{Task: "executes_failing_task_as_cmd"}))
+	if buff.String() != "task: 1 != 0 obviously!\n" {
+		t.Errorf("Wrong output message: %s", buff.String())
+	}
+	buff.Reset()
+}
+
 func TestGenerates(t *testing.T) {
 	const (
 		srcTask        = "sub/src.txt"
diff --git a/testdata/precondition/Taskfile.yml b/testdata/precondition/Taskfile.yml
new file mode 100644
index 00000000..405140f7
--- /dev/null
+++ b/testdata/precondition/Taskfile.yml
@@ -0,0 +1,19 @@
+version: '2'
+
+tasks:
+  foo:
+    preconditions:
+      - test -f foo.txt
+
+  impossible:
+    preconditions:
+      - sh: "[ 1 = 0 ]"
+        msg: "1 != 0 obviously!"
+
+  depends_on_imposssible:
+    deps:
+      - impossible
+
+  executes_failing_task_as_cmd:
+    cmds:
+      - task: impossible