From 6ae136b545e739b1387222820578ece78c3a44ef Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 14 Aug 2024 22:32:56 -0300 Subject: [PATCH] feat(defer): expose `EXIT_CODE` special variable to `defer:` Co-authored-by: Dor Sahar --- CHANGELOG.md | 2 ++ internal/execext/exec.go | 8 ------- task.go | 31 +++++++++++++++++++++------ task_test.go | 28 ++++++++++++++++++++++++ testdata/exit_code/Taskfile.yml | 17 +++++++++++++++ variables.go | 6 ++++++ website/docs/reference/templating.mdx | 1 + website/docs/usage.mdx | 14 ++++++++++++ 8 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 testdata/exit_code/Taskfile.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 640f37780c..655f536954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Added a CI lint job to ensure that the docs are updated correctly (#1719 by @vmaerten). - Updated minimum required Go version to 1.22 (#1758 by @pd93). +- Expose a new `EXIT_CODE` special variable on `defer:` when a command finishes + with a non-zero exit code (#1484, #1762 by @dorimon-1 and @andreynering). ## v3.38.0 - 2024-06-30 diff --git a/internal/execext/exec.go b/internal/execext/exec.go index a04a7167e6..69ab8865f3 100644 --- a/internal/execext/exec.go +++ b/internal/execext/exec.go @@ -90,14 +90,6 @@ func RunCommand(ctx context.Context, opts *RunCommandOptions) error { return r.Run(ctx, p) } -// IsExitError returns true the given error is an exis status error -func IsExitError(err error) bool { - if _, ok := interp.IsExitStatus(err); ok { - return true - } - return false -} - // Expand is a helper to mvdan.cc/shell.Fields that returns the first field // if available. func Expand(s string) (string, error) { diff --git a/task.go b/task.go index 0f03ba0db2..d0f82a2d3a 100644 --- a/task.go +++ b/task.go @@ -11,6 +11,8 @@ import ( "sync/atomic" "time" + "mvdan.cc/sh/v3/interp" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/compiler" "github.com/go-task/task/v3/internal/env" @@ -247,9 +249,11 @@ func (e *Executor) RunTask(ctx context.Context, call *ast.Call) error { e.Logger.Errf(logger.Red, "task: cannot make directory %q: %v\n", t.Dir, err) } + var deferredExitCode uint8 + for i := range t.Cmds { if t.Cmds[i].Defer { - defer e.runDeferred(t, call, i) + defer e.runDeferred(t, call, i, &deferredExitCode) continue } @@ -258,9 +262,13 @@ func (e *Executor) RunTask(ctx context.Context, call *ast.Call) error { e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2) } - if execext.IsExitError(err) && t.IgnoreError { - e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err) - continue + exitCode, isExitError := interp.IsExitStatus(err) + if isExitError { + if t.IgnoreError { + e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err) + continue + } + deferredExitCode = exitCode } if call.Indirect { @@ -312,10 +320,21 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { return g.Wait() } -func (e *Executor) runDeferred(t *ast.Task, call *ast.Call, i int) { +func (e *Executor) runDeferred(t *ast.Task, call *ast.Call, i int, deferredExitCode *uint8) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + cmd := t.Cmds[i] + vars, _ := e.Compiler.FastGetVariables(t, call) + cache := &templater.Cache{Vars: vars} + extra := map[string]any{} + + if deferredExitCode != nil && *deferredExitCode > 0 { + extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode) + } + + cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) + if err := e.runCommand(ctx, t, call, i); err != nil { e.Logger.VerboseErrf(logger.Yellow, "task: ignored error in deferred cmd: %s\n", err.Error()) } @@ -372,7 +391,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *ast.Call, if closeErr := close(err); closeErr != nil { e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr) } - if execext.IsExitError(err) && cmd.IgnoreError { + if _, isExitError := interp.IsExitStatus(err); isExitError && cmd.IgnoreError { e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err) return nil } diff --git a/task_test.go b/task_test.go index 751c87bdc9..d8117eeefc 100644 --- a/task_test.go +++ b/task_test.go @@ -1738,6 +1738,34 @@ task-1 ran successfully assert.Contains(t, buff.String(), expectedOutputOrder) } +func TestExitCodeZero(t *testing.T) { + const dir = "testdata/exit_code" + var buff bytes.Buffer + e := task.Executor{ + Dir: dir, + Stdout: &buff, + Stderr: &buff, + } + require.NoError(t, e.Setup()) + + require.NoError(t, e.Run(context.Background(), &ast.Call{Task: "exit-zero"})) + assert.Equal(t, "EXIT_CODE=", strings.TrimSpace(buff.String())) +} + +func TestExitCodeOne(t *testing.T) { + const dir = "testdata/exit_code" + var buff bytes.Buffer + e := task.Executor{ + Dir: dir, + Stdout: &buff, + Stderr: &buff, + } + require.NoError(t, e.Setup()) + + require.Error(t, e.Run(context.Background(), &ast.Call{Task: "exit-one"})) + assert.Equal(t, "EXIT_CODE=1", strings.TrimSpace(buff.String())) +} + func TestIgnoreNilElements(t *testing.T) { tests := []struct { name string diff --git a/testdata/exit_code/Taskfile.yml b/testdata/exit_code/Taskfile.yml new file mode 100644 index 0000000000..ce4568d0c4 --- /dev/null +++ b/testdata/exit_code/Taskfile.yml @@ -0,0 +1,17 @@ +version: '3' + +silent: true + +vars: + PREFIX: EXIT_CODE= + +tasks: + exit-zero: + cmds: + - defer: echo {{.PREFIX}}{{.EXIT_CODE}} + - exit 0 + + exit-one: + cmds: + - defer: echo {{.PREFIX}}{{.EXIT_CODE}} + - exit 1 diff --git a/variables.go b/variables.go index 22b9d9d0e5..db2a3fc4da 100644 --- a/variables.go +++ b/variables.go @@ -161,6 +161,12 @@ func (e *Executor) compiledTask(call *ast.Call, evaluateShVars bool) (*ast.Task, } continue } + // Defer commands are replaced in a lazy manner because + // we need to include EXIT_CODE. + if cmd.Defer { + new.Cmds = append(new.Cmds, cmd.DeepCopy()) + continue + } newCmd := cmd.DeepCopy() newCmd.Cmd = templater.Replace(cmd.Cmd, cache) newCmd.Task = templater.Replace(cmd.Task, cache) diff --git a/website/docs/reference/templating.mdx b/website/docs/reference/templating.mdx index 2aee444799..06fb655e20 100644 --- a/website/docs/reference/templating.mdx +++ b/website/docs/reference/templating.mdx @@ -117,6 +117,7 @@ special variable will be overridden. | `TIMESTAMP` | The date object of the greatest timestamp of the files listed in `sources`. Only available within the `status` prop and if method is set to `timestamp`. | | `TASK_VERSION` | The current version of task. | | `ITEM` | The value of the current iteration when using the `for` property. Can be changed to a different variable name using `as:`. | +| `EXIT_CODE` | Available exclusively inside the `defer:` command. Contains the failed command exit code. Only set when non-zero. | ## Functions diff --git a/website/docs/usage.mdx b/website/docs/usage.mdx index 66fb3f3a10..8f95940b1a 100644 --- a/website/docs/usage.mdx +++ b/website/docs/usage.mdx @@ -1520,6 +1520,20 @@ commands are executed in the reverse order if you schedule multiple of them. ::: +A special variable `.EXIT_CODE` is exposed when a command exited with a non-zero +exit code. You can check its presence to know if the task completed successfully +or not: + +```yaml +version: '3' + +tasks: + default: + cmds: + - defer: echo '{{if .EXIT_CODE}}Failed with {{.EXIT_CODE}}!{{else}}Success!{{end}}' + - exit 1 +``` + ## Help Running `task --list` (or `task -l`) lists all tasks with a description. The