Compare commits

..

5 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
Andreas **Felix** Häberle
70b6cd8ee0 docs: add call internal task within a task example (#2789)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-04-20 21:58:42 +02:00
Valentin Maerten
1eb5720e7e chore: changelog for #2788 2026-04-20 21:53:50 +02:00
Mateen Anjum
1b06da16f6 feat(templater): add absPath template function (#2788)
Signed-off-by: Mateen Anjum <mateenali66@gmail.com>
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-04-20 21:50:03 +02:00
36 changed files with 802 additions and 444 deletions

View File

@@ -1,5 +1,10 @@
# Changelog
## Unreleased
- Added `absPath` template function that resolves a path to its absolute form,
cleaning `..` and `.` components (#2681, #2788 by @mateenanjum).
## v3.50.0 - 2026-04-13
- Added `enum.ref` support in `requires`: enum constraints can now reference

View File

@@ -51,7 +51,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
return nil, err
}
for k, v := range specialVars {
result.Set(k, ast.Var{Value: v, Secret: false})
result.Set(k, ast.Var{Value: v})
}
getRangeFunc := func(dir string) func(k string, v ast.Var) error {
@@ -63,12 +63,12 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
// This stops empty interface errors when using the templater to replace values later
// Preserve the Sh field so it can be displayed in summary
if !evaluateShVars && newVar.Value == nil {
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh, Secret: v.Secret})
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
return nil
}
// If the variable should not be evaluated and it is set, we can set it and return
if !evaluateShVars {
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh, Secret: v.Secret})
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
return nil
}
// Now we can check for errors since we've handled all the cases when we don't want to evaluate
@@ -77,7 +77,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
}
// If the variable is already set, we can set it and return
if newVar.Value != nil || newVar.Sh == nil {
result.Set(k, ast.Var{Value: newVar.Value, Secret: v.Secret})
result.Set(k, ast.Var{Value: newVar.Value})
return nil
}
// If the variable is dynamic, we need to resolve it first
@@ -85,7 +85,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
if err != nil {
return err
}
result.Set(k, ast.Var{Value: static, Secret: v.Secret})
result.Set(k, ast.Var{Value: static})
return nil
}
}

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

@@ -283,45 +283,6 @@ func TestVars(t *testing.T) {
)
}
func TestSecretVars(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("secret vars are masked in logs"),
WithExecutorOptions(
task.WithDir("testdata/secrets"),
),
WithTask("test-secret-masking"),
)
NewExecutorTest(t,
WithName("multiple secrets masked"),
WithExecutorOptions(
task.WithDir("testdata/secrets"),
),
WithTask("test-multiple-secrets"),
)
NewExecutorTest(t,
WithName("mixed secret and public vars"),
WithExecutorOptions(
task.WithDir("testdata/secrets"),
),
WithTask("test-mixed"),
)
NewExecutorTest(t,
WithName("deferred command with secrets"),
WithExecutorOptions(
task.WithDir("testdata/secrets"),
),
WithTask("test-deferred-secret"),
)
NewExecutorTest(t,
WithName("env secret limitation"),
WithExecutorOptions(
task.WithDir("testdata/secrets"),
),
WithTask("test-env-secret-limitation"),
)
}
func TestRequires(t *testing.T) {
t.Parallel()
NewExecutorTest(t,

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

@@ -34,6 +34,7 @@ func init() {
"IsSH": IsSH, // Deprecated
"joinPath": filepath.Join,
"relPath": filepath.Rel,
"absPath": filepath.Abs,
"merge": merge,
"spew": spew.Sdump,
"fromYaml": fromYaml,

View File

@@ -1,70 +0,0 @@
package templater
import (
"github.com/go-task/task/v3/taskfile/ast"
)
// MaskSecrets replaces template placeholders with their values, masking secrets.
// This function uses the Go templater to resolve all variables ({{.VAR}}) while
// masking secret ones as "*****".
func MaskSecrets(cmdTemplate string, vars *ast.Vars) string {
if vars == nil || vars.Len() == 0 {
return cmdTemplate
}
// Create a cache map with secrets masked
maskedVars := vars.DeepCopy()
for name, v := range maskedVars.All() {
if v.Secret {
// Replace secret value with mask
maskedVars.Set(name, ast.Var{
Value: "*****",
Secret: true,
})
}
}
// Use the templater to resolve the template with masked secrets
cache := &Cache{Vars: maskedVars}
result := Replace(cmdTemplate, cache)
// If there was an error, return the original template
if cache.Err() != nil {
return cmdTemplate
}
return result
}
// MaskSecretsWithExtra is like MaskSecrets but also resolves extra variables (e.g., loop vars).
func MaskSecretsWithExtra(cmdTemplate string, vars *ast.Vars, extra map[string]any) string {
if vars == nil || vars.Len() == 0 {
// Still need to resolve extra vars even if no vars
cache := &Cache{Vars: ast.NewVars()}
result := ReplaceWithExtra(cmdTemplate, cache, extra)
if cache.Err() != nil {
return cmdTemplate
}
return result
}
// Create a cache map with secrets masked
maskedVars := vars.DeepCopy()
for name, v := range maskedVars.All() {
if v.Secret {
maskedVars.Set(name, ast.Var{
Value: "*****",
Secret: true,
})
}
}
cache := &Cache{Vars: maskedVars}
result := ReplaceWithExtra(cmdTemplate, cache, extra)
if cache.Err() != nil {
return cmdTemplate
}
return result
}

View File

@@ -121,15 +121,14 @@ func ReplaceVar(v ast.Var, cache *Cache) ast.Var {
func ReplaceVarWithExtra(v ast.Var, cache *Cache, extra map[string]any) ast.Var {
if v.Ref != "" {
return ast.Var{Value: ResolveRef(v.Ref, cache), Secret: v.Secret}
return ast.Var{Value: ResolveRef(v.Ref, cache)}
}
return ast.Var{
Value: ReplaceWithExtra(v.Value, cache, extra),
Sh: ReplaceWithExtra(v.Sh, cache, extra),
Live: v.Live,
Ref: v.Ref,
Dir: v.Dir,
Secret: v.Secret,
Value: ReplaceWithExtra(v.Value, cache, extra),
Sh: ReplaceWithExtra(v.Sh, cache, extra),
Live: v.Live,
Ref: v.Ref,
Dir: v.Dir,
}
}

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)
})
}
}

14
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)
@@ -349,8 +352,6 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode)
}
// Resolve template with secrets masked for logging
cmd.LogCmd = templater.MaskSecretsWithExtra(cmd.Cmd, vars, extra)
cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
cmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
@@ -395,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.LogCmd)
e.printCmdAnnouncement(ctx, t, cmd.Cmd)
}
if e.Dry {
@@ -411,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

@@ -2601,6 +2601,27 @@ func TestSplitArgs(t *testing.T) {
assert.Equal(t, "3\n", buff.String())
}
func TestAbsPath(t *testing.T) {
t.Parallel()
var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir("testdata/abs_path"),
task.WithStdout(&buff),
task.WithStderr(&buff),
task.WithSilent(true),
)
require.NoError(t, e.Setup())
err := e.Run(t.Context(), &task.Call{Task: "default"})
require.NoError(t, err)
cwd, err := os.Getwd()
require.NoError(t, err)
expected := filepath.Join(cwd, "bar") + "\n"
assert.Equal(t, expected, buff.String())
}
func TestSingleCmdDep(t *testing.T) {
t.Parallel()

View File

@@ -9,8 +9,7 @@ import (
// Cmd is a task command
type Cmd struct {
Cmd string // Resolved command (used for execution and fingerprinting)
LogCmd string // Command with secrets masked (used for logging)
Cmd string
Task string
For *For
If string
@@ -29,7 +28,6 @@ func (c *Cmd) DeepCopy() *Cmd {
}
return &Cmd{
Cmd: c.Cmd,
LogCmd: c.LogCmd,
Task: c.Task,
For: c.For.DeepCopy(),
If: c.If,

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

@@ -8,12 +8,11 @@ import (
// Var represents either a static or dynamic variable.
type Var struct {
Value any
Live any
Sh *string
Ref string
Dir string
Secret bool
Value any
Live any
Sh *string
Ref string
Dir string
}
func (v *Var) UnmarshalYAML(node *yaml.Node) error {
@@ -24,29 +23,21 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error {
key = node.Content[0].Value
}
switch key {
case "sh", "ref", "map", "value":
case "sh", "ref", "map":
var m struct {
Sh *string
Ref string
Map any
Value any
Secret bool
Sh *string
Ref string
Map any
}
if err := node.Decode(&m); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
v.Sh = m.Sh
v.Ref = m.Ref
v.Secret = m.Secret
// Handle both "map" and "value" keys
if m.Map != nil {
v.Value = m.Map
} else if m.Value != nil {
v.Value = m.Value
}
v.Value = m.Map
return nil
default:
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map", "value" or using a scalar value`, key)
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map" or using a scalar value`, key)
}
default:
var value any

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)
})
})
}

6
testdata/abs_path/Taskfile.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: '3'
tasks:
default:
cmds:
- cmd: echo '{{absPath "foo/../bar"}}'

View File

@@ -1,65 +0,0 @@
version: '3'
vars:
# Public variable
APP_NAME: myapp
# Secret variable with value
API_KEY:
value: "secret-api-key-123"
secret: true
# Secret variable from shell command
PASSWORD:
sh: "echo 'my-super-secret-password'"
secret: true
# Non-secret variable
PUBLIC_URL: https://example.com
tasks:
test-secret-masking:
desc: Test that secret variables are masked in logs
cmds:
- echo "Deploying {{.APP_NAME}} to {{.PUBLIC_URL}}"
- echo "Using API key {{.API_KEY}}"
- echo "Password is {{.PASSWORD}}"
- echo "Public app name is {{.APP_NAME}}"
test-multiple-secrets:
desc: Test multiple secrets in one command
cmds:
- echo "API={{.API_KEY}} PWD={{.PASSWORD}}"
test-mixed:
desc: Test mix of secret and public vars
vars:
LOCAL_SECRET:
value: "task-level-secret"
secret: true
cmds:
- echo "App={{.APP_NAME}} Secret={{.LOCAL_SECRET}} URL={{.PUBLIC_URL}}"
test-deferred-secret:
desc: Test that deferred commands mask secrets
vars:
DEFERRED_SECRET:
value: "deferred-secret-value"
secret: true
cmds:
- echo "Starting task"
- defer: echo "Cleanup with secret={{.DEFERRED_SECRET}} and app={{.APP_NAME}}"
- echo "Main command executed"
test-env-secret-limitation:
desc: Test showing that env vars with secret flag are NOT masked (limitation)
env:
SECRET_TOKEN:
value: "env-secret-token-123"
PUBLIC_ENV: "public-value"
cmds:
# Templates {{.VAR}} don't work with env - they're empty
- echo "Token via template is {{.SECRET_TOKEN}}"
# Shell $VAR works but is NOT masked (env vars not in template system)
- echo "Token via shell is $SECRET_TOKEN"
- echo "Public env is {{.PUBLIC_ENV}}"

View File

@@ -1,6 +0,0 @@
task: [test-deferred-secret] echo "Starting task"
Starting task
task: [test-deferred-secret] echo "Main command executed"
Main command executed
task: [test-deferred-secret] echo "Cleanup with secret=***** and app=myapp"
Cleanup with secret=deferred-secret-value and app=myapp

View File

@@ -1,6 +0,0 @@
task: [test-env-secret-limitation] echo "Token via template is "
Token via template is
task: [test-env-secret-limitation] echo "Token via shell is $SECRET_TOKEN"
Token via shell is env-secret-token-123
task: [test-env-secret-limitation] echo "Public env is "
Public env is

View File

@@ -1,2 +0,0 @@
task: [test-mixed] echo "App=myapp Secret=***** URL=https://example.com"
App=myapp Secret=task-level-secret URL=https://example.com

View File

@@ -1,2 +0,0 @@
task: [test-multiple-secrets] echo "API=***** PWD=*****"
API=secret-api-key-123 PWD=my-super-secret-password

View File

@@ -1,8 +0,0 @@
task: [test-secret-masking] echo "Deploying myapp to https://example.com"
Deploying myapp to https://example.com
task: [test-secret-masking] echo "Using API key *****"
Using API key secret-api-key-123
task: [test-secret-masking] echo "Password is *****"
Password is my-super-secret-password
task: [test-secret-masking] echo "Public app name is myapp"
Public app name is myapp

View File

@@ -239,8 +239,6 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
extra["KEY"] = keys[i]
}
newCmd := cmd.DeepCopy()
// Resolve template with secrets masked + loop vars for logging
newCmd.LogCmd = templater.MaskSecretsWithExtra(cmd.Cmd, cache.Vars, extra)
newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
newCmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
@@ -256,8 +254,6 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
continue
}
newCmd := cmd.DeepCopy()
// Resolve template with secrets masked for logging
newCmd.LogCmd = templater.MaskSecrets(cmd.Cmd, cache.Vars)
newCmd.Cmd = templater.Replace(cmd.Cmd, cache)
newCmd.Task = templater.Replace(cmd.Task, cache)
newCmd.If = templater.Replace(cmd.If, cache)

View File

@@ -1614,163 +1614,6 @@ tasks:
map[a:1 b:2 c:3]
```
### Secret variables
Task supports marking variables as `secret` to prevent their values from being
displayed in command logs. When a variable is marked as secret, its value will
be replaced with `*****` in the task output logs.
::: warning
**Security Notice**: This feature helps prevent accidental exposure of secrets
in logs, but is **not a substitute** for proper secret management practices.
**What this protects:**
- ✅ Secret values in console/terminal logs
- ✅ Secret values in CI/CD logs
- ✅ Accidental copy-paste of logs containing secrets
**What this does NOT protect:**
- ❌ Secrets visible in process inspection (e.g., `ps aux`)
- ❌ Secrets in shell history
- ❌ Secrets in command output (stdout/stderr)
Always use proper secret management tools (HashiCorp Vault, AWS Secrets
Manager, etc.) for production environments.
:::
To mark a variable as secret, add `secret: true` to the variable definition:
```yaml
version: '3'
vars:
API_KEY:
value: 'sk-1234567890abcdef'
secret: true
tasks:
deploy:
cmds:
- curl -H "Authorization: {{.API_KEY}}" api.example.com
# Logged as: task: [deploy] curl -H "Authorization: *****" api.example.com
```
Secret variables work with all variable types:
::: code-group
```yaml [Simple Value]
version: '3'
vars:
PASSWORD:
value: 'my-secret-password'
secret: true
tasks:
connect:
cmds:
- psql -U user -p {{.PASSWORD}} mydb
# Logged as: psql -U user -p ***** mydb
```
```yaml [Shell Command]
version: '3'
vars:
DB_PASSWORD:
sh: vault read -field=password secret/db
secret: true
tasks:
migrate:
cmds:
- psql -U admin -p {{.DB_PASSWORD}} mydb
# Password from vault is masked in logs
```
```yaml [Task-Level Secret]
version: '3'
vars:
PUBLIC_URL: https://example.com
tasks:
deploy:
vars:
DEPLOY_TOKEN:
value: 'secret-token-123'
secret: true
cmds:
- echo "Deploying to {{.PUBLIC_URL}} with token {{.DEPLOY_TOKEN}}"
# Logged as: echo "Deploying to https://example.com with token *****"
```
:::
Multiple secrets in the same command are all masked:
```yaml
version: '3'
vars:
API_KEY:
value: 'api-key-123'
secret: true
PASSWORD:
value: 'password-456'
secret: true
tasks:
setup:
cmds:
- ./setup.sh --api {{.API_KEY}} --pwd {{.PASSWORD}}
# Logged as: ./setup.sh --api ***** --pwd *****
```
::: tip
**Best practices for secret variables:**
1. **Use shell commands to load secrets**, not hardcoded values:
```yaml
# ❌ BAD - Secret visible in Taskfile
vars:
API_KEY:
value: 'hardcoded-secret'
secret: true
# ✅ GOOD - Secret loaded from external source
vars:
API_KEY:
sh: vault kv get -field=api_key secret/myapp
secret: true
```
2. **Combine with environment variables:**
```yaml
vars:
API_KEY:
sh: echo $MY_API_KEY
secret: true
```
3. **Use .gitignore for secret files:**
If you use dotenv files, add them to `.gitignore`:
```yaml
dotenv: ['.env.local'] # Load from .env.local (in .gitignore)
```
:::
## Looping over values
Task allows you to loop over certain values and execute a command for each.
@@ -2583,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:
@@ -2692,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.
@@ -2720,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
@@ -274,6 +274,12 @@ includes:
internal:
taskfile: ./internal.yml
internal: true
[...]
tasks:
example:
desc: using an internal task
cmds:
- task: internal:default
```
### `aliases`
@@ -379,33 +385,6 @@ vars:
ttl: 3600
```
### Secret Variables (`secret`)
Mark variables as secret to mask their values in command logs.
```yaml
vars:
API_KEY:
value: 'sk-1234567890abcdef'
secret: true # This variable will be masked in logs
DB_PASSWORD:
sh: vault read -field=password secret/db
secret: true # Works with dynamic variables too
```
When a variable is marked as `secret: true`, Task will replace its value with
`*****` in command logs. The actual command execution still receives the real
value.
::: info
For complete documentation on secret variables, including security
considerations and best practices, see the
[Secret variables](/docs/guide#secret-variables) section in the Guide.
:::
### Variable Ordering
Variables can reference previously defined variables:

View File

@@ -617,6 +617,7 @@ tasks:
- echo "{{.WIN_PATH | fromSlash}}" # Convert to OS-specific slashes
- echo "{{joinPath .OUTPUT_DIR .BINARY_NAME}}" # Join path elements
- echo "Relative {{relPath .ROOT_DIR .TASKFILE_DIR}}" # Get relative path
- echo '{{absPath "../sibling"}}' # Resolve to an absolute path
```
### Data Structure Functions

View File

@@ -318,10 +318,6 @@
"map": {
"type": "object",
"description": "The value will be treated as a literal map type and stored in the variable"
},
"secret": {
"type": "boolean",
"description": "Marks the variable as secret. Secret values will be masked as ***** in command logs to prevent accidental exposure of sensitive information."
}
},
"additionalProperties": false
@@ -599,7 +595,7 @@
},
"outputString": {
"type": "string",
"enum": ["interleaved", "prefixed", "group"],
"enum": ["interleaved", "prefixed", "group", "gitlab"],
"default": "interleaved"
},
"outputObject": {
@@ -620,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