mirror of
				https://github.com/go-task/task.git
				synced 2025-10-30 23:58:01 +02:00 
			
		
		
		
	Add Preconditions to Tasks
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -21,3 +21,6 @@ dist/ | ||||
|  | ||||
| # intellij idea/goland | ||||
| .idea/ | ||||
|  | ||||
| # exuberant ctags | ||||
| tags | ||||
|   | ||||
| @@ -141,6 +141,21 @@ includes: | ||||
|   docker: ./DockerTasks.yml | ||||
| ``` | ||||
|  | ||||
| ## Version 2.3 | ||||
|  | ||||
| Version 2.3 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 | ||||
|   | ||||
| @@ -344,6 +344,53 @@ 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 to do 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.  Adding `ignore_errors` | ||||
| to the precondition will cause parent tasks to execute even if the sub task | ||||
| can not run.  Note that a task executed directly with a failing precondition | ||||
| will not run unless `--force` is given. | ||||
|  | ||||
| ```yaml | ||||
| version: '2' | ||||
| tasks: | ||||
|   task_will_fail: | ||||
|     preconditions: | ||||
|       - sh: "exit 1" | ||||
|         ignore_errors: true | ||||
|  | ||||
|   task_will_succeed: | ||||
|   deps: | ||||
|     - task_will_fail | ||||
|  | ||||
|   task_will_succeed: | ||||
|   cmds: | ||||
|     - task: task_will_fail | ||||
|     - echo "I will run" | ||||
| ``` | ||||
|  | ||||
| ## Variables | ||||
|  | ||||
| When doing interpolation of variables, Task will look for the below. | ||||
|   | ||||
							
								
								
									
										51
									
								
								internal/taskfile/precondition.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								internal/taskfile/precondition.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| 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 | ||||
| 	IgnoreError bool | ||||
| } | ||||
|  | ||||
| // 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) | ||||
| 		p.IgnoreError = false | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	var sh struct { | ||||
| 		Sh          string | ||||
| 		Msg         string | ||||
| 		IgnoreError bool `yaml:"ignore_error"` | ||||
| 	} | ||||
|  | ||||
| 	err := unmarshal(&sh) | ||||
|  | ||||
| 	if err == nil { | ||||
| 		p.Sh = sh.Sh | ||||
| 		p.Msg = sh.Msg | ||||
| 		if p.Msg == "" { | ||||
| 			p.Msg = fmt.Sprintf("%s failed", sh.Sh) | ||||
| 		} | ||||
|  | ||||
| 		p.IgnoreError = sh.IgnoreError | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	return err | ||||
| } | ||||
							
								
								
									
										49
									
								
								internal/taskfile/precondition_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								internal/taskfile/precondition_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| 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", IgnoreError: false}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"sh: '[ 1 = 0 ]'", | ||||
| 			&taskfile.Precondition{}, | ||||
| 			&taskfile.Precondition{Sh: "[ 1 = 0 ]", Msg: "[ 1 = 0 ] failed", IgnoreError: false}, | ||||
| 		}, | ||||
| 		{` | ||||
| sh: "[ 1 = 2 ]" | ||||
| msg: "1 is not 2" | ||||
| `, | ||||
| 			&taskfile.Precondition{}, | ||||
| 			&taskfile.Precondition{Sh: "[ 1 = 2 ]", Msg: "1 is not 2", IgnoreError: false}, | ||||
| 		}, | ||||
| 		{` | ||||
| sh: "[ 1 = 2 ]" | ||||
| msg: "1 is not 2" | ||||
| ignore_error: true | ||||
| `, | ||||
| 			&taskfile.Precondition{}, | ||||
| 			&taskfile.Precondition{Sh: "[ 1 = 2 ]", Msg: "1 is not 2", IgnoreError: true}, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, test := range tests { | ||||
| 		err := yaml.Unmarshal([]byte(test.content), test.v) | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, test.expected, test.v) | ||||
| 	} | ||||
| } | ||||
| @@ -13,6 +13,7 @@ type Task struct { | ||||
| 	Sources      []string | ||||
| 	Generates    []string | ||||
| 	Status       []string | ||||
| 	Precondition []*Precondition | ||||
| 	Dir          string | ||||
| 	Vars         Vars | ||||
| 	Env          Vars | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
							
								
								
									
										44
									
								
								precondition.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								precondition.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| // Package task provides ... | ||||
| package task | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
|  | ||||
| 	"github.com/go-task/task/v2/internal/execext" | ||||
| 	"github.com/go-task/task/v2/internal/taskfile" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	// ErrNecessaryPreconditionFailed is returned when a precondition fails | ||||
| 	ErrNecessaryPreconditionFailed = errors.New("task: precondition not met") | ||||
| 	// ErrOptionalPreconditionFailed is returned when a precondition fails | ||||
| 	// that has ignore_error set to true | ||||
| 	ErrOptionalPreconditionFailed = errors.New("task: optional precondition not met") | ||||
| ) | ||||
|  | ||||
| func (e *Executor) areTaskPreconditionsMet(ctx context.Context, t *taskfile.Task) (bool, error) { | ||||
| 	var optionalPreconditionFailed bool | ||||
| 	for _, p := range t.Precondition { | ||||
| 		err := execext.RunCommand(ctx, &execext.RunCommandOptions{ | ||||
| 			Command: p.Sh, | ||||
| 			Dir:     t.Dir, | ||||
| 			Env:     getEnviron(t), | ||||
| 		}) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			e.Logger.Outf(p.Msg) | ||||
| 			if p.IgnoreError == true { | ||||
| 				optionalPreconditionFailed = true | ||||
| 			} else { | ||||
| 				return false, ErrNecessaryPreconditionFailed | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if optionalPreconditionFailed == true { | ||||
| 		return true, ErrOptionalPreconditionFailed | ||||
| 	} | ||||
|  | ||||
| 	return true, nil | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										35
									
								
								task.go
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								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 != "" { | ||||
| @@ -192,7 +193,13 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error { | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if upToDate { | ||||
|  | ||||
| 		preCondMet, err := e.areTaskPreconditionsMet(ctx, t) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if upToDate && preCondMet { | ||||
| 			if !e.Silent { | ||||
| 				e.Logger.Errf(`task: Task "%s" is up to date`, t.Task) | ||||
| 			} | ||||
| @@ -224,7 +231,15 @@ 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 { | ||||
| 				if err == ErrOptionalPreconditionFailed { | ||||
| 					e.Logger.Errf("%s", err) | ||||
| 				} else { | ||||
| 					return err | ||||
| 				} | ||||
| 			} | ||||
| 			return nil | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| @@ -236,7 +251,15 @@ 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 { | ||||
| 			if err == ErrOptionalPreconditionFailed { | ||||
| 				e.Logger.Errf("%s", err) | ||||
| 			} else { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 		return nil | ||||
| 	case cmd.Cmd != "": | ||||
| 		if e.Verbose || (!cmd.Silent && !t.Silent && !e.Silent) { | ||||
| 			e.Logger.Errf(cmd.Cmd) | ||||
|   | ||||
							
								
								
									
										62
									
								
								task_test.go
									
									
									
									
									
								
							
							
						
						
									
										62
									
								
								task_test.go
									
									
									
									
									
								
							| @@ -273,6 +273,68 @@ 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, | ||||
| 		Silent: false, | ||||
| 	} | ||||
|  | ||||
| 	// 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() != "1 != 0\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() != "1 != 0\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() != "1 != 0\n" { | ||||
| 		t.Errorf("Wrong output message: %s", buff.String()) | ||||
| 	} | ||||
| 	buff.Reset() | ||||
|  | ||||
| 	// A task with a failing precondition and ignore_errors on still fails | ||||
| 	assert.Error(t, e.Run(context.Background(), taskfile.Call{Task: "impossible_but_i_dont_care"})) | ||||
| 	if buff.String() != "2 != 1\n" { | ||||
| 		t.Errorf("Wrong output message: %s", buff.String()) | ||||
| 	} | ||||
| 	buff.Reset() | ||||
|  | ||||
| 	// If a precondition has ignore errors, then it will allow _dependent_ tasks to execute | ||||
| 	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "depends_on_failure_of_impossible"})) | ||||
| 	if buff.String() != "2 != 1\ntask: optional precondition not met\n" { | ||||
| 		t.Errorf("Wrong output message: %s", buff.String()) | ||||
| 	} | ||||
| 	buff.Reset() | ||||
|  | ||||
| 	// If a precondition has ignore errors, then it will allow tasks calling it to execute | ||||
| 	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "executes_failing_task_as_cmd_but_succeeds"})) | ||||
| 	if buff.String() != "2 != 1\ntask: optional precondition not met\n" { | ||||
| 		t.Errorf("Wrong output message: %s", buff.String()) | ||||
| 	} | ||||
|  | ||||
| } | ||||
|  | ||||
| func TestGenerates(t *testing.T) { | ||||
| 	const ( | ||||
| 		srcTask        = "sub/src.txt" | ||||
|   | ||||
							
								
								
									
										34
									
								
								testdata/precondition/Taskfile.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								testdata/precondition/Taskfile.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| version: '2' | ||||
|  | ||||
| tasks: | ||||
|   foo: | ||||
|     precondition: | ||||
|       - test -f foo.txt | ||||
|  | ||||
|   impossible: | ||||
|     precondition: | ||||
|       - sh: "[ 1 = 0 ]" | ||||
|         msg: "1 != 0" | ||||
|  | ||||
|   impossible_but_i_dont_care: | ||||
|     precondition: | ||||
|       - sh: "[ 2 = 1 ]" | ||||
|         msg: "2 != 1" | ||||
|         ignore_error: true | ||||
|  | ||||
|   depends_on_imposssible: | ||||
|     deps: | ||||
|       - impossible | ||||
|  | ||||
|   executes_failing_task_as_cmd: | ||||
|     cmds: | ||||
|       - task: impossible | ||||
|  | ||||
|   depends_on_failure_of_impossible: | ||||
|     deps: | ||||
|       - impossible_but_i_dont_care | ||||
|  | ||||
|   executes_failing_task_as_cmd_but_succeeds: | ||||
|     cmds: | ||||
|       - task: impossible_but_i_dont_care | ||||
|  | ||||
							
								
								
									
										0
									
								
								testdata/precondition/foo.txt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								testdata/precondition/foo.txt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
								
								
									
										11
									
								
								variables.go
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								variables.go
									
									
									
									
									
								
							| @@ -73,6 +73,7 @@ func (e *Executor) CompiledTask(call taskfile.Call) (*taskfile.Task, error) { | ||||
| 				IgnoreError: cmd.IgnoreError, | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
| 	if len(origTask.Deps) > 0 { | ||||
| 		new.Deps = make([]*taskfile.Dep, len(origTask.Deps)) | ||||
| @@ -83,6 +84,16 @@ func (e *Executor) CompiledTask(call taskfile.Call) (*taskfile.Task, error) { | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	if len(origTask.Precondition) > 0 { | ||||
| 		new.Precondition = make([]*taskfile.Precondition, len(origTask.Precondition)) | ||||
| 		for i, precond := range origTask.Precondition { | ||||
| 			new.Precondition[i] = &taskfile.Precondition{ | ||||
| 				Sh:          r.Replace(precond.Sh), | ||||
| 				Msg:         r.Replace(precond.Msg), | ||||
| 				IgnoreError: precond.IgnoreError, | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return &new, r.Err() | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user