Skip to content

Commit d533aca

Browse files
committed
Add Preconditions to Tasks
1 parent 6ff9ba9 commit d533aca

File tree

14 files changed

+373
-21
lines changed

14 files changed

+373
-21
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ dist/
2121

2222
# intellij idea/goland
2323
.idea/
24+
25+
# exuberant ctags
26+
tags

docs/taskfile_versions.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,21 @@ includes:
141141
docker: ./DockerTasks.yml
142142
```
143143

144+
## Version 2.3
145+
146+
Version 2.3 comes with `preconditions` stanza in tasks.
147+
148+
```yaml
149+
version: '2'
150+
151+
tasks:
152+
upload_environment:
153+
preconditions:
154+
- test -f .env
155+
cmds:
156+
- aws s3 cp .env s3://myenvironment
157+
```
158+
144159
Please check the [documentation][includes]
145160

146161
[output]: usage.md#output-syntax

docs/usage.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,53 @@ up-to-date.
344344
Also, `task --status [tasks]...` will exit with a non-zero exit code if any of
345345
the tasks are not up-to-date.
346346

347+
If you need a certain set of conditions to be _true_ you can use the
348+
`preconditions` stanza. `preconditions` are very similar to `status`
349+
lines except they support `sh` expansion and they SHOULD all return 0
350+
351+
```yaml
352+
version: '2'
353+
354+
tasks:
355+
generate-files:
356+
cmds:
357+
- mkdir directory
358+
- touch directory/file1.txt
359+
- touch directory/file2.txt
360+
# test existence of files
361+
preconditions:
362+
- test -f .env
363+
- sh: "[ 1 = 0 ]"
364+
msg: "One doesn't equal Zero, Halting"
365+
```
366+
367+
Preconditions can set specific failure messages that can tell
368+
a user what to do using the `msg` field.
369+
370+
If a task has a dependency on a sub-task with a precondition, and that
371+
precondition is not met - the calling task will fail. Adding `ignore_errors`
372+
to the precondition will cause parent tasks to execute even if the sub task
373+
can not run. Note that a task executed directly with a failing precondition
374+
will not run unless `--force` is given.
375+
376+
```yaml
377+
version: '2'
378+
tasks:
379+
task_will_fail:
380+
preconditions:
381+
- sh: "exit 1"
382+
ignore_errors: true
383+
384+
task_will_succeed:
385+
deps:
386+
- task_will_fail
387+
388+
task_will_succeed:
389+
cmds:
390+
- task: task_will_fail
391+
- echo "I will run"
392+
```
393+
347394
## Variables
348395

349396
When doing interpolation of variables, Task will look for the below.

internal/taskfile/precondition.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package taskfile
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
var (
9+
// ErrCantUnmarshalPrecondition is returned for invalid precond YAML.
10+
ErrCantUnmarshalPrecondition = errors.New("task: can't unmarshal precondition value")
11+
)
12+
13+
// Precondition represents a precondition necessary for a task to run
14+
type Precondition struct {
15+
Sh string
16+
Msg string
17+
IgnoreError bool
18+
}
19+
20+
// UnmarshalYAML implements yaml.Unmarshaler interface.
21+
func (p *Precondition) UnmarshalYAML(unmarshal func(interface{}) error) error {
22+
var cmd string
23+
24+
if err := unmarshal(&cmd); err == nil {
25+
p.Sh = cmd
26+
p.Msg = fmt.Sprintf("`%s` failed", cmd)
27+
p.IgnoreError = false
28+
return nil
29+
}
30+
31+
var sh struct {
32+
Sh string
33+
Msg string
34+
IgnoreError bool `yaml:"ignore_error"`
35+
}
36+
37+
err := unmarshal(&sh)
38+
39+
if err == nil {
40+
p.Sh = sh.Sh
41+
p.Msg = sh.Msg
42+
if p.Msg == "" {
43+
p.Msg = fmt.Sprintf("%s failed", sh.Sh)
44+
}
45+
46+
p.IgnoreError = sh.IgnoreError
47+
return nil
48+
}
49+
50+
return err
51+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package taskfile_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/go-task/task/v2/internal/taskfile"
7+
8+
"github.com/stretchr/testify/assert"
9+
"gopkg.in/yaml.v2"
10+
)
11+
12+
func TestPreconditionParse(t *testing.T) {
13+
tests := []struct {
14+
content string
15+
v interface{}
16+
expected interface{}
17+
}{
18+
{
19+
"test -f foo.txt",
20+
&taskfile.Precondition{},
21+
&taskfile.Precondition{Sh: `test -f foo.txt`, Msg: "`test -f foo.txt` failed", IgnoreError: false},
22+
},
23+
{
24+
"sh: '[ 1 = 0 ]'",
25+
&taskfile.Precondition{},
26+
&taskfile.Precondition{Sh: "[ 1 = 0 ]", Msg: "[ 1 = 0 ] failed", IgnoreError: false},
27+
},
28+
{`
29+
sh: "[ 1 = 2 ]"
30+
msg: "1 is not 2"
31+
`,
32+
&taskfile.Precondition{},
33+
&taskfile.Precondition{Sh: "[ 1 = 2 ]", Msg: "1 is not 2", IgnoreError: false},
34+
},
35+
{`
36+
sh: "[ 1 = 2 ]"
37+
msg: "1 is not 2"
38+
ignore_error: true
39+
`,
40+
&taskfile.Precondition{},
41+
&taskfile.Precondition{Sh: "[ 1 = 2 ]", Msg: "1 is not 2", IgnoreError: true},
42+
},
43+
}
44+
for _, test := range tests {
45+
err := yaml.Unmarshal([]byte(test.content), test.v)
46+
assert.NoError(t, err)
47+
assert.Equal(t, test.expected, test.v)
48+
}
49+
}

internal/taskfile/task.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ type Tasks map[string]*Task
55

66
// Task represents a task
77
type Task struct {
8-
Task string
9-
Cmds []*Cmd
10-
Deps []*Dep
11-
Desc string
12-
Summary string
13-
Sources []string
14-
Generates []string
15-
Status []string
16-
Dir string
17-
Vars Vars
18-
Env Vars
19-
Silent bool
20-
Method string
21-
Prefix string
22-
IgnoreError bool `yaml:"ignore_error"`
8+
Task string
9+
Cmds []*Cmd
10+
Deps []*Dep
11+
Desc string
12+
Summary string
13+
Sources []string
14+
Generates []string
15+
Status []string
16+
Precondition []*Precondition
17+
Dir string
18+
Vars Vars
19+
Env Vars
20+
Silent bool
21+
Method string
22+
Prefix string
23+
IgnoreError bool `yaml:"ignore_error"`
2324
}

internal/taskfile/version/version.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ var (
1010
v21 = mustVersion("2.1")
1111
v22 = mustVersion("2.2")
1212
v23 = mustVersion("2.3")
13+
v24 = mustVersion("2.4")
14+
v25 = mustVersion("2.5")
1315
)
1416

1517
// IsV1 returns if is a given Taskfile version is version 1
@@ -37,6 +39,16 @@ func IsV23(v *semver.Constraints) bool {
3739
return v.Check(v23)
3840
}
3941

42+
// IsV24 returns if is a given Taskfile version is at least version 2.4
43+
func IsV24(v *semver.Constraints) bool {
44+
return v.Check(v24)
45+
}
46+
47+
// IsV25 returns if is a given Taskfile version is at least version 2.5
48+
func IsV25(v *semver.Constraints) bool {
49+
return v.Check(v25)
50+
}
51+
4052
func mustVersion(s string) *semver.Version {
4153
v, err := semver.NewVersion(s)
4254
if err != nil {

precondition.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Package task provides ...
2+
package task
3+
4+
import (
5+
"context"
6+
"errors"
7+
8+
"github.com/go-task/task/v2/internal/execext"
9+
"github.com/go-task/task/v2/internal/taskfile"
10+
)
11+
12+
var (
13+
// ErrPreconditionFailed is returned when a precondition fails
14+
ErrNecessaryPreconditionFailed = errors.New("task: precondition not met")
15+
ErrOptionalPreconditionFailed = errors.New("task: optional precondition not met")
16+
)
17+
18+
func (e *Executor) areTaskPreconditionsMet(ctx context.Context, t *taskfile.Task) (bool, error) {
19+
var optionalPreconditionFailed bool
20+
for _, p := range t.Precondition {
21+
err := execext.RunCommand(ctx, &execext.RunCommandOptions{
22+
Command: p.Sh,
23+
Dir: t.Dir,
24+
Env: getEnviron(t),
25+
})
26+
27+
if err != nil {
28+
e.Logger.Outf(p.Msg)
29+
if p.IgnoreError == true {
30+
optionalPreconditionFailed = true
31+
} else {
32+
return false, ErrNecessaryPreconditionFailed
33+
}
34+
}
35+
}
36+
37+
if optionalPreconditionFailed == true {
38+
return true, ErrOptionalPreconditionFailed
39+
}
40+
41+
return true, nil
42+
}

status.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,10 @@ func (e *Executor) isTaskUpToDateStatus(ctx context.Context, t *taskfile.Task) (
7878
Env: getEnviron(t),
7979
})
8080
if err != nil {
81+
e.Logger.VerboseOutf("task: status command %s exited non-zero: %s", s, err)
8182
return false, nil
8283
}
84+
e.Logger.VerboseOutf("task: status command %s exited zero", s)
8385
}
8486
return true, nil
8587
}

task.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,17 @@ func (e *Executor) Setup() error {
119119
Vars: e.taskvars,
120120
Logger: e.Logger,
121121
}
122-
case version.IsV2(v), version.IsV21(v), version.IsV22(v):
122+
case version.IsV2(v), version.IsV21(v), version.IsV22(v), version.IsV23(v):
123123
e.Compiler = &compilerv2.CompilerV2{
124124
Dir: e.Dir,
125125
Taskvars: e.taskvars,
126126
TaskfileVars: e.Taskfile.Vars,
127127
Expansions: e.Taskfile.Expansions,
128128
Logger: e.Logger,
129129
}
130-
case version.IsV23(v):
131-
return fmt.Errorf(`task: Taskfile versions greater than v2.3 not implemented in the version of Task`)
130+
131+
case version.IsV24(v):
132+
return fmt.Errorf(`task: Taskfile versions greater than v2.4 not implemented in the version of Task`)
132133
}
133134

134135
if !version.IsV21(v) && e.Taskfile.Output != "" {
@@ -192,7 +193,13 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error {
192193
if err != nil {
193194
return err
194195
}
195-
if upToDate {
196+
197+
preCondMet, err := e.areTaskPreconditionsMet(ctx, t)
198+
if err != nil {
199+
return err
200+
}
201+
202+
if upToDate && preCondMet {
196203
if !e.Silent {
197204
e.Logger.Errf(`task: Task "%s" is up to date`, t.Task)
198205
}
@@ -224,7 +231,15 @@ func (e *Executor) runDeps(ctx context.Context, t *taskfile.Task) error {
224231
d := d
225232

226233
g.Go(func() error {
227-
return e.RunTask(ctx, taskfile.Call{Task: d.Task, Vars: d.Vars})
234+
err := e.RunTask(ctx, taskfile.Call{Task: d.Task, Vars: d.Vars})
235+
if err != nil {
236+
if err == ErrOptionalPreconditionFailed {
237+
e.Logger.Errf("%s", err)
238+
} else {
239+
return err
240+
}
241+
}
242+
return nil
228243
})
229244
}
230245

@@ -236,7 +251,15 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi
236251

237252
switch {
238253
case cmd.Task != "":
239-
return e.RunTask(ctx, taskfile.Call{Task: cmd.Task, Vars: cmd.Vars})
254+
err := e.RunTask(ctx, taskfile.Call{Task: cmd.Task, Vars: cmd.Vars})
255+
if err != nil {
256+
if err == ErrOptionalPreconditionFailed {
257+
e.Logger.Errf("%s", err)
258+
} else {
259+
return err
260+
}
261+
}
262+
return nil
240263
case cmd.Cmd != "":
241264
if e.Verbose || (!cmd.Silent && !t.Silent && !e.Silent) {
242265
e.Logger.Errf(cmd.Cmd)

0 commit comments

Comments
 (0)