diff --git a/executor_test.go b/executor_test.go index 6e3ff3e1ec..5566100d79 100644 --- a/executor_test.go +++ b/executor_test.go @@ -609,6 +609,48 @@ func TestPrecondition(t *testing.T) { ) } +func TestGlobalPrecondition(t *testing.T) { + t.Parallel() + NewExecutorTest(t, + WithName("global precondition met"), + WithExecutorOptions( + task.WithDir("testdata/global_precondition"), + ), + WithTask("passing"), + ) + NewExecutorTest(t, + WithName("global precondition met - included task"), + WithExecutorOptions( + task.WithDir("testdata/global_precondition"), + ), + WithTask("inc:task"), + ) + NewExecutorTest(t, + WithName("global precondition not met"), + WithExecutorOptions( + task.WithDir("testdata/global_precondition_failing"), + ), + WithTask("task"), + WithRunError(), + ) + NewExecutorTest(t, + WithName("global precondition not met - included task"), + WithExecutorOptions( + task.WithDir("testdata/global_precondition_failing"), + ), + WithTask("inc:task"), + WithRunError(), + ) + NewExecutorTest(t, + WithName("global precondition not met - task with own precondition"), + WithExecutorOptions( + task.WithDir("testdata/global_precondition_failing"), + ), + WithTask("task-with-own-precondition"), + WithRunError(), + ) +} + func TestAlias(t *testing.T) { t.Parallel() diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go index cb30093d88..b467a4c723 100644 --- a/taskfile/ast/graph.go +++ b/taskfile/ast/graph.go @@ -3,6 +3,7 @@ package ast import ( "fmt" "os" + "slices" "sync" "github.com/dominikbraun/graph" @@ -116,5 +117,12 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { return nil, err } + // Apply the root taskfile's global preconditions to all tasks. + if len(rootVertex.Taskfile.Preconditions) > 0 { + for task := range rootVertex.Taskfile.Tasks.Values(nil) { + task.Preconditions = slices.Concat(rootVertex.Taskfile.Preconditions, task.Preconditions) + } + } + return rootVertex.Taskfile, nil } diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 4e3a3e4255..c49d725aac 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -20,20 +20,21 @@ var ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles c // Taskfile is the abstract syntax tree for a Taskfile type Taskfile struct { - Location string - Version *semver.Version - Output Output - Method string - Includes *Includes - Set []string - Shopt []string - Vars *Vars - Env *Vars - Tasks *Tasks - Silent bool - Dotenv []string - Run string - Interval time.Duration + Location string + Version *semver.Version + Output Output + Method string + Includes *Includes + Set []string + Shopt []string + Vars *Vars + Env *Vars + Tasks *Tasks + Silent bool + Dotenv []string + Run string + Interval time.Duration + Preconditions []*Precondition } // Merge merges the second Taskfile into the first @@ -69,26 +70,27 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { } t1.Vars.Merge(t2.Vars, include) t1.Env.Merge(t2.Env, include) - return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) + return t1.Tasks.Merge(t2.Tasks, include, t1.Vars, t2.Preconditions) } func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { switch node.Kind { case yaml.MappingNode: var taskfile struct { - Version *semver.Version - Output Output - Method string - Includes *Includes - Set []string - Shopt []string - Vars *Vars - Env *Vars - Tasks *Tasks - Silent bool - Dotenv []string - Run string - Interval time.Duration + Version *semver.Version + Output Output + Method string + Includes *Includes + Set []string + Shopt []string + Vars *Vars + Env *Vars + Tasks *Tasks + Silent bool + Dotenv []string + Run string + Interval time.Duration + Preconditions []*Precondition } if err := node.Decode(&taskfile); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -106,6 +108,7 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { tf.Dotenv = taskfile.Dotenv tf.Run = taskfile.Run tf.Interval = taskfile.Interval + tf.Preconditions = taskfile.Preconditions if tf.Includes == nil { tf.Includes = NewIncludes() } diff --git a/taskfile/ast/tasks.go b/taskfile/ast/tasks.go index 62aa53a6b4..f9962e2742 100644 --- a/taskfile/ast/tasks.go +++ b/taskfile/ast/tasks.go @@ -118,13 +118,15 @@ func (t *Tasks) Values(sorter sort.Sorter) iter.Seq[*Task] { } } -func (t1 *Tasks) Merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars) error { +func (t1 *Tasks) Merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars, globalPreconditions []*Precondition) error { defer t2.mutex.RUnlock() t2.mutex.RLock() for name, v := range t2.All(nil) { // We do a deep copy of the task struct here to ensure that no data can // be changed elsewhere once the taskfile is merged. task := v.DeepCopy() + // Prepend the included taskfile's global preconditions to the task's own preconditions. + task.Preconditions = slices.Concat(globalPreconditions, task.Preconditions) // Set the task to internal if EITHER the included task or the included // taskfile are marked as internal task.Internal = task.Internal || (include != nil && include.Internal) diff --git a/testdata/global_precondition/Taskfile.yml b/testdata/global_precondition/Taskfile.yml new file mode 100644 index 0000000000..51768ad10a --- /dev/null +++ b/testdata/global_precondition/Taskfile.yml @@ -0,0 +1,17 @@ +version: '3' + +preconditions: + - sh: "[ 1 = 1 ]" + msg: "global precondition met" + +includes: + inc: ./included + +tasks: + passing: + cmds: + - echo "ran passing" + + failing: + cmds: + - echo "ran failing" diff --git a/testdata/global_precondition/included/Taskfile.yml b/testdata/global_precondition/included/Taskfile.yml new file mode 100644 index 0000000000..2d3b6099a0 --- /dev/null +++ b/testdata/global_precondition/included/Taskfile.yml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + task: + cmds: + - echo "ran included task" diff --git a/testdata/global_precondition/testdata/TestGlobalPrecondition-global_precondition_met.golden b/testdata/global_precondition/testdata/TestGlobalPrecondition-global_precondition_met.golden new file mode 100644 index 0000000000..34ba3ec32e --- /dev/null +++ b/testdata/global_precondition/testdata/TestGlobalPrecondition-global_precondition_met.golden @@ -0,0 +1,2 @@ +task: [passing] echo "ran passing" +ran passing diff --git a/testdata/global_precondition/testdata/TestGlobalPrecondition-global_precondition_met_-_included_task.golden b/testdata/global_precondition/testdata/TestGlobalPrecondition-global_precondition_met_-_included_task.golden new file mode 100644 index 0000000000..ba1720bf6c --- /dev/null +++ b/testdata/global_precondition/testdata/TestGlobalPrecondition-global_precondition_met_-_included_task.golden @@ -0,0 +1,2 @@ +task: [inc:task] echo "ran included task" +ran included task diff --git a/testdata/global_precondition_failing/Taskfile.yml b/testdata/global_precondition_failing/Taskfile.yml new file mode 100644 index 0000000000..73ab1a9ef9 --- /dev/null +++ b/testdata/global_precondition_failing/Taskfile.yml @@ -0,0 +1,20 @@ +version: '3' + +preconditions: + - sh: "[ 1 = 0 ]" + msg: "global precondition failed" + +includes: + inc: ./included + +tasks: + task: + cmds: + - echo "should not run" + + task-with-own-precondition: + preconditions: + - sh: "[ 1 = 1 ]" + msg: "own precondition" + cmds: + - echo "should not run either" diff --git a/testdata/global_precondition_failing/included/Taskfile.yml b/testdata/global_precondition_failing/included/Taskfile.yml new file mode 100644 index 0000000000..9d0dcf14e6 --- /dev/null +++ b/testdata/global_precondition_failing/included/Taskfile.yml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + task: + cmds: + - echo "should not run" diff --git a/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met-err-run.golden b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met-err-run.golden new file mode 100644 index 0000000000..bbfbd18c53 --- /dev/null +++ b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met-err-run.golden @@ -0,0 +1 @@ +task: Failed to run task "task": task: precondition not met \ No newline at end of file diff --git a/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met.golden b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met.golden new file mode 100644 index 0000000000..259c7301cf --- /dev/null +++ b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met.golden @@ -0,0 +1 @@ +task: global precondition failed diff --git a/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_included_task-err-run.golden b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_included_task-err-run.golden new file mode 100644 index 0000000000..06be420d05 --- /dev/null +++ b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_included_task-err-run.golden @@ -0,0 +1 @@ +task: Failed to run task "inc:task": task: precondition not met \ No newline at end of file diff --git a/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_included_task.golden b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_included_task.golden new file mode 100644 index 0000000000..259c7301cf --- /dev/null +++ b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_included_task.golden @@ -0,0 +1 @@ +task: global precondition failed diff --git a/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_task_with_own_precondition-err-run.golden b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_task_with_own_precondition-err-run.golden new file mode 100644 index 0000000000..bd7b34a852 --- /dev/null +++ b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_task_with_own_precondition-err-run.golden @@ -0,0 +1 @@ +task: Failed to run task "task-with-own-precondition": task: precondition not met \ No newline at end of file diff --git a/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_task_with_own_precondition.golden b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_task_with_own_precondition.golden new file mode 100644 index 0000000000..259c7301cf --- /dev/null +++ b/testdata/global_precondition_failing/testdata/TestGlobalPrecondition-global_precondition_not_met_-_task_with_own_precondition.golden @@ -0,0 +1 @@ +task: global precondition failed diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index 6c3eb912bf..ea5b8c727f 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1020,6 +1020,34 @@ tasks: - echo "I will not run" ``` +### Global preconditions + +You can define preconditions at the Taskfile level that apply to every task, +including tasks from included Taskfiles. This is useful for enforcing +environment-wide requirements — such as ensuring a repository has been +initialized — without repeating the check on every task. + +```yaml +version: '3' + +preconditions: + - sh: test -f .env + msg: "Missing .env file. Run 'task init' to set up the project." + +tasks: + build: + cmds: + - go build ./... + + test: + cmds: + - go test ./... +``` + +Global preconditions are checked before the task's own preconditions. They are +also inherited by tasks in included Taskfiles — preconditions from a parent +Taskfile propagate downward through the include hierarchy. + ### Conditional execution with `if` The `if` attribute allows you to conditionally skip tasks or commands based on a diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index f70f336bcf..90c91a74e8 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -187,6 +187,19 @@ run: once interval: 1s ``` +### [`preconditions`](#precondition) + +- **Type**: `[]Precondition` +- **Description**: Global preconditions that must be met before running any + task. These are prepended to each task's own preconditions and are inherited + by tasks in included Taskfiles. + +```yaml +preconditions: + - sh: '[ "$CI" = "true" ]' + msg: "This Taskfile must be run in CI" +``` + ### `set` - **Type**: `[]string` diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 28ae66110b..4e95edbf4c 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -780,6 +780,13 @@ "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)$" + }, + "preconditions": { + "description": "Global preconditions that must be met before running any task. Prepended to each task's own preconditions and inherited by tasks in included Taskfiles.", + "type": "array", + "items": { + "$ref": "#/definitions/precondition" + } } }, "additionalProperties": false,