mirror of
https://github.com/go-task/task.git
synced 2026-06-25 13:46:13 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fbca3420 | ||
|
|
7323fe8009 | ||
|
|
c8efbc2f4a | ||
|
|
17257a1c31 | ||
|
|
2810c267dd | ||
|
|
a57a16efca | ||
|
|
5ef7313e95 | ||
|
|
e05c9f7793 | ||
|
|
edee501b6b | ||
|
|
efaea39503 | ||
|
|
04b8b75525 | ||
|
|
0dbeaaf187 | ||
|
|
da927ad5fe | ||
|
|
9732f7e08b | ||
|
|
1b418409d1 | ||
|
|
026c899d90 | ||
|
|
f6720760b4 | ||
|
|
065236f076 | ||
|
|
1bd5aa6bd5 | ||
|
|
c3fd3c4b5e | ||
|
|
299232ee7d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ tags
|
|||||||
/testdata/vars/v1
|
/testdata/vars/v1
|
||||||
/tmp
|
/tmp
|
||||||
node_modules
|
node_modules
|
||||||
|
website/.netlify/
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# 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
|
## v3.47.0 - 2026-01-24
|
||||||
|
|
||||||
- Fixed remote git Taskfiles: cloning now works without explicit ref, and
|
- Fixed remote git Taskfiles: cloning now works without explicit ref, and
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ func run() error {
|
|||||||
|
|
||||||
// Merge CLI variables first (e.g. FOO=bar) so they take priority over Taskfile defaults
|
// Merge CLI variables first (e.g. FOO=bar) so they take priority over Taskfile defaults
|
||||||
e.Taskfile.Vars.Merge(globals, nil)
|
e.Taskfile.Vars.Merge(globals, nil)
|
||||||
|
// Store CLI vars for scoped mode where they need highest priority
|
||||||
|
e.Compiler.CLIVars = globals
|
||||||
|
|
||||||
// Then ReverseMerge special variables so they're available for templating
|
// Then ReverseMerge special variables so they're available for templating
|
||||||
cliArgsPostDashQuoted, err := args.ToQuotedString(cliArgsPostDash)
|
cliArgsPostDashQuoted, err := args.ToQuotedString(cliArgsPostDash)
|
||||||
|
|||||||
245
compiler.go
245
compiler.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-task/task/v3/experiments"
|
||||||
"github.com/go-task/task/v3/internal/env"
|
"github.com/go-task/task/v3/internal/env"
|
||||||
"github.com/go-task/task/v3/internal/execext"
|
"github.com/go-task/task/v3/internal/execext"
|
||||||
"github.com/go-task/task/v3/internal/filepathext"
|
"github.com/go-task/task/v3/internal/filepathext"
|
||||||
@@ -25,6 +26,8 @@ type Compiler struct {
|
|||||||
|
|
||||||
TaskfileEnv *ast.Vars
|
TaskfileEnv *ast.Vars
|
||||||
TaskfileVars *ast.Vars
|
TaskfileVars *ast.Vars
|
||||||
|
CLIVars *ast.Vars // CLI vars passed via command line (e.g., task foo VAR=value)
|
||||||
|
Graph *ast.TaskfileGraph
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
|
|
||||||
@@ -44,8 +47,236 @@ func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error)
|
|||||||
return c.getVariables(t, call, false)
|
return c.getVariables(t, call, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isScopedMode returns true if scoped variable resolution should be used.
|
||||||
|
// Scoped mode requires the experiment to be enabled, a task with location info, and a graph.
|
||||||
|
func (c *Compiler) isScopedMode(t *ast.Task) bool {
|
||||||
|
return experiments.ScopedTaskfiles.Enabled() &&
|
||||||
|
t != nil &&
|
||||||
|
t.Location != nil &&
|
||||||
|
c.Graph != nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
|
func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
|
||||||
result := env.GetEnviron()
|
if c.isScopedMode(t) {
|
||||||
|
return c.getScopedVariables(t, call, evaluateShVars)
|
||||||
|
}
|
||||||
|
return c.getLegacyVariables(t, call, evaluateShVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getScopedVariables resolves variables in scoped mode.
|
||||||
|
// In scoped mode:
|
||||||
|
// - OS env vars are in {{.env.XXX}} namespace, not at root
|
||||||
|
// - Variables from sibling includes are isolated
|
||||||
|
//
|
||||||
|
// Variable resolution order (lowest to highest priority):
|
||||||
|
// 1. Root Taskfile vars
|
||||||
|
// 2. Include Taskfile vars
|
||||||
|
// 3. Include passthrough vars (includes: name: vars:)
|
||||||
|
// 4. Task vars
|
||||||
|
// 5. Call vars
|
||||||
|
// 6. CLI vars
|
||||||
|
func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
|
||||||
|
result := ast.NewVars()
|
||||||
|
|
||||||
|
specialVars, err := c.getSpecialVars(t, call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, v := range specialVars {
|
||||||
|
result.Set(k, ast.Var{Value: v})
|
||||||
|
}
|
||||||
|
|
||||||
|
getRangeFunc := func(dir string) func(k string, v ast.Var) error {
|
||||||
|
return func(k string, v ast.Var) error {
|
||||||
|
cache := &templater.Cache{Vars: result}
|
||||||
|
newVar := templater.ReplaceVar(v, cache)
|
||||||
|
if !evaluateShVars && newVar.Value == nil {
|
||||||
|
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !evaluateShVars {
|
||||||
|
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := cache.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if newVar.Value != nil || newVar.Sh == nil {
|
||||||
|
result.Set(k, ast.Var{Value: newVar.Value})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Set(k, ast.Var{Value: static})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rangeFunc := getRangeFunc(c.Dir)
|
||||||
|
|
||||||
|
var taskRangeFunc func(k string, v ast.Var) error
|
||||||
|
if t != nil {
|
||||||
|
cache := &templater.Cache{Vars: result}
|
||||||
|
dir := templater.Replace(t.Dir, cache)
|
||||||
|
if err := cache.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir = filepathext.SmartJoin(c.Dir, dir)
|
||||||
|
taskRangeFunc = getRangeFunc(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootVertex, err := c.Graph.Root()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
envMap := make(map[string]any)
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
k, v, _ := strings.Cut(e, "=")
|
||||||
|
envMap[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveEnvToMap := func(k string, v ast.Var, dir string) error {
|
||||||
|
cache := &templater.Cache{Vars: result}
|
||||||
|
newVar := templater.ReplaceVar(v, cache)
|
||||||
|
if err := cache.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if newVar.Value != nil || newVar.Sh == nil {
|
||||||
|
if newVar.Value != nil {
|
||||||
|
envMap[k] = newVar.Value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if evaluateShVars {
|
||||||
|
envSlice := os.Environ()
|
||||||
|
for ek, ev := range envMap {
|
||||||
|
if s, ok := ev.(string); ok {
|
||||||
|
envSlice = append(envSlice, fmt.Sprintf("%s=%s", ek, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static, err := c.HandleDynamicVar(newVar, dir, envSlice)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
envMap[k] = static
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range rootVertex.Taskfile.Env.All() {
|
||||||
|
if err := resolveEnvToMap(k, v, c.Dir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range rootVertex.Taskfile.Vars.All() {
|
||||||
|
if err := rangeFunc(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Location.Taskfile != rootVertex.URI {
|
||||||
|
predecessorMap, err := c.Graph.PredecessorMap()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentChain []*ast.TaskfileVertex
|
||||||
|
currentURI := t.Location.Taskfile
|
||||||
|
for {
|
||||||
|
edges := predecessorMap[currentURI]
|
||||||
|
if len(edges) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var parentURI string
|
||||||
|
for _, edge := range edges {
|
||||||
|
parentURI = edge.Source
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if parentURI == rootVertex.URI {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parentVertex, err := c.Graph.Vertex(parentURI)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parentChain = append([]*ast.TaskfileVertex{parentVertex}, parentChain...)
|
||||||
|
currentURI = parentURI
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, parent := range parentChain {
|
||||||
|
parentDir := filepath.Dir(parent.URI)
|
||||||
|
for k, v := range parent.Taskfile.Env.All() {
|
||||||
|
if err := resolveEnvToMap(k, v, parentDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Vars use the parent's directory too
|
||||||
|
parentRangeFunc := getRangeFunc(parentDir)
|
||||||
|
for k, v := range parent.Taskfile.Vars.All() {
|
||||||
|
if err := parentRangeFunc(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
includeVertex, err := c.Graph.Vertex(t.Location.Taskfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
includeDir := filepath.Dir(includeVertex.URI)
|
||||||
|
for k, v := range includeVertex.Taskfile.Env.All() {
|
||||||
|
if err := resolveEnvToMap(k, v, includeDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
includeRangeFunc := getRangeFunc(includeDir)
|
||||||
|
for k, v := range includeVertex.Taskfile.Vars.All() {
|
||||||
|
if err := includeRangeFunc(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.IncludeVars != nil {
|
||||||
|
for k, v := range t.IncludeVars.All() {
|
||||||
|
if err := rangeFunc(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if call != nil {
|
||||||
|
for k, v := range t.Vars.All() {
|
||||||
|
if err := taskRangeFunc(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range call.Vars.All() {
|
||||||
|
if err := taskRangeFunc(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range c.CLIVars.All() {
|
||||||
|
if err := rangeFunc(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Set("env", ast.Var{Value: envMap})
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLegacyVariables resolves variables in legacy mode.
|
||||||
|
// In legacy mode, all variables (including OS env) are merged at root level.
|
||||||
|
func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
|
||||||
|
result := env.GetEnviron()
|
||||||
|
|
||||||
specialVars, err := c.getSpecialVars(t, call)
|
specialVars, err := c.getSpecialVars(t, call)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -57,30 +288,22 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
|
|||||||
getRangeFunc := func(dir string) func(k string, v ast.Var) error {
|
getRangeFunc := func(dir string) func(k string, v ast.Var) error {
|
||||||
return func(k string, v ast.Var) error {
|
return func(k string, v ast.Var) error {
|
||||||
cache := &templater.Cache{Vars: result}
|
cache := &templater.Cache{Vars: result}
|
||||||
// Replace values
|
|
||||||
newVar := templater.ReplaceVar(v, cache)
|
newVar := templater.ReplaceVar(v, cache)
|
||||||
// If the variable should not be evaluated, but is nil, set it to an empty string
|
|
||||||
// This stops empty interface errors when using the templater to replace values later
|
|
||||||
// Preserve the Sh field so it can be displayed in summary
|
|
||||||
if !evaluateShVars && newVar.Value == nil {
|
if !evaluateShVars && newVar.Value == nil {
|
||||||
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
|
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// If the variable should not be evaluated and it is set, we can set it and return
|
|
||||||
if !evaluateShVars {
|
if !evaluateShVars {
|
||||||
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
|
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Now we can check for errors since we've handled all the cases when we don't want to evaluate
|
|
||||||
if err := cache.Err(); err != nil {
|
if err := cache.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// If the variable is already set, we can set it and return
|
|
||||||
if newVar.Value != nil || newVar.Sh == nil {
|
if newVar.Value != nil || newVar.Sh == nil {
|
||||||
result.Set(k, ast.Var{Value: newVar.Value})
|
result.Set(k, ast.Var{Value: newVar.Value})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// If the variable is dynamic, we need to resolve it first
|
|
||||||
static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
|
static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -93,8 +316,6 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
|
|||||||
|
|
||||||
var taskRangeFunc func(k string, v ast.Var) error
|
var taskRangeFunc func(k string, v ast.Var) error
|
||||||
if t != nil {
|
if t != nil {
|
||||||
// NOTE(@andreynering): We're manually joining these paths here because
|
|
||||||
// this is the raw task, not the compiled one.
|
|
||||||
cache := &templater.Cache{Vars: result}
|
cache := &templater.Cache{Vars: result}
|
||||||
dir := templater.Replace(t.Dir, cache)
|
dir := templater.Replace(t.Dir, cache)
|
||||||
if err := cache.Err(); err != nil {
|
if err := cache.Err(); err != nil {
|
||||||
@@ -114,6 +335,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t != nil {
|
if t != nil {
|
||||||
for k, v := range t.IncludeVars.All() {
|
for k, v := range t.IncludeVars.All() {
|
||||||
if err := rangeFunc(k, v); err != nil {
|
if err := rangeFunc(k, v); err != nil {
|
||||||
@@ -149,7 +371,6 @@ func (c *Compiler) HandleDynamicVar(v ast.Var, dir string, e []string) (string,
|
|||||||
c.muDynamicCache.Lock()
|
c.muDynamicCache.Lock()
|
||||||
defer c.muDynamicCache.Unlock()
|
defer c.muDynamicCache.Unlock()
|
||||||
|
|
||||||
// If the variable is not dynamic or it is empty, return an empty string
|
|
||||||
if v.Sh == nil || *v.Sh == "" {
|
if v.Sh == nil || *v.Sh == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ function _task()
|
|||||||
_filedir -d
|
_filedir -d
|
||||||
return $?
|
return $?
|
||||||
;;
|
;;
|
||||||
|
--cacert|--cert|--cert-key)
|
||||||
|
_filedir
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
-t|--taskfile)
|
-t|--taskfile)
|
||||||
_filedir yaml || return $?
|
_filedir yaml || return $?
|
||||||
_filedir yml
|
_filedir yml
|
||||||
|
|||||||
@@ -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 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 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 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
|
# RemoteTaskfiles experiment - Operations
|
||||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile'
|
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile'
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock {
|
|||||||
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout')
|
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout')
|
||||||
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry')
|
$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('--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
|
# Operations
|
||||||
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
|
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
|
||||||
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')
|
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ _task() {
|
|||||||
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
|
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
|
||||||
'(--expiry)--expiry[cache expiry duration]:duration: '
|
'(--expiry)--expiry[cache expiry duration]:duration: '
|
||||||
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs'
|
'(--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
|
fi
|
||||||
|
|
||||||
|
|||||||
43
executor.go
43
executor.go
@@ -38,6 +38,9 @@ type (
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
CacheExpiryDuration time.Duration
|
CacheExpiryDuration time.Duration
|
||||||
RemoteCacheDir string
|
RemoteCacheDir string
|
||||||
|
CACert string
|
||||||
|
Cert string
|
||||||
|
CertKey string
|
||||||
Watch bool
|
Watch bool
|
||||||
Verbose bool
|
Verbose bool
|
||||||
Silent bool
|
Silent bool
|
||||||
@@ -60,6 +63,7 @@ type (
|
|||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
Taskfile *ast.Taskfile
|
Taskfile *ast.Taskfile
|
||||||
|
Graph *ast.TaskfileGraph
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Compiler *Compiler
|
Compiler *Compiler
|
||||||
Output output.Output
|
Output output.Output
|
||||||
@@ -287,6 +291,45 @@ func (o *remoteCacheDirOption) ApplyToExecutor(e *Executor) {
|
|||||||
e.RemoteCacheDir = o.dir
|
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
|
// 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
|
// for changes to the fingerprint of the tasks that are run. When changes are
|
||||||
// detected, a new task run is triggered.
|
// detected, a new task run is triggered.
|
||||||
|
|||||||
124
executor_test.go
124
executor_test.go
@@ -364,6 +364,7 @@ func TestSpecialVars(t *testing.T) {
|
|||||||
// Root
|
// Root
|
||||||
"print-task",
|
"print-task",
|
||||||
"print-root-dir",
|
"print-root-dir",
|
||||||
|
"print-root-taskfile",
|
||||||
"print-taskfile",
|
"print-taskfile",
|
||||||
"print-taskfile-dir",
|
"print-taskfile-dir",
|
||||||
"print-task-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) {
|
func TestFailfast(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1167,3 +1180,114 @@ func TestIf(t *testing.T) {
|
|||||||
NewExecutorTest(t, opts...)
|
NewExecutorTest(t, opts...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // enableExperimentForTest modifies global state
|
||||||
|
func TestScopedTaskfiles(t *testing.T) {
|
||||||
|
// Legacy tests (without experiment) - vars should be merged globally
|
||||||
|
t.Run("legacy", func(t *testing.T) {
|
||||||
|
// Test with scoped taskfiles disabled (legacy) - vars should be merged globally
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("default"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// In legacy mode, UNIQUE_B should be accessible (merged globally)
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("cross-include"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("a:try-access-b"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scoped tests (with experiment enabled) - vars should be isolated
|
||||||
|
t.Run("scoped", func(t *testing.T) {
|
||||||
|
enableExperimentForTest(t, &experiments.ScopedTaskfiles, 1)
|
||||||
|
|
||||||
|
// Test with scoped taskfiles enabled - vars should be isolated
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("default"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Test inheritance: include can access root vars
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("inheritance-a"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("a:print"),
|
||||||
|
)
|
||||||
|
// Test isolation: each include sees its own vars
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("isolation-b"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("b:print"),
|
||||||
|
)
|
||||||
|
// In scoped mode, UNIQUE_B should be empty (isolated)
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("cross-include"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("a:try-access-b"),
|
||||||
|
)
|
||||||
|
// Test env namespace: {{.env.XXX}} should access env vars
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("env-namespace"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("print-env"),
|
||||||
|
)
|
||||||
|
// Test env separation: {{.ROOT_ENV}} at root should be empty (env not at root level)
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("env-separation"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("test-env-separation"),
|
||||||
|
)
|
||||||
|
// Test include env: include's env is accessible via {{.env.XXX}}
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("include-env"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("a:print-env"),
|
||||||
|
)
|
||||||
|
// Test call vars: vars passed when calling a task override task vars
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("call-vars"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("call-with-vars"),
|
||||||
|
)
|
||||||
|
// Test nested includes (3 levels: root → a → nested)
|
||||||
|
// Verifies that nested includes inherit vars from their parent chain
|
||||||
|
NewExecutorTest(t,
|
||||||
|
WithName("nested"),
|
||||||
|
WithExecutorOptions(
|
||||||
|
task.WithDir("testdata/scoped_taskfiles"),
|
||||||
|
task.WithSilent(true),
|
||||||
|
),
|
||||||
|
WithTask("a:nested:print"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var (
|
|||||||
GentleForce Experiment
|
GentleForce Experiment
|
||||||
RemoteTaskfiles Experiment
|
RemoteTaskfiles Experiment
|
||||||
EnvPrecedence Experiment
|
EnvPrecedence Experiment
|
||||||
|
ScopedTaskfiles Experiment
|
||||||
)
|
)
|
||||||
|
|
||||||
// Inactive experiments. These are experiments that cannot be enabled, but are
|
// Inactive experiments. These are experiments that cannot be enabled, but are
|
||||||
@@ -43,6 +44,7 @@ func ParseWithConfig(dir string, config *ast.TaskRC) {
|
|||||||
GentleForce = New("GENTLE_FORCE", config, 1)
|
GentleForce = New("GENTLE_FORCE", config, 1)
|
||||||
RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1)
|
RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1)
|
||||||
EnvPrecedence = New("ENV_PRECEDENCE", config, 1)
|
EnvPrecedence = New("ENV_PRECEDENCE", config, 1)
|
||||||
|
ScopedTaskfiles = New("SCOPED_TASKFILES", config, 1)
|
||||||
AnyVariables = New("ANY_VARIABLES", config)
|
AnyVariables = New("ANY_VARIABLES", config)
|
||||||
MapVariables = New("MAP_VARIABLES", config)
|
MapVariables = New("MAP_VARIABLES", config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ type Copier[T any] interface {
|
|||||||
DeepCopy() T
|
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 {
|
func Slice[T any](orig []T) []T {
|
||||||
if orig == nil {
|
if orig == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ var (
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
CacheExpiryDuration time.Duration
|
CacheExpiryDuration time.Duration
|
||||||
RemoteCacheDir string
|
RemoteCacheDir string
|
||||||
|
CACert string
|
||||||
|
Cert string
|
||||||
|
CertKey string
|
||||||
Interactive bool
|
Interactive bool
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,6 +171,9 @@ func init() {
|
|||||||
pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.")
|
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.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(&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()
|
pflag.Parse()
|
||||||
|
|
||||||
@@ -236,6 +242,11 @@ func Validate() error {
|
|||||||
return errors.New("task: --nested only applies to --json with --list or --list-all")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +289,9 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
|||||||
task.WithTimeout(Timeout),
|
task.WithTimeout(Timeout),
|
||||||
task.WithCacheExpiryDuration(CacheExpiryDuration),
|
task.WithCacheExpiryDuration(CacheExpiryDuration),
|
||||||
task.WithRemoteCacheDir(RemoteCacheDir),
|
task.WithRemoteCacheDir(RemoteCacheDir),
|
||||||
|
task.WithCACert(CACert),
|
||||||
|
task.WithCert(Cert),
|
||||||
|
task.WithCertKey(CertKey),
|
||||||
task.WithWatch(Watch),
|
task.WithWatch(Watch),
|
||||||
task.WithVerbose(Verbose),
|
task.WithVerbose(Verbose),
|
||||||
task.WithSilent(Silent),
|
task.WithSilent(Silent),
|
||||||
|
|||||||
15
setup.go
15
setup.go
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/sajari/fuzzy"
|
"github.com/sajari/fuzzy"
|
||||||
|
|
||||||
"github.com/go-task/task/v3/errors"
|
"github.com/go-task/task/v3/errors"
|
||||||
|
"github.com/go-task/task/v3/experiments"
|
||||||
"github.com/go-task/task/v3/internal/env"
|
"github.com/go-task/task/v3/internal/env"
|
||||||
"github.com/go-task/task/v3/internal/execext"
|
"github.com/go-task/task/v3/internal/execext"
|
||||||
"github.com/go-task/task/v3/internal/filepathext"
|
"github.com/go-task/task/v3/internal/filepathext"
|
||||||
@@ -55,7 +56,11 @@ func (e *Executor) Setup() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) getRootNode() (taskfile.Node, 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) {
|
if os.IsNotExist(err) {
|
||||||
return nil, errors.TaskfileNotFoundError{
|
return nil, errors.TaskfileNotFoundError{
|
||||||
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
|
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
|
||||||
@@ -67,6 +72,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Dir = node.Dir()
|
e.Dir = node.Dir()
|
||||||
|
e.Entrypoint = node.Location()
|
||||||
return node, err
|
return node, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +92,9 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
|
|||||||
taskfile.WithTrustedHosts(e.TrustedHosts),
|
taskfile.WithTrustedHosts(e.TrustedHosts),
|
||||||
taskfile.WithTempDir(e.TempDir.Remote),
|
taskfile.WithTempDir(e.TempDir.Remote),
|
||||||
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
|
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
|
||||||
|
taskfile.WithReaderCACert(e.CACert),
|
||||||
|
taskfile.WithReaderCert(e.Cert),
|
||||||
|
taskfile.WithReaderCertKey(e.CertKey),
|
||||||
taskfile.WithDebugFunc(debugFunc),
|
taskfile.WithDebugFunc(debugFunc),
|
||||||
taskfile.WithPromptFunc(promptFunc),
|
taskfile.WithPromptFunc(promptFunc),
|
||||||
)
|
)
|
||||||
@@ -96,7 +105,8 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if e.Taskfile, err = graph.Merge(); err != nil {
|
e.Graph = graph
|
||||||
|
if e.Taskfile, err = graph.Merge(experiments.ScopedTaskfiles.Enabled()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -218,6 +228,7 @@ func (e *Executor) setupCompiler() error {
|
|||||||
UserWorkingDir: e.UserWorkingDir,
|
UserWorkingDir: e.UserWorkingDir,
|
||||||
TaskfileEnv: e.Taskfile.Env,
|
TaskfileEnv: e.Taskfile.Env,
|
||||||
TaskfileVars: e.Taskfile.Vars,
|
TaskfileVars: e.Taskfile.Vars,
|
||||||
|
Graph: e.Graph,
|
||||||
Logger: e.Logger,
|
Logger: e.Logger,
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
4
task.go
4
task.go
@@ -228,7 +228,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if upToDate && preCondMet {
|
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()
|
name := t.Name()
|
||||||
if e.OutputStyle.Name == "prefixed" {
|
if e.OutputStyle.Name == "prefixed" {
|
||||||
name = t.Prefix
|
name = t.Prefix
|
||||||
@@ -383,7 +383,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
|||||||
return nil
|
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)
|
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,21 @@ func (tfg *TaskfileGraph) Visualize(filename string) error {
|
|||||||
return draw.DOT(tfg.Graph, f)
|
return draw.DOT(tfg.Graph, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tfg *TaskfileGraph) Merge() (*Taskfile, error) {
|
// Root returns the root vertex of the graph (the entrypoint Taskfile).
|
||||||
|
func (tfg *TaskfileGraph) Root() (*TaskfileVertex, error) {
|
||||||
|
hashes, err := graph.TopologicalSort(tfg.Graph)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
return nil, fmt.Errorf("task: graph has no vertices")
|
||||||
|
}
|
||||||
|
return tfg.Vertex(hashes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge merges all included Taskfiles into the root Taskfile.
|
||||||
|
// If skipVarsMerge is true, variables are not merged (used for scoped includes).
|
||||||
|
func (tfg *TaskfileGraph) Merge(skipVarsMerge bool) (*Taskfile, error) {
|
||||||
hashes, err := graph.TopologicalSort(tfg.Graph)
|
hashes, err := graph.TopologicalSort(tfg.Graph)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -92,6 +106,7 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) {
|
|||||||
if err := vertex.Taskfile.Merge(
|
if err := vertex.Taskfile.Merge(
|
||||||
includedVertex.Taskfile,
|
includedVertex.Taskfile,
|
||||||
include,
|
include,
|
||||||
|
skipVarsMerge,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type Task struct {
|
|||||||
Vars *Vars
|
Vars *Vars
|
||||||
Env *Vars
|
Env *Vars
|
||||||
Dotenv []string
|
Dotenv []string
|
||||||
Silent bool
|
Silent *bool
|
||||||
Interactive bool
|
Interactive bool
|
||||||
Internal bool
|
Internal bool
|
||||||
Method string
|
Method string
|
||||||
@@ -69,6 +69,12 @@ func (t *Task) LocalName() string {
|
|||||||
return name
|
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.
|
// 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) {
|
func (t *Task) WildcardMatch(name string) (bool, []string) {
|
||||||
names := append([]string{t.Task}, t.Aliases...)
|
names := append([]string{t.Task}, t.Aliases...)
|
||||||
@@ -138,7 +144,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
Vars *Vars
|
Vars *Vars
|
||||||
Env *Vars
|
Env *Vars
|
||||||
Dotenv []string
|
Dotenv []string
|
||||||
Silent bool
|
Silent *bool `yaml:"silent,omitempty"`
|
||||||
Interactive bool
|
Interactive bool
|
||||||
Internal bool
|
Internal bool
|
||||||
Method string
|
Method string
|
||||||
@@ -178,7 +184,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
t.Vars = task.Vars
|
t.Vars = task.Vars
|
||||||
t.Env = task.Env
|
t.Env = task.Env
|
||||||
t.Dotenv = task.Dotenv
|
t.Dotenv = task.Dotenv
|
||||||
t.Silent = task.Silent
|
t.Silent = deepcopy.Scalar(task.Silent)
|
||||||
t.Interactive = task.Interactive
|
t.Interactive = task.Interactive
|
||||||
t.Internal = task.Internal
|
t.Internal = task.Internal
|
||||||
t.Method = task.Method
|
t.Method = task.Method
|
||||||
@@ -221,7 +227,7 @@ func (t *Task) DeepCopy() *Task {
|
|||||||
Vars: t.Vars.DeepCopy(),
|
Vars: t.Vars.DeepCopy(),
|
||||||
Env: t.Env.DeepCopy(),
|
Env: t.Env.DeepCopy(),
|
||||||
Dotenv: deepcopy.Slice(t.Dotenv),
|
Dotenv: deepcopy.Slice(t.Dotenv),
|
||||||
Silent: t.Silent,
|
Silent: deepcopy.Scalar(t.Silent),
|
||||||
Interactive: t.Interactive,
|
Interactive: t.Interactive,
|
||||||
Internal: t.Internal,
|
Internal: t.Internal,
|
||||||
Method: t.Method,
|
Method: t.Method,
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ type Taskfile struct {
|
|||||||
Interval time.Duration
|
Interval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge merges the second Taskfile into the first
|
// Merge merges the second Taskfile into the first.
|
||||||
func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error {
|
// If skipVarsMerge is true, variables are not merged (used for scoped includes).
|
||||||
|
func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include, skipVarsMerge bool) error {
|
||||||
if !t1.Version.Equal(t2.Version) {
|
if !t1.Version.Equal(t2.Version) {
|
||||||
return fmt.Errorf(`task: Taskfiles versions should match. First is "%s" but second is "%s"`, t1.Version, t2.Version)
|
return fmt.Errorf(`task: Taskfiles versions should match. First is "%s" but second is "%s"`, t1.Version, t2.Version)
|
||||||
}
|
}
|
||||||
@@ -59,8 +60,19 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error {
|
|||||||
if t1.Tasks == nil {
|
if t1.Tasks == nil {
|
||||||
t1.Tasks = NewTasks()
|
t1.Tasks = NewTasks()
|
||||||
}
|
}
|
||||||
t1.Vars.Merge(t2.Vars, include)
|
if t2.Silent {
|
||||||
t1.Env.Merge(t2.Env, include)
|
for _, t := range t2.Tasks.All(nil) {
|
||||||
|
if t.Silent == nil {
|
||||||
|
v := true
|
||||||
|
t.Silent = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only merge vars if not using scoped includes, or if flattening
|
||||||
|
if !skipVarsMerge || include.Flatten {
|
||||||
|
t1.Vars.Merge(t2.Vars, include)
|
||||||
|
t1.Env.Merge(t2.Env, include)
|
||||||
|
}
|
||||||
return t1.Tasks.Merge(t2.Tasks, include, t1.Vars)
|
return t1.Tasks.Merge(t2.Tasks, include, t1.Vars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ func NewRootNode(
|
|||||||
dir string,
|
dir string,
|
||||||
insecure bool,
|
insecure bool,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
|
opts ...NodeOption,
|
||||||
) (Node, error) {
|
) (Node, error) {
|
||||||
dir = fsext.DefaultDir(entrypoint, dir)
|
dir = fsext.DefaultDir(entrypoint, dir)
|
||||||
// If the entrypoint is "-", we read from stdin
|
// If the entrypoint is "-", we read from stdin
|
||||||
if entrypoint == "-" {
|
if entrypoint == "-" {
|
||||||
return NewStdinNode(dir)
|
return NewStdinNode(dir)
|
||||||
}
|
}
|
||||||
return NewNode(entrypoint, dir, insecure)
|
return NewNode(entrypoint, dir, insecure, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNode(
|
func NewNode(
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ type (
|
|||||||
parent Node
|
parent Node
|
||||||
dir string
|
dir string
|
||||||
checksum 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 {
|
func (node *baseNode) Verify(checksum string) bool {
|
||||||
return node.checksum == "" || node.checksum == checksum
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package taskfile
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,7 +20,54 @@ import (
|
|||||||
// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP.
|
// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP.
|
||||||
type HTTPNode struct {
|
type HTTPNode struct {
|
||||||
*baseNode
|
*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(
|
func NewHTTPNode(
|
||||||
@@ -34,9 +84,16 @@ func NewHTTPNode(
|
|||||||
if url.Scheme == "http" && !insecure {
|
if url.Scheme == "http" && !insecure {
|
||||||
return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()}
|
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{
|
return &HTTPNode{
|
||||||
baseNode: base,
|
baseNode: base,
|
||||||
url: url,
|
url: url,
|
||||||
|
client: client,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +106,7 @@ func (node *HTTPNode) Read() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (node *HTTPNode) ReadContext(ctx context.Context) ([]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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -58,7 +115,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
|
|||||||
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
|
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 err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
package taskfile
|
package taskfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -47,3 +58,227 @@ func TestHTTPNode_CacheKey(t *testing.T) {
|
|||||||
assert.Equal(t, tt.expectedKey, key)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ type (
|
|||||||
trustedHosts []string
|
trustedHosts []string
|
||||||
tempDir string
|
tempDir string
|
||||||
cacheExpiryDuration time.Duration
|
cacheExpiryDuration time.Duration
|
||||||
|
caCert string
|
||||||
|
cert string
|
||||||
|
certKey string
|
||||||
debugFunc DebugFunc
|
debugFunc DebugFunc
|
||||||
promptFunc PromptFunc
|
promptFunc PromptFunc
|
||||||
promptMutex sync.Mutex
|
promptMutex sync.Mutex
|
||||||
@@ -199,6 +202,45 @@ func (o *promptFuncOption) ApplyToReader(r *Reader) {
|
|||||||
r.promptFunc = o.promptFunc
|
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
|
// Read will read the Taskfile defined by the [Reader]'s [Node] and recurse
|
||||||
// through any [ast.Includes] it finds, reading each included Taskfile and
|
// 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
|
// 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,
|
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
|
||||||
WithParent(node),
|
WithParent(node),
|
||||||
WithChecksum(include.Checksum),
|
WithChecksum(include.Checksum),
|
||||||
|
WithCACert(r.caCert),
|
||||||
|
WithCert(r.cert),
|
||||||
|
WithCertKey(r.certKey),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if include.Optional {
|
if include.Optional {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ var (
|
|||||||
// at the given URL with any of the default Taskfile files names. If any of
|
// 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
|
// these match a file, the first matching path will be returned. If no files are
|
||||||
// found, an error will be returned.
|
// 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
|
// Create a new HEAD request for the given URL to check if the resource exists
|
||||||
req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,7 +46,7 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request the given URL
|
// Request the given URL
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, fmt.Errorf("checking remote file: %w", ctx.Err())
|
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
|
req.URL = alt
|
||||||
|
|
||||||
// Try the alternative URL
|
// Try the alternative URL
|
||||||
resp, err = http.DefaultClient.Do(req)
|
resp, err = client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
|
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type Remote struct {
|
|||||||
CacheExpiry *time.Duration `yaml:"cache-expiry"`
|
CacheExpiry *time.Duration `yaml:"cache-expiry"`
|
||||||
CacheDir *string `yaml:"cache-dir"`
|
CacheDir *string `yaml:"cache-dir"`
|
||||||
TrustedHosts []string `yaml:"trusted-hosts"`
|
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.
|
// 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)
|
slices.Sort(merged)
|
||||||
t.Remote.TrustedHosts = slices.Compact(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.Verbose = cmp.Or(other.Verbose, t.Verbose)
|
||||||
t.Color = cmp.Or(other.Color, t.Color)
|
t.Color = cmp.Or(other.Color, t.Color)
|
||||||
|
|||||||
22
testdata/includes_silent/Taskfile-inc.yml
vendored
Normal file
22
testdata/includes_silent/Taskfile-inc.yml
vendored
Normal 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
13
testdata/includes_silent/Taskfile.yml
vendored
Normal 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
|
||||||
7
testdata/includes_silent/testdata/TestIncludeSilent-include-taskfile-silent.golden
vendored
Normal file
7
testdata/includes_silent/testdata/TestIncludeSilent-include-taskfile-silent.golden
vendored
Normal 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
|
||||||
57
testdata/scoped_taskfiles/Taskfile.yml
vendored
Normal file
57
testdata/scoped_taskfiles/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
version: "3"
|
||||||
|
|
||||||
|
env:
|
||||||
|
ROOT_ENV: env_from_root
|
||||||
|
SHARED_ENV: shared_from_root
|
||||||
|
|
||||||
|
vars:
|
||||||
|
ROOT_VAR: from_root
|
||||||
|
|
||||||
|
includes:
|
||||||
|
a: ./inc_a
|
||||||
|
b: ./inc_b
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
desc: Test scoped includes - vars should be isolated
|
||||||
|
cmds:
|
||||||
|
- task: a:print
|
||||||
|
- task: b:print
|
||||||
|
|
||||||
|
print-root-var:
|
||||||
|
desc: Print ROOT_VAR from root
|
||||||
|
cmds:
|
||||||
|
- echo "ROOT_VAR={{.ROOT_VAR}}"
|
||||||
|
|
||||||
|
print-env:
|
||||||
|
desc: Print env vars using {{.env.XXX}} syntax
|
||||||
|
cmds:
|
||||||
|
- echo "ROOT_ENV={{.env.ROOT_ENV}}"
|
||||||
|
- echo "SHARED_ENV={{.env.SHARED_ENV}}"
|
||||||
|
- echo "PATH_EXISTS={{if .env.PATH}}yes{{else}}no{{end}}"
|
||||||
|
|
||||||
|
test-env-separation:
|
||||||
|
desc: Test that env is NOT at root level in scoped mode
|
||||||
|
cmds:
|
||||||
|
# In scoped mode, {{.ROOT_ENV}} should be empty (env not at root)
|
||||||
|
# In legacy mode, {{.ROOT_ENV}} would have the value
|
||||||
|
- echo "ROOT_ENV_AT_ROOT={{.ROOT_ENV}}"
|
||||||
|
|
||||||
|
prout:
|
||||||
|
vars:
|
||||||
|
LOL: prout_from_root
|
||||||
|
cmds:
|
||||||
|
- echo "{{.LOL}}"
|
||||||
|
|
||||||
|
call-with-vars:
|
||||||
|
desc: Test calling a task with vars override
|
||||||
|
cmds:
|
||||||
|
- task: print-name
|
||||||
|
vars:
|
||||||
|
NAME: from_caller
|
||||||
|
|
||||||
|
print-name:
|
||||||
|
vars:
|
||||||
|
NAME: default_name
|
||||||
|
cmds:
|
||||||
|
- echo "NAME={{.NAME}}"
|
||||||
38
testdata/scoped_taskfiles/inc_a/Taskfile.yml
vendored
Normal file
38
testdata/scoped_taskfiles/inc_a/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
version: "3"
|
||||||
|
|
||||||
|
env:
|
||||||
|
INC_A_ENV: env_from_a
|
||||||
|
SHARED_ENV: shared_from_a
|
||||||
|
|
||||||
|
vars:
|
||||||
|
VAR: value_from_a
|
||||||
|
UNIQUE_A: only_in_a
|
||||||
|
|
||||||
|
includes:
|
||||||
|
nested: ./nested
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
print:
|
||||||
|
desc: Print vars from include A
|
||||||
|
cmds:
|
||||||
|
- echo "A:UNIQUE_A={{.UNIQUE_A}}"
|
||||||
|
- echo "A:ROOT_VAR={{.ROOT_VAR}}"
|
||||||
|
|
||||||
|
try-access-b:
|
||||||
|
desc: Try to access B's unique var (should fail in scoped mode)
|
||||||
|
cmds:
|
||||||
|
- echo "A:UNIQUE_B={{.UNIQUE_B}}"
|
||||||
|
|
||||||
|
print-env:
|
||||||
|
desc: Print env vars from include A
|
||||||
|
cmds:
|
||||||
|
- echo "A:INC_A_ENV={{.env.INC_A_ENV}}"
|
||||||
|
- echo "A:ROOT_ENV={{.env.ROOT_ENV}}"
|
||||||
|
- echo "A:SHARED_ENV={{.env.SHARED_ENV}}"
|
||||||
|
|
||||||
|
test-env-in-var:
|
||||||
|
desc: Test using env in a var template
|
||||||
|
vars:
|
||||||
|
COMPOSED: "env={{.env.ROOT_ENV}}"
|
||||||
|
cmds:
|
||||||
|
- echo "{{.COMPOSED}}"
|
||||||
22
testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml
vendored
Normal file
22
testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
version: "3"
|
||||||
|
|
||||||
|
env:
|
||||||
|
NESTED_ENV: env_from_nested
|
||||||
|
|
||||||
|
vars:
|
||||||
|
NESTED_VAR: from_nested
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
print:
|
||||||
|
desc: Print vars from nested include (3 levels deep)
|
||||||
|
cmds:
|
||||||
|
- echo "NESTED:ROOT_VAR={{.ROOT_VAR}}"
|
||||||
|
- echo "NESTED:UNIQUE_A={{.UNIQUE_A}}"
|
||||||
|
- echo "NESTED:NESTED_VAR={{.NESTED_VAR}}"
|
||||||
|
- echo "NESTED:NESTED_ENV={{.env.NESTED_ENV}}"
|
||||||
|
- echo "NESTED:ROOT_ENV={{.env.ROOT_ENV}}"
|
||||||
|
|
||||||
|
try-access-b:
|
||||||
|
desc: Try to access B's unique var (should fail - sibling isolation)
|
||||||
|
cmds:
|
||||||
|
- echo "NESTED:UNIQUE_B={{.UNIQUE_B}}"
|
||||||
23
testdata/scoped_taskfiles/inc_b/Taskfile.yml
vendored
Normal file
23
testdata/scoped_taskfiles/inc_b/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
version: "3"
|
||||||
|
|
||||||
|
env:
|
||||||
|
INC_B_ENV: env_from_b
|
||||||
|
SHARED_ENV: shared_from_b
|
||||||
|
|
||||||
|
vars:
|
||||||
|
VAR: value_from_b
|
||||||
|
UNIQUE_B: only_in_b
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
print:
|
||||||
|
desc: Print vars from include B
|
||||||
|
cmds:
|
||||||
|
- echo "B:UNIQUE_B={{.UNIQUE_B}}"
|
||||||
|
- echo "B:ROOT_VAR={{.ROOT_VAR}}"
|
||||||
|
|
||||||
|
print-env:
|
||||||
|
desc: Print env vars from include B
|
||||||
|
cmds:
|
||||||
|
- echo "B:INC_B_ENV={{.env.INC_B_ENV}}"
|
||||||
|
- echo "B:ROOT_ENV={{.env.ROOT_ENV}}"
|
||||||
|
- echo "B:SHARED_ENV={{.env.SHARED_ENV}}"
|
||||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
A:UNIQUE_B=only_in_b
|
||||||
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden
vendored
Normal file
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
A:UNIQUE_A=only_in_a
|
||||||
|
A:ROOT_VAR=from_root
|
||||||
|
B:UNIQUE_B=only_in_b
|
||||||
|
B:ROOT_VAR=from_root
|
||||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NAME=from_caller
|
||||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
A:UNIQUE_B=
|
||||||
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden
vendored
Normal file
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
A:UNIQUE_A=only_in_a
|
||||||
|
A:ROOT_VAR=from_root
|
||||||
|
B:UNIQUE_B=only_in_b
|
||||||
|
B:ROOT_VAR=from_root
|
||||||
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden
vendored
Normal file
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ROOT_ENV=env_from_root
|
||||||
|
SHARED_ENV=shared_from_root
|
||||||
|
PATH_EXISTS=yes
|
||||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ROOT_ENV_AT_ROOT=
|
||||||
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden
vendored
Normal file
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
A:INC_A_ENV=env_from_a
|
||||||
|
A:ROOT_ENV=env_from_root
|
||||||
|
A:SHARED_ENV=shared_from_a
|
||||||
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden
vendored
Normal file
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
A:UNIQUE_A=only_in_a
|
||||||
|
A:ROOT_VAR=from_root
|
||||||
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden
vendored
Normal file
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
B:UNIQUE_B=only_in_b
|
||||||
|
B:ROOT_VAR=from_root
|
||||||
5
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden
vendored
Normal file
5
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
NESTED:ROOT_VAR=from_root
|
||||||
|
NESTED:UNIQUE_A=only_in_a
|
||||||
|
NESTED:NESTED_VAR=from_nested
|
||||||
|
NESTED:NESTED_ENV=env_from_nested
|
||||||
|
NESTED:ROOT_ENV=env_from_root
|
||||||
1
testdata/special_vars/Taskfile.yml
vendored
1
testdata/special_vars/Taskfile.yml
vendored
@@ -11,6 +11,7 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- echo {{.TASK}}
|
- echo {{.TASK}}
|
||||||
print-root-dir: echo {{.ROOT_DIR}}
|
print-root-dir: echo {{.ROOT_DIR}}
|
||||||
|
print-root-taskfile: echo {{.ROOT_TASKFILE}}
|
||||||
print-taskfile: echo {{.TASKFILE}}
|
print-taskfile: echo {{.TASKFILE}}
|
||||||
print-taskfile-dir: echo {{.TASKFILE_DIR}}
|
print-taskfile-dir: echo {{.TASKFILE_DIR}}
|
||||||
print-task-version: echo {{.TASK_VERSION}}
|
print-task-version: echo {{.TASK_VERSION}}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{{.TEST_DIR}}/testdata/special_vars/Taskfile.yml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{.TEST_DIR}}/testdata/special_vars/Taskfile.yml
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
"github.com/go-task/task/v3/errors"
|
"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/env"
|
||||||
"github.com/go-task/task/v3/internal/execext"
|
"github.com/go-task/task/v3/internal/execext"
|
||||||
"github.com/go-task/task/v3/internal/filepathext"
|
"github.com/go-task/task/v3/internal/filepathext"
|
||||||
@@ -57,7 +58,7 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) {
|
|||||||
Vars: vars,
|
Vars: vars,
|
||||||
Env: nil,
|
Env: nil,
|
||||||
Dotenv: origTask.Dotenv,
|
Dotenv: origTask.Dotenv,
|
||||||
Silent: origTask.Silent,
|
Silent: deepcopy.Scalar(origTask.Silent),
|
||||||
Interactive: origTask.Interactive,
|
Interactive: origTask.Interactive,
|
||||||
Internal: origTask.Internal,
|
Internal: origTask.Internal,
|
||||||
Method: origTask.Method,
|
Method: origTask.Method,
|
||||||
@@ -113,7 +114,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
|||||||
Vars: vars,
|
Vars: vars,
|
||||||
Env: nil,
|
Env: nil,
|
||||||
Dotenv: templater.Replace(origTask.Dotenv, cache),
|
Dotenv: templater.Replace(origTask.Dotenv, cache),
|
||||||
Silent: origTask.Silent,
|
Silent: deepcopy.Scalar(origTask.Silent),
|
||||||
Interactive: origTask.Interactive,
|
Interactive: origTask.Interactive,
|
||||||
Internal: origTask.Internal,
|
Internal: origTask.Internal,
|
||||||
Method: templater.Replace(origTask.Method, cache),
|
Method: templater.Replace(origTask.Method, cache),
|
||||||
|
|||||||
@@ -35,31 +35,19 @@ export default defineConfig({
|
|||||||
description: taskDescription,
|
description: taskDescription,
|
||||||
lang: 'en-US',
|
lang: 'en-US',
|
||||||
head: [
|
head: [
|
||||||
[
|
// Favicon ICO for legacy browsers (auto-discovery)
|
||||||
'link',
|
['link', { rel: 'icon', href: '/favicon.ico', sizes: '48x48' }],
|
||||||
{
|
// Favicon SVG for modern browsers (scalable)
|
||||||
rel: 'icon',
|
['link', { rel: 'icon', href: '/img/logo.svg', type: 'image/svg+xml' }],
|
||||||
type: 'image/x-icon',
|
// Apple Touch Icon for iOS devices
|
||||||
href: '/img/favicon.ico',
|
['link', { rel: 'apple-touch-icon', href: '/img/logo.png' }],
|
||||||
sizes: '48x48'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'link',
|
|
||||||
{
|
|
||||||
rel: 'icon',
|
|
||||||
sizes: 'any',
|
|
||||||
type: 'image/svg+xml',
|
|
||||||
href: '/img/logo.svg'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'meta',
|
'meta',
|
||||||
{ name: 'author', content: `${team.map((c) => c.name).join(', ')}` }
|
{ name: 'author', content: `${team.map((c) => c.name).join(', ')}` }
|
||||||
],
|
],
|
||||||
// Open Graph
|
// Open Graph
|
||||||
['meta', { property: 'og:type', content: 'website' }],
|
['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 }],
|
['meta', { property: 'og:image', content: ogImage }],
|
||||||
// Twitter Card
|
// Twitter Card
|
||||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||||
@@ -80,6 +68,16 @@ export default defineConfig({
|
|||||||
src: "https://u.taskfile.dev/script.js",
|
src: "https://u.taskfile.dev/script.js",
|
||||||
"data-website-id": "084030b0-0e3f-4891-8d2a-0c12c40f5933"
|
"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 }) {
|
transformHead({ pageData }) {
|
||||||
@@ -304,6 +302,10 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
text: 'Remote Taskfiles (#1317)',
|
text: 'Remote Taskfiles (#1317)',
|
||||||
link: '/docs/experiments/remote-taskfiles'
|
link: '/docs/experiments/remote-taskfiles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Scoped Taskfiles',
|
||||||
|
link: '/docs/experiments/scoped-taskfiles'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
protected by TLS are vulnerable to man-in-the-middle attacks and should be
|
||||||
avoided unless you know what you are doing.
|
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
|
## Caching & Running Offline
|
||||||
|
|
||||||
Whenever you run a remote Taskfile, the latest copy will be downloaded from the
|
Whenever you run a remote Taskfile, the latest copy will be downloaded from the
|
||||||
@@ -313,6 +345,9 @@ remote:
|
|||||||
trusted-hosts:
|
trusted-hosts:
|
||||||
- github.com
|
- github.com
|
||||||
- gitlab.com
|
- gitlab.com
|
||||||
|
cacert: ""
|
||||||
|
cert: ""
|
||||||
|
cert-key: ""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `insecure`
|
#### `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
|
# Trust a host with a specific port
|
||||||
task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml
|
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"
|
||||||
|
```
|
||||||
|
|||||||
281
website/src/docs/experiments/scoped-taskfiles.md
Normal file
281
website/src/docs/experiments/scoped-taskfiles.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
title: 'Scoped Taskfiles (#2035)'
|
||||||
|
description:
|
||||||
|
Experiment for variable isolation and env namespace in included Taskfiles
|
||||||
|
outline: deep
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scoped Taskfiles (#2035)
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
|
||||||
|
All experimental features are subject to breaking changes and/or removal _at any
|
||||||
|
time_. We strongly recommend that you do not use these features in a production
|
||||||
|
environment. They are intended for testing and feedback only.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: danger
|
||||||
|
|
||||||
|
This experiment breaks the following functionality:
|
||||||
|
|
||||||
|
- **Environment variables are no longer available at root level in templates**
|
||||||
|
- Before: <span v-pre>`{{.PATH}}`</span>, <span v-pre>`{{.MY_ENV}}`</span>
|
||||||
|
- After: <span v-pre>`{{.env.PATH}}`</span>,
|
||||||
|
<span v-pre>`{{.env.MY_ENV}}`</span>
|
||||||
|
- **Variables from sibling includes are no longer visible**
|
||||||
|
- Include A cannot access variables defined in Include B
|
||||||
|
- Each include only sees: root vars + its own vars + parent vars
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: info
|
||||||
|
|
||||||
|
To enable this experiment, set the environment variable:
|
||||||
|
`TASK_X_SCOPED_TASKFILES=1`. Check out
|
||||||
|
[our guide to enabling experiments](./index.md#enabling-experiments) for more
|
||||||
|
information.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
This experiment introduces two major changes to how variables work in Task:
|
||||||
|
|
||||||
|
1. **Environment namespace**: Environment variables (both OS and Taskfile `env:`
|
||||||
|
sections) are moved to a dedicated <span v-pre>`{{.env.XXX}}`</span>
|
||||||
|
namespace, separating them from regular variables
|
||||||
|
2. **Variable scoping**: Variables defined in included Taskfiles are isolated -
|
||||||
|
sibling includes cannot see each other's variables
|
||||||
|
|
||||||
|
## Environment Namespace
|
||||||
|
|
||||||
|
With this experiment enabled, environment variables are no longer mixed with
|
||||||
|
regular variables at the template root level. Instead, they are accessible
|
||||||
|
through the <span v-pre>`{{.env.XXX}}`</span> namespace.
|
||||||
|
|
||||||
|
### Comparison Table
|
||||||
|
|
||||||
|
| Template | Legacy | SCOPED_TASKFILES |
|
||||||
|
| ----------------------------------------------- | ------ | ------------------------- |
|
||||||
|
| <span v-pre>`{{.MY_VAR}}`</span> (from `vars:`) | Works | Works |
|
||||||
|
| <span v-pre>`{{.MY_ENV}}`</span> (from `env:`) | Works | `<no value>` |
|
||||||
|
| <span v-pre>`{{.env.MY_ENV}}`</span> | - | Works |
|
||||||
|
| <span v-pre>`{{.PATH}}`</span> (OS) | Works | `<no value>` |
|
||||||
|
| <span v-pre>`{{.env.PATH}}`</span> (OS) | - | Works |
|
||||||
|
| <span v-pre>`{{.TASK}}`</span> (special) | Works | Works (stays at root) |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
env:
|
||||||
|
DB_HOST: localhost
|
||||||
|
|
||||||
|
vars:
|
||||||
|
DB_NAME: mydb
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
show:
|
||||||
|
cmds:
|
||||||
|
# Access Taskfile env: section
|
||||||
|
- echo "Host: {{.env.DB_HOST}}"
|
||||||
|
|
||||||
|
# Access regular vars (unchanged)
|
||||||
|
- echo "Name: {{.DB_NAME}}"
|
||||||
|
|
||||||
|
# Access OS environment variables
|
||||||
|
- echo "Path: {{.env.PATH}}"
|
||||||
|
|
||||||
|
# Special variables stay at root level
|
||||||
|
- echo "Task: {{.TASK}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variable Scoping
|
||||||
|
|
||||||
|
Variables defined in included Taskfiles are now isolated from each other.
|
||||||
|
Sibling includes cannot access each other's variables, but child includes can
|
||||||
|
still inherit variables from their parent.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
::: code-group
|
||||||
|
|
||||||
|
```yaml [Taskfile.yml]
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
ROOT_VAR: from_root
|
||||||
|
|
||||||
|
includes:
|
||||||
|
api: ./api
|
||||||
|
web: ./web
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml [api/Taskfile.yml]
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
API_VAR: from_api
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
show:
|
||||||
|
cmds:
|
||||||
|
# Inherited from root - works
|
||||||
|
- echo "ROOT_VAR={{.ROOT_VAR}}"
|
||||||
|
|
||||||
|
# Own variable - works
|
||||||
|
- echo "API_VAR={{.API_VAR}}"
|
||||||
|
|
||||||
|
# From sibling include - NOT visible
|
||||||
|
- echo "WEB_VAR={{.WEB_VAR}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml [web/Taskfile.yml]
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
WEB_VAR: from_web
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
show:
|
||||||
|
cmds:
|
||||||
|
# Inherited from root - works
|
||||||
|
- echo "ROOT_VAR={{.ROOT_VAR}}"
|
||||||
|
|
||||||
|
# Own variable - works
|
||||||
|
- echo "WEB_VAR={{.WEB_VAR}}"
|
||||||
|
|
||||||
|
# From sibling include - NOT visible
|
||||||
|
- echo "API_VAR={{.API_VAR}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Variable Priority
|
||||||
|
|
||||||
|
With this experiment, variables follow a clear priority order (lowest to
|
||||||
|
highest):
|
||||||
|
|
||||||
|
| Priority | Source | Description |
|
||||||
|
| -------- | ------------------------ | ---------------------------------------- |
|
||||||
|
| 1 | Root Taskfile vars | `vars:` in the root Taskfile |
|
||||||
|
| 2 | Include Taskfile vars | `vars:` in the included Taskfile |
|
||||||
|
| 3 | Include passthrough vars | `includes: name: vars:` from parent |
|
||||||
|
| 4 | Task vars | `tasks: name: vars:` in the task |
|
||||||
|
| 5 | Call vars | `task: name` with `vars:` when calling |
|
||||||
|
| 6 | CLI vars | `task foo VAR=value` on command line |
|
||||||
|
|
||||||
|
### Example: Call vars override task vars
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
greet:
|
||||||
|
vars:
|
||||||
|
NAME: default
|
||||||
|
cmds:
|
||||||
|
- echo "Hello {{.NAME}}"
|
||||||
|
|
||||||
|
caller:
|
||||||
|
cmds:
|
||||||
|
- task: greet
|
||||||
|
vars:
|
||||||
|
NAME: from_caller
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Direct call uses task default
|
||||||
|
task greet
|
||||||
|
# Output: Hello default
|
||||||
|
|
||||||
|
# Call vars override task vars
|
||||||
|
task caller
|
||||||
|
# Output: Hello from_caller
|
||||||
|
|
||||||
|
# CLI vars override everything
|
||||||
|
task greet NAME=cli
|
||||||
|
# Output: Hello cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
To migrate your Taskfiles to use this experiment:
|
||||||
|
|
||||||
|
1. **Update environment variable references** in your templates:
|
||||||
|
|
||||||
|
- <span v-pre>`{{.PATH}}`</span> becomes
|
||||||
|
<span v-pre>`{{.env.PATH}}`</span>
|
||||||
|
- <span v-pre>`{{.HOME}}`</span> becomes
|
||||||
|
<span v-pre>`{{.env.HOME}}`</span>
|
||||||
|
- <span v-pre>`{{.MY_TASKFILE_ENV}}`</span> becomes
|
||||||
|
<span v-pre>`{{.env.MY_TASKFILE_ENV}}`</span>
|
||||||
|
|
||||||
|
2. **Variables in `vars:` sections remain unchanged**:
|
||||||
|
|
||||||
|
- <span v-pre>`{{.MY_VAR}}`</span> still works the same way
|
||||||
|
|
||||||
|
3. **Special variables stay at root level**:
|
||||||
|
|
||||||
|
- <span v-pre>`{{.TASK}}`</span>, <span v-pre>`{{.ROOT_DIR}}`</span>,
|
||||||
|
<span v-pre>`{{.TASKFILE}}`</span>, <span v-pre>`{{.TASKFILE_DIR}}`</span>,
|
||||||
|
etc.
|
||||||
|
|
||||||
|
4. **Review cross-include variable dependencies**:
|
||||||
|
- If your included Taskfiles rely on variables from sibling includes, you'll
|
||||||
|
need to either move those variables to the root Taskfile or pass them
|
||||||
|
explicitly via the `vars:` attribute in the `includes:` section.
|
||||||
|
|
||||||
|
5. **Use `flatten: true` for gradual migration**:
|
||||||
|
- If an include needs the legacy behavior (access to sibling variables), you
|
||||||
|
can use `flatten: true` on that include as an escape hatch.
|
||||||
|
|
||||||
|
## Using `flatten: true`
|
||||||
|
|
||||||
|
The `flatten: true` option on includes bypasses scoping for that specific
|
||||||
|
include. When an include has `flatten: true`:
|
||||||
|
|
||||||
|
- Its variables are merged globally (legacy behavior)
|
||||||
|
- It can access variables from sibling includes
|
||||||
|
- Sibling includes can access its variables
|
||||||
|
|
||||||
|
This is useful for gradual migration or when you have includes that genuinely
|
||||||
|
need to share variables.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
ROOT_VAR: from_root
|
||||||
|
|
||||||
|
includes:
|
||||||
|
# Scoped include - isolated from siblings
|
||||||
|
api:
|
||||||
|
taskfile: ./api
|
||||||
|
|
||||||
|
# Flattened include - uses legacy merge behavior
|
||||||
|
shared:
|
||||||
|
taskfile: ./shared
|
||||||
|
flatten: true
|
||||||
|
|
||||||
|
# Another scoped include
|
||||||
|
web:
|
||||||
|
taskfile: ./web
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example:
|
||||||
|
|
||||||
|
- `api` and `web` are isolated from each other (cannot see each other's vars)
|
||||||
|
- `shared` uses legacy behavior: its vars are merged globally
|
||||||
|
- Both `api` and `web` can access variables from `shared`
|
||||||
|
- `shared` can access variables from `api` and `web`
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
|
||||||
|
Use `flatten: true` sparingly. The goal of scoped taskfiles is to improve
|
||||||
|
isolation and predictability. Flattening should be a temporary measure during
|
||||||
|
migration or for utility includes that genuinely need global scope.
|
||||||
|
|
||||||
|
:::
|
||||||
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 |
4
website/src/public/robots.txt
Normal file
4
website/src/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://taskfile.dev/sitemap.xml
|
||||||
Reference in New Issue
Block a user