diff --git a/executor.go b/executor.go index 03b951a166..1f756dc0ea 100644 --- a/executor.go +++ b/executor.go @@ -26,31 +26,32 @@ type ( // within them. Executor struct { // Flags - Dir string - Entrypoint string - TempDir TempDir - Force bool - ForceAll bool - Insecure bool - Download bool - Offline bool - TrustedHosts []string - Timeout time.Duration - CacheExpiryDuration time.Duration - RemoteCacheDir string - Watch bool - Verbose bool - Silent bool - DisableFuzzy bool - AssumeYes bool - AssumeTerm bool // Used for testing - Dry bool - Summary bool - Parallel bool - Color bool - Concurrency int - Interval time.Duration - Failfast bool + Dir string + Entrypoint string + TempDir TempDir + Force bool + ForceAll bool + Insecure bool + Download bool + Offline bool + TrustedHosts []string + Timeout time.Duration + CacheExpiryDuration time.Duration + RemoteCacheDir string + Watch bool + Verbose bool + Silent bool + DisableFuzzy bool + AssumeYes bool + AssumeTerm bool // Used for testing + Dry bool + Summary bool + Parallel bool + PropagateSharedErrors bool + Color bool + Concurrency int + Interval time.Duration + Failfast bool // I/O Stdin io.Reader @@ -409,6 +410,22 @@ func (o *parallelOption) ApplyToExecutor(e *Executor) { e.Parallel = o.parallel } +// WithPropagateSharedErrors tells the [Executor] to propagate errors from shared +// task executions (e.g. tasks with `run: once`) to all concurrent waiters. +// When disabled, Task will still wait for the shared execution to complete, but +// other waiters will not fail if the shared execution fails (legacy behavior). +func WithPropagateSharedErrors(propagate bool) ExecutorOption { + return &propagateSharedErrorsOption{propagate} +} + +type propagateSharedErrorsOption struct { + propagate bool +} + +func (o *propagateSharedErrorsOption) ApplyToExecutor(e *Executor) { + e.PropagateSharedErrors = o.propagate +} + // WithColor tells the [Executor] whether or not to output using colorized // strings. func WithColor(color bool) ExecutorOption { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 6019e51d5f..4671232812 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -44,45 +44,46 @@ Options: ` var ( - Version bool - Help bool - Init bool - Completion string - List bool - ListAll bool - ListJson bool - TaskSort string - Status bool - NoStatus bool - Nested bool - Insecure bool - Force bool - ForceAll bool - Watch bool - Verbose bool - Silent bool - DisableFuzzy bool - AssumeYes bool - Dry bool - Summary bool - ExitCode bool - Parallel bool - Concurrency int - Dir string - Entrypoint string - Output ast.Output - Color bool - Interval time.Duration - Failfast bool - Global bool - Experiments bool - Download bool - Offline bool - TrustedHosts []string - ClearCache bool - Timeout time.Duration - CacheExpiryDuration time.Duration - RemoteCacheDir string + Version bool + Help bool + Init bool + Completion string + List bool + ListAll bool + ListJson bool + TaskSort string + Status bool + NoStatus bool + Nested bool + Insecure bool + Force bool + ForceAll bool + Watch bool + Verbose bool + Silent bool + DisableFuzzy bool + AssumeYes bool + Dry bool + Summary bool + ExitCode bool + Parallel bool + PropagateSharedErrors bool + Concurrency int + Dir string + Entrypoint string + Output ast.Output + Color bool + Interval time.Duration + Failfast bool + Global bool + Experiments bool + Download bool + Offline bool + TrustedHosts []string + ClearCache bool + Timeout time.Duration + CacheExpiryDuration time.Duration + RemoteCacheDir string ) func init() { @@ -133,6 +134,7 @@ func init() { pflag.BoolVar(&DisableFuzzy, "disable-fuzzy", getConfig(config, func() *bool { return config.DisableFuzzy }, false), "Disables fuzzy matching for task names.") pflag.BoolVarP(&AssumeYes, "yes", "y", false, "Assume \"yes\" as answer to all prompts.") pflag.BoolVarP(&Parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.") + pflag.BoolVar(&PropagateSharedErrors, "propagate-shared-errors", false, "When tasks share execution (e.g. run: once), propagate errors from the shared task to all dependents.") pflag.BoolVarP(&Dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.") pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.") pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.") @@ -284,6 +286,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithDry(Dry || Status), task.WithSummary(Summary), task.WithParallel(Parallel), + task.WithPropagateSharedErrors(PropagateSharedErrors), task.WithColor(Color), task.WithConcurrency(Concurrency), task.WithInterval(Interval), diff --git a/task.go b/task.go index 489ef7e5dd..fd4e64e4c5 100644 --- a/task.go +++ b/task.go @@ -387,16 +387,23 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func defer reacquire() <-otherExecutionCtx.Done() - return nil + + // Legacy behavior: shared executions are waited on, but their errors are not propagated. + if !e.PropagateSharedErrors { + return nil + } + return context.Cause(otherExecutionCtx) } - ctx, cancel := context.WithCancel(ctx) - defer cancel() + ctx, cancel := context.WithCancelCause(ctx) + defer func() { cancel(err) }() e.executionHashes[h] = ctx e.executionHashesMutex.Unlock() - return execute(ctx) + // Save err in variable so it also applied to the cancel defer + err = execute(ctx) + return err } // FindMatchingTasks returns a list of tasks that match the given call. A task diff --git a/task_test.go b/task_test.go index 52a147f02a..0fbfacc3c7 100644 --- a/task_test.go +++ b/task_test.go @@ -1874,6 +1874,32 @@ func TestRunOnceSharedDeps(t *testing.T) { assert.Contains(t, buff.String(), `task: [service-b:build] echo "build b"`) } +func TestRunOnceSharedDepsFail(t *testing.T) { + t.Parallel() + + const dir = "testdata/run_once_shared_deps_fail" + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithForceAll(true), + task.WithPropagateSharedErrors(true), + ) + require.NoError(t, e.Setup()) + require.Error(t, e.Run(t.Context(), &task.Call{Task: "build"})) + + // The shared dependency should still only be attempted once. + rx := regexp.MustCompile(`task: \[service-[a,b]:library:build\] echo "build library" && exit 1`) + matches := rx.FindAllStringSubmatch(buff.String(), -1) + assert.Len(t, matches, 1) + + // If the shared dependency fails, both branches must be blocked from running their own commands. + assert.NotContains(t, buff.String(), `task: [service-a:build] echo "build a"`) + assert.NotContains(t, buff.String(), `task: [service-b:build] echo "build b"`) +} + func TestRunWhenChanged(t *testing.T) { t.Parallel() diff --git a/testdata/run_once_shared_deps_fail/Taskfile.yml b/testdata/run_once_shared_deps_fail/Taskfile.yml new file mode 100644 index 0000000000..3a5194add8 --- /dev/null +++ b/testdata/run_once_shared_deps_fail/Taskfile.yml @@ -0,0 +1,13 @@ +version: '3' + +includes: + service-a: ./service-a + service-b: ./service-b + +tasks: + build: + deps: + - service-a:build + - service-b:build + + diff --git a/testdata/run_once_shared_deps_fail/library/Taskfile.yml b/testdata/run_once_shared_deps_fail/library/Taskfile.yml new file mode 100644 index 0000000000..b7100e11e8 --- /dev/null +++ b/testdata/run_once_shared_deps_fail/library/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +tasks: + build: + run: once + cmds: + - echo "build library" && exit 1 + + diff --git a/testdata/run_once_shared_deps_fail/service-a/Taskfile.yml b/testdata/run_once_shared_deps_fail/service-a/Taskfile.yml new file mode 100644 index 0000000000..dac297b2a5 --- /dev/null +++ b/testdata/run_once_shared_deps_fail/service-a/Taskfile.yml @@ -0,0 +1,15 @@ +version: '3' + +includes: + library: + taskfile: ../library/Taskfile.yml + dir: ../library + +tasks: + build: + run: once + deps: [library:build] + cmds: + - echo "build a" + + diff --git a/testdata/run_once_shared_deps_fail/service-b/Taskfile.yml b/testdata/run_once_shared_deps_fail/service-b/Taskfile.yml new file mode 100644 index 0000000000..9f36af6f9c --- /dev/null +++ b/testdata/run_once_shared_deps_fail/service-b/Taskfile.yml @@ -0,0 +1,13 @@ +version: '3' + +includes: + library: + taskfile: ../library/Taskfile.yml + dir: ../library + +tasks: + build: + run: once + deps: [library:build] + cmds: + - echo "build b" diff --git a/website/src/docs/reference/cli.md b/website/src/docs/reference/cli.md index c434e5b854..919a444ff5 100644 --- a/website/src/docs/reference/cli.md +++ b/website/src/docs/reference/cli.md @@ -152,6 +152,14 @@ Execute multiple tasks in parallel. task test lint --parallel ``` +#### `--propagate-shared-errors` + +When tasks share execution (e.g. tasks with `run: once`), propagate errors from the shared task to all dependents. + +```bash +task build --propagate-shared-errors +``` + #### `-C, --concurrency ` Limit the number of concurrent tasks. Zero means unlimited.