1
0
mirror of https://github.com/go-task/task.git synced 2026-10-23 11:48:55 +03:00

feat(remote): add remote.headers to use HTTP Header (#2976)

This commit is contained in:
Valentin Maerten
2026-09-19 12:18:21 +02:00
committed by GitHub
parent 05c4708d87
commit 2ac05e7ab6
16 changed files with 662 additions and 19 deletions

View File

@@ -6,12 +6,22 @@
- 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 bug on Windows where watching source paths did not work as expected
(#2863, #3028 by @pd93).
- 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
- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support

View File

@@ -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"
)
@@ -36,6 +37,7 @@ type (
Download bool
Offline bool
TrustedHosts []string
RemoteHeaders taskfile.HeadersByHost
Timeout time.Duration
CacheExpiryDuration time.Duration
RemoteCacheDir string
@@ -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 {

2
go.mod
View File

@@ -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/v3 v3.14.1
@@ -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

View File

@@ -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"
@@ -79,6 +80,7 @@ var (
Download bool
Offline bool
TrustedHosts []string
RemoteHeaders taskfile.HeadersByHost
ClearCache bool
Timeout time.Duration
CacheExpiryDuration time.Duration
@@ -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() {
@@ -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),
@@ -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 != "" {

View File

@@ -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
@@ -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),
)

97
taskfile/http_headers.go Normal file
View File

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

View File

@@ -0,0 +1,271 @@
package taskfile
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
tests := []struct {
name string
headersByHost HeadersByHost
host string
env map[string]string
want map[string]string
wantErr string
}{
{
name: "no configuration",
headersByHost: nil,
host: "gitlab.com",
},
{
name: "host does not match",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
host: "example.com",
},
{
name: "port is part of the host",
headersByHost: HeadersByHost{"example.com": {"PRIVATE-TOKEN": "token"}},
host: "example.com:8080",
},
{
name: "literal value",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
host: "gitlab.com",
want: map[string]string{"PRIVATE-TOKEN": "token"},
},
{
name: "environment variable",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}}, //nolint:gosec // an env var reference, not a credential
host: "gitlab.com",
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
want: map[string]string{"PRIVATE-TOKEN": "s3cret"},
},
{
name: "environment variable inside a longer value",
headersByHost: HeadersByHost{"gitlab.com": {"Authorization": `Bearer {{env "TASK_TEST_TOKEN"}}`}},
host: "gitlab.com",
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
want: map[string]string{"Authorization": "Bearer s3cret"},
},
{
name: "undefined environment variable expands to nothing",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_UNSET"}}`}}, //nolint:gosec // an env var reference, not a credential
host: "gitlab.com",
want: map[string]string{"PRIVATE-TOKEN": ""},
},
{
name: "functions compose, so Basic auth needs no manual base64",
headersByHost: HeadersByHost{"gitlab.com": {"Authorization": `Basic {{ printf "%s:%s" (env "TASK_TEST_USER") (env "TASK_TEST_TOKEN") | b64enc }}`}},
host: "gitlab.com",
env: map[string]string{"TASK_TEST_USER": "alice", "TASK_TEST_TOKEN": "s3cret"},
want: map[string]string{"Authorization": "Basic YWxpY2U6czNjcmV0"},
},
{
name: "a variable reference resolves to nothing",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "{{.TASK_TEST_TOKEN}}"}}, //nolint:gosec // a template, not a credential
host: "gitlab.com",
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
want: map[string]string{"PRIVATE-TOKEN": ""},
},
{
name: "a literal value is left untouched",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "p$ssw0rd"}}, //nolint:gosec // a test fixture
host: "gitlab.com",
want: map[string]string{"PRIVATE-TOKEN": "p$ssw0rd"},
},
{
name: "malformed template",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"`}}, //nolint:gosec // a template, not a credential
host: "gitlab.com",
wantErr: `remote headers for host "gitlab.com": template: :1: unclosed action`,
},
{
name: "header name with a space",
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE TOKEN": "token"}},
host: "gitlab.com",
wantErr: `remote headers for host "gitlab.com": invalid header name "PRIVATE TOKEN"`,
},
{
name: "header name outside the HTTP token grammar",
headersByHost: HeadersByHost{"gitlab.com": {"X-Foo(bar)": "token"}},
host: "gitlab.com",
wantErr: `remote headers for host "gitlab.com": invalid header name "X-Foo(bar)"`,
},
{
name: "empty header name",
headersByHost: HeadersByHost{"gitlab.com": {"": "token"}},
host: "gitlab.com",
wantErr: `remote headers for host "gitlab.com": invalid header name ""`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for name, value := range test.env {
t.Setenv(name, value)
}
headers, err := resolveHeaders(test.headersByHost, test.host)
if test.wantErr != "" {
require.EqualError(t, err, test.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, test.want, headers)
})
}
}
func TestHeadersTransport(t *testing.T) {
t.Parallel()
transport := &headersTransport{
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(req), nil }),
host: "gitlab.com",
headers: map[string]string{"PRIVATE-TOKEN": "token"},
}
t.Run("sets the headers on the configured host", func(t *testing.T) {
t.Parallel()
req := newRequest(t, "https://gitlab.com/api/v4/Taskfile.yml")
resp, err := transport.RoundTrip(req)
require.NoError(t, err)
assert.Equal(t, "token", resp.Request.Header.Get("PRIVATE-TOKEN"))
assert.Empty(t, req.Header.Get("PRIVATE-TOKEN"))
})
t.Run("leaves any other host alone", func(t *testing.T) {
t.Parallel()
req := newRequest(t, "https://example.com/Taskfile.yml")
resp, err := transport.RoundTrip(req)
require.NoError(t, err)
assert.Empty(t, resp.Request.Header.Get("PRIVATE-TOKEN"))
})
}
func TestWithHeadersDoesNotMutateTheDefaultClient(t *testing.T) {
t.Parallel()
client := withHeaders(http.DefaultClient, "gitlab.com", map[string]string{"PRIVATE-TOKEN": "token"})
assert.NotSame(t, http.DefaultClient, client)
assert.Nil(t, http.DefaultClient.Transport)
assert.IsType(t, &headersTransport{}, client.Transport)
}
// Downloads probe with HEAD before GET; both requests need the headers.
func TestHTTPNodeHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
var methods []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("PRIVATE-TOKEN") != "s3cret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.Header.Get("Accept") != "application/yaml" || r.Header.Get("X-Custom-Header") != "custom-value" {
w.WriteHeader(http.StatusBadRequest)
return
}
methods = append(methods, r.Method)
w.Header().Set("Content-Type", "text/yaml")
_, _ = w.Write([]byte("version: '3'\n"))
}))
defer srv.Close()
t.Setenv("TASK_TEST_TOKEN", "s3cret")
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
WithHeaders(HeadersByHost{
mustHost(t, srv.URL): { //nolint:gosec // an env var reference, not a credential
"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`,
"Accept": "application/yaml",
"X-Custom-Header": "custom-value",
},
}),
)
require.NoError(t, err)
b, err := node.Read()
require.NoError(t, err)
assert.Equal(t, "version: '3'\n", string(b))
assert.Equal(t, []string{"HEAD", "GET"}, methods)
}
func TestHTTPNodeHeadersNotSentOnRedirect(t *testing.T) {
t.Parallel()
var received []string
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received = append(received, r.Header.Get("PRIVATE-TOKEN"), r.Header.Get("Accept"), r.Header.Get("X-Custom-Header"))
w.Header().Set("Content-Type", "text/yaml")
_, _ = w.Write([]byte("version: '3'\n"))
}))
defer elsewhere.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, elsewhere.URL+"/Taskfile.yml", http.StatusFound)
}))
defer srv.Close()
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
WithHeaders(HeadersByHost{
mustHost(t, srv.URL): {
"PRIVATE-TOKEN": "s3cret",
"Accept": "application/yaml",
"X-Custom-Header": "custom-value",
},
}),
)
require.NoError(t, err)
_, err = node.Read()
require.NoError(t, err)
require.NotEmpty(t, received)
for _, header := range received {
assert.Empty(t, header, "configured headers must not follow a redirect to another host")
}
}
func TestHTTPNodeHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
node, err := NewHTTPNode("https://gitlab.com/Taskfile.yml", "", false,
WithHeaders(HeadersByHost{
"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential
}),
)
require.NoError(t, err)
// Set the value after construction to verify lazy resolution.
t.Setenv("TASK_TEST_LAZY", "s3cret")
headers, err := resolveHeaders(node.headersByHost, node.url.Host)
require.NoError(t, err)
assert.Equal(t, map[string]string{"PRIVATE-TOKEN": "s3cret"}, headers)
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func newRequest(t *testing.T, rawURL string) *http.Request {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, rawURL, nil)
require.NoError(t, err)
return req
}
func newResponse(req *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Request: req, Header: http.Header{}}
}
func mustHost(t *testing.T, rawURL string) string {
t.Helper()
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
return parsed.Host
}

View File

@@ -7,12 +7,13 @@ type (
// designed to be embedded in other node types so that this boilerplate code
// does not need to be repeated.
baseNode struct {
parent Node
dir string
checksum string
caCert string
cert string
certKey string
parent Node
dir string
checksum string
caCert string
cert string
certKey string
headersByHost HeadersByHost
}
)
@@ -75,3 +76,10 @@ func WithCertKey(certKey string) NodeOption {
node.certKey = certKey
}
}
// WithHeaders sets the HTTP headers to send, keyed by host.
func WithHeaders(headersByHost HeadersByHost) NodeOption {
return func(node *baseNode) {
node.headersByHost = headersByHost
}
}

View File

@@ -106,7 +106,11 @@ func (node *HTTPNode) Read() ([]byte, error) {
}
func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
url, err := RemoteExists(ctx, *node.url, node.client)
client, err := node.clientWithHeaders()
if err != nil {
return nil, err
}
url, err := RemoteExists(ctx, *node.url, client)
if err != nil {
return nil, err
}
@@ -115,7 +119,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
}
resp, err := node.client.Do(req.WithContext(ctx))
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
if ctx.Err() != nil {
return nil, err

View File

@@ -51,6 +51,7 @@ type (
caCert string
cert string
certKey string
headersByHost HeadersByHost
debugFunc DebugFunc
promptFunc PromptFunc
promptMutex sync.Mutex
@@ -242,6 +243,19 @@ func (o *readerCertKeyOption) ApplyToReader(r *Reader) {
r.certKey = o.certKey
}
// WithReaderHeaders sets the HTTP headers to send to each configured host.
func WithReaderHeaders(headersByHost HeadersByHost) ReaderOption {
return &readerHeadersOption{headersByHost: headersByHost}
}
type readerHeadersOption struct {
headersByHost HeadersByHost
}
func (o *readerHeadersOption) ApplyToReader(r *Reader) {
r.headersByHost = o.headersByHost
}
// Read will read the Taskfile defined by the [Reader]'s [Node] and recurse
// through any [ast.Includes] it finds, reading each included Taskfile and
// building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be
@@ -286,7 +300,9 @@ func (r *Reader) isTrusted(uri string) bool {
host := parsedURL.Host
// Check against each trusted pattern (exact match including port if provided)
return slices.Contains(r.trustedHosts, host)
return slices.ContainsFunc(r.trustedHosts, func(pattern string) bool {
return hostMatches(pattern, host)
})
}
func (r *Reader) include(ctx context.Context, node Node) error {
@@ -355,6 +371,7 @@ func (r *Reader) include(ctx context.Context, node Node) error {
WithCACert(r.caCert),
WithCert(r.cert),
WithCertKey(r.certKey),
WithHeaders(r.headersByHost),
)
if err != nil {
if include.Optional {

View File

@@ -66,6 +66,13 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL
return &u, nil
}
// The default names need the same credentials, so trying them would only
// add rejected requests. A 403 is left alone: it is also what a server
// without directory listing answers for a readable directory.
if resp.StatusCode == http.StatusUnauthorized {
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted(), HTTPStatusCode: resp.StatusCode}
}
// If the request was not successful, append the default Taskfile names to
// the URL and return the URL of the first successful request
for _, taskfile := range DefaultTaskfiles {

View File

@@ -24,15 +24,22 @@ type TaskRC struct {
}
type Remote struct {
Insecure *bool `yaml:"insecure"`
Offline *bool `yaml:"offline"`
Timeout *time.Duration `yaml:"timeout"`
CacheExpiry *time.Duration `yaml:"cache-expiry"`
CacheDir *string `yaml:"cache-dir"`
TrustedHosts []string `yaml:"trusted-hosts"`
CACert *string `yaml:"cacert"`
Cert *string `yaml:"cert"`
CertKey *string `yaml:"cert-key"`
Insecure *bool `yaml:"insecure"`
Offline *bool `yaml:"offline"`
Timeout *time.Duration `yaml:"timeout"`
CacheExpiry *time.Duration `yaml:"cache-expiry"`
CacheDir *string `yaml:"cache-dir"`
TrustedHosts []string `yaml:"trusted-hosts"`
Headers []RemoteHeaders `yaml:"headers"`
CACert *string `yaml:"cacert"`
Cert *string `yaml:"cert"`
CertKey *string `yaml:"cert-key"`
}
// RemoteHeaders configures HTTP headers for a single host.
type RemoteHeaders struct {
Host string `yaml:"host"`
Headers map[string]string `yaml:"headers"`
}
// Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC.
@@ -60,6 +67,7 @@ func (t *TaskRC) Merge(other *TaskRC) {
slices.Sort(merged)
t.Remote.TrustedHosts = slices.Compact(merged)
}
t.Remote.Headers = mergeHeaders(t.Remote.Headers, other.Remote.Headers)
t.Remote.CACert = cmp.Or(other.Remote.CACert, t.Remote.CACert)
t.Remote.Cert = cmp.Or(other.Remote.Cert, t.Remote.Cert)
t.Remote.CertKey = cmp.Or(other.Remote.CertKey, t.Remote.CertKey)
@@ -73,3 +81,19 @@ func (t *TaskRC) Merge(other *TaskRC) {
t.Failfast = cmp.Or(other.Failfast, t.Failfast)
t.TempDir = cmp.Or(other.TempDir, t.TempDir)
}
// Replace each host's headers as a whole so closer config files can drop headers.
func mergeHeaders(base, other []RemoteHeaders) []RemoteHeaders {
if len(other) == 0 {
return base
}
byHost := make(map[string]RemoteHeaders, len(base)+len(other))
for _, entry := range slices.Concat(base, other) {
byHost[entry.Host] = entry
}
merged := slices.Collect(maps.Values(byHost))
slices.SortFunc(merged, func(a, b RemoteHeaders) int {
return cmp.Compare(a.Host, b.Host)
})
return merged
}

View File

@@ -341,3 +341,67 @@ remote:
assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.TrustedHosts)
})
}
func TestGetConfig_RemoteHeaders(t *testing.T) { //nolint:paralleltest // cannot run in parallel
_, _, localDir := setupDirs(t)
configYAML := `
remote:
headers:
- host: gitlab.com
headers:
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
- host: example.com:8080
headers:
Authorization: Bearer token
Accept: application/yaml
X-Custom-Header: custom-value
`
writeFile(t, localDir, ".taskrc.yml", configYAML)
cfg, err := GetConfig(localDir)
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, []ast.RemoteHeaders{
{Host: "gitlab.com", Headers: map[string]string{"PRIVATE-TOKEN": `{{env "GITLAB_TOKEN"}}`}}, //nolint:gosec // an env var reference, not a credential
{Host: "example.com:8080", Headers: map[string]string{
"Authorization": "Bearer token",
"Accept": "application/yaml",
"X-Custom-Header": "custom-value",
}},
}, cfg.Remote.Headers)
}
func TestGetConfig_RemoteHeadersMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel
xdgConfigDir, homeDir, localDir := setupDirs(t)
writeFile(t, xdgConfigDir, "taskrc.yml", `
remote:
headers:
- host: gitlab.com
headers:
PRIVATE-TOKEN: from-xdg
X-Extra: from-xdg
- host: example.com
headers:
Authorization: from-xdg
`)
// The closer file redefines gitlab.com as a whole and leaves example.com
// untouched.
writeFile(t, homeDir, ".taskrc.yml", `
remote:
headers:
- host: gitlab.com
headers:
JOB-TOKEN: from-home
`)
cfg, err := GetConfig(localDir)
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, []ast.RemoteHeaders{
{Host: "example.com", Headers: map[string]string{"Authorization": "from-xdg"}},
{Host: "gitlab.com", Headers: map[string]string{"JOB-TOKEN": "from-home"}},
}, cfg.Remote.Headers)
}

View File

@@ -302,6 +302,81 @@ task --trusted-hosts github.com,gitlab.com -t https://github.com/user/repo.git//
task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml
```
#### `remote.headers`
- **Type**: `array of objects`
- **Default**: `[]` (empty list)
- **Description**: HTTP headers to send when downloading a remote Taskfile from
a given host, including authentication and custom headers
```yaml
remote:
headers:
- host: gitlab.com
headers:
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
- host: artifacts.example.com:8443
headers:
Authorization: 'Bearer {{env "ARTIFACTS_TOKEN"}}'
Accept: application/yaml
X-Custom-Header: custom-value
```
Use this option for authentication, content negotiation or other headers your
server expects. For authentication, this is the recommended way to access a
remote Taskfile. Unlike a credential placed in the URL, the header never appears
in your Taskfile, in the confirmation prompt or in an error message, so the
include URL stays safe to commit.
Each entry applies to a single host, matched exactly and including the port if
the URL has one — the same rule as
[`remote.trusted-hosts`](#remote-trusted-hosts). Header values may reference
[templating functions](./templating.md), evaluated when Task contacts the host.
Values starting with <span v-pre>`{{`</span> must be quoted, as YAML would
otherwise read them as a mapping. An undefined environment variable expands to
nothing, so the header is sent empty. For an authentication header, this may
cause the server to reject the request with a `401`.
Functions compose, so an `Authorization` header needs no manual encoding:
```yaml
remote:
headers:
- host: artifacts.example.com
headers:
Authorization: 'Basic {{ printf "%s:%s" (env "USER") (env "PASS") | b64enc }}'
```
::: warning
Only functions are available here — <span v-pre>`{{.GITLAB_TOKEN}}`</span> and
other variable references resolve to nothing. The configuration file is read
before any Taskfile, so no variable exists yet. Use
<span v-pre>`{{env "GITLAB_TOKEN"}}`</span> instead.
:::
The header your server expects depends on the service:
| Service | Header |
| ----------- | -------------------------------------- |
| GitLab API | `PRIVATE-TOKEN` (or `JOB-TOKEN` in CI) |
| GitHub API | `Authorization: Bearer <token>` |
| Artifactory | `X-JFrog-Art-Api` |
There is no CLI flag or environment variable for this option: a token given on
the command line would be visible to any process listing it.
::: warning
Headers are only sent to the host they are configured for. If that host answers
with a redirect to another one, the request follows the redirect **without**
them, and will likely fail — point the URL at the final host instead. Headers
are also HTTP-only: a Taskfile fetched over `git` should authenticate with SSH
or a git credential helper.
:::
#### `remote.cacert`
- **Type**: `string`
@@ -356,6 +431,10 @@ remote:
trusted-hosts:
- github.com
- gitlab.com
headers:
- host: gitlab.com
headers:
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
cacert: ''
cert: ''
cert-key: ''

View File

@@ -100,6 +100,11 @@ includes:
shared: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml
```
Prefer the [`remote.headers`](./reference/config.md#remote-headers) configuration
option when the server accepts a header. A credential in the URL ends up in
error messages and in the confirmation prompt, and the include can no longer be
committed as-is.
## Choose a source {#node-types}
### HTTP/HTTPS

View File

@@ -49,6 +49,28 @@
"items": {
"type": "string"
}
},
"headers": {
"type": "array",
"description": "HTTP headers to send when downloading remote Taskfiles, per host, including authentication and custom headers.",
"items": {
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "Host the headers apply to, including the port if the URL has one (e.g., 'gitlab.com', 'example.com:8080')."
},
"headers": {
"type": "object",
"description": "Headers to send. Values support templating functions, e.g. {{env \"GITLAB_TOKEN\"}}.",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["host", "headers"],
"additionalProperties": false
}
}
},
"additionalProperties": false