diff --git a/CHANGELOG.md b/CHANGELOG.md index 32390ef0..10283d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- A small behavior change was made to dependencies. Task will now wait for all + dependencies to finish running before continuing, even if any of them fail. + To opt for the previous behavior, set `failfast: true` either globally or per + task, or use the `--failfast` flag, which will also work for `--parallel` + (#1246, #2525 by @andreynering). - Fix RPM upload to Cloudsmith by including the version in the filename to ensure unique filenames (#2507 by @vmaerten). - Fix `run: when_changed` to work properly for Taskfiles included multiple times diff --git a/executor.go b/executor.go index 6ecf910a..723d65e2 100644 --- a/executor.go +++ b/executor.go @@ -47,6 +47,7 @@ type ( Color bool Concurrency int Interval time.Duration + Failfast bool // I/O Stdin io.Reader @@ -502,3 +503,16 @@ type versionCheckOption struct { func (o *versionCheckOption) ApplyToExecutor(e *Executor) { e.EnableVersionCheck = o.enableVersionCheck } + +// WithFailfast tells the [Executor] whether or not to check the version of +func WithFailfast(failfast bool) ExecutorOption { + return &failfastOption{failfast} +} + +type failfastOption struct { + failfast bool +} + +func (o *failfastOption) ApplyToExecutor(e *Executor) { + e.Failfast = o.failfast +} diff --git a/executor_test.go b/executor_test.go index 7f6a3feb..454a62ab 100644 --- a/executor_test.go +++ b/executor_test.go @@ -996,3 +996,64 @@ func TestIncludeChecksum(t *testing.T) { WithFixtureTemplating(), ) } + +func TestFailfast(t *testing.T) { + t.Parallel() + + t.Run("Default", func(t *testing.T) { + t.Parallel() + + NewExecutorTest(t, + WithName("default"), + WithExecutorOptions( + task.WithDir("testdata/failfast/default"), + task.WithSilent(true), + ), + WithPostProcessFn(PPSortedLines), + WithRunError(), + ) + }) + + t.Run("Option", func(t *testing.T) { + t.Parallel() + + NewExecutorTest(t, + WithName("default"), + WithExecutorOptions( + task.WithDir("testdata/failfast/default"), + task.WithSilent(true), + task.WithFailfast(true), + ), + WithPostProcessFn(PPSortedLines), + WithRunError(), + ) + }) + + t.Run("Global", func(t *testing.T) { + t.Parallel() + + NewExecutorTest(t, + WithName("global"), + WithExecutorOptions( + task.WithDir("testdata/failfast/global"), + task.WithSilent(true), + ), + WithPostProcessFn(PPSortedLines), + WithRunError(), + ) + }) + + t.Run("Task", func(t *testing.T) { + t.Parallel() + + NewExecutorTest(t, + WithName("task"), + WithExecutorOptions( + task.WithDir("testdata/failfast/task"), + task.WithSilent(true), + ), + WithPostProcessFn(PPSortedLines), + WithRunError(), + ) + }) +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 5787e17a..635d9de7 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -69,6 +69,7 @@ var ( Output ast.Output Color bool Interval time.Duration + Failfast bool Global bool Experiments bool Download bool @@ -137,6 +138,7 @@ func init() { pflag.BoolVarP(&Color, "color", "c", true, "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.") pflag.IntVarP(&Concurrency, "concurrency", "C", getConfig(config, func() *int { return config.Concurrency }, 0), "Limit number of tasks to run concurrently.") pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.") + pflag.BoolVar(&Failfast, "failfast", false, "When running tasks in parallel, stop all tasks if one fails.") pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.") pflag.BoolVar(&Experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.") @@ -253,6 +255,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithOutputStyle(Output), task.WithTaskSorter(sorter), task.WithVersionCheck(true), + task.WithFailfast(Failfast), ) } diff --git a/task.go b/task.go index 79bc36ac..cbdb8d8c 100644 --- a/task.go +++ b/task.go @@ -78,7 +78,10 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { return err } - g, ctx := errgroup.WithContext(ctx) + g := &errgroup.Group{} + if e.Failfast || e.Taskfile.Failfast { + g, ctx = errgroup.WithContext(ctx) + } for _, c := range regularCalls { if e.Parallel { g.Go(func() error { return e.RunTask(ctx, c) }) @@ -257,7 +260,10 @@ func (e *Executor) mkdir(t *ast.Task) error { } func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { - g, ctx := errgroup.WithContext(ctx) + g := &errgroup.Group{} + if e.Failfast || e.Taskfile.Failfast || t.Failfast { + g, ctx = errgroup.WithContext(ctx) + } reacquire := e.releaseConcurrencyLimit() defer reacquire() diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 325b713e..57db5ee1 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -42,6 +42,7 @@ type Task struct { Platforms []*Platform Watch bool Location *Location + Failfast bool // Populated during merging Namespace string `hash:"ignore"` IncludeVars *Vars @@ -143,6 +144,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { Platforms []*Platform Requires *Requires Watch bool + Failfast bool } if err := node.Decode(&task); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -181,6 +183,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { t.Platforms = task.Platforms t.Requires = task.Requires t.Watch = task.Watch + t.Failfast = task.Failfast return nil } @@ -226,6 +229,7 @@ func (t *Task) DeepCopy() *Task { Requires: t.Requires.DeepCopy(), Namespace: t.Namespace, FullName: t.FullName, + Failfast: t.Failfast, } return c } diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 8085e41b..1d29c4f6 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -34,6 +34,7 @@ type Taskfile struct { Dotenv []string Run string Interval time.Duration + Failfast bool } // Merge merges the second Taskfile into the first @@ -81,6 +82,7 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { Dotenv []string Run string Interval time.Duration + Failfast bool } if err := node.Decode(&taskfile); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -98,6 +100,7 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { tf.Dotenv = taskfile.Dotenv tf.Run = taskfile.Run tf.Interval = taskfile.Interval + tf.Failfast = taskfile.Failfast if tf.Includes == nil { tf.Includes = NewIncludes() } diff --git a/testdata/failfast/default/Taskfile.yaml b/testdata/failfast/default/Taskfile.yaml new file mode 100644 index 00000000..079ad05a --- /dev/null +++ b/testdata/failfast/default/Taskfile.yaml @@ -0,0 +1,14 @@ +version: '3' + +tasks: + default: + deps: + - dep1 + - dep2 + - dep3 + - dep4 + + dep1: sleep 0.1 && echo 'dep1' + dep2: sleep 0.2 && echo 'dep2' + dep3: sleep 0.3 && echo 'dep3' + dep4: exit 1 diff --git a/testdata/failfast/default/testdata/TestFailfast-Default-default-err-run.golden b/testdata/failfast/default/testdata/TestFailfast-Default-default-err-run.golden new file mode 100644 index 00000000..fccb62ab --- /dev/null +++ b/testdata/failfast/default/testdata/TestFailfast-Default-default-err-run.golden @@ -0,0 +1 @@ +task: Failed to run task "default": task: Failed to run task "dep4": exit status 1 diff --git a/testdata/failfast/default/testdata/TestFailfast-Default-default.golden b/testdata/failfast/default/testdata/TestFailfast-Default-default.golden new file mode 100644 index 00000000..46e96daa --- /dev/null +++ b/testdata/failfast/default/testdata/TestFailfast-Default-default.golden @@ -0,0 +1,3 @@ +dep1 +dep2 +dep3 diff --git a/testdata/failfast/default/testdata/TestFailfast-Option-default-err-run.golden b/testdata/failfast/default/testdata/TestFailfast-Option-default-err-run.golden new file mode 100644 index 00000000..fccb62ab --- /dev/null +++ b/testdata/failfast/default/testdata/TestFailfast-Option-default-err-run.golden @@ -0,0 +1 @@ +task: Failed to run task "default": task: Failed to run task "dep4": exit status 1 diff --git a/testdata/failfast/default/testdata/TestFailfast-Option-default.golden b/testdata/failfast/default/testdata/TestFailfast-Option-default.golden new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/testdata/failfast/default/testdata/TestFailfast-Option-default.golden @@ -0,0 +1 @@ + diff --git a/testdata/failfast/global/Taskfile.yaml b/testdata/failfast/global/Taskfile.yaml new file mode 100644 index 00000000..ac44f0cd --- /dev/null +++ b/testdata/failfast/global/Taskfile.yaml @@ -0,0 +1,16 @@ +version: '3' + +failfast: true + +tasks: + default: + deps: + - dep1 + - dep2 + - dep3 + - dep4 + + dep1: sleep 0.1 && echo 'dep1' + dep2: sleep 0.2 && echo 'dep2' + dep3: sleep 0.3 && echo 'dep3' + dep4: exit 1 diff --git a/testdata/failfast/global/testdata/TestFailfast-Global-global-err-run.golden b/testdata/failfast/global/testdata/TestFailfast-Global-global-err-run.golden new file mode 100644 index 00000000..fccb62ab --- /dev/null +++ b/testdata/failfast/global/testdata/TestFailfast-Global-global-err-run.golden @@ -0,0 +1 @@ +task: Failed to run task "default": task: Failed to run task "dep4": exit status 1 diff --git a/testdata/failfast/global/testdata/TestFailfast-Global-global.golden b/testdata/failfast/global/testdata/TestFailfast-Global-global.golden new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/testdata/failfast/global/testdata/TestFailfast-Global-global.golden @@ -0,0 +1 @@ + diff --git a/testdata/failfast/task/Taskfile.yaml b/testdata/failfast/task/Taskfile.yaml new file mode 100644 index 00000000..078ed17d --- /dev/null +++ b/testdata/failfast/task/Taskfile.yaml @@ -0,0 +1,15 @@ +version: '3' + +tasks: + default: + deps: + - dep1 + - dep2 + - dep3 + - dep4 + failfast: true + + dep1: sleep 0.1 && echo 'dep1' + dep2: sleep 0.2 && echo 'dep2' + dep3: sleep 0.3 && echo 'dep3' + dep4: exit 1 diff --git a/testdata/failfast/task/testdata/TestFailfast-Task-task-err-run.golden b/testdata/failfast/task/testdata/TestFailfast-Task-task-err-run.golden new file mode 100644 index 00000000..fccb62ab --- /dev/null +++ b/testdata/failfast/task/testdata/TestFailfast-Task-task-err-run.golden @@ -0,0 +1 @@ +task: Failed to run task "default": task: Failed to run task "dep4": exit status 1 diff --git a/testdata/failfast/task/testdata/TestFailfast-Task-task.golden b/testdata/failfast/task/testdata/TestFailfast-Task-task.golden new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/testdata/failfast/task/testdata/TestFailfast-Task-task.golden @@ -0,0 +1 @@ + diff --git a/variables.go b/variables.go index bec946bd..9e40edb2 100644 --- a/variables.go +++ b/variables.go @@ -71,6 +71,7 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) { Requires: origTask.Requires, Watch: origTask.Watch, Namespace: origTask.Namespace, + Failfast: origTask.Failfast, }, nil } @@ -125,6 +126,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err Location: origTask.Location, Requires: origTask.Requires, Watch: origTask.Watch, + Failfast: origTask.Failfast, Namespace: origTask.Namespace, FullName: fullName, } diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index 1fcee419..714d038f 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -591,6 +591,32 @@ tasks: - echo {{.TEXT}} ``` +### Fail-fast dependencies + +By default, Task waits for all dependencies to finish running before continuing. +If you want Task to stop executing further dependencies as soon as one fails, +you can set `failfast: true`: + +```yaml +version: '3' + +failfast: true # global option so it applies to all tasks + +tasks: + # ... +``` + +```yaml +version: '3' + +tasks: + default: + deps: [task1, task2, task3] + failfast: true # applies only to this task +``` + +Alternatively, you can use `--failfast`, which also work for `--parallel`. + ## Platform specific tasks and commands If you want to restrict the running of tasks to explicit platforms, this can be diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 8605d98b..d5ddc1e1 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -201,6 +201,11 @@ "description": "Configures a task to run in watch mode automatically.", "type": "boolean", "default": false + }, + "failfast": { + "description": "When running tasks in parallel, stop all tasks if one fails.", + "type": "boolean", + "default": false } } }, @@ -740,6 +745,11 @@ "description": "Sets a different watch interval when using `--watch`, the default being 100 milliseconds. This string should be a valid Go duration: https://pkg.go.dev/time#ParseDuration.", "type": "string", "pattern": "^[0-9]+(?:m|s|ms)$" + }, + "failfast": { + "description": "When running tasks in parallel, stop all tasks if one fails.", + "type": "boolean", + "default": false } }, "additionalProperties": false,