Compare commits

...

8 Commits

Author SHA1 Message Date
Valentin Maerten
9a96a47c99 style(ast): fix Ask field alignment in Cmd struct 2026-02-21 10:48:10 +01:00
Valentin Maerten
93cdccefce feat(cmd): add ask option to commands
Add an `ask` attribute to commands that shows a y/n confirmation
before executing. If the user declines, the command is skipped but
the task continues with the next command.

Example:
```yaml
cmds:
  - cmd: echo "Deploying..."
    ask: "Deploy to production?"
  - task: run-migrations
    ask: "Run database migrations?"
```

Behavior:
- Default: asks for y/n confirmation
- --yes: auto-confirms all asks
- --dry: shows commands without asking

This differs from task-level `prompt:` which cancels the entire task
if declined. Command-level `ask:` only skips the individual command.
2026-01-29 19:02:44 +01:00
Valentin Maerten
026c899d90 feat: support self-signed certificates for remote taskfiles (#2537) 2026-01-25 18:51:30 +01:00
Timothy Rule
f6720760b4 fix(includes): propagate silent mode from included Taskfiles to tasks (#2640) 2026-01-25 16:33:52 +01:00
Valentin Maerten
065236f076 chore: changelog for #2635 2026-01-25 16:08:11 +01:00
Timothy Rule
1bd5aa6bd5 fix: correct the value of ROOT_TASKFILE when no entrypoint (#2635) 2026-01-25 16:06:13 +01:00
Valentin Maerten
c3fd3c4b5e chore: add website/.netlify to gitignore 2026-01-25 14:26:53 +01:00
Valentin Maerten
299232ee7d fix(website): improve SEO with favicons, structured data and robots.txt (#2657) 2026-01-25 14:16:23 +01:00
36 changed files with 703 additions and 35 deletions

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ tags
/testdata/vars/v1
/tmp
node_modules
website/.netlify/

View File

@@ -1,5 +1,17 @@
# Changelog
## Unreleased
- Fixed `ROOT_TASKFILE` variable pointing to directory instead of the actual
Taskfile path when no explicit `-t` flag is provided (#2635, #1706 by
@trulede).
- Included Taskfiles with `silent: true` now properly propagate silence to their
tasks, while still allowing individual tasks to override with `silent: false`
(#2640, #1319 by @trulede).
- Added TLS certificate options for Remote Taskfiles: use `--cacert` for
self-signed certificates and `--cert`/`--cert-key` for mTLS authentication
(#2537, #2242 by @vmaerten).
## v3.47.0 - 2026-01-24
- Fixed remote git Taskfiles: cloning now works without explicit ref, and

View File

@@ -26,6 +26,10 @@ function _task()
_filedir -d
return $?
;;
--cacert|--cert|--cert-key)
_filedir
return $?
;;
-t|--taskfile)
_filedir yaml || return $?
_filedir yml

View File

@@ -111,6 +111,9 @@ complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES"
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l timeout -d 'timeout for remote Taskfile downloads'
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l expiry -d 'cache expiry duration'
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)"
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cacert -d 'custom CA certificate for TLS' -r
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert -d 'client certificate for mTLS' -r
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key -d 'client certificate private key' -r
# RemoteTaskfiles experiment - Operations
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile'

View File

@@ -77,6 +77,9 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock {
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout')
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry')
$completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory')
$completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate')
$completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate')
$completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key')
# Operations
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')

View File

@@ -117,6 +117,9 @@ _task() {
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
'(--expiry)--expiry[cache expiry duration]:duration: '
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs'
'(--cacert)--cacert[custom CA certificate for TLS]:file:_files'
'(--cert)--cert[client certificate for mTLS]:file:_files'
'(--cert-key)--cert-key[client certificate private key]:file:_files'
)
fi

View File

@@ -38,6 +38,9 @@ type (
Timeout time.Duration
CacheExpiryDuration time.Duration
RemoteCacheDir string
CACert string
Cert string
CertKey string
Watch bool
Verbose bool
Silent bool
@@ -287,6 +290,45 @@ func (o *remoteCacheDirOption) ApplyToExecutor(e *Executor) {
e.RemoteCacheDir = o.dir
}
// WithCACert sets the path to a custom CA certificate for TLS connections.
func WithCACert(caCert string) ExecutorOption {
return &caCertOption{caCert: caCert}
}
type caCertOption struct {
caCert string
}
func (o *caCertOption) ApplyToExecutor(e *Executor) {
e.CACert = o.caCert
}
// WithCert sets the path to a client certificate for TLS connections.
func WithCert(cert string) ExecutorOption {
return &certOption{cert: cert}
}
type certOption struct {
cert string
}
func (o *certOption) ApplyToExecutor(e *Executor) {
e.Cert = o.cert
}
// WithCertKey sets the path to a client certificate key for TLS connections.
func WithCertKey(certKey string) ExecutorOption {
return &certKeyOption{certKey: certKey}
}
type certKeyOption struct {
certKey string
}
func (o *certKeyOption) ApplyToExecutor(e *Executor) {
e.CertKey = o.certKey
}
// WithWatch tells the [Executor] to keep running in the background and watch
// for changes to the fingerprint of the tasks that are run. When changes are
// detected, a new task run is triggered.

View File

@@ -364,6 +364,7 @@ func TestSpecialVars(t *testing.T) {
// Root
"print-task",
"print-root-dir",
"print-root-taskfile",
"print-taskfile",
"print-taskfile-dir",
"print-task-dir",
@@ -1059,6 +1060,18 @@ func TestIncludeChecksum(t *testing.T) {
)
}
func TestIncludeSilent(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("include-taskfile-silent"),
WithExecutorOptions(
task.WithDir("testdata/includes_silent"),
),
WithTask("default"),
)
}
func TestFailfast(t *testing.T) {
t.Parallel()

View File

@@ -10,6 +10,15 @@ type Copier[T any] interface {
DeepCopy() T
}
func Scalar[T any](orig *T) *T {
if orig == nil {
return nil
} else {
v := *orig
return &v
}
}
func Slice[T any](orig []T) []T {
if orig == nil {
return nil

View File

@@ -83,6 +83,9 @@ var (
Timeout time.Duration
CacheExpiryDuration time.Duration
RemoteCacheDir string
CACert string
Cert string
CertKey string
Interactive bool
)
@@ -168,6 +171,9 @@ func init() {
pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.")
pflag.DurationVar(&CacheExpiryDuration, "expiry", getConfig(config, func() *time.Duration { return config.Remote.CacheExpiry }, 0), "Expiry duration for cached remote Taskfiles.")
pflag.StringVar(&RemoteCacheDir, "remote-cache-dir", getConfig(config, func() *string { return config.Remote.CacheDir }, env.GetTaskEnv("REMOTE_DIR")), "Directory to cache remote Taskfiles.")
pflag.StringVar(&CACert, "cacert", getConfig(config, func() *string { return config.Remote.CACert }, ""), "Path to a custom CA certificate for HTTPS connections.")
pflag.StringVar(&Cert, "cert", getConfig(config, func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.")
pflag.StringVar(&CertKey, "cert-key", getConfig(config, func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.")
}
pflag.Parse()
@@ -236,6 +242,11 @@ func Validate() error {
return errors.New("task: --nested only applies to --json with --list or --list-all")
}
// Validate certificate flags
if (Cert != "" && CertKey == "") || (Cert == "" && CertKey != "") {
return errors.New("task: --cert and --cert-key must be provided together")
}
return nil
}
@@ -278,6 +289,9 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
task.WithTimeout(Timeout),
task.WithCacheExpiryDuration(CacheExpiryDuration),
task.WithRemoteCacheDir(RemoteCacheDir),
task.WithCACert(CACert),
task.WithCert(Cert),
task.WithCertKey(CertKey),
task.WithWatch(Watch),
task.WithVerbose(Verbose),
task.WithSilent(Silent),

View File

@@ -55,7 +55,11 @@ func (e *Executor) Setup() error {
}
func (e *Executor) getRootNode() (taskfile.Node, error) {
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout)
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout,
taskfile.WithCACert(e.CACert),
taskfile.WithCert(e.Cert),
taskfile.WithCertKey(e.CertKey),
)
if os.IsNotExist(err) {
return nil, errors.TaskfileNotFoundError{
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
@@ -67,6 +71,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
return nil, err
}
e.Dir = node.Dir()
e.Entrypoint = node.Location()
return node, err
}
@@ -86,6 +91,9 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
taskfile.WithTrustedHosts(e.TrustedHosts),
taskfile.WithTempDir(e.TempDir.Remote),
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
taskfile.WithReaderCACert(e.CACert),
taskfile.WithReaderCert(e.Cert),
taskfile.WithReaderCertKey(e.CertKey),
taskfile.WithDebugFunc(debugFunc),
taskfile.WithPromptFunc(promptFunc),
)

20
task.go
View File

@@ -228,7 +228,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
}
if upToDate && preCondMet {
if e.Verbose || (!call.Silent && !t.Silent && !e.Taskfile.Silent && !e.Silent) {
if e.Verbose || (!call.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) {
name := t.Name()
if e.OutputStyle.Name == "prefixed" {
name = t.Prefix
@@ -365,6 +365,22 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
}
}
// Handle ask attached to command (y/n confirmation)
if cmd.Ask != "" && !e.Dry {
if e.AssumeYes {
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] %s [assuming yes]\n", t.Name(), cmd.Ask)
} else {
if err := e.Logger.Prompt(logger.Yellow, cmd.Ask, "n", "y", "yes"); errors.Is(err, logger.ErrNoTerminal) {
return &errors.TaskCancelledNoTerminalError{TaskName: call.Task}
} else if errors.Is(err, logger.ErrPromptCancelled) {
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] ask declined - skipped\n", t.Name())
return nil
} else if err != nil {
return err
}
}
}
switch {
case cmd.Task != "":
reacquire := e.releaseConcurrencyLimit()
@@ -383,7 +399,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
return nil
}
if e.Verbose || (!call.Silent && !cmd.Silent && !t.Silent && !e.Taskfile.Silent && !e.Silent) {
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.Cmd)
}

View File

@@ -20,6 +20,7 @@ type Cmd struct {
IgnoreError bool
Defer bool
Platforms []*Platform
Ask string
}
func (c *Cmd) DeepCopy() *Cmd {
@@ -38,6 +39,7 @@ func (c *Cmd) DeepCopy() *Cmd {
IgnoreError: c.IgnoreError,
Defer: c.Defer,
Platforms: deepcopy.Slice(c.Platforms),
Ask: c.Ask,
}
}
@@ -65,6 +67,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
IgnoreError bool `yaml:"ignore_error"`
Defer *Defer
Platforms []*Platform
Ask string
}
if err := node.Decode(&cmdStruct); err != nil {
return errors.NewTaskfileDecodeError(err, node)
@@ -98,6 +101,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
c.If = cmdStruct.If
c.Silent = cmdStruct.Silent
c.IgnoreError = cmdStruct.IgnoreError
c.Ask = cmdStruct.Ask
return nil
}
@@ -111,6 +115,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
c.Shopt = cmdStruct.Shopt
c.IgnoreError = cmdStruct.IgnoreError
c.Platforms = cmdStruct.Platforms
c.Ask = cmdStruct.Ask
return nil
}

View File

@@ -32,7 +32,7 @@ type Task struct {
Vars *Vars
Env *Vars
Dotenv []string
Silent bool
Silent *bool
Interactive bool
Internal bool
Method string
@@ -69,6 +69,12 @@ func (t *Task) LocalName() string {
return name
}
// IsSilent returns true if the task has silent mode explicitly enabled.
// Returns false if Silent is nil (not set) or explicitly set to false.
func (t *Task) IsSilent() bool {
return t.Silent != nil && *t.Silent
}
// WildcardMatch will check if the given string matches the name of the Task and returns any wildcard values.
func (t *Task) WildcardMatch(name string) (bool, []string) {
names := append([]string{t.Task}, t.Aliases...)
@@ -138,7 +144,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
Vars *Vars
Env *Vars
Dotenv []string
Silent bool
Silent *bool `yaml:"silent,omitempty"`
Interactive bool
Internal bool
Method string
@@ -178,7 +184,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
t.Vars = task.Vars
t.Env = task.Env
t.Dotenv = task.Dotenv
t.Silent = task.Silent
t.Silent = deepcopy.Scalar(task.Silent)
t.Interactive = task.Interactive
t.Internal = task.Internal
t.Method = task.Method
@@ -221,7 +227,7 @@ func (t *Task) DeepCopy() *Task {
Vars: t.Vars.DeepCopy(),
Env: t.Env.DeepCopy(),
Dotenv: deepcopy.Slice(t.Dotenv),
Silent: t.Silent,
Silent: deepcopy.Scalar(t.Silent),
Interactive: t.Interactive,
Internal: t.Internal,
Method: t.Method,

View File

@@ -59,6 +59,14 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error {
if t1.Tasks == nil {
t1.Tasks = NewTasks()
}
if t2.Silent {
for _, t := range t2.Tasks.All(nil) {
if t.Silent == nil {
v := true
t.Silent = &v
}
}
}
t1.Vars.Merge(t2.Vars, include)
t1.Env.Merge(t2.Env, include)
return t1.Tasks.Merge(t2.Tasks, include, t1.Vars)

View File

@@ -34,13 +34,14 @@ func NewRootNode(
dir string,
insecure bool,
timeout time.Duration,
opts ...NodeOption,
) (Node, error) {
dir = fsext.DefaultDir(entrypoint, dir)
// If the entrypoint is "-", we read from stdin
if entrypoint == "-" {
return NewStdinNode(dir)
}
return NewNode(entrypoint, dir, insecure)
return NewNode(entrypoint, dir, insecure, opts...)
}
func NewNode(

View File

@@ -10,6 +10,9 @@ type (
parent Node
dir string
checksum string
caCert string
cert string
certKey string
}
)
@@ -54,3 +57,21 @@ func (node *baseNode) Checksum() string {
func (node *baseNode) Verify(checksum string) bool {
return node.checksum == "" || node.checksum == checksum
}
func WithCACert(caCert string) NodeOption {
return func(node *baseNode) {
node.caCert = caCert
}
}
func WithCert(cert string) NodeOption {
return func(node *baseNode) {
node.cert = cert
}
}
func WithCertKey(certKey string) NodeOption {
return func(node *baseNode) {
node.certKey = certKey
}
}

View File

@@ -2,10 +2,13 @@ package taskfile
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -17,7 +20,54 @@ import (
// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP.
type HTTPNode struct {
*baseNode
url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml)
url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml)
client *http.Client // HTTP client with optional TLS configuration
}
// buildHTTPClient creates an HTTP client with optional TLS configuration.
// If no certificate options are provided, it returns http.DefaultClient.
func buildHTTPClient(insecure bool, caCert, cert, certKey string) (*http.Client, error) {
// Validate that cert and certKey are provided together
if (cert != "" && certKey == "") || (cert == "" && certKey != "") {
return nil, fmt.Errorf("both --cert and --cert-key must be provided together")
}
// If no TLS customization is needed, return the default client
if !insecure && caCert == "" && cert == "" {
return http.DefaultClient, nil
}
tlsConfig := &tls.Config{
InsecureSkipVerify: insecure,
}
// Load custom CA certificate if provided
if caCert != "" {
caCertData, err := os.ReadFile(caCert)
if err != nil {
return nil, fmt.Errorf("failed to read CA certificate: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCertData) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
tlsConfig.RootCAs = caCertPool
}
// Load client certificate and key if provided
if cert != "" && certKey != "" {
clientCert, err := tls.LoadX509KeyPair(cert, certKey)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{clientCert}
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}, nil
}
func NewHTTPNode(
@@ -34,9 +84,16 @@ func NewHTTPNode(
if url.Scheme == "http" && !insecure {
return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()}
}
client, err := buildHTTPClient(insecure, base.caCert, base.cert, base.certKey)
if err != nil {
return nil, err
}
return &HTTPNode{
baseNode: base,
url: url,
client: client,
}, nil
}
@@ -49,7 +106,7 @@ func (node *HTTPNode) Read() ([]byte, error) {
}
func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
url, err := RemoteExists(ctx, *node.url)
url, err := RemoteExists(ctx, *node.url, node.client)
if err != nil {
return nil, err
}
@@ -58,7 +115,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
}
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
resp, err := node.client.Do(req.WithContext(ctx))
if err != nil {
if ctx.Err() != nil {
return nil, err

View File

@@ -1,7 +1,18 @@
package taskfile
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -47,3 +58,227 @@ func TestHTTPNode_CacheKey(t *testing.T) {
assert.Equal(t, tt.expectedKey, key)
}
}
func TestBuildHTTPClient_Default(t *testing.T) {
t.Parallel()
// When no TLS customization is needed, should return http.DefaultClient
client, err := buildHTTPClient(false, "", "", "")
require.NoError(t, err)
assert.Equal(t, http.DefaultClient, client)
}
func TestBuildHTTPClient_Insecure(t *testing.T) {
t.Parallel()
client, err := buildHTTPClient(true, "", "", "")
require.NoError(t, err)
require.NotNil(t, client)
assert.NotEqual(t, http.DefaultClient, client)
// Check that InsecureSkipVerify is set
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.TLSClientConfig)
assert.True(t, transport.TLSClientConfig.InsecureSkipVerify)
}
func TestBuildHTTPClient_CACert(t *testing.T) {
t.Parallel()
// Create a temporary CA cert file
tempDir := t.TempDir()
caCertPath := filepath.Join(tempDir, "ca.crt")
// Generate a valid CA certificate
caCertPEM := generateTestCACert(t)
err := os.WriteFile(caCertPath, caCertPEM, 0o600)
require.NoError(t, err)
client, err := buildHTTPClient(false, caCertPath, "", "")
require.NoError(t, err)
require.NotNil(t, client)
assert.NotEqual(t, http.DefaultClient, client)
// Check that custom RootCAs is set
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.TLSClientConfig)
assert.NotNil(t, transport.TLSClientConfig.RootCAs)
}
func TestBuildHTTPClient_CACertNotFound(t *testing.T) {
t.Parallel()
client, err := buildHTTPClient(false, "/nonexistent/ca.crt", "", "")
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "failed to read CA certificate")
}
func TestBuildHTTPClient_CACertInvalid(t *testing.T) {
t.Parallel()
// Create a temporary file with invalid content
tempDir := t.TempDir()
caCertPath := filepath.Join(tempDir, "invalid.crt")
err := os.WriteFile(caCertPath, []byte("not a valid certificate"), 0o600)
require.NoError(t, err)
client, err := buildHTTPClient(false, caCertPath, "", "")
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "failed to parse CA certificate")
}
func TestBuildHTTPClient_CertWithoutKey(t *testing.T) {
t.Parallel()
client, err := buildHTTPClient(false, "", "/path/to/cert.crt", "")
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "both --cert and --cert-key must be provided together")
}
func TestBuildHTTPClient_KeyWithoutCert(t *testing.T) {
t.Parallel()
client, err := buildHTTPClient(false, "", "", "/path/to/key.pem")
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "both --cert and --cert-key must be provided together")
}
func TestBuildHTTPClient_CertAndKey(t *testing.T) {
t.Parallel()
// Create temporary cert and key files
tempDir := t.TempDir()
certPath := filepath.Join(tempDir, "client.crt")
keyPath := filepath.Join(tempDir, "client.key")
// Generate a self-signed certificate and key for testing
cert, key := generateTestCertAndKey(t)
err := os.WriteFile(certPath, cert, 0o600)
require.NoError(t, err)
err = os.WriteFile(keyPath, key, 0o600)
require.NoError(t, err)
client, err := buildHTTPClient(false, "", certPath, keyPath)
require.NoError(t, err)
require.NotNil(t, client)
assert.NotEqual(t, http.DefaultClient, client)
// Check that client certificate is set
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.TLSClientConfig)
assert.Len(t, transport.TLSClientConfig.Certificates, 1)
}
func TestBuildHTTPClient_CertNotFound(t *testing.T) {
t.Parallel()
client, err := buildHTTPClient(false, "", "/nonexistent/cert.crt", "/nonexistent/key.pem")
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "failed to load client certificate")
}
func TestBuildHTTPClient_InsecureWithCACert(t *testing.T) {
t.Parallel()
// Create a temporary CA cert file
tempDir := t.TempDir()
caCertPath := filepath.Join(tempDir, "ca.crt")
// Generate a valid CA certificate
caCertPEM := generateTestCACert(t)
err := os.WriteFile(caCertPath, caCertPEM, 0o600)
require.NoError(t, err)
// Both insecure and CA cert can be set together
client, err := buildHTTPClient(true, caCertPath, "", "")
require.NoError(t, err)
require.NotNil(t, client)
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.TLSClientConfig)
assert.True(t, transport.TLSClientConfig.InsecureSkipVerify)
assert.NotNil(t, transport.TLSClientConfig.RootCAs)
}
// generateTestCertAndKey generates a self-signed certificate and key for testing
func generateTestCertAndKey(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
// Generate a new ECDSA private key
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
// Create a certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Task Org"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
// Create the certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
// Encode certificate to PEM
certPEM = pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
// Encode private key to PEM
keyDER, err := x509.MarshalECPrivateKey(privateKey)
require.NoError(t, err)
keyPEM = pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: keyDER,
})
return certPEM, keyPEM
}
// generateTestCACert generates a self-signed CA certificate for testing
func generateTestCACert(t *testing.T) []byte {
t.Helper()
// Generate a new ECDSA private key
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
// Create a CA certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test CA"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}
// Create the certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
// Encode certificate to PEM
return pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
}

View File

@@ -47,6 +47,9 @@ type (
trustedHosts []string
tempDir string
cacheExpiryDuration time.Duration
caCert string
cert string
certKey string
debugFunc DebugFunc
promptFunc PromptFunc
promptMutex sync.Mutex
@@ -199,6 +202,45 @@ func (o *promptFuncOption) ApplyToReader(r *Reader) {
r.promptFunc = o.promptFunc
}
// WithReaderCACert sets the path to a custom CA certificate for TLS connections.
func WithReaderCACert(caCert string) ReaderOption {
return &readerCACertOption{caCert: caCert}
}
type readerCACertOption struct {
caCert string
}
func (o *readerCACertOption) ApplyToReader(r *Reader) {
r.caCert = o.caCert
}
// WithReaderCert sets the path to a client certificate for TLS connections.
func WithReaderCert(cert string) ReaderOption {
return &readerCertOption{cert: cert}
}
type readerCertOption struct {
cert string
}
func (o *readerCertOption) ApplyToReader(r *Reader) {
r.cert = o.cert
}
// WithReaderCertKey sets the path to a client certificate key for TLS connections.
func WithReaderCertKey(certKey string) ReaderOption {
return &readerCertKeyOption{certKey: certKey}
}
type readerCertKeyOption struct {
certKey string
}
func (o *readerCertKeyOption) ApplyToReader(r *Reader) {
r.certKey = o.certKey
}
// 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
@@ -314,6 +356,9 @@ func (r *Reader) include(ctx context.Context, node Node) error {
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
WithParent(node),
WithChecksum(include.Checksum),
WithCACert(r.caCert),
WithCert(r.cert),
WithCertKey(r.certKey),
)
if err != nil {
if include.Optional {

View File

@@ -38,7 +38,7 @@ var (
// at the given URL with any of the default Taskfile files names. If any of
// these match a file, the first matching path will be returned. If no files are
// found, an error will be returned.
func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) {
func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL, error) {
// Create a new HEAD request for the given URL to check if the resource exists
req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil)
if err != nil {
@@ -46,7 +46,7 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) {
}
// Request the given URL
resp, err := http.DefaultClient.Do(req)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("checking remote file: %w", ctx.Err())
@@ -78,7 +78,7 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) {
req.URL = alt
// Try the alternative URL
resp, err = http.DefaultClient.Do(req)
resp, err = client.Do(req)
if err != nil {
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
}

View File

@@ -28,6 +28,9 @@ type Remote struct {
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"`
}
// Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC.
@@ -55,6 +58,9 @@ func (t *TaskRC) Merge(other *TaskRC) {
slices.Sort(merged)
t.Remote.TrustedHosts = slices.Compact(merged)
}
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)
t.Verbose = cmp.Or(other.Verbose, t.Verbose)
t.Color = cmp.Or(other.Color, t.Color)

View File

@@ -0,0 +1,22 @@
version: '3'
silent: true
tasks:
hello:
cmds:
- echo "Hello from include"
hello-silent:
silent: true
cmds:
- echo "Hello from include silent task"
hello-silent-not-set:
cmds:
- echo "Hello from include silent not set task"
hello-silent-set-false:
silent: false
cmds:
- echo "Hello from include silent false task"

13
testdata/includes_silent/Taskfile.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
version: '3'
includes:
inc: Taskfile-inc.yml
tasks:
default:
cmds:
- echo "Hello from root Taskfile"
- task: inc:hello
- task: inc:hello-silent
- task: inc:hello-silent-not-set
- task: inc:hello-silent-set-false

View File

@@ -0,0 +1,7 @@
task: [default] echo "Hello from root Taskfile"
Hello from root Taskfile
Hello from include
Hello from include silent task
Hello from include silent not set task
task: [inc:hello-silent-set-false] echo "Hello from include silent false task"
Hello from include silent false task

View File

@@ -11,6 +11,7 @@ tasks:
cmds:
- echo {{.TASK}}
print-root-dir: echo {{.ROOT_DIR}}
print-root-taskfile: echo {{.ROOT_TASKFILE}}
print-taskfile: echo {{.TASKFILE}}
print-taskfile-dir: echo {{.TASKFILE_DIR}}
print-task-version: echo {{.TASK_VERSION}}

View File

@@ -0,0 +1 @@
{{.TEST_DIR}}/testdata/special_vars/Taskfile.yml

View File

@@ -0,0 +1 @@
{{.TEST_DIR}}/testdata/special_vars/Taskfile.yml

View File

@@ -10,6 +10,7 @@ import (
"github.com/joho/godotenv"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/deepcopy"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
@@ -57,7 +58,7 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) {
Vars: vars,
Env: nil,
Dotenv: origTask.Dotenv,
Silent: origTask.Silent,
Silent: deepcopy.Scalar(origTask.Silent),
Interactive: origTask.Interactive,
Internal: origTask.Internal,
Method: origTask.Method,
@@ -113,7 +114,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
Vars: vars,
Env: nil,
Dotenv: templater.Replace(origTask.Dotenv, cache),
Silent: origTask.Silent,
Silent: deepcopy.Scalar(origTask.Silent),
Interactive: origTask.Interactive,
Internal: origTask.Internal,
Method: templater.Replace(origTask.Method, cache),

View File

@@ -35,31 +35,19 @@ export default defineConfig({
description: taskDescription,
lang: 'en-US',
head: [
[
'link',
{
rel: 'icon',
type: 'image/x-icon',
href: '/img/favicon.ico',
sizes: '48x48'
}
],
[
'link',
{
rel: 'icon',
sizes: 'any',
type: 'image/svg+xml',
href: '/img/logo.svg'
}
],
// Favicon ICO for legacy browsers (auto-discovery)
['link', { rel: 'icon', href: '/favicon.ico', sizes: '48x48' }],
// Favicon SVG for modern browsers (scalable)
['link', { rel: 'icon', href: '/img/logo.svg', type: 'image/svg+xml' }],
// Apple Touch Icon for iOS devices
['link', { rel: 'apple-touch-icon', href: '/img/logo.png' }],
[
'meta',
{ name: 'author', content: `${team.map((c) => c.name).join(', ')}` }
],
// Open Graph
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: taskName }],
['meta', { property: 'og:site_name', content: 'Task' }],
['meta', { property: 'og:image', content: ogImage }],
// Twitter Card
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
@@ -80,6 +68,16 @@ export default defineConfig({
src: "https://u.taskfile.dev/script.js",
"data-website-id": "084030b0-0e3f-4891-8d2a-0c12c40f5933"
}
],
[
"script",
{ type: "application/ld+json" },
JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Task",
"url": "https://taskfile.dev/"
})
]
],
transformHead({ pageData }) {

View File

@@ -263,6 +263,38 @@ Taskfile that is downloaded via an unencrypted connection. Sources that are not
protected by TLS are vulnerable to man-in-the-middle attacks and should be
avoided unless you know what you are doing.
#### Custom Certificates
If your remote Taskfiles are hosted on a server that uses a custom CA
certificate (e.g., a corporate internal server), you can specify the CA
certificate using the `--cacert` flag:
```shell
task --taskfile https://internal.example.com/Taskfile.yml --cacert /path/to/ca.crt
```
For servers that require client certificate authentication (mTLS), you can
provide a client certificate and key:
```shell
task --taskfile https://secure.example.com/Taskfile.yml \
--cert /path/to/client.crt \
--cert-key /path/to/client.key
```
::: warning
Encrypted private keys are not currently supported. If your key is encrypted,
you must decrypt it first:
```shell
openssl rsa -in encrypted.key -out decrypted.key
```
:::
These options can also be configured in the [configuration file](#configuration).
## Caching & Running Offline
Whenever you run a remote Taskfile, the latest copy will be downloaded from the
@@ -313,6 +345,9 @@ remote:
trusted-hosts:
- github.com
- gitlab.com
cacert: ""
cert: ""
cert-key: ""
```
#### `insecure`
@@ -410,3 +445,36 @@ task --trusted-hosts github.com,gitlab.com -t https://github.com/user/repo.git//
# Trust a host with a specific port
task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml
```
#### `cacert`
- **Type**: `string`
- **Default**: `""`
- **Description**: Path to a custom CA certificate file for TLS verification
```yaml
remote:
cacert: "/path/to/ca.crt"
```
#### `cert`
- **Type**: `string`
- **Default**: `""`
- **Description**: Path to a client certificate file for mTLS authentication
```yaml
remote:
cert: "/path/to/client.crt"
```
#### `cert-key`
- **Type**: `string`
- **Default**: `""`
- **Description**: Path to the client certificate private key file
```yaml
remote:
cert-key: "/path/to/client.key"
```

View File

@@ -741,6 +741,8 @@ tasks:
platforms: [linux, darwin]
set: [errexit]
shopt: [globstar]
if: '[ "$CI" = "true" ]'
ask: "Run this command?"
```
### Task References
@@ -857,6 +859,36 @@ tasks:
if: '[ "{{.ITEM}}" != "b" ]'
```
### Command Confirmations
Use `ask` to request user confirmation before executing a command. If the
user declines (answers "n" or "no"), the command is skipped but the task
continues.
```yaml
tasks:
deploy:
cmds:
- cmd: echo "Deploying to production..."
ask: "Deploy to production?"
- cmd: echo "Updating database..."
ask: "Run database migrations?"
- echo "Done!" # No ask, always runs
```
| Flag | Behavior |
|------|----------|
| (none) | Asks user for y/n confirmation |
| `--yes` | Auto-confirms all asks |
| `--dry` | Shows commands without asking |
:::note
This is different from the task-level `prompt:` which cancels the entire task
if declined. Command-level `ask:` only skips the individual command.
:::
## Shell Options
### Set Options

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://taskfile.dev/sitemap.xml

View File

@@ -340,6 +340,10 @@
"if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string"
},
"ask": {
"description": "A y/n confirmation shown before executing this task call. If declined, the task call is skipped.",
"type": "string"
}
},
"additionalProperties": false,
@@ -381,6 +385,10 @@
"if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string"
},
"ask": {
"description": "A y/n confirmation shown before executing this command. If declined, the command is skipped.",
"type": "string"
}
},
"additionalProperties": false,