Compare commits

...

2 Commits

Author SHA1 Message Date
Valentin Maerten
b8abadb4f0 🐛 fix(output): wrap gitlab sections at task level (#2806)
Previously the gitlab output wrapped each command individually, causing
two visible bugs in real GitLab pipelines:

- every section displayed a duration of 00:00, because start and end
  markers were emitted microseconds apart for instant commands
- the `task: [NAME] CMD` announcement lines were rendered outside the
  sections, because Logger.Errf bypassed the cmd-level wrapper

Fix by wrapping output at the task level via a new optional
[output.TaskWrapper] interface that GitLab implements. Task-scoped
writers are threaded via ctx so nested `task:` invocations produce
properly nested sections (GitLab supports this natively), and deps
running in parallel each get their own buffer with mutex-protected
flushes into the parent's buffer.

- `internal/output/output.go`: add TaskWrapper interface
- `internal/output/gitlab.go`: logic moved from WrapWriter to WrapTask;
  WrapWriter becomes passthrough; sync.Mutex around the buffer for
  concurrent flushes from parallel sub-task sections
- `task_output.go` (new): ctx plumbing + helpers kept out of task.go
- `task.go`: 7 lines of surgical edits — name the lambda's error
  return, wrap before the cmd loop, defer the closer with the final
  error, and swap the cmd announcement to `printCmdAnnouncement` which
  writes into the task-scoped stderr
2026-04-22 17:22:54 +02:00
Valentin Maerten
542fe465e9 feat(output): add gitlab output mode (#2806)
Adds a new `gitlab` output style that wraps each task's output in GitLab
CI collapsible section markers. Section IDs are generated automatically
so that start and end markers always match and stay unique per
invocation — even when the same task runs multiple times in one job.

Options: `collapsed` (maps to GitLab's native `[collapsed=true]`) and
`error_only` (Task-level behavior, identical to `group.error_only`).

Also introduces `output-ci-auto` (taskrc + TASK_OUTPUT_CI_AUTO env var)
that auto-selects a CI-aware output style when a supported CI runner is
detected (currently `GITLAB_CI=true` → gitlab) and no output style is
explicitly configured. Keeps the Taskfile neutral so local devs are not
forced into CI-shaped output.

Refs #2806.
2026-04-22 14:10:55 +02:00
18 changed files with 739 additions and 17 deletions

View File

@@ -67,6 +67,7 @@ type (
Compiler *Compiler
Output output.Output
OutputStyle ast.Output
OutputCIAuto bool
TaskSorter sort.Sorter
UserWorkingDir string
EnableVersionCheck bool
@@ -522,6 +523,21 @@ func (o *outputStyleOption) ApplyToExecutor(e *Executor) {
e.OutputStyle = o.outputStyle
}
// WithOutputCIAuto enables automatic selection of a CI-aware output style
// (e.g. "gitlab") when a supported CI environment is detected and no explicit
// output style is configured in the Taskfile or via CLI.
func WithOutputCIAuto(enabled bool) ExecutorOption {
return &outputCIAutoOption{enabled}
}
type outputCIAutoOption struct {
enabled bool
}
func (o *outputCIAutoOption) ApplyToExecutor(e *Executor) {
e.OutputCIAuto = o.enabled
}
// WithTaskSorter sets the sorter that the [Executor] will use to sort tasks. By
// default, the sorter is set to sort tasks alphabetically, but with tasks with
// no namespace (in the root Taskfile) first.

View File

@@ -71,6 +71,7 @@ var (
Dir string
Entrypoint string
Output ast.Output
OutputCIAuto bool
Color bool
Interval time.Duration
Failfast bool
@@ -143,10 +144,11 @@ func init() {
pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.")
pflag.StringVarP(&Dir, "dir", "d", "", "Sets the directory in which Task will execute and look for a Taskfile.")
pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`)
pflag.StringVarP(&Output.Name, "output", "o", "", "Sets output style: [interleaved|group|prefixed].")
pflag.StringVarP(&Output.Name, "output", "o", "", "Sets output style: [interleaved|group|prefixed|gitlab].")
pflag.StringVar(&Output.Group.Begin, "output-group-begin", "", "Message template to print before a task's grouped output.")
pflag.StringVar(&Output.Group.End, "output-group-end", "", "Message template to print after a task's grouped output.")
pflag.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", false, "Swallow output from successful tasks.")
OutputCIAuto = getConfig(config, "OUTPUT_CI_AUTO", func() *bool { return config.OutputCIAuto }, false)
pflag.BoolVarP(&Color, "color", "c", getConfig(config, "COLOR", func() *bool { return config.Color }, true), "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.")
pflag.IntVarP(&Concurrency, "concurrency", "C", getConfig(config, "CONCURRENCY", func() *int { return config.Concurrency }, 0), "Limit number of tasks to run concurrently.")
pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.")
@@ -305,6 +307,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
task.WithConcurrency(Concurrency),
task.WithInterval(Interval),
task.WithOutputStyle(Output),
task.WithOutputCIAuto(OutputCIAuto),
task.WithTaskSorter(sorter),
task.WithVersionCheck(true),
task.WithFailfast(Failfast),

116
internal/output/gitlab.go Normal file
View File

@@ -0,0 +1,116 @@
package output
import (
"bytes"
"fmt"
"io"
"regexp"
"sync"
"time"
"github.com/google/uuid"
"github.com/go-task/task/v3/internal/templater"
)
// GitLab renders a task's output wrapped in [GitLab CI collapsible
// section markers]. Section IDs are generated automatically so that
// start and end markers always match and stay unique per invocation.
//
// GitLab wraps output at the task level via the [TaskWrapper] interface,
// so each task (including its command announcements and all its cmds)
// appears inside a single collapsible section. Nested task invocations
// produce nested sections.
//
// [GitLab CI collapsible section markers]: https://docs.gitlab.com/ci/jobs/job_logs/#create-custom-collapsible-sections
type GitLab struct {
Collapsed bool
ErrorOnly bool
}
// WrapWriter is a passthrough for GitLab: wrapping happens at the task
// level via WrapTask, not per command.
func (g GitLab) WrapWriter(stdOut, stdErr io.Writer, _ string, _ *templater.Cache) (io.Writer, io.Writer, CloseFunc) {
return stdOut, stdErr, func(error) error { return nil }
}
// WrapTask wraps an entire task's output in a single collapsible section.
func (g GitLab) WrapTask(stdOut, _ io.Writer, cache *templater.Cache) (io.Writer, io.Writer, CloseFunc) {
header := ""
if cache != nil {
header = templater.Replace("{{.TASK}}", cache)
}
if header == "" {
header = "task"
}
id := fmt.Sprintf("%s_%s", gitlabSectionSlug(header), uuid.New().String()[:8])
gw := &gitlabWriter{
writer: stdOut,
id: id,
header: header,
collapsed: g.Collapsed,
startTS: time.Now().Unix(),
}
return gw, gw, func(err error) error {
if g.ErrorOnly && err == nil {
return nil
}
return gw.close()
}
}
type gitlabWriter struct {
mu sync.Mutex
writer io.Writer
buff bytes.Buffer
id string
header string
collapsed bool
startTS int64
}
func (gw *gitlabWriter) Write(p []byte) (int, error) {
gw.mu.Lock()
defer gw.mu.Unlock()
return gw.buff.Write(p)
}
func (gw *gitlabWriter) close() error {
gw.mu.Lock()
defer gw.mu.Unlock()
if gw.buff.Len() == 0 {
return nil
}
var b bytes.Buffer
b.WriteString(gitlabSectionStart(gw.startTS, gw.id, gw.header, gw.collapsed))
if _, err := io.Copy(&b, &gw.buff); err != nil {
return err
}
b.WriteString(gitlabSectionEnd(time.Now().Unix(), gw.id))
_, err := io.Copy(gw.writer, &b)
return err
}
func gitlabSectionStart(ts int64, id, header string, collapsed bool) string {
options := ""
if collapsed {
options = "[collapsed=true]"
}
return fmt.Sprintf("\x1b[0Ksection_start:%d:%s%s\r\x1b[0K%s\n", ts, id, options, header)
}
func gitlabSectionEnd(ts int64, id string) string {
return fmt.Sprintf("\x1b[0Ksection_end:%d:%s\r\x1b[0K\n", ts, id)
}
var gitlabSlugDisallowed = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
func gitlabSectionSlug(s string) string {
return gitlabSlugDisallowed.ReplaceAllString(s, "_")
}

View File

@@ -13,6 +13,14 @@ type Output interface {
WrapWriter(stdOut, stdErr io.Writer, prefix string, cache *templater.Cache) (io.Writer, io.Writer, CloseFunc)
}
// TaskWrapper is an optional interface that Output implementations can satisfy
// to wrap an entire task's execution in a single enclosing block — including
// the task's command announcements and all its commands' output — instead of
// wrapping each command individually via WrapWriter.
type TaskWrapper interface {
WrapTask(stdOut, stdErr io.Writer, cache *templater.Cache) (io.Writer, io.Writer, CloseFunc)
}
type CloseFunc func(err error) error
// Build the Output for the requested ast.Output.
@@ -34,6 +42,14 @@ func BuildFor(o *ast.Output, logger *logger.Logger) (Output, error) {
return nil, err
}
return NewPrefixed(logger), nil
case "gitlab":
if err := checkOutputGroupUnset(o); err != nil {
return nil, err
}
return GitLab{
Collapsed: o.GitLab.Collapsed,
ErrorOnly: o.GitLab.ErrorOnly,
}, nil
default:
return nil, fmt.Errorf(`task: output style %q not recognized`, o.Name)
}

View File

@@ -5,7 +5,12 @@ import (
"errors"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/fatih/color"
"github.com/stretchr/testify/assert"
@@ -121,6 +126,238 @@ func TestGroupErrorOnlyShowsOutputOnError(t *testing.T) {
assert.Equal(t, "std-out\nstd-err\n", b.String())
}
func gitlabTaskCache(taskName string) *templater.Cache {
return &templater.Cache{
Vars: ast.NewVars(
&ast.VarElement{
Key: "TASK",
Value: ast.Var{Value: taskName},
},
),
}
}
var gitlabMarkerPattern = regexp.MustCompile(
`\x1b\[0Ksection_start:(\d+):(\S+?)(\[[^\]]+\])?\r\x1b\[0K(.*)\n` +
`(?s)(.*)` +
`\x1b\[0Ksection_end:(\d+):(\S+)\r\x1b\[0K\n$`,
)
func TestGitLab(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{}
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("build"))
fmt.Fprintln(w, "hello")
assert.Equal(t, "", b.String(), "output must be buffered until close")
require.NoError(t, cleanup(nil))
m := gitlabMarkerPattern.FindStringSubmatch(b.String())
require.NotNil(t, m, "output should match GitLab section markers, got: %q", b.String())
assert.Equal(t, m[2], m[7], "start and end section IDs must match")
assert.Empty(t, m[3], "collapsed option should not be present by default")
assert.Equal(t, "build", m[4], "section header should be the task name")
assert.Equal(t, "hello\n", m[5], "wrapped content must be preserved")
assert.Contains(t, m[2], "build_", "section ID should be prefixed with slugged task name")
}
func TestGitLabUniqueSectionIDs(t *testing.T) {
t.Parallel()
o := output.GitLab{}
ids := make([]string, 3)
for i := range ids {
var b bytes.Buffer
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("build"))
fmt.Fprintln(w, "x")
require.NoError(t, cleanup(nil))
m := gitlabMarkerPattern.FindStringSubmatch(b.String())
require.NotNil(t, m)
ids[i] = m[2]
}
assert.NotEqual(t, ids[0], ids[1])
assert.NotEqual(t, ids[1], ids[2])
assert.NotEqual(t, ids[0], ids[2])
}
func TestGitLabCollapsed(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{Collapsed: true}
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("build"))
fmt.Fprintln(w, "x")
require.NoError(t, cleanup(nil))
m := gitlabMarkerPattern.FindStringSubmatch(b.String())
require.NotNil(t, m)
assert.Equal(t, "[collapsed=true]", m[3])
}
func TestGitLabErrorOnlySwallowsOutputOnNoError(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{ErrorOnly: true}
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("build"))
fmt.Fprintln(w, "hello")
require.NoError(t, cleanup(nil))
assert.Empty(t, b.String())
}
func TestGitLabErrorOnlyShowsOutputOnError(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{ErrorOnly: true}
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("build"))
fmt.Fprintln(w, "hello")
require.NoError(t, cleanup(errors.New("boom")))
m := gitlabMarkerPattern.FindStringSubmatch(b.String())
require.NotNil(t, m)
assert.Equal(t, "hello\n", m[5])
}
func TestGitLabSlugSanitizesTaskName(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{}
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("my task:with spaces"))
fmt.Fprintln(w, "x")
require.NoError(t, cleanup(nil))
m := gitlabMarkerPattern.FindStringSubmatch(b.String())
require.NotNil(t, m)
assert.Regexp(t, `^[a-zA-Z0-9_.-]+$`, m[2], "section ID must only contain GitLab-allowed chars")
}
func TestGitLabWrapWriterIsPassthrough(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{}
w, _, cleanup := o.WrapWriter(&b, io.Discard, "", nil)
fmt.Fprintln(w, "hello")
assert.Equal(t, "hello\n", b.String(), "WrapWriter must be a passthrough for GitLab")
assert.NoError(t, cleanup(nil))
assert.Equal(t, "hello\n", b.String(), "closer must be a no-op")
}
func TestGitLabWrapTaskSingleSection(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{}
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("build"))
// Simulate multiple cmd outputs being written during a task's execution.
fmt.Fprintln(w, "cmd 1 output")
fmt.Fprintln(w, "cmd 2 output")
fmt.Fprintln(w, "cmd 3 output")
require.NoError(t, cleanup(nil))
// There must be exactly one section_start and one section_end.
assert.Equal(t, 1, strings.Count(b.String(), "section_start:"))
assert.Equal(t, 1, strings.Count(b.String(), "section_end:"))
m := gitlabMarkerPattern.FindStringSubmatch(b.String())
require.NotNil(t, m)
assert.Equal(t, "cmd 1 output\ncmd 2 output\ncmd 3 output\n", m[5])
}
func TestGitLabWrapTaskDurationElapsed(t *testing.T) {
t.Parallel()
var b bytes.Buffer
o := output.GitLab{}
w, _, cleanup := o.WrapTask(&b, io.Discard, gitlabTaskCache("slow"))
fmt.Fprintln(w, "started")
time.Sleep(1100 * time.Millisecond)
fmt.Fprintln(w, "done")
require.NoError(t, cleanup(nil))
m := gitlabMarkerPattern.FindStringSubmatch(b.String())
require.NotNil(t, m)
startTS, err := strconv.ParseInt(m[1], 10, 64)
require.NoError(t, err)
endTS, err := strconv.ParseInt(m[6], 10, 64)
require.NoError(t, err)
assert.GreaterOrEqual(t, endTS-startTS, int64(1),
"end TS must be at least 1 second after start TS when task takes >1s")
}
func TestGitLabWrapTaskNested(t *testing.T) {
t.Parallel()
var root bytes.Buffer
parent := output.GitLab{}
parentW, _, parentClose := parent.WrapTask(&root, io.Discard, gitlabTaskCache("parent"))
fmt.Fprintln(parentW, "before child")
child := output.GitLab{}
childW, _, childClose := child.WrapTask(parentW, io.Discard, gitlabTaskCache("child"))
fmt.Fprintln(childW, "inside child")
require.NoError(t, childClose(nil))
fmt.Fprintln(parentW, "after child")
require.NoError(t, parentClose(nil))
out := root.String()
// Two section_start and two section_end
assert.Equal(t, 2, strings.Count(out, "section_start:"))
assert.Equal(t, 2, strings.Count(out, "section_end:"))
// Order: parent start → child start → child end → parent end
parentStart := strings.Index(out, "section_start:") // first
childStart := strings.Index(out[parentStart+1:], "section_start:") + parentStart + 1
childEnd := strings.Index(out, "section_end:")
parentEnd := strings.LastIndex(out, "section_end:")
assert.Less(t, parentStart, childStart, "child_start must come after parent_start")
assert.Less(t, childStart, childEnd, "child_end must come after child_start")
assert.Less(t, childEnd, parentEnd, "parent_end must come after child_end")
}
func TestGitLabWrapTaskConcurrentWrites(t *testing.T) {
t.Parallel()
var root bytes.Buffer
parent := output.GitLab{}
parentW, _, parentClose := parent.WrapTask(&root, io.Discard, gitlabTaskCache("parent"))
const numChildren = 10
var wg sync.WaitGroup
for i := 0; i < numChildren; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
child := output.GitLab{}
childW, _, childClose := child.WrapTask(parentW, io.Discard, gitlabTaskCache(fmt.Sprintf("child%d", i)))
fmt.Fprintf(childW, "child %d output\n", i)
_ = childClose(nil)
}(i)
}
wg.Wait()
require.NoError(t, parentClose(nil))
out := root.String()
// 1 parent + 10 children = 11 section_start and 11 section_end
assert.Equal(t, 11, strings.Count(out, "section_start:"))
assert.Equal(t, 11, strings.Count(out, "section_end:"))
// All 10 child outputs present
for i := 0; i < numChildren; i++ {
assert.Contains(t, out, fmt.Sprintf("child %d output", i))
}
}
func TestPrefixed(t *testing.T) { //nolint:paralleltest // cannot run in parallel
var b bytes.Buffer
l := &logger.Logger{

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
@@ -202,12 +203,27 @@ func (e *Executor) setupOutput() error {
if !e.OutputStyle.IsSet() {
e.OutputStyle = e.Taskfile.Output
}
if !e.OutputStyle.IsSet() && e.OutputCIAuto {
if name := detectCIOutput(); name != "" {
e.OutputStyle.Name = name
}
}
var err error
e.Output, err = output.BuildFor(&e.OutputStyle, e.Logger)
return err
}
// detectCIOutput returns the name of a CI-aware output style to use based
// on environment variables set by common CI runners. Returns an empty string
// when no supported CI environment is detected.
func detectCIOutput() string {
if isGitLab, _ := strconv.ParseBool(os.Getenv("GITLAB_CI")); isGitLab {
return "gitlab"
}
return ""
}
func (e *Executor) setupCompiler() error {
if e.UserWorkingDir == "" {
var err error

97
setup_test.go Normal file
View File

@@ -0,0 +1,97 @@
package task
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile/ast"
)
func TestDetectCIOutput(t *testing.T) {
cases := []struct {
name string
env map[string]string
want string
}{
{name: "no CI detected", env: nil, want: ""},
{name: "GITLAB_CI=true", env: map[string]string{"GITLAB_CI": "true"}, want: "gitlab"},
{name: "GITLAB_CI=1", env: map[string]string{"GITLAB_CI": "1"}, want: "gitlab"},
{name: "GITLAB_CI=false", env: map[string]string{"GITLAB_CI": "false"}, want: ""},
{name: "GITLAB_CI empty", env: map[string]string{"GITLAB_CI": ""}, want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("GITLAB_CI", "") // reset
for k, v := range tc.env {
t.Setenv(k, v)
}
assert.Equal(t, tc.want, detectCIOutput())
})
}
}
func TestSetupOutputPriority(t *testing.T) {
cases := []struct {
name string
cliStyle ast.Output
taskfileStyle ast.Output
ciAuto bool
gitlabEnv string
wantName string
}{
{
name: "CLI wins over everything",
cliStyle: ast.Output{Name: "prefixed"},
taskfileStyle: ast.Output{Name: "group", Group: ast.OutputGroup{
Begin: "b", End: "e",
}},
ciAuto: true,
gitlabEnv: "true",
wantName: "prefixed",
},
{
name: "Taskfile wins over auto-detect",
taskfileStyle: ast.Output{Name: "prefixed"},
ciAuto: true,
gitlabEnv: "true",
wantName: "prefixed",
},
{
name: "auto-detect activates when nothing explicit",
ciAuto: true,
gitlabEnv: "true",
wantName: "gitlab",
},
{
name: "auto-detect disabled does nothing",
ciAuto: false,
gitlabEnv: "true",
wantName: "",
},
{
name: "auto-detect without CI env does nothing",
ciAuto: true,
gitlabEnv: "",
wantName: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("GITLAB_CI", tc.gitlabEnv)
e := &Executor{
OutputStyle: tc.cliStyle,
OutputCIAuto: tc.ciAuto,
Taskfile: &ast.Taskfile{Output: tc.taskfileStyle},
Logger: &logger.Logger{},
}
require.NoError(t, e.setupOutput())
assert.Equal(t, tc.wantName, e.OutputStyle.Name)
})
}
}

12
task.go
View File

@@ -204,9 +204,9 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
release := e.acquireConcurrencyLimit()
defer release()
if err = e.startExecution(ctx, t, func(ctx context.Context) error {
if err = e.startExecution(ctx, t, func(ctx context.Context) (err error) {
e.Logger.VerboseErrf(logger.Magenta, "task: %q started\n", call.Task)
if err := e.runDeps(ctx, t); err != nil {
if err = e.runDeps(ctx, t); err != nil {
return err
}
@@ -266,6 +266,9 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
var deferredExitCode uint8
ctx, taskOutCloser := e.wrapTaskOutput(ctx, t, call)
defer func() { taskOutCloser(err) }()
for i := range t.Cmds {
if t.Cmds[i].Defer {
defer e.runDeferred(t, call, i, t.Vars, &deferredExitCode)
@@ -393,7 +396,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
}
if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) {
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd)
e.printCmdAnnouncement(ctx, t, cmd.Cmd)
}
if e.Dry {
@@ -409,7 +412,8 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
if err != nil {
return fmt.Errorf("task: failed to get variables: %w", err)
}
stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater)
taskStdOut, taskStdErr := e.writersFromCtx(ctx)
stdOut, stdErr, closer := outputWrapper.WrapWriter(taskStdOut, taskStdErr, t.Prefix, outputTemplater)
err = execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: cmd.Cmd,

70
task_output.go Normal file
View File

@@ -0,0 +1,70 @@
package task
import (
"context"
"io"
"github.com/fatih/color"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/output"
"github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/taskfile/ast"
)
type taskWritersKey struct{}
type taskWriters struct {
stdout, stderr io.Writer
}
// writersFromCtx returns the task-scoped writers if set, otherwise the
// Executor's own stdout/stderr.
func (e *Executor) writersFromCtx(ctx context.Context) (io.Writer, io.Writer) {
if tw, ok := ctx.Value(taskWritersKey{}).(*taskWriters); ok && tw != nil {
return tw.stdout, tw.stderr
}
return e.Stdout, e.Stderr
}
// wrapTaskOutput wraps a task's output in a task-scoped block if e.Output
// implements [output.TaskWrapper] and the task is not interactive. Returns
// the (possibly updated) ctx and a closer that flushes the block. The closer
// is always safe to call — it is a no-op when no wrapping took place.
func (e *Executor) wrapTaskOutput(ctx context.Context, t *ast.Task, call *Call) (context.Context, func(error)) {
noop := func(error) {}
if t.Interactive {
return ctx, noop
}
tw, ok := e.Output.(output.TaskWrapper)
if !ok {
return ctx, noop
}
stdOut, stdErr := e.writersFromCtx(ctx)
vars, err := e.Compiler.FastGetVariables(t, call)
if err != nil {
e.Logger.VerboseErrf(logger.Yellow, "task: output setup: %v\n", err)
return ctx, noop
}
wOut, wErr, closer := tw.WrapTask(stdOut, stdErr, &templater.Cache{Vars: vars})
ctx = context.WithValue(ctx, taskWritersKey{}, &taskWriters{stdout: wOut, stderr: wErr})
return ctx, func(loopErr error) {
if err := closer(loopErr); err != nil {
e.Logger.Errf(logger.Red, "task: output close: %v\n", err)
}
}
}
// printCmdAnnouncement prints the "task: [NAME] CMD" line using the
// task-scoped stderr if available, so the announcement ends up inside the
// task's output block.
func (e *Executor) printCmdAnnouncement(ctx context.Context, t *ast.Task, cmdStr string) {
_, stdErr := e.writersFromCtx(ctx)
if stdErr == e.Stderr {
// No task-scoped writer — fall back to the Logger to preserve existing
// behavior (respects Logger's color config, etc.).
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmdStr)
return
}
_, _ = color.New(color.FgGreen).Fprintf(stdErr, "task: [%s] %s\n", t.Name(), cmdStr)
}

View File

@@ -12,6 +12,8 @@ type Output struct {
Name string `yaml:"-"`
// Group specific style
Group OutputGroup
// GitLab specific style
GitLab OutputGitLab
}
// IsSet returns true if and only if a custom output style is set.
@@ -32,19 +34,30 @@ func (s *Output) UnmarshalYAML(node *yaml.Node) error {
case yaml.MappingNode:
var tmp struct {
Group *OutputGroup
Group *OutputGroup
GitLab *OutputGitLab `yaml:"gitlab"`
}
if err := node.Decode(&tmp); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
if tmp.Group == nil {
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`output style must have the "group" key when in mapping form`)
switch {
case tmp.Group != nil && tmp.GitLab != nil:
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`output style cannot set both "group" and "gitlab"`)
case tmp.Group != nil:
*s = Output{
Name: "group",
Group: *tmp.Group,
}
return nil
case tmp.GitLab != nil:
*s = Output{
Name: "gitlab",
GitLab: *tmp.GitLab,
}
return nil
default:
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`output style must have the "group" or "gitlab" key when in mapping form`)
}
*s = Output{
Name: "group",
Group: *tmp.Group,
}
return nil
}
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("output")
@@ -63,3 +76,9 @@ func (g *OutputGroup) IsSet() bool {
}
return g.Begin != "" || g.End != ""
}
// OutputGitLab is the style options specific to the GitLab style.
type OutputGitLab struct {
Collapsed bool
ErrorOnly bool `yaml:"error_only"`
}

View File

@@ -17,6 +17,7 @@ type TaskRC struct {
DisableFuzzy *bool `yaml:"disable-fuzzy"`
Concurrency *int `yaml:"concurrency"`
Interactive *bool `yaml:"interactive"`
OutputCIAuto *bool `yaml:"output-ci-auto"`
Remote Remote `yaml:"remote"`
Failfast bool `yaml:"failfast"`
Experiments map[string]int `yaml:"experiments"`
@@ -69,5 +70,6 @@ func (t *TaskRC) Merge(other *TaskRC) {
t.DisableFuzzy = cmp.Or(other.DisableFuzzy, t.DisableFuzzy)
t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency)
t.Interactive = cmp.Or(other.Interactive, t.Interactive)
t.OutputCIAuto = cmp.Or(other.OutputCIAuto, t.OutputCIAuto)
t.Failfast = cmp.Or(other.Failfast, t.Failfast)
}

View File

@@ -306,4 +306,27 @@ remote:
assert.Equal(t, &cacheExpiry, base.Remote.CacheExpiry)
assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.TrustedHosts)
})
t.Run("output-ci-auto merge", func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel
trueVal := true
falseVal := false
t.Run("other overrides nil base", func(t *testing.T) { //nolint:paralleltest
base := &ast.TaskRC{}
base.Merge(&ast.TaskRC{OutputCIAuto: &trueVal})
assert.Equal(t, &trueVal, base.OutputCIAuto)
})
t.Run("other overrides base", func(t *testing.T) { //nolint:paralleltest
base := &ast.TaskRC{OutputCIAuto: &falseVal}
base.Merge(&ast.TaskRC{OutputCIAuto: &trueVal})
assert.Equal(t, &trueVal, base.OutputCIAuto)
})
t.Run("nil other does not override base", func(t *testing.T) { //nolint:paralleltest
base := &ast.TaskRC{OutputCIAuto: &trueVal}
base.Merge(&ast.TaskRC{})
assert.Equal(t, &trueVal, base.OutputCIAuto)
})
})
}

View File

@@ -2426,12 +2426,13 @@ the shell in real-time. This is good for having live feedback for logging
printed by commands, but the output can become messy if you have multiple
commands running simultaneously and printing lots of stuff.
To make this more customizable, there are currently three different output
To make this more customizable, there are currently four different output
options you can choose:
- `interleaved` (default)
- `group`
- `prefixed`
- `gitlab`
To choose another one, just set it to root in the Taskfile:
@@ -2535,6 +2536,44 @@ $ task default
[print-baz] baz
```
The `gitlab` output wraps each task's output in
[GitLab CI collapsible section markers](https://docs.gitlab.com/ci/jobs/job_logs/#create-custom-collapsible-sections).
Section names are generated automatically so that start and end markers always
match and stay unique per invocation — even when the same task runs multiple
times in the same job.
```yaml
version: '3'
output: gitlab
```
Two options are available:
- `collapsed`: maps to GitLab's native
[`[collapsed=true]`](https://docs.gitlab.com/ci/jobs/job_logs/#create-custom-collapsible-sections)
option, which tells GitLab to fold the section by default in the UI.
- `error_only`: a Task-level option (same as in the [`group`](#output-syntax)
style) that swallows the command output — markers included — for tasks that
exit with a zero status code.
```yaml
version: '3'
output:
gitlab:
collapsed: true
error_only: true
```
::: tip
Rather than hard-coding `output: gitlab` in your Taskfile (which also affects
local development), consider using [`output-ci-auto`](#automatic-ci-output) so
the mode is only activated in CI.
:::
::: tip
The `output` option can also be specified by the `--output` or `-o` flags.
@@ -2563,6 +2602,28 @@ summary, making it easier to spot failures without scrolling through logs.
This feature requires no configuration and works automatically.
### Automatic CI output
When `output-ci-auto: true` is set in a [`.taskrc.yml`](./taskrc.md) file, Task
will automatically select a CI-aware [output style](#output-syntax) based on
the environment it is running in, but only when no output style is configured
explicitly (via the Taskfile, `--output`, or `TASK_X_OUTPUT`).
Currently supported:
| Environment variable | Output style selected |
| -------------------- | --------------------- |
| `GITLAB_CI=true` | `gitlab` |
This lets you keep your Taskfile neutral — local developers get the default
`interleaved` output, while CI runs get their matching CI-flavored output
without any per-job configuration.
```yaml
# .taskrc.yml
output-ci-auto: true
```
## Interactive CLI application
When running interactive CLI applications inside Task they can sometimes behave

View File

@@ -224,7 +224,8 @@ task backup --global
#### `-o, --output <mode>`
Set output style. Available modes: `interleaved`, `group`, `prefixed`.
Set output style. Available modes: `interleaved`, `group`, `prefixed`,
`gitlab`.
```bash
task test --output group

View File

@@ -166,6 +166,21 @@ failfast: true
interactive: true
```
### `output-ci-auto`
- **Type**: `boolean`
- **Default**: `false`
- **Description**: Automatically select a CI-aware
[output style](../guide.md#output-syntax) when a supported CI environment
is detected and no output style is explicitly configured (via the Taskfile
or `--output`). Currently maps `GITLAB_CI=true` to the `gitlab` output
style.
- **Environment variable**: [`TASK_OUTPUT_CI_AUTO`](./environment.md#task-output-ci-auto)
```yaml
output-ci-auto: true
```
## Example Configuration
Here's a complete example of a `.taskrc.yml` file with all available options:

View File

@@ -81,6 +81,16 @@ variables. The priority order is: CLI flags > environment variables > config fil
- **Default**: `false`
- **Description**: Prompt for missing required variables
### `TASK_OUTPUT_CI_AUTO`
- **Type**: `boolean` (`true`, `false`, `1`, `0`)
- **Default**: `false`
- **Description**: Automatically select a CI-aware output style when a
supported CI environment is detected and no output style is explicitly
configured. See [output syntax](../guide.md#output-syntax) and
[automatic CI output](../guide.md#automatic-ci-output).
- **Config equivalent**: [`output-ci-auto`](./config.md#output-ci-auto)
### `TASK_TEMP_DIR`
Defines the location of Task's temporary directory which is used for storing

View File

@@ -29,7 +29,7 @@ version: '3'
- **Type**: `string` or `object`
- **Default**: `interleaved`
- **Options**: `interleaved`, `group`, `prefixed`
- **Options**: `interleaved`, `group`, `prefixed`, `gitlab`
- **Description**: Controls how task output is displayed
```yaml

View File

@@ -595,7 +595,7 @@
},
"outputString": {
"type": "string",
"enum": ["interleaved", "prefixed", "group"],
"enum": ["interleaved", "prefixed", "group", "gitlab"],
"default": "interleaved"
},
"outputObject": {
@@ -616,6 +616,22 @@
"default": false
}
}
},
"gitlab": {
"type": "object",
"description": "Wraps each task's output in GitLab CI collapsible section markers",
"properties": {
"collapsed": {
"description": "Passes the native GitLab [collapsed=true] option so sections are folded by default in the GitLab CI UI",
"type": "boolean",
"default": false
},
"error_only": {
"description": "Swallows command output on zero exit code (Task-level behavior, identical to group.error_only)",
"type": "boolean",
"default": false
}
}
}
},
"additionalProperties": false