Compare commits

..

5 Commits

Author SHA1 Message Date
Valentin Maerten
22ff746965 feat(website): improve adopters carousel SEO and accessibility
Promotes the carousel label from <p> to <h2>, links the section to the
heading via aria-labelledby, adds an accessible label per chip, and
turns the opaque logo <img> tags into proper lazy-loaded assets with
explicit dimensions.

Adds a subline naming seven brands (Docker, Microsoft, HashiCorp,
Vercel, Google Cloud, AWS, Anthropic) so the homepage now has
indexable text referencing actual adopters — previously the brand
information only lived inside animated chips.

Emits a schema.org ItemList of Organization entities (JSON-LD) on the
homepage, which none of the comparable OSS sites (Vite, Biome, Astro,
Nx, Turborepo) currently expose — cheap differentiator for rich
search results.
2026-04-19 14:55:17 +02:00
Valentin Maerten
bc755b8391 refactor(website): drop /adopters page and move discovery tool out of repo
Drops the dedicated /adopters page, its Vue component, the navbar entry,
the sidebar hack, and the contributing-guide section. The homepage
carousel already carries the social-proof signal — a separate page
attracted virtually no traffic on comparable OSS sites and added
maintenance surface without a clear payoff.

Also removes the "See all" CTA from the carousel header now that there
is nowhere to send visitors to, and centers the remaining label.

The find-adopters Go tool moves out of the repo (to ../find-adopters/)
— it was always a one-off analysis helper, not code that ships with
Task. The adopters.ts file remains the submission surface for anyone
motivated enough to PR a new entry.
2026-04-19 14:09:21 +02:00
Valentin Maerten
4bee0c6d66 feat(website): expand adopters list after full scan, harden discovery tool
Runs the refreshed find-adopters tool against every public Taskfile on
GitHub (1190 unique repos, 13 min) and uses the findings to swap in four
higher-signal entries: Azure/Azure-Sentinel replaces Microsoft's niche
Fabric provider, flet-dev/flet (16k stars, #1 non-Task hit), Anthropic's
Rust protobuf, and charmbracelet/glamour join the list. Gogs drops out
since it no longer surfaces in the best-match slice GitHub exposes.

Rewrites the discovery strategy: GitHub Code Search caps at 1000 results
per query and its size: qualifier turned out unreliable (non-monotone
total_count, sporadic 404s), so the tool now paginates each of the four
Taskfile variants to the cap and supplements with an org: scan over
~100 curated organizations. That's the practical ceiling without GH
Archive or BigQuery, and it captures every big-brand hit we care about.
Also drops the code-search rate from 24 to 8.5 req/min to match the
real 10 req/min authenticated limit.
2026-04-19 14:01:12 +02:00
Valentin Maerten
cb7b4dc235 feat(website): refresh adopters list and add discovery tool
Reshuffles the adopters list to lead with big brands (Docker, HashiCorp,
Microsoft, Vercel, Google Cloud, AWS) followed by high-profile OSS
(FerretDB, Tyk, Outline, etc.), so the section reads as strong social
proof rather than a niche catalog.

Adds website/scripts/find-adopters, a small Go CLI that sidesteps the
1000-result cap on GitHub Code Search by partitioning queries per star
bucket (and per pushed-year when a bucket overflows), then enriches
every hit via a batched GraphQL call. The result is a ranked TSV/JSON
of adopter candidates, filterable by min stars and owner type, that
can be rerun periodically to keep the list fresh. Exposed via
`task find-adopters` in website/Taskfile.yml.
2026-04-19 13:26:18 +02:00
Valentin Maerten
38e12c9f8f feat(website): add adopters section to highlight notable projects
Introduces a new /adopters page listing notable OSS projects using Task,
along with an infinite-scroll carousel on the homepage linking to it.
The adopter list lives in .vitepress/adopters.ts for easy PR-based
submissions; contributing docs explain the process.
2026-04-19 12:37:35 +02:00
27 changed files with 329 additions and 779 deletions

View File

@@ -1,10 +1,5 @@
# 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

@@ -67,7 +67,6 @@ type (
Compiler *Compiler
Output output.Output
OutputStyle ast.Output
OutputCIAuto bool
TaskSorter sort.Sorter
UserWorkingDir string
EnableVersionCheck bool
@@ -523,21 +522,6 @@ 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,7 +71,6 @@ var (
Dir string
Entrypoint string
Output ast.Output
OutputCIAuto bool
Color bool
Interval time.Duration
Failfast bool
@@ -144,11 +143,10 @@ 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|gitlab].")
pflag.StringVarP(&Output.Name, "output", "o", "", "Sets output style: [interleaved|group|prefixed].")
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.")
@@ -307,7 +305,6 @@ 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),

View File

@@ -1,116 +0,0 @@
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,14 +13,6 @@ 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.
@@ -42,14 +34,6 @@ 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,12 +5,7 @@ import (
"errors"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/fatih/color"
"github.com/stretchr/testify/assert"
@@ -126,238 +121,6 @@ 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,7 +34,6 @@ func init() {
"IsSH": IsSH, // Deprecated
"joinPath": filepath.Join,
"relPath": filepath.Rel,
"absPath": filepath.Abs,
"merge": merge,
"spew": spew.Sdump,
"fromYaml": fromYaml,

View File

@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
@@ -203,27 +202,12 @@ 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

View File

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

12
task.go
View File

@@ -204,9 +204,9 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
release := e.acquireConcurrencyLimit()
defer release()
if err = e.startExecution(ctx, t, func(ctx context.Context) (err error) {
if err = e.startExecution(ctx, t, func(ctx context.Context) 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,9 +266,6 @@ 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)
@@ -396,7 +393,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.printCmdAnnouncement(ctx, t, cmd.Cmd)
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd)
}
if e.Dry {
@@ -412,8 +409,7 @@ 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)
}
taskStdOut, taskStdErr := e.writersFromCtx(ctx)
stdOut, stdErr, closer := outputWrapper.WrapWriter(taskStdOut, taskStdErr, t.Prefix, outputTemplater)
stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater)
err = execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: cmd.Cmd,

View File

@@ -1,70 +0,0 @@
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,27 +2601,6 @@ 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

@@ -12,8 +12,6 @@ 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.
@@ -34,30 +32,19 @@ func (s *Output) UnmarshalYAML(node *yaml.Node) error {
case yaml.MappingNode:
var tmp struct {
Group *OutputGroup
GitLab *OutputGitLab `yaml:"gitlab"`
Group *OutputGroup
}
if err := node.Decode(&tmp); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
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`)
if tmp.Group == nil {
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`output style must have the "group" key when in mapping form`)
}
*s = Output{
Name: "group",
Group: *tmp.Group,
}
return nil
}
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("output")
@@ -76,9 +63,3 @@ 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,7 +17,6 @@ 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"`
@@ -70,6 +69,5 @@ 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,27 +306,4 @@ 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

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

View File

@@ -0,0 +1,80 @@
export interface Adopter {
name: string;
url: string;
img: string;
}
export const adopters: Adopter[] = [
// Big brand names
{
name: 'Docker',
url: 'https://github.com/docker/mcp-registry',
img: 'https://github.com/docker.png'
},
{
name: 'Microsoft',
url: 'https://github.com/Azure/Azure-Sentinel',
img: 'https://github.com/microsoft.png'
},
{
name: 'HashiCorp',
url: 'https://github.com/hashicorp/terraform-aws-terraform-enterprise-hvd',
img: 'https://github.com/hashicorp.png'
},
{
name: 'Vercel',
url: 'https://github.com/vercel/terraform-provider-vercel',
img: 'https://github.com/vercel.png'
},
{
name: 'Google Cloud',
url: 'https://github.com/GoogleCloudPlatform/deploystack',
img: 'https://github.com/GoogleCloudPlatform.png'
},
{
name: 'AWS',
url: 'https://github.com/aws-samples/appmod-blueprints',
img: 'https://github.com/aws-samples.png'
},
{
name: 'Anthropic',
url: 'https://github.com/anthropics/buffa',
img: 'https://github.com/anthropics.png'
},
// Notable open source projects
{
name: 'Flet',
url: 'https://github.com/flet-dev/flet',
img: 'https://github.com/flet-dev.png'
},
{
name: 'GoReleaser',
url: 'https://github.com/goreleaser/goreleaser',
img: 'https://github.com/goreleaser.png'
},
{
name: 'Arduino CLI',
url: 'https://github.com/arduino/arduino-cli',
img: 'https://github.com/arduino.png'
},
{
name: 'FerretDB',
url: 'https://github.com/FerretDB/FerretDB',
img: 'https://github.com/FerretDB.png'
},
{
name: 'Tyk',
url: 'https://github.com/TykTechnologies/tyk',
img: 'https://github.com/TykTechnologies.png'
},
{
name: 'Charmbracelet',
url: 'https://github.com/charmbracelet/glamour',
img: 'https://github.com/charmbracelet.png'
},
{
name: 'Outline',
url: 'https://github.com/OutlineFoundation/outline-server',
img: 'https://github.com/OutlineFoundation.png'
}
];

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { adopters } from '../adopters';
const loop = [...adopters, ...adopters];
</script>
<template>
<section class="adopters-carousel" aria-labelledby="adopters-heading">
<h2 id="adopters-heading" class="label">
<span class="slashes">//</span>
Trusted by open source projects
</h2>
<p class="subline">
Adopted by <strong>Docker</strong>, <strong>Microsoft</strong>,
<strong>HashiCorp</strong>, <strong>Vercel</strong>,
<strong>Google Cloud</strong>, <strong>AWS</strong>,
<strong>Anthropic</strong> and more.
</p>
<div class="viewport">
<div class="track">
<a
v-for="(item, i) in loop"
:key="`${item.name}-${i}`"
:href="item.url"
target="_blank"
rel="noopener"
class="chip"
:aria-label="`${item.name} on GitHub`"
>
<img
:src="item.img"
:alt="`${item.name} logo`"
class="logo"
loading="lazy"
decoding="async"
width="28"
height="28"
/>
<span class="name">{{ item.name }}</span>
<span class="chevron" aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
</section>
</template>
<style scoped>
.adopters-carousel {
max-width: 1248px;
margin: 5rem auto 2rem;
padding: 0 24px;
}
.label {
font-family: var(--vp-font-family-mono);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--vp-c-text-2);
text-transform: uppercase;
text-align: center;
margin: 0 0 0.75rem;
}
.slashes {
color: var(--vp-c-brand-1);
margin-right: 0.4em;
}
.subline {
text-align: center;
font-size: 0.95rem;
color: var(--vp-c-text-2);
max-width: 640px;
margin: 0 auto 2rem;
line-height: 1.5;
}
.subline strong {
color: var(--vp-c-text-1);
font-weight: 600;
}
.viewport {
overflow: hidden;
-webkit-mask-image: linear-gradient(
90deg,
transparent 0,
#000 6%,
#000 94%,
transparent 100%
);
mask-image: linear-gradient(
90deg,
transparent 0,
#000 6%,
#000 94%,
transparent 100%
);
}
.track {
display: flex;
gap: 0.875rem;
width: max-content;
animation: scroll 55s linear infinite;
padding: 6px 0;
}
.track:hover {
animation-play-state: paused;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.125rem 0.625rem 0.625rem;
border: 1px solid var(--vp-c-divider);
border-radius: 999px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
text-decoration: none !important;
white-space: nowrap;
transition:
border-color 0.25s ease,
background 0.25s ease,
transform 0.25s ease,
box-shadow 0.25s ease;
}
.chip:hover {
border-color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
transform: translateY(-2px);
box-shadow: 0 6px 20px -10px
color-mix(in srgb, var(--vp-c-brand-1) 60%, transparent);
}
.logo {
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #fff;
}
.name {
font-size: 0.9rem;
font-weight: 500;
letter-spacing: -0.005em;
}
.chevron {
font-family: var(--vp-font-family-mono);
font-size: 0.85rem;
color: var(--vp-c-text-3);
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.25s ease,
transform 0.25s ease,
color 0.25s ease;
margin-left: -0.25rem;
}
.chip:hover .chevron {
opacity: 1;
transform: translateX(0);
color: var(--vp-c-brand-1);
}
@keyframes scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-50% - 0.4375rem));
}
}
@media (max-width: 640px) {
.adopters-carousel {
margin-top: 3.5rem;
}
}
@media (prefers-reduced-motion: reduce) {
.track {
animation: none;
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.chip:hover {
transform: none;
}
}
</style>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { VPHomeSponsors } from 'vitepress/theme';
import { sponsors } from '../sponsors';
import AdoptersCarousel from './AdoptersCarousel.vue';
</script>
<template>
<div class="content">
<div class="content-container">
<main class="main">
<AdoptersCarousel />
<VPHomeSponsors
v-if="sponsors"
message="Task is free and open source, made possible by wonderful sponsors."

View File

@@ -9,6 +9,7 @@ import {
localIconLoader
} from 'vitepress-plugin-group-icons';
import { team } from './team.ts';
import { adopters } from './adopters.ts';
import { taskDescription, taskName, ogUrl, ogImage } from './meta.ts';
import { fileURLToPath, URL } from 'node:url';
import llmstxt from 'vitepress-plugin-llms';
@@ -107,6 +108,34 @@ export default defineConfig({
head.push(['meta', { name: 'robots', content: 'noindex, nofollow' }])
}
// Structured data for the adopters carousel on the homepage: an ItemList
// of Organization entities so search engines can surface Task's adopters
// directly in rich results.
if (isHome) {
head.push([
'script',
{ type: 'application/ld+json' },
JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Organizations and projects using Task',
itemListOrder: 'https://schema.org/ItemListUnordered',
numberOfItems: adopters.length,
itemListElement: adopters.map((a, i) => ({
'@type': 'ListItem',
position: i + 1,
item: {
'@type': 'Organization',
name: a.name,
url: a.url,
logo: a.img,
sameAs: [a.url]
}
}))
})
])
}
return head
},
srcDir: 'src',

View File

@@ -2426,13 +2426,12 @@ 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 four different output
To make this more customizable, there are currently three different output
options you can choose:
- `interleaved` (default)
- `group`
- `prefixed`
- `gitlab`
To choose another one, just set it to root in the Taskfile:
@@ -2536,44 +2535,6 @@ $ 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.
@@ -2602,28 +2563,6 @@ 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,8 +224,7 @@ task backup --global
#### `-o, --output <mode>`
Set output style. Available modes: `interleaved`, `group`, `prefixed`,
`gitlab`.
Set output style. Available modes: `interleaved`, `group`, `prefixed`.
```bash
task test --output group

View File

@@ -166,21 +166,6 @@ 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,16 +81,6 @@ 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`, `gitlab`
- **Options**: `interleaved`, `group`, `prefixed`
- **Description**: Controls how task output is displayed
```yaml
@@ -274,12 +274,6 @@ includes:
internal:
taskfile: ./internal.yml
internal: true
[...]
tasks:
example:
desc: using an internal task
cmds:
- task: internal:default
```
### `aliases`

View File

@@ -617,7 +617,6 @@ 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

@@ -595,7 +595,7 @@
},
"outputString": {
"type": "string",
"enum": ["interleaved", "prefixed", "group", "gitlab"],
"enum": ["interleaved", "prefixed", "group"],
"default": "interleaved"
},
"outputObject": {
@@ -616,22 +616,6 @@
"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