mirror of
https://github.com/go-task/task.git
synced 2026-06-11 09:51:50 +00:00
✨ 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:
16
executor.go
16
executor.go
@@ -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.
|
||||
|
||||
@@ -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
97
internal/output/gitlab.go
Normal 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, "_")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
16
setup.go
16
setup.go
@@ -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
97
setup_test.go
Normal 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,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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user