Skip to content

Add Preconditions to Tasks #205

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ dist/

# intellij idea/goland
.idea/

# exuberant ctags
tags
15 changes: 15 additions & 0 deletions docs/taskfile_versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions internal/taskfile/precondition.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions internal/taskfile/precondition_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
31 changes: 16 additions & 15 deletions internal/taskfile/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
12 changes: 12 additions & 0 deletions internal/taskfile/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreynering. please correct me if I am wrong.

2.4 and 2.5 have been released, so when adding a new feature to the taskfile here, it should probably come as a 2.6 version.

Since there are not checks for 2.4 and 2.5 today, I presume these two releases does not provide any updated to the Taskfile format, and does not need the v2X and Isv2X() definitions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I have forgot to bump these version checks. Let's keep these new variables, and I plan to fix the checks for for the [few] changes we had recently.

This new change should be guarded on v2.6 (the next release), though.

)

// IsV1 returns if is a given Taskfile version is version 1
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions precondition.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
27 changes: 21 additions & 6 deletions task.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,17 @@ 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):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreynering Should this be changed to version.isV26(v)? I'm a little confused about how this version detection works - since if I change the point version in my actual Taskfiles I get "X is only available on "

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal repro:

---
version: '2.3'
output: prefixed
task: Taskfile option "output" is only available starting on Taskfile version v2.1

This seems like a bug to me in that '2.3' is obviously > 2.1 - but maybe the version checking is only against the major version?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stephenprater Indeed, the version code needs some love.

In theory this should actually be:

case version.IsV2(v), version.IsV21(v), version.IsV22(v), version.IsV23(v), version.IsV24(v), version.IsV25(v), version.IsV26(v):

But there's likely a way to create a specific constraint for >= 2, < 3.

I'd say, let this as is, and I'll add a reminder to fix this once I merge this PR to master. 🙂

e.Compiler = &compilerv2.CompilerV2{
Dir: e.Dir,
Taskvars: e.taskvars,
TaskfileVars: e.Taskfile.Vars,
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 != "" {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
})
}

Expand All @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading