diff --git a/executor_test.go b/executor_test.go index 48a18807a9..e635e6e144 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1067,3 +1067,65 @@ func TestFailfast(t *testing.T) { ) }) } + +func TestIf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + task string + vars map[string]any + verbose bool + }{ + // Basic command-level if + {name: "cmd-if-true", task: "cmd-if-true"}, + {name: "cmd-if-false", task: "cmd-if-false"}, + + // Task-level if + {name: "task-if-true", task: "task-if-true"}, + {name: "task-if-false", task: "task-if-false", verbose: true}, + + // Task call with if + {name: "task-call-if-true", task: "task-call-if-true"}, + {name: "task-call-if-false", task: "task-call-if-false", verbose: true}, + + // Go template conditions + {name: "template-eq-true", task: "template-eq-true"}, + {name: "template-eq-false", task: "template-eq-false", verbose: true}, + {name: "template-ne", task: "template-ne"}, + {name: "template-bool-true", task: "template-bool-true"}, + {name: "template-bool-false", task: "template-bool-false"}, + {name: "template-direct-true", task: "template-direct-true"}, + {name: "template-direct-false", task: "template-direct-false"}, + {name: "template-and", task: "template-and"}, + {name: "template-or", task: "template-or"}, + + // CLI variable override + {name: "template-cli-var", task: "template-cli-var", vars: map[string]any{"MY_VAR": "yes"}}, + + // Task-level if with template + {name: "task-level-template", task: "task-level-template"}, + {name: "task-level-template-false", task: "task-level-template-false", verbose: true}, + + // For loop with if + {name: "if-in-for-loop", task: "if-in-for-loop", verbose: true}, + } + + for _, test := range tests { + opts := []ExecutorTestOption{ + WithName(test.name), + WithExecutorOptions( + task.WithDir("testdata/if"), + task.WithSilent(true), + task.WithVerbose(test.verbose), + ), + WithTask(test.task), + } + if test.vars != nil { + for k, v := range test.vars { + opts = append(opts, WithVar(k, v)) + } + } + NewExecutorTest(t, opts...) + } +} diff --git a/task.go b/task.go index 603d4e908a..acac7e4575 100644 --- a/task.go +++ b/task.go @@ -129,6 +129,17 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { return nil } + if t.If != "" { + if err := execext.RunCommand(ctx, &execext.RunCommandOptions{ + Command: t.If, + Dir: t.Dir, + Env: env.Get(t), + }); err != nil { + e.Logger.VerboseOutf(logger.Yellow, "task: %q if condition not met - skipped\n", call.Task) + return nil + } + } + if err := e.areTaskRequiredVarsSet(t); err != nil { return err } @@ -295,6 +306,7 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) + cmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra) cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) if err := e.runCommand(ctx, t, call, i); err != nil { @@ -305,6 +317,18 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error { cmd := t.Cmds[i] + // Check if condition for any command type + if cmd.If != "" { + if err := execext.RunCommand(ctx, &execext.RunCommandOptions{ + Command: cmd.If, + Dir: t.Dir, + Env: env.Get(t), + }); err != nil { + e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name()) + return nil + } + } + switch { case cmd.Task != "": reacquire := e.releaseConcurrencyLimit() diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index b590e6bb96..684036bc06 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -12,6 +12,7 @@ type Cmd struct { Cmd string Task string For *For + If string Silent bool Set []string Shopt []string @@ -29,6 +30,7 @@ func (c *Cmd) DeepCopy() *Cmd { Cmd: c.Cmd, Task: c.Task, For: c.For.DeepCopy(), + If: c.If, Silent: c.Silent, Set: deepcopy.Slice(c.Set), Shopt: deepcopy.Slice(c.Shopt), @@ -55,6 +57,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { Cmd string Task string For *For + If string Silent bool Set []string Shopt []string @@ -92,6 +95,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { c.Task = cmdStruct.Task c.Vars = cmdStruct.Vars c.For = cmdStruct.For + c.If = cmdStruct.If c.Silent = cmdStruct.Silent return nil } @@ -100,6 +104,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { if cmdStruct.Cmd != "" { c.Cmd = cmdStruct.Cmd c.For = cmdStruct.For + c.If = cmdStruct.If c.Silent = cmdStruct.Silent c.Set = cmdStruct.Set c.Shopt = cmdStruct.Shopt diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 57db5ee1c3..e3ecdba479 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -40,6 +40,7 @@ type Task struct { IgnoreError bool Run string Platforms []*Platform + If string Watch bool Location *Location Failfast bool @@ -142,6 +143,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { IgnoreError bool `yaml:"ignore_error"` Run string Platforms []*Platform + If string Requires *Requires Watch bool Failfast bool @@ -181,6 +183,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { t.IgnoreError = task.IgnoreError t.Run = task.Run t.Platforms = task.Platforms + t.If = task.If t.Requires = task.Requires t.Watch = task.Watch t.Failfast = task.Failfast @@ -225,6 +228,7 @@ func (t *Task) DeepCopy() *Task { IncludeVars: t.IncludeVars.DeepCopy(), IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(), Platforms: deepcopy.Slice(t.Platforms), + If: t.If, Location: t.Location.DeepCopy(), Requires: t.Requires.DeepCopy(), Namespace: t.Namespace, diff --git a/testdata/if/Taskfile.yml b/testdata/if/Taskfile.yml new file mode 100644 index 0000000000..316b732ff0 --- /dev/null +++ b/testdata/if/Taskfile.yml @@ -0,0 +1,160 @@ +version: '3' + +vars: + SHOULD_RUN: "yes" + ENV: "prod" + FEATURE_ENABLED: "true" + FEATURE_DISABLED: "false" + +tasks: + # Basic command-level if (condition met) + cmd-if-true: + cmds: + - cmd: echo "executed" + if: "true" + + # Basic command-level if (condition not met) + cmd-if-false: + cmds: + - cmd: echo "should not appear" + if: "false" + - echo "this runs" + + # Task-level if (condition met) + task-if-true: + if: "true" + cmds: + - echo "task executed" + + # Task-level if (condition not met) + task-if-false: + if: "false" + cmds: + - echo "should not appear" + + # With template variables + if-with-template: + cmds: + - cmd: echo "Running because SHOULD_RUN={{.SHOULD_RUN}}" + if: '[ "{{.SHOULD_RUN}}" = "yes" ]' + + # If inside for loop + if-in-for-loop: + cmds: + - for: ["a", "b", "c"] + cmd: echo "processing {{.ITEM}}" + if: '[ "{{.ITEM}}" != "b" ]' + + # If on task call + if-on-task-call: + cmds: + - task: subtask + if: "true" + + subtask: + internal: true + cmds: + - echo "subtask ran" + + # If combined with platforms (both must pass) + if-with-platforms: + cmds: + - cmd: echo "condition and platform met" + platforms: [linux, darwin, windows] + if: "true" + + # Skip task call + skip-task-call: + cmds: + - task: subtask + if: "false" + - echo "after skipped task call" + + # Task call in cmds with if condition met + task-call-if-true: + cmds: + - task: subtask + if: "true" + - echo "after task call" + + # Task call in cmds with if condition not met + task-call-if-false: + cmds: + - task: subtask + if: "false" + - echo "continues after skipped task" + + # Template eq - condition met + template-eq-true: + cmds: + - cmd: echo "env is prod" + if: '{{ eq .ENV "prod" }}' + + # Template eq - condition not met + template-eq-false: + cmds: + - cmd: echo "should not appear" + if: '{{ eq .ENV "dev" }}' + - echo "this runs" + + # Template ne (not equal) + template-ne: + cmds: + - cmd: echo "env is not dev" + if: '{{ ne .ENV "dev" }}' + + # Template with boolean-like variable + template-bool-true: + cmds: + - cmd: echo "feature enabled" + if: '{{ eq .FEATURE_ENABLED "true" }}' + + # Template with boolean-like variable (false) + template-bool-false: + cmds: + - cmd: echo "should not appear" + if: '{{ eq .FEATURE_DISABLED "true" }}' + - echo "feature was disabled" + + # Direct true/false from template + template-direct-true: + cmds: + - cmd: echo "direct true works" + if: '{{ .FEATURE_ENABLED }}' + + # Direct true/false from template (false case) + template-direct-false: + cmds: + - cmd: echo "should not appear" + if: '{{ .FEATURE_DISABLED }}' + - echo "direct false skipped correctly" + + # Template with CLI variable override + template-cli-var: + cmds: + - cmd: echo "MY_VAR is yes" + if: '{{ eq .MY_VAR "yes" }}' + + # Combined template conditions with and + template-and: + cmds: + - cmd: echo "both conditions met" + if: '{{ and (eq .ENV "prod") (eq .FEATURE_ENABLED "true") }}' + + # Combined template conditions with or + template-or: + cmds: + - cmd: echo "at least one condition met" + if: '{{ or (eq .ENV "dev") (eq .ENV "prod") }}' + + # Task-level if with template + task-level-template: + if: '{{ eq .ENV "prod" }}' + cmds: + - echo "task runs in prod" + + # Task-level if with template (not met) + task-level-template-false: + if: '{{ eq .ENV "dev" }}' + cmds: + - echo "should not appear" diff --git a/testdata/if/testdata/TestIf-cmd-if-false.golden b/testdata/if/testdata/TestIf-cmd-if-false.golden new file mode 100644 index 0000000000..56157dc39f --- /dev/null +++ b/testdata/if/testdata/TestIf-cmd-if-false.golden @@ -0,0 +1 @@ +this runs diff --git a/testdata/if/testdata/TestIf-cmd-if-true.golden b/testdata/if/testdata/TestIf-cmd-if-true.golden new file mode 100644 index 0000000000..6966f0ad93 --- /dev/null +++ b/testdata/if/testdata/TestIf-cmd-if-true.golden @@ -0,0 +1 @@ +executed diff --git a/testdata/if/testdata/TestIf-if-in-for-loop.golden b/testdata/if/testdata/TestIf-if-in-for-loop.golden new file mode 100644 index 0000000000..73ea6a602b --- /dev/null +++ b/testdata/if/testdata/TestIf-if-in-for-loop.golden @@ -0,0 +1,7 @@ +task: "if-in-for-loop" started +task: [if-in-for-loop] echo "processing a" +processing a +task: [if-in-for-loop] if condition not met - skipped +task: [if-in-for-loop] echo "processing c" +processing c +task: "if-in-for-loop" finished diff --git a/testdata/if/testdata/TestIf-task-call-if-false.golden b/testdata/if/testdata/TestIf-task-call-if-false.golden new file mode 100644 index 0000000000..51e65e887f --- /dev/null +++ b/testdata/if/testdata/TestIf-task-call-if-false.golden @@ -0,0 +1,5 @@ +task: "task-call-if-false" started +task: [task-call-if-false] if condition not met - skipped +task: [task-call-if-false] echo "continues after skipped task" +continues after skipped task +task: "task-call-if-false" finished diff --git a/testdata/if/testdata/TestIf-task-call-if-true.golden b/testdata/if/testdata/TestIf-task-call-if-true.golden new file mode 100644 index 0000000000..80e46c7659 --- /dev/null +++ b/testdata/if/testdata/TestIf-task-call-if-true.golden @@ -0,0 +1,2 @@ +subtask ran +after task call diff --git a/testdata/if/testdata/TestIf-task-if-false.golden b/testdata/if/testdata/TestIf-task-if-false.golden new file mode 100644 index 0000000000..d21c985a47 --- /dev/null +++ b/testdata/if/testdata/TestIf-task-if-false.golden @@ -0,0 +1 @@ +task: "task-if-false" if condition not met - skipped diff --git a/testdata/if/testdata/TestIf-task-if-true.golden b/testdata/if/testdata/TestIf-task-if-true.golden new file mode 100644 index 0000000000..f69342ab92 --- /dev/null +++ b/testdata/if/testdata/TestIf-task-if-true.golden @@ -0,0 +1 @@ +task executed diff --git a/testdata/if/testdata/TestIf-task-level-template-false.golden b/testdata/if/testdata/TestIf-task-level-template-false.golden new file mode 100644 index 0000000000..549d157d29 --- /dev/null +++ b/testdata/if/testdata/TestIf-task-level-template-false.golden @@ -0,0 +1 @@ +task: "task-level-template-false" if condition not met - skipped diff --git a/testdata/if/testdata/TestIf-task-level-template.golden b/testdata/if/testdata/TestIf-task-level-template.golden new file mode 100644 index 0000000000..1f2a3643c9 --- /dev/null +++ b/testdata/if/testdata/TestIf-task-level-template.golden @@ -0,0 +1 @@ +task runs in prod diff --git a/testdata/if/testdata/TestIf-template-and.golden b/testdata/if/testdata/TestIf-template-and.golden new file mode 100644 index 0000000000..f2c87fb508 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-and.golden @@ -0,0 +1 @@ +both conditions met diff --git a/testdata/if/testdata/TestIf-template-bool-false.golden b/testdata/if/testdata/TestIf-template-bool-false.golden new file mode 100644 index 0000000000..520bca1211 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-bool-false.golden @@ -0,0 +1 @@ +feature was disabled diff --git a/testdata/if/testdata/TestIf-template-bool-true.golden b/testdata/if/testdata/TestIf-template-bool-true.golden new file mode 100644 index 0000000000..63063af790 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-bool-true.golden @@ -0,0 +1 @@ +feature enabled diff --git a/testdata/if/testdata/TestIf-template-cli-var.golden b/testdata/if/testdata/TestIf-template-cli-var.golden new file mode 100644 index 0000000000..5e19287bb4 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-cli-var.golden @@ -0,0 +1 @@ +MY_VAR is yes diff --git a/testdata/if/testdata/TestIf-template-direct-false.golden b/testdata/if/testdata/TestIf-template-direct-false.golden new file mode 100644 index 0000000000..aa18cb6058 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-direct-false.golden @@ -0,0 +1 @@ +direct false skipped correctly diff --git a/testdata/if/testdata/TestIf-template-direct-true.golden b/testdata/if/testdata/TestIf-template-direct-true.golden new file mode 100644 index 0000000000..feaf4490b8 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-direct-true.golden @@ -0,0 +1 @@ +direct true works diff --git a/testdata/if/testdata/TestIf-template-eq-false.golden b/testdata/if/testdata/TestIf-template-eq-false.golden new file mode 100644 index 0000000000..12850e7cfd --- /dev/null +++ b/testdata/if/testdata/TestIf-template-eq-false.golden @@ -0,0 +1,5 @@ +task: "template-eq-false" started +task: [template-eq-false] if condition not met - skipped +task: [template-eq-false] echo "this runs" +this runs +task: "template-eq-false" finished diff --git a/testdata/if/testdata/TestIf-template-eq-true.golden b/testdata/if/testdata/TestIf-template-eq-true.golden new file mode 100644 index 0000000000..bfae5dd6ca --- /dev/null +++ b/testdata/if/testdata/TestIf-template-eq-true.golden @@ -0,0 +1 @@ +env is prod diff --git a/testdata/if/testdata/TestIf-template-ne.golden b/testdata/if/testdata/TestIf-template-ne.golden new file mode 100644 index 0000000000..10e2e20a60 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-ne.golden @@ -0,0 +1 @@ +env is not dev diff --git a/testdata/if/testdata/TestIf-template-or.golden b/testdata/if/testdata/TestIf-template-or.golden new file mode 100644 index 0000000000..57aca4f7f7 --- /dev/null +++ b/testdata/if/testdata/TestIf-template-or.golden @@ -0,0 +1 @@ +at least one condition met diff --git a/variables.go b/variables.go index 9e40edb2b3..f2d23aeab6 100644 --- a/variables.go +++ b/variables.go @@ -123,6 +123,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err IncludeVars: origTask.IncludeVars, IncludedTaskfileVars: origTask.IncludedTaskfileVars, Platforms: origTask.Platforms, + If: templater.Replace(origTask.If, cache), Location: origTask.Location, Requires: origTask.Requires, Watch: origTask.Watch, @@ -228,6 +229,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err newCmd := cmd.DeepCopy() newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) + newCmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra) newCmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) new.Cmds = append(new.Cmds, newCmd) } @@ -242,6 +244,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err newCmd := cmd.DeepCopy() newCmd.Cmd = templater.Replace(cmd.Cmd, cache) newCmd.Task = templater.Replace(cmd.Task, cache) + newCmd.If = templater.Replace(cmd.If, cache) newCmd.Vars = templater.ReplaceVars(cmd.Vars, cache) new.Cmds = append(new.Cmds, newCmd) } diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index 80ccf98dbf..bff9cb76e3 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1007,6 +1007,99 @@ tasks: - echo "I will not run" ``` +### Conditional execution with `if` + +The `if` attribute allows you to conditionally skip tasks or commands based on a +shell command's exit code. Unlike `preconditions` which fail and stop execution, +`if` simply skips the task or command when the condition is not met and continues +with the rest of the Taskfile. + +#### Task-level `if` + +When `if` is set on a task, the entire task is skipped if the condition fails: + +```yaml +version: '3' + +tasks: + deploy: + if: '[ "$CI" = "true" ]' + cmds: + - echo "Deploying..." + - ./deploy.sh +``` + +#### Command-level `if` + +When `if` is set on a command, only that specific command is skipped: + +```yaml +version: '3' + +tasks: + build: + cmds: + - cmd: echo "Building for production" + if: '[ "$ENV" = "production" ]' + - cmd: echo "Building for development" + if: '[ "$ENV" = "development" ]' + - go build ./... +``` + +#### Using templates in `if` conditions + +You can use Go template expressions in `if` conditions. Template expressions like +`{{eq .VAR "value"}}` evaluate to `true` or `false`, which are valid shell +commands (`true` exits with 0, `false` exits with 1): + +```yaml +version: '3' + +tasks: + conditional: + vars: + ENABLE_FEATURE: "true" + cmds: + - cmd: echo "Feature is enabled" + if: '{{eq .ENABLE_FEATURE "true"}}' + - cmd: echo "Feature is disabled" + if: '{{ne .ENABLE_FEATURE "true"}}' +``` + +#### Using `if` with `for` loops + +When used inside a `for` loop, the `if` condition is evaluated for each iteration: + +```yaml +version: '3' + +tasks: + process-items: + cmds: + - for: ['a', 'b', 'c'] + cmd: echo "processing {{.ITEM}}" + if: '[ "{{.ITEM}}" != "b" ]' +``` + +This will output: + +``` +processing a +processing c +``` + +#### `if` vs `preconditions` + +| Aspect | `if` | `preconditions` | +|--------|------|-----------------| +| On failure | Skips (continues) | Fails (stops) | +| Message | Only in verbose mode | Always shown | +| Use case | "Run if possible" | "Must be true" | + +Use `if` when you want optional conditional execution that shouldn't stop the +workflow. Use `preconditions` when the condition must be met for the task to +make sense. + ### Limiting when tasks run If a task executed by multiple `cmds` or multiple `deps` you can control when it diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index f28b299857..b47b808e0f 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -614,6 +614,27 @@ tasks: - ./deploy.sh ``` +#### `if` + +- **Type**: `string` +- **Description**: Shell command to conditionally execute the task. If the + command exits with a non-zero code, the task is skipped (not failed). + +```yaml +tasks: + # Task only runs in CI environment + deploy: + if: '[ "$CI" = "true" ]' + cmds: + - ./deploy.sh + + # Using Go template expressions + build-prod: + if: '{{eq .ENV "production"}}' + cmds: + - go build -ldflags="-s -w" ./... +``` + ### `dir` - **Type**: `string` @@ -810,6 +831,27 @@ tasks: SERVICE: '{{.ITEM}}' ``` +### Conditional Commands + +Use `if` to conditionally execute a command. If the shell command exits with a +non-zero code, the command is skipped. + +```yaml +tasks: + build: + cmds: + # Only run in production + - cmd: echo "Optimizing for production" + if: '[ "$ENV" = "production" ]' + # Using Go templates + - cmd: echo "Feature enabled" + if: '{{eq .ENABLE_FEATURE "true"}}' + # Inside for loops (evaluated per iteration) + - for: [a, b, c] + cmd: echo "processing {{.ITEM}}" + if: '[ "{{.ITEM}}" != "b" ]' +``` + ## Shell Options ### Set Options diff --git a/website/src/public/schema.json b/website/src/public/schema.json index a7faa3a973..707f818755 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -193,6 +193,10 @@ "description": "Specifies which platforms the task should be run on.", "$ref": "#/definitions/platforms" }, + "if": { + "description": "A shell command to evaluate. If the exit code is non-zero, the task is skipped.", + "type": "string" + }, "requires": { "description": "A list of variables which should be set if this task is to run, if any of these variables are unset the task will error and not run", "$ref": "#/definitions/requires_obj" @@ -329,6 +333,10 @@ "silent": { "description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.", "type": "boolean" + }, + "if": { + "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", + "type": "string" } }, "additionalProperties": false, @@ -366,6 +374,10 @@ "platforms": { "description": "Specifies which platforms the command should be run on.", "$ref": "#/definitions/platforms" + }, + "if": { + "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", + "type": "string" } }, "additionalProperties": false, @@ -426,6 +438,10 @@ "platforms": { "description": "Specifies which platforms the command should be run on.", "$ref": "#/definitions/platforms" + }, + "if": { + "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", + "type": "string" } }, "oneOf": [{ "required": ["cmd"] }, { "required": ["task"] }],