feat: add conditional execution for tasks and commands (#2564)

This commit is contained in:
Valentin Maerten
2026-01-21 23:05:40 +01:00
committed by GitHub
parent da7eb0c855
commit 9bc1efbc47
28 changed files with 444 additions and 0 deletions

View File

@@ -1105,3 +1105,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...)
}
}

25
task.go
View File

@@ -6,6 +6,7 @@ import (
"os"
"runtime"
"slices"
"strings"
"sync/atomic"
"golang.org/x/sync/errgroup"
@@ -129,6 +130,17 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
return nil
}
if strings.TrimSpace(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: if condition not met - skipped: %q\n", call.Task)
return nil
}
}
if err := e.areTaskRequiredVarsSet(t); err != nil {
return err
}
@@ -299,6 +311,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 {
@@ -309,6 +322,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 strings.TrimSpace(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()

View File

@@ -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
c.IgnoreError = cmdStruct.IgnoreError
return nil
@@ -101,6 +105,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

View File

@@ -40,6 +40,7 @@ type Task struct {
IgnoreError bool
Run string
Platforms []*Platform
If string
Watch bool
Location *Location
Failfast bool
@@ -145,6 +146,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
@@ -184,6 +186,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
@@ -228,6 +231,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,

160
testdata/if/Taskfile.yml vendored Normal file
View File

@@ -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"

View File

@@ -0,0 +1 @@
this runs

View File

@@ -0,0 +1 @@
executed

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
subtask ran
after task call

View File

@@ -0,0 +1 @@
task: if condition not met - skipped: "task-if-false"

View File

@@ -0,0 +1 @@
task executed

View File

@@ -0,0 +1 @@
task: if condition not met - skipped: "task-level-template-false"

View File

@@ -0,0 +1 @@
task runs in prod

View File

@@ -0,0 +1 @@
both conditions met

View File

@@ -0,0 +1 @@
feature was disabled

View File

@@ -0,0 +1 @@
feature enabled

View File

@@ -0,0 +1 @@
MY_VAR is yes

View File

@@ -0,0 +1 @@
direct false skipped correctly

View File

@@ -0,0 +1 @@
direct true works

View File

@@ -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

View File

@@ -0,0 +1 @@
env is prod

View File

@@ -0,0 +1 @@
env is not dev

View File

@@ -0,0 +1 @@
at least one condition met

View File

@@ -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)
}

View File

@@ -1020,6 +1020,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
<span v-pre>`{{eq .VAR "value"}}`</span> 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

View File

@@ -616,6 +616,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`
@@ -812,6 +833,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

View File

@@ -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"
@@ -332,6 +336,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,
@@ -369,6 +377,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,
@@ -447,6 +459,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,