mirror of
https://github.com/go-task/task.git
synced 2026-08-29 01:58:56 +02:00
feat: command timeouts (#2898)
This commit is contained in:
@@ -44,6 +44,13 @@
|
||||
- Added Nushell completions, available via `task --completion nu`. They complete
|
||||
task names and aliases, every flag with its description, and the values of
|
||||
`--completion`, `--output` and `--sort` (#2966 by @vmaerten).
|
||||
- :warning: Added a per-command `timeout` that terminates a command once it
|
||||
exceeds the given duration (Go duration syntax). It covers shell commands,
|
||||
task calls, deferred commands, `deps` and the `if` condition, obeys
|
||||
`ignore_error`, and reports exit code `124`. Callers that join a `run: once`
|
||||
or `when_changed` task already running now honor their own `timeout`, and
|
||||
inherit that task's failure instead of being told it succeeded (#1569, #2898
|
||||
by @vmaerten).
|
||||
|
||||
## v3.52.0 - 2026-07-02
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
CodeTaskCancelled
|
||||
CodeTaskMissingRequiredVars
|
||||
CodeTaskNotAllowedVars
|
||||
CodeTaskTimedOut
|
||||
)
|
||||
|
||||
// TaskError extends the standard error interface with a Code method. This code will
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mvdan.cc/sh/v3/interp"
|
||||
)
|
||||
@@ -51,6 +52,10 @@ func (err *TaskRunError) TaskExitCode() int {
|
||||
if errors.As(err.Err, &exit) {
|
||||
return int(exit)
|
||||
}
|
||||
var timeout *TaskTimeoutError
|
||||
if errors.As(err.Err, &timeout) {
|
||||
return TimeoutExitCode
|
||||
}
|
||||
return err.Code()
|
||||
}
|
||||
|
||||
@@ -58,6 +63,25 @@ func (err *TaskRunError) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// TimeoutExitCode is what a killed command reports in place of the exit status
|
||||
// it never got, following the convention of timeout(1).
|
||||
const TimeoutExitCode = 124
|
||||
|
||||
// TaskTimeoutError is returned when a command exceeds the timeout it declared.
|
||||
// It must not unwrap to context.DeadlineExceeded, which --watch swallows.
|
||||
type TaskTimeoutError struct {
|
||||
TaskName string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (err *TaskTimeoutError) Error() string {
|
||||
return fmt.Sprintf(`task: [%s] command timeout exceeded (%s)`, err.TaskName, err.Timeout)
|
||||
}
|
||||
|
||||
func (err *TaskTimeoutError) Code() int {
|
||||
return CodeTaskTimedOut
|
||||
}
|
||||
|
||||
// TaskInternalError when the user attempts to invoke a task that is internal.
|
||||
type TaskInternalError struct {
|
||||
TaskName string
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -80,7 +79,7 @@ type (
|
||||
concurrencySemaphore chan struct{}
|
||||
taskCallCount map[string]*int32
|
||||
mkdirMutexMap map[string]*sync.Mutex
|
||||
executionHashes map[string]context.Context
|
||||
executionHashes map[string]*executionState
|
||||
executionHashesMutex sync.Mutex
|
||||
watchedDirs *xsync.Map[string, bool]
|
||||
}
|
||||
@@ -108,7 +107,7 @@ func NewExecutor(opts ...ExecutorOption) *Executor {
|
||||
concurrencySemaphore: nil,
|
||||
taskCallCount: map[string]*int32{},
|
||||
mkdirMutexMap: map[string]*sync.Mutex{},
|
||||
executionHashes: map[string]context.Context{},
|
||||
executionHashes: map[string]*executionState{},
|
||||
executionHashesMutex: sync.Mutex{},
|
||||
}
|
||||
e.Options(opts...)
|
||||
|
||||
2
setup.go
2
setup.go
@@ -261,7 +261,7 @@ func (e *Executor) setupDefaults() {
|
||||
}
|
||||
|
||||
func (e *Executor) setupConcurrencyState() {
|
||||
e.executionHashes = make(map[string]context.Context)
|
||||
e.executionHashes = make(map[string]*executionState)
|
||||
|
||||
e.taskCallCount = make(map[string]*int32, e.Taskfile.Tasks.Len())
|
||||
e.mkdirMutexMap = make(map[string]*sync.Mutex, e.Taskfile.Tasks.Len())
|
||||
|
||||
104
task.go
104
task.go
@@ -266,14 +266,20 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
|
||||
e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2)
|
||||
}
|
||||
|
||||
var exitCode interp.ExitStatus
|
||||
if errors.As(err, &exitCode) {
|
||||
if t.IgnoreError {
|
||||
if t.IgnoreError && isCommandFailure(err) {
|
||||
e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
e.Logger.VerboseErrf(logger.Red, "task: %q failed: %v\n", call.Task, err)
|
||||
|
||||
var exitCode interp.ExitStatus
|
||||
var timeout *errors.TaskTimeoutError
|
||||
switch {
|
||||
case errors.As(err, &exitCode):
|
||||
deferredExitCode = uint8(exitCode)
|
||||
case errors.As(err, &timeout):
|
||||
deferredExitCode = errors.TimeoutExitCode
|
||||
}
|
||||
|
||||
return err
|
||||
@@ -316,11 +322,20 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error {
|
||||
|
||||
for _, d := range t.Deps {
|
||||
g.Go(func() error {
|
||||
err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true})
|
||||
if err != nil {
|
||||
return err
|
||||
depCtx := ctx
|
||||
var timeout *errors.TaskTimeoutError
|
||||
if d.Timeout > 0 {
|
||||
timeout = &errors.TaskTimeoutError{TaskName: d.Task, Timeout: d.Timeout}
|
||||
var cancel context.CancelFunc
|
||||
depCtx, cancel = context.WithTimeoutCause(ctx, d.Timeout, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
return nil
|
||||
|
||||
err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true})
|
||||
if err != nil && timedOut(depCtx, timeout) {
|
||||
return timeout
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,6 +369,15 @@ 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]
|
||||
|
||||
// In place before the if condition, which would otherwise run unbounded.
|
||||
var timeout *errors.TaskTimeoutError
|
||||
if cmd.Timeout > 0 {
|
||||
timeout = &errors.TaskTimeoutError{TaskName: t.Name(), Timeout: cmd.Timeout}
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeoutCause(ctx, cmd.Timeout, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// Check if condition for any command type
|
||||
if strings.TrimSpace(cmd.If) != "" {
|
||||
if err := execext.RunCommand(ctx, &execext.RunCommandOptions{
|
||||
@@ -361,6 +385,9 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
||||
Dir: t.Dir,
|
||||
Env: env.Get(t),
|
||||
}); err != nil {
|
||||
if timedOut(ctx, timeout) {
|
||||
return timeout
|
||||
}
|
||||
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name())
|
||||
return nil
|
||||
}
|
||||
@@ -372,8 +399,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
||||
defer reacquire()
|
||||
|
||||
err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true})
|
||||
var exitCode interp.ExitStatus
|
||||
if errors.As(err, &exitCode) && cmd.IgnoreError {
|
||||
if err != nil && timedOut(ctx, timeout) {
|
||||
err = timeout
|
||||
}
|
||||
if cmd.IgnoreError && isCommandFailure(err) {
|
||||
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] task error ignored: %v\n", t.Name(), err)
|
||||
return nil
|
||||
}
|
||||
@@ -416,8 +445,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
||||
if closeErr := closer(err); closeErr != nil {
|
||||
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr)
|
||||
}
|
||||
var exitCode interp.ExitStatus
|
||||
if errors.As(err, &exitCode) && cmd.IgnoreError {
|
||||
if err != nil && timedOut(ctx, timeout) {
|
||||
err = timeout
|
||||
}
|
||||
if cmd.IgnoreError && isCommandFailure(err) {
|
||||
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err)
|
||||
return nil
|
||||
}
|
||||
@@ -427,6 +458,27 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
||||
}
|
||||
}
|
||||
|
||||
// isCommandFailure reports whether the command failed on its own terms - a
|
||||
// non-zero exit status or its timeout - rather than Task failing to run it.
|
||||
func isCommandFailure(err error) bool {
|
||||
var exitCode interp.ExitStatus
|
||||
var timeout *errors.TaskTimeoutError
|
||||
return errors.As(err, &exitCode) || errors.As(err, &timeout)
|
||||
}
|
||||
|
||||
// timedOut reports whether ctx was cancelled by the given timeout rather than by
|
||||
// an inherited deadline, which a derived context reports as its own.
|
||||
func timedOut(ctx context.Context, timeout *errors.TaskTimeoutError) bool {
|
||||
return timeout != nil && errors.Is(context.Cause(ctx), timeout)
|
||||
}
|
||||
|
||||
// executionState is the outcome of a task execution, shared with the callers
|
||||
// that join it. err is written before done is closed; read it only once closed.
|
||||
type executionState struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func(ctx context.Context) error) error {
|
||||
h, err := e.GetHash(t)
|
||||
if err != nil {
|
||||
@@ -439,7 +491,7 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func
|
||||
|
||||
e.executionHashesMutex.Lock()
|
||||
|
||||
if otherExecutionCtx, ok := e.executionHashes[h]; ok {
|
||||
if other, ok := e.executionHashes[h]; ok {
|
||||
e.executionHashesMutex.Unlock()
|
||||
e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h)
|
||||
|
||||
@@ -447,17 +499,33 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func
|
||||
reacquire := e.releaseConcurrencyLimit()
|
||||
defer reacquire()
|
||||
|
||||
<-otherExecutionCtx.Done()
|
||||
return nil
|
||||
// A finished execution wins even if our context is done: there is
|
||||
// nothing left to wait for, and select would otherwise pick at random.
|
||||
select {
|
||||
case <-other.done:
|
||||
return other.err
|
||||
default:
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
select {
|
||||
case <-other.done:
|
||||
// Its outcome is ours. Returning nil would hide an execution that
|
||||
// failed, or that another caller's timeout killed.
|
||||
return other.err
|
||||
case <-ctx.Done():
|
||||
// We did not start it, so we can only stop waiting. Report the cause
|
||||
// so that our own timeout surfaces as one.
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
e.executionHashes[h] = ctx
|
||||
state := &executionState{done: make(chan struct{})}
|
||||
e.executionHashes[h] = state
|
||||
e.executionHashesMutex.Unlock()
|
||||
|
||||
return execute(ctx)
|
||||
defer close(state.done)
|
||||
state.err = execute(ctx)
|
||||
return state.err
|
||||
}
|
||||
|
||||
// FindMatchingTasks returns a list of tasks that match the given call. A task
|
||||
|
||||
298
task_test.go
298
task_test.go
@@ -2,6 +2,7 @@ package task_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -1034,6 +1035,44 @@ func TestTaskIgnoreErrors(t *testing.T) {
|
||||
require.Error(t, e.Run(t.Context(), &task.Call{Task: "cmd-should-fail"}))
|
||||
}
|
||||
|
||||
func TestIgnoreErrorsOnTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/ignore_errors"
|
||||
tests := []struct {
|
||||
name string
|
||||
task string
|
||||
expectError bool
|
||||
}{
|
||||
{name: "ignored at task level", task: "task-timeout-should-pass"},
|
||||
{name: "ignored at command level", task: "cmd-timeout-should-pass"},
|
||||
{name: "not ignored", task: "cmd-timeout-should-fail", expectError: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buff bytes.Buffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
err := e.Run(t.Context(), &task.Call{Task: test.task})
|
||||
if test.expectError {
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, buff.String(), "reached the end")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, buff.String(), "reached the end")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2342,6 +2381,54 @@ func TestRunOnceSharedDeps(t *testing.T) {
|
||||
assert.Contains(t, buff.String(), `task: [service-b:build] echo "build b"`)
|
||||
}
|
||||
|
||||
func TestRunOnceSharedFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/run_once_failure"
|
||||
|
||||
var buff bytes.Buffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
err := e.Run(t.Context(), &task.Call{Task: "default"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `Failed to run task "shared"`)
|
||||
assert.NotContains(t, buff.String(), "should not be reached")
|
||||
// The shared task still ran only once, which is the point of run: once.
|
||||
assert.Equal(t, 1, strings.Count(buff.String(), "shared ran"))
|
||||
}
|
||||
|
||||
func TestRunOnceJoinerHonorsItsOwnTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/run_once_timeout"
|
||||
|
||||
// The two deps run concurrently, so they need a buffer they can share.
|
||||
var buff SyncBuffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
start := time.Now()
|
||||
err := e.Run(t.Context(), &task.Call{Task: "default"})
|
||||
require.Error(t, err)
|
||||
// The joiner used to wait on the shared execution alone, ignoring its own
|
||||
// timeout for as long as that execution took.
|
||||
assert.Less(t, time.Since(start), 5*time.Second)
|
||||
|
||||
var timeoutErr *errors.TaskTimeoutError
|
||||
require.ErrorAs(t, err, &timeoutErr)
|
||||
assert.Equal(t, "joiner", timeoutErr.TaskName)
|
||||
assert.NotContains(t, buff.buf.String(), "should not be reached")
|
||||
}
|
||||
|
||||
func TestRunWhenChanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2395,6 +2482,27 @@ task-1 ran successfully
|
||||
assert.Contains(t, buff.String(), "child task deferred value-from-parent")
|
||||
}
|
||||
|
||||
func TestDeferredTaskTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/deferred"
|
||||
var buff bytes.Buffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
task.WithVerbose(true),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
start := time.Now()
|
||||
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "parent-with-timeout"}))
|
||||
assert.Less(t, time.Since(start), 500*time.Millisecond)
|
||||
assert.Contains(t, buff.String(), "parent completed")
|
||||
assert.NotContains(t, buff.String(), "\ncleanup completed\n")
|
||||
assert.Contains(t, buff.String(), "ignored error in deferred cmd")
|
||||
}
|
||||
|
||||
func TestExitCodeZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2427,6 +2535,27 @@ func TestExitCodeOne(t *testing.T) {
|
||||
assert.Equal(t, "FOO=bar - DYNAMIC_FOO=bar - EXIT_CODE=1", strings.TrimSpace(buff.String()))
|
||||
}
|
||||
|
||||
func TestExitCodeTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/exit_code"
|
||||
var buff bytes.Buffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
err := e.Run(t.Context(), &task.Call{Task: "exit-timeout"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "EXIT_CODE=124", strings.TrimSpace(buff.String()))
|
||||
|
||||
var runErr *errors.TaskRunError
|
||||
require.ErrorAs(t, err, &runErr)
|
||||
assert.Equal(t, errors.TimeoutExitCode, runErr.TaskExitCode())
|
||||
}
|
||||
|
||||
func TestIgnoreNilElements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2631,6 +2760,175 @@ func TestErrorCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/timeout"
|
||||
tests := []struct {
|
||||
name string
|
||||
task string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "timeout exceeded",
|
||||
task: "timeout-exceeded",
|
||||
expectError: true,
|
||||
errorContains: "timeout exceeded",
|
||||
},
|
||||
{
|
||||
name: "timeout not exceeded",
|
||||
task: "timeout-not-exceeded",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no timeout",
|
||||
task: "no-timeout",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "multiple commands with timeout",
|
||||
task: "multiple-cmds-timeout",
|
||||
expectError: true,
|
||||
errorContains: "timeout exceeded",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buff bytes.Buffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
err := e.Run(t.Context(), &task.Call{Task: test.task})
|
||||
if test.expectError {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), test.errorContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/dep_timeout"
|
||||
|
||||
t.Run("timeout exceeded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buff SyncBuffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
start := time.Now()
|
||||
err := e.Run(t.Context(), &task.Call{Task: "timeout-exceeded"})
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(start), 5*time.Second)
|
||||
|
||||
var timeoutErr *errors.TaskTimeoutError
|
||||
require.ErrorAs(t, err, &timeoutErr)
|
||||
assert.Equal(t, "slow", timeoutErr.TaskName)
|
||||
assert.NotContains(t, buff.buf.String(), "should not be reached")
|
||||
})
|
||||
|
||||
t.Run("timeout not exceeded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buff SyncBuffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "timeout-not-exceeded"}))
|
||||
assert.Contains(t, buff.buf.String(), "reached the end")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommandTimeoutBoundsIfCondition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buff bytes.Buffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir("testdata/timeout"),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
start := time.Now()
|
||||
err := e.Run(t.Context(), &task.Call{Task: "slow-if-condition"})
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(start), 5*time.Second)
|
||||
|
||||
var timeoutErr *errors.TaskTimeoutError
|
||||
require.ErrorAs(t, err, &timeoutErr)
|
||||
// A condition that times out fails the command, it does not skip it.
|
||||
assert.NotContains(t, buff.String(), "condition was met")
|
||||
}
|
||||
|
||||
func TestCommandTimeoutAttribution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "testdata/timeout"
|
||||
tests := []struct {
|
||||
name string
|
||||
task string
|
||||
notContains string
|
||||
}{
|
||||
{
|
||||
name: "a command declaring no timeout is not blamed for one",
|
||||
task: "inherited-timeout",
|
||||
notContains: "(0s)",
|
||||
},
|
||||
{
|
||||
name: "a command is not blamed for a timeout it never reached",
|
||||
task: "larger-child-timeout",
|
||||
notContains: "10m",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(io.Discard),
|
||||
task.WithStderr(io.Discard),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
err := e.Run(t.Context(), &task.Call{Task: test.task})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "command timeout exceeded (500ms)")
|
||||
assert.NotContains(t, err.Error(), test.notContains)
|
||||
|
||||
var timeoutErr *errors.TaskTimeoutError
|
||||
require.ErrorAs(t, err, &timeoutErr)
|
||||
assert.Equal(t, test.task, timeoutErr.TaskName)
|
||||
|
||||
// --watch swallows context errors; a timeout must not look like one.
|
||||
assert.False(t, errors.Is(err, context.DeadlineExceeded))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateSymlinksInPaths(t *testing.T) { // nolint:paralleltest // cannot run in parallel
|
||||
const dir = "testdata/evaluate_symlinks_in_paths"
|
||||
var buff bytes.Buffer
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package ast
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"github.com/go-task/task/v3/errors"
|
||||
@@ -21,6 +23,7 @@ type Cmd struct {
|
||||
IgnoreError bool
|
||||
Defer bool
|
||||
Platforms []*Platform
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (c *Cmd) DeepCopy() *Cmd {
|
||||
@@ -40,6 +43,7 @@ func (c *Cmd) DeepCopy() *Cmd {
|
||||
IgnoreError: c.IgnoreError,
|
||||
Defer: c.Defer,
|
||||
Platforms: deepcopy.Slice(c.Platforms),
|
||||
Timeout: c.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +71,26 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
|
||||
IgnoreError bool `yaml:"ignore_error"`
|
||||
Defer *Defer
|
||||
Platforms []*Platform
|
||||
Timeout string
|
||||
}
|
||||
if err := node.Decode(&cmdStruct); err != nil {
|
||||
return errors.NewTaskfileDecodeError(err, node)
|
||||
}
|
||||
|
||||
if cmdStruct.Timeout != "" {
|
||||
timeout, err := parseTimeout(cmdStruct.Timeout, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Timeout = timeout
|
||||
}
|
||||
|
||||
if cmdStruct.Defer != nil {
|
||||
// Rejected rather than dropped: without the field, yaml would
|
||||
// swallow the key without a word.
|
||||
if cmdStruct.Defer.Timeout != "" {
|
||||
return errors.NewTaskfileDecodeError(nil, node).WithMessage("timeout must be set next to defer, not inside it")
|
||||
}
|
||||
|
||||
// A deferred command
|
||||
if cmdStruct.Defer.Cmd != "" {
|
||||
@@ -121,3 +140,16 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
|
||||
|
||||
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("command")
|
||||
}
|
||||
|
||||
// parseTimeout rejects non-positive durations, which would otherwise read as no
|
||||
// timeout at all - the unbounded run the key exists to prevent.
|
||||
func parseTimeout(s string, node *yaml.Node) (time.Duration, error) {
|
||||
timeout, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, errors.NewTaskfileDecodeError(err, node).WithMessage("invalid timeout format")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return 0, errors.NewTaskfileDecodeError(nil, node).WithMessage("timeout must be greater than zero")
|
||||
}
|
||||
return timeout, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type Defer struct {
|
||||
Task string
|
||||
Vars *Vars
|
||||
Silent bool
|
||||
Timeout string
|
||||
}
|
||||
|
||||
func (d *Defer) UnmarshalYAML(node *yaml.Node) error {
|
||||
@@ -30,6 +31,7 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error {
|
||||
Task string
|
||||
Vars *Vars
|
||||
Silent bool
|
||||
Timeout string
|
||||
}
|
||||
if err := node.Decode(&deferStruct); err != nil {
|
||||
return errors.NewTaskfileDecodeError(err, node)
|
||||
@@ -38,6 +40,7 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error {
|
||||
d.Task = deferStruct.Task
|
||||
d.Vars = deferStruct.Vars
|
||||
d.Silent = deferStruct.Silent
|
||||
d.Timeout = deferStruct.Timeout
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package ast
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"github.com/go-task/task/v3/errors"
|
||||
@@ -12,6 +14,7 @@ type Dep struct {
|
||||
For *For
|
||||
Vars *Vars
|
||||
Silent bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (d *Dep) DeepCopy() *Dep {
|
||||
@@ -23,6 +26,7 @@ func (d *Dep) DeepCopy() *Dep {
|
||||
For: d.For.DeepCopy(),
|
||||
Vars: d.Vars.DeepCopy(),
|
||||
Silent: d.Silent,
|
||||
Timeout: d.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +47,18 @@ func (d *Dep) UnmarshalYAML(node *yaml.Node) error {
|
||||
For *For
|
||||
Vars *Vars
|
||||
Silent bool
|
||||
Timeout string
|
||||
}
|
||||
if err := node.Decode(&taskCall); err != nil {
|
||||
return errors.NewTaskfileDecodeError(err, node)
|
||||
}
|
||||
if taskCall.Timeout != "" {
|
||||
timeout, err := parseTimeout(taskCall.Timeout, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Timeout = timeout
|
||||
}
|
||||
d.Task = taskCall.Task
|
||||
d.For = taskCall.For
|
||||
d.Vars = taskCall.Vars
|
||||
|
||||
@@ -2,6 +2,7 @@ package ast_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -23,7 +24,9 @@ vars:
|
||||
PARAM2: VALUE2
|
||||
`
|
||||
yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }`
|
||||
yamlDeferredCallWithTimeout = `{ defer: { task: some_task }, timeout: 1s }`
|
||||
yamlDeferredCmd = `defer: echo 'test'`
|
||||
yamlDeferredCmdWithTimeout = `{ defer: echo 'test', timeout: 1s }`
|
||||
)
|
||||
tests := []struct {
|
||||
content string
|
||||
@@ -77,6 +80,16 @@ vars:
|
||||
Defer: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
yamlDeferredCallWithTimeout,
|
||||
&ast.Cmd{},
|
||||
&ast.Cmd{Task: "some_task", Defer: true, Timeout: time.Second},
|
||||
},
|
||||
{
|
||||
yamlDeferredCmdWithTimeout,
|
||||
&ast.Cmd{},
|
||||
&ast.Cmd{Cmd: `echo 'test'`, Defer: true, Timeout: time.Second},
|
||||
},
|
||||
{
|
||||
yamlDep,
|
||||
&ast.Dep{},
|
||||
@@ -110,3 +123,55 @@ vars:
|
||||
assert.Equal(t, test.expected, test.v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutParseError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "unparsable duration",
|
||||
content: `{cmd: echo, timeout: invalid}`,
|
||||
message: "invalid timeout format",
|
||||
},
|
||||
{
|
||||
name: "zero duration",
|
||||
content: `{cmd: echo, timeout: 0s}`,
|
||||
message: "timeout must be greater than zero",
|
||||
},
|
||||
{
|
||||
name: "negative duration",
|
||||
content: `{cmd: echo, timeout: -1s}`,
|
||||
message: "timeout must be greater than zero",
|
||||
},
|
||||
{
|
||||
name: "negative duration on a deferred task",
|
||||
content: `{defer: {task: some_task}, timeout: -5m}`,
|
||||
message: "timeout must be greater than zero",
|
||||
},
|
||||
{
|
||||
name: "unparsable duration on a deferred task",
|
||||
content: `{defer: {task: some_task}, timeout: invalid}`,
|
||||
message: "invalid timeout format",
|
||||
},
|
||||
{
|
||||
name: "timeout nested inside defer",
|
||||
content: `{defer: {task: some_task, timeout: 1s}}`,
|
||||
message: "timeout must be set next to defer, not inside it",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var cmd ast.Cmd
|
||||
err := yaml.Unmarshal([]byte(test.content), &cmd)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, test.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
12
testdata/deferred/Taskfile.yml
vendored
12
testdata/deferred/Taskfile.yml
vendored
@@ -27,3 +27,15 @@ tasks:
|
||||
child:
|
||||
cmds:
|
||||
- cmd: echo "child {{.VAR1}}"
|
||||
|
||||
parent-with-timeout:
|
||||
cmds:
|
||||
- defer:
|
||||
task: slow-cleanup
|
||||
silent: true
|
||||
timeout: 100ms
|
||||
- echo 'parent completed'
|
||||
|
||||
slow-cleanup:
|
||||
cmds:
|
||||
- sleep 1 && echo 'cleanup completed'
|
||||
|
||||
26
testdata/dep_timeout/Taskfile.yml
vendored
Normal file
26
testdata/dep_timeout/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
version: '3'
|
||||
|
||||
silent: true
|
||||
|
||||
tasks:
|
||||
timeout-exceeded:
|
||||
deps:
|
||||
- task: slow
|
||||
timeout: 300ms
|
||||
cmds:
|
||||
- echo "should not be reached"
|
||||
|
||||
timeout-not-exceeded:
|
||||
deps:
|
||||
- task: quick
|
||||
timeout: 5s
|
||||
cmds:
|
||||
- echo "reached the end"
|
||||
|
||||
slow:
|
||||
cmds:
|
||||
- sleep 10
|
||||
|
||||
quick:
|
||||
cmds:
|
||||
- echo "quick"
|
||||
6
testdata/exit_code/Taskfile.yml
vendored
6
testdata/exit_code/Taskfile.yml
vendored
@@ -23,3 +23,9 @@ tasks:
|
||||
cmds:
|
||||
- defer: echo FOO={{.FOO}} - DYNAMIC_FOO={{.DYNAMIC_FOO}} - {{.PREFIX}}{{.EXIT_CODE}}
|
||||
- exit 1
|
||||
|
||||
exit-timeout:
|
||||
cmds:
|
||||
- defer: echo {{.PREFIX}}{{.EXIT_CODE}}
|
||||
- cmd: sleep 10
|
||||
timeout: 200ms
|
||||
|
||||
20
testdata/ignore_errors/Taskfile.yml
vendored
20
testdata/ignore_errors/Taskfile.yml
vendored
@@ -18,3 +18,23 @@ tasks:
|
||||
cmd-should-fail:
|
||||
cmds:
|
||||
- cmd: exit 1
|
||||
|
||||
task-timeout-should-pass:
|
||||
cmds:
|
||||
- cmd: sleep 10
|
||||
timeout: 200ms
|
||||
- echo "reached the end"
|
||||
ignore_error: true
|
||||
|
||||
cmd-timeout-should-pass:
|
||||
cmds:
|
||||
- cmd: sleep 10
|
||||
timeout: 200ms
|
||||
ignore_error: true
|
||||
- echo "reached the end"
|
||||
|
||||
cmd-timeout-should-fail:
|
||||
cmds:
|
||||
- cmd: sleep 10
|
||||
timeout: 200ms
|
||||
- echo "reached the end"
|
||||
|
||||
18
testdata/run_once_failure/Taskfile.yml
vendored
Normal file
18
testdata/run_once_failure/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
version: '3'
|
||||
|
||||
silent: true
|
||||
|
||||
tasks:
|
||||
default:
|
||||
cmds:
|
||||
- task: shared
|
||||
ignore_error: true
|
||||
# Joins the finished execution, and must inherit its error.
|
||||
- task: shared
|
||||
- echo "should not be reached"
|
||||
|
||||
shared:
|
||||
run: once
|
||||
cmds:
|
||||
- echo "shared ran"
|
||||
- exit 1
|
||||
27
testdata/run_once_timeout/Taskfile.yml
vendored
Normal file
27
testdata/run_once_timeout/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
version: '3'
|
||||
|
||||
silent: true
|
||||
|
||||
tasks:
|
||||
default:
|
||||
# Without failfast the run waits for `owner` anyway, hiding what is tested.
|
||||
failfast: true
|
||||
deps: [owner, joiner]
|
||||
|
||||
# Holds the shared task far longer than `joiner` waits, so `joiner` reaches
|
||||
# the deduplication path while it is still running.
|
||||
owner:
|
||||
cmds:
|
||||
- task: shared
|
||||
|
||||
joiner:
|
||||
cmds:
|
||||
- sleep 0.2
|
||||
- task: shared
|
||||
timeout: 500ms
|
||||
- echo "should not be reached"
|
||||
|
||||
shared:
|
||||
run: once
|
||||
cmds:
|
||||
- sleep 10
|
||||
57
testdata/timeout/Taskfile.yml
vendored
Normal file
57
testdata/timeout/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
timeout-exceeded:
|
||||
desc: Command that should timeout
|
||||
cmds:
|
||||
- cmd: sleep 10
|
||||
timeout: 1s
|
||||
|
||||
timeout-not-exceeded:
|
||||
desc: Command that completes within timeout
|
||||
cmds:
|
||||
- cmd: echo "quick command"
|
||||
timeout: 5s
|
||||
|
||||
no-timeout:
|
||||
desc: Command with no timeout specified
|
||||
cmds:
|
||||
- echo "no timeout"
|
||||
|
||||
multiple-cmds-timeout:
|
||||
desc: Multiple commands where one exceeds its timeout
|
||||
cmds:
|
||||
- cmd: echo "first"
|
||||
timeout: 1s
|
||||
- cmd: sleep 10
|
||||
timeout: 1s
|
||||
- cmd: echo "third"
|
||||
timeout: 1s
|
||||
|
||||
slow-if-condition:
|
||||
desc: Condition that hangs must be bounded by the command timeout
|
||||
cmds:
|
||||
- cmd: echo "condition was met"
|
||||
if: sleep 10
|
||||
timeout: 500ms
|
||||
|
||||
inherited-timeout:
|
||||
desc: Calls a task whose command declares no timeout of its own
|
||||
cmds:
|
||||
- task: slow-without-timeout
|
||||
timeout: 500ms
|
||||
|
||||
slow-without-timeout:
|
||||
cmds:
|
||||
- sleep 10
|
||||
|
||||
larger-child-timeout:
|
||||
desc: Calls a task whose command declares a timeout it never reaches
|
||||
cmds:
|
||||
- task: slow-with-larger-timeout
|
||||
timeout: 500ms
|
||||
|
||||
slow-with-larger-timeout:
|
||||
cmds:
|
||||
- cmd: sleep 10
|
||||
timeout: 10m
|
||||
@@ -847,6 +847,7 @@ tasks:
|
||||
platforms: [linux, darwin]
|
||||
set: [errexit]
|
||||
shopt: [globstar]
|
||||
timeout: 5m
|
||||
```
|
||||
|
||||
### Task References
|
||||
@@ -963,6 +964,58 @@ tasks:
|
||||
if: '[ "{{.ITEM}}" != "b" ]'
|
||||
```
|
||||
|
||||
### Command Timeouts
|
||||
|
||||
Use `timeout` to limit how long a command may run. The value uses Go duration
|
||||
syntax (e.g. `30s`, `5m`, `1h30m`) and must be greater than zero.
|
||||
|
||||
```yaml
|
||||
tasks:
|
||||
deploy:
|
||||
cmds:
|
||||
- cmd: npm run build
|
||||
timeout: 5m
|
||||
- cmd: ./deploy.sh
|
||||
timeout: 30m
|
||||
```
|
||||
|
||||
When a command exceeds its timeout, it is terminated and the task fails with an
|
||||
error, preventing commands from hanging indefinitely in a pipeline. The timeout
|
||||
bounds the whole step, so an [`if`](#command) condition that hangs is cut short
|
||||
too, and [`ignore_error`](#command) covers a timeout like any other failure. A
|
||||
timed-out command reports [`EXIT_CODE`](/docs/reference/templating#exit_code)
|
||||
`124`, following the convention of `timeout(1)`.
|
||||
|
||||
A dependency takes the same key:
|
||||
|
||||
```yaml
|
||||
tasks:
|
||||
build:
|
||||
deps:
|
||||
- task: fetch-assets
|
||||
timeout: 2m
|
||||
```
|
||||
|
||||
The key goes next to the command whatever form it takes, including a `defer`:
|
||||
|
||||
```yaml
|
||||
tasks:
|
||||
deploy:
|
||||
cmds:
|
||||
- defer:
|
||||
task: cleanup
|
||||
timeout: 30s
|
||||
- defer: ./cleanup.sh
|
||||
timeout: 30s
|
||||
```
|
||||
|
||||
A timed-out deferred command is logged and ignored, like other deferred errors.
|
||||
|
||||
Calling a task that is already running under [`run: once`](#task) or
|
||||
[`run: when_changed`](#task) joins that execution instead of starting a second
|
||||
one. A `timeout` on such a call bounds how long you wait for it, not the shared
|
||||
execution itself, which only the caller that started it can bound.
|
||||
|
||||
## Shell Options
|
||||
|
||||
### Set Options
|
||||
|
||||
@@ -334,7 +334,8 @@ tasks:
|
||||
|
||||
- **Type**: `int`
|
||||
- **Description**: Failed command exit code (only in `defer`, only when
|
||||
non-zero)
|
||||
non-zero). A command killed by its [`timeout`](/docs/reference/schema#command)
|
||||
is reported as `124`, following the convention of `timeout(1)`.
|
||||
|
||||
```yaml
|
||||
tasks:
|
||||
|
||||
@@ -356,6 +356,10 @@
|
||||
"if": {
|
||||
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -397,11 +401,38 @@
|
||||
"if": {
|
||||
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["cmd"]
|
||||
},
|
||||
"deferred_task_call": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"description": "Name of the task to run",
|
||||
"type": "string"
|
||||
},
|
||||
"vars": {
|
||||
"description": "Values passed to the task called",
|
||||
"$ref": "#/definitions/vars"
|
||||
},
|
||||
"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,
|
||||
"required": ["task"]
|
||||
},
|
||||
"defer_task_call": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -409,9 +440,13 @@
|
||||
"description": "Run a command when the task completes. This command will run even when the task fails",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/task_call"
|
||||
"$ref": "#/definitions/deferred_task_call"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -427,6 +462,10 @@
|
||||
"silent": {
|
||||
"description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -471,6 +510,10 @@
|
||||
"if": {
|
||||
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -505,6 +548,10 @@
|
||||
"if": {
|
||||
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -527,6 +574,10 @@
|
||||
"vars": {
|
||||
"description": "Values passed to the task called",
|
||||
"$ref": "#/definitions/vars"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
Reference in New Issue
Block a user