Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@

- Added versioned Homebrew casks (`go-task@<major>.<minor>`) to install a specific
minor version of Task (#3023 by @vmaerten).
- Added a `remote.headers` config option to send HTTP headers when downloading a
remote Taskfile, configured per host. Header values support templating
functions, e.g. `{{env "GITLAB_TOKEN"}}`. This keeps the credential out of the
include URL, where it would leak into error messages and the confirmation
prompt (#2329 by @vmaerten).

### 🐛 Fixes

- Fixed a remote Taskfile whose server refuses the credentials being reported as
a missing Taskfile. A `401` now stops the search and reports the status code,
instead of retrying every default Taskfile name and concluding that no
Taskfile exists (#2329 by @vmaerten).

### 📦 Package API

Expand Down
16 changes: 16 additions & 0 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/output"
"github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/taskfile/ast"
)

Expand All @@ -36,6 +37,7 @@ type (
Download bool
Offline bool
TrustedHosts []string
RemoteHeaders taskfile.HeadersByHost
Timeout time.Duration
CacheExpiryDuration time.Duration
RemoteCacheDir string
Expand Down Expand Up @@ -277,6 +279,20 @@ func (o *trustedHostsOption) ApplyToExecutor(e *Executor) {
e.TrustedHosts = o.trustedHosts
}

// WithRemoteHeaders configures the [Executor] with the HTTP headers to send when
// fetching a remote Taskfile, keyed by host.
func WithRemoteHeaders(remoteHeaders taskfile.HeadersByHost) ExecutorOption {
return &remoteHeadersOption{remoteHeaders}
}

type remoteHeadersOption struct {
remoteHeaders taskfile.HeadersByHost
}

func (o *remoteHeadersOption) ApplyToExecutor(e *Executor) {
e.RemoteHeaders = o.remoteHeaders
}

// WithTimeout sets the [Executor]'s timeout for fetching remote taskfiles. By
// default, the timeout is set to 10 seconds.
func WithTimeout(timeout time.Duration) ExecutorOption {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/zeebo/xxh3 v1.1.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/net v0.58.0
golang.org/x/sync v0.23.0
golang.org/x/term v0.45.0
mvdan.cc/sh/moreinterp v0.0.0-20260907224004-5864ca90e1a1
Expand Down Expand Up @@ -121,7 +122,6 @@ require (
go.opentelemetry.io/otel/trace v1.45.0 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
Expand Down
17 changes: 17 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/taskfile/ast"
"github.com/go-task/task/v3/taskrc"
taskrcast "github.com/go-task/task/v3/taskrc/ast"
Expand Down Expand Up @@ -79,6 +80,7 @@ var (
Download bool
Offline bool
TrustedHosts []string
RemoteHeaders taskfile.HeadersByHost
ClearCache bool
Timeout time.Duration
CacheExpiryDuration time.Duration
Expand Down Expand Up @@ -165,6 +167,8 @@ func init() {
pflag.StringVar(&CACert, "cacert", getConfig(config, "REMOTE_CACERT", func() *string { return config.Remote.CACert }, ""), "Path to a custom CA certificate for HTTPS connections.")
pflag.StringVar(&Cert, "cert", getConfig(config, "REMOTE_CERT", func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.")
pflag.StringVar(&CertKey, "cert-key", getConfig(config, "REMOTE_CERT_KEY", func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.")
// No flag: a token on the command line is visible to any process listing it.
RemoteHeaders = remoteHeaders(config)

// Gentle force experiment will override the force flag and add a new force-all flag
if experiments.GentleForce.Enabled() {
Expand Down Expand Up @@ -285,6 +289,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
task.WithDownload(Download),
task.WithOffline(Offline),
task.WithTrustedHosts(TrustedHosts),
task.WithRemoteHeaders(RemoteHeaders),
task.WithTimeout(Timeout),
task.WithCacheExpiryDuration(CacheExpiryDuration),
task.WithRemoteCacheDir(RemoteCacheDir),
Expand All @@ -311,6 +316,18 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
)
}

// The last entry for each host wins, matching config file merging.
func remoteHeaders(config *taskrcast.TaskRC) taskfile.HeadersByHost {
if config == nil || len(config.Remote.Headers) == 0 {
return nil
}
byHost := make(taskfile.HeadersByHost, len(config.Remote.Headers))
for _, entry := range config.Remote.Headers {
byHost[entry.Host] = entry.Headers
}
return byHost
}

// getConfig extracts a config value with priority: env var > taskrc config > fallback
func getConfig[T any](config *taskrcast.TaskRC, envKey string, fieldFunc func() *T, fallback T) T {
if envKey != "" {
Expand Down
2 changes: 2 additions & 0 deletions setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
taskfile.WithCACert(e.CACert),
taskfile.WithCert(e.Cert),
taskfile.WithCertKey(e.CertKey),
taskfile.WithHeaders(e.RemoteHeaders),
)
if taskNotFoundError, ok := errors.AsType[errors.TaskfileNotFoundError](err); ok {
taskNotFoundError.AskInit = true
Expand Down Expand Up @@ -90,6 +91,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
taskfile.WithReaderCACert(e.CACert),
taskfile.WithReaderCert(e.Cert),
taskfile.WithReaderCertKey(e.CertKey),
taskfile.WithReaderHeaders(e.RemoteHeaders),
taskfile.WithDebugFunc(debugFunc),
taskfile.WithPromptFunc(promptFunc),
)
Expand Down
97 changes: 97 additions & 0 deletions taskfile/http_headers.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vmaerten Hi, just looking at this and wondering if a more generic "headers" approach would be viable. Similar to curl with its -H option. Its the same code, just without "auth" (also drop from the schema).

Rational is that headers can be set for a number of reasons, from which authorisation is only a subset.

Just for example:

for _, headers := range config.Remote.headers {
	byHost[auth.Host] = headers
}

Also, it might be useful, or necessary, to have different headers for requests against the same host. If I understand correctly, you are consolidating (last wins).

But OK, I see that you put this in the taskrc file, and not the includes, so there is no solution for that.

Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package taskfile

import (
"cmp"
"fmt"
"maps"
"net/http"
"slices"

"golang.org/x/net/http/httpguts"

"github.com/go-task/task/v3/internal/templater"
)

// HeadersByHost configures HTTP headers per host for remote Taskfiles.
// Values support template functions, but not Taskfile variables.
type HeadersByHost map[string]map[string]string

type headersTransport struct {
base http.RoundTripper
host string
headers map[string]string
}

func (t *headersTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Scope all configured headers to this host, including after redirects.
if !hostMatches(t.host, req.URL.Host) {
return t.base.RoundTrip(req)
}
req = req.Clone(req.Context())
for name, value := range t.headers {
req.Header.Set(name, value)
}
return t.base.RoundTrip(req)
}

// Resolve headers only when downloading, so cached runs need no credentials.
func (node *HTTPNode) clientWithHeaders() (*http.Client, error) {
headers, err := resolveHeaders(node.headersByHost, node.url.Host)
if err != nil {
return nil, err
}
if len(headers) == 0 {
return node.client, nil
}
return withHeaders(node.client, node.url.Host, headers), nil
}

func withHeaders(client *http.Client, host string, headers map[string]string) *http.Client {
// The client may be http.DefaultClient; leave it unchanged.
configured := *client
configured.Transport = &headersTransport{
base: cmp.Or(client.Transport, http.DefaultTransport),
host: host,
headers: headers,
}
return &configured
}

func resolveHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) {
var headers map[string]string
for pattern, patternHeaders := range headersByHost {
if hostMatches(pattern, host) {
headers = patternHeaders
break
}
}
if len(headers) == 0 {
return nil, nil
}

cache := &templater.Cache{}
resolved := make(map[string]string, len(headers))
for _, name := range slices.Sorted(maps.Keys(headers)) {
if err := validateHeaderName(name); err != nil {
return nil, fmt.Errorf(`remote headers for host %q: %w`, host, err)
}
resolved[name] = templater.Replace(headers[name], cache)
}
if err := cache.Err(); err != nil {
return nil, fmt.Errorf(`remote headers for host %q: %w`, host, err)
}
return resolved, nil
}

// Validate names here because ReadContext hides transport errors.
func validateHeaderName(name string) error {
if !httpguts.ValidHeaderFieldName(name) {
return fmt.Errorf("invalid header name %q", name)
}
return nil
}

// Match the host and port exactly for both headers and trusted hosts.
func hostMatches(pattern, host string) bool {
return pattern == host
}
Loading