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.
This commit is contained in:
Valentin Maerten
2026-04-22 14:10:55 +02:00
parent 70b6cd8ee0
commit 542fe465e9
16 changed files with 509 additions and 13 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),

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

@@ -0,0 +1,97 @@
package output
import (
"bytes"
"fmt"
"io"
"regexp"
"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 CI collapsible section markers]: https://docs.gitlab.com/ci/jobs/job_logs/#create-custom-collapsible-sections
type GitLab struct {
Collapsed bool
ErrorOnly bool
}
func (g GitLab) WrapWriter(stdOut, _ io.Writer, _ string, 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 {
writer io.Writer
buff bytes.Buffer
id string
header string
collapsed bool
startTS int64
}
func (gw *gitlabWriter) Write(p []byte) (int, error) {
return gw.buff.Write(p)
}
func (gw *gitlabWriter) close() error {
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

@@ -34,6 +34,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,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"regexp"
"testing"
"github.com/fatih/color"
@@ -121,6 +122,117 @@ 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.WrapWriter(&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.WrapWriter(&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.WrapWriter(&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.WrapWriter(&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.WrapWriter(&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.WrapWriter(&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 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)
})
}
}

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