Compare commits

...

21 Commits

Author SHA1 Message Date
Valentin Maerten
81fbca3420 refactor(compiler): remove unnecessary closure comments 2026-01-25 20:54:49 +01:00
Valentin Maerten
7323fe8009 fix: resolve lint issues after rebase
- Fix import order in setup.go (gci)
- Fix variable alignment in experiments.go (gofmt)
- Add nolint:paralleltest directive for TestScopedTaskfiles
2026-01-25 20:45:46 +01:00
Valentin Maerten
c8efbc2f4a docs(experiments): reference issue #2035 in scoped taskfiles doc 2026-01-25 19:54:04 +01:00
Valentin Maerten
17257a1c31 chore: add scoped variables planning documents (to be reverted) 2026-01-25 19:54:04 +01:00
Valentin Maerten
2810c267dd feat(scoped): refactor compiler, add nested includes, document flatten
Refactor compiler.go for better maintainability:
- Extract isScopedMode() helper function
- Split getVariables() into getScopedVariables() and getLegacyVariables()
- Fix directory resolution: parent chain env/vars now resolve from their
  own directory instead of the current task's directory

Add nested includes support and tests:
- Add testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml (3 levels deep)
- Add test case for nested include inheritance (root → a → nested)
- Verify nested includes inherit vars from full parent chain

Fix flaky tests:
- Remove VAR from print tasks (defined in both inc_a and inc_b)
- Test only unique variables (UNIQUE_A, UNIQUE_B, ROOT_VAR)

Document flatten: true escape hatch:
- Add migration guide step for using flatten: true
- Add new section explaining flatten bypasses scoping
- Include example and usage recommendations
2026-01-25 19:54:04 +01:00
Valentin Maerten
a57a16efca fix(compiler): add call.Vars support in scoped mode
When calling a task with vars (e.g., `task: name` with `vars:`),
those vars were not being applied in scoped mode. This fix adds
call.Vars to the variable resolution chain.

Variable priority (lowest to highest):
1. Root Taskfile vars
2. Include Taskfile vars
3. Include passthrough vars
4. Task vars
5. Call vars (NEW)
6. CLI vars
2026-01-25 19:54:04 +01:00
Valentin Maerten
5ef7313e95 docs(experiments): add SCOPED_TASKFILES documentation
Document the new experiment with:
- Environment namespace ({{.env.XXX}}) explanation
- Variable scoping between includes
- CLI variables priority
- Migration guide from legacy mode
- Comparison table between legacy and scoped modes
2026-01-25 19:54:04 +01:00
Valentin Maerten
e05c9f7793 fix(compiler): CLI vars have highest priority in scoped mode
In scoped mode, CLI vars (e.g., `task foo VAR=value`) now correctly
override task-level vars. This is achieved by:

1. Adding a `CLIVars` field to the Compiler struct
2. Storing CLI globals in this field after parsing
3. Applying CLI vars last in scoped mode to ensure they override everything

The order of variable resolution in scoped mode is now:
1. OS env → {{.env.XXX}}
2. Root taskfile env → {{.env.XXX}}
3. Root taskfile vars → {{.VAR}}
4. Include taskfile env/vars (if applicable)
5. IncludeVars (vars passed via includes: section)
6. Task-level vars
7. CLI vars (highest priority)

Legacy mode behavior is unchanged.
2026-01-25 19:54:04 +01:00
Valentin Maerten
edee501b6b feat(experiments): rename SCOPED_INCLUDES to SCOPED_TASKFILES and add env namespace
Rename the experiment from SCOPED_INCLUDES to SCOPED_TASKFILES to better
reflect its expanded scope. This experiment now provides:

1. Variable scoping (existing): includes see only their own vars + parent vars
2. Environment namespace (new): env vars accessible via {{.env.XXX}}

With TASK_X_SCOPED_TASKFILES=1:
- {{.VAR}} accesses vars only (scoped per include)
- {{.env.VAR}} accesses env (OS + Taskfile env:, inherited)
- {{.TASK}} and other special vars remain at root level

This is a breaking change for the experimental feature:
- {{.PATH}} no longer works, use {{.env.PATH}} instead
- Env vars are no longer at root level in templates
2026-01-25 19:54:04 +01:00
Valentin Maerten
efaea39503 test(scoped-includes): add tests for variable isolation
Tests verify:
- Legacy mode: vars merged globally (A sees B's VAR, can access UNIQUE_B)
- Scoped mode: vars isolated (A sees own VAR, cannot access UNIQUE_B)
- Inheritance: includes can still access root vars (ROOT_VAR)

Test structure:
- testdata/scoped_includes/ with main Taskfile and two includes
- inc_a and inc_b both define VAR with different values
- Cross-include test shows A trying to access B's UNIQUE_B
2026-01-25 19:53:38 +01:00
Valentin Maerten
04b8b75525 feat(compiler): implement lazy variable resolution for scoped includes
When SCOPED_INCLUDES experiment is enabled:
- Resolve vars from DAG instead of merged vars
- Apply root Taskfile vars first (inheritance)
- Then apply task's source Taskfile vars from DAG
- Apply IncludeVars passed via includes: section
- Skip IncludedTaskfileVars (contains parent's vars, not source's)

This ensures tasks in included Taskfiles see:
1. Root vars (inheritance from parent)
2. Their own Taskfile's vars
3. Vars passed through includes: section
4. Call vars and task-level vars
2026-01-25 19:53:04 +01:00
Valentin Maerten
0dbeaaf187 feat(taskfile): skip var merge when SCOPED_INCLUDES enabled
When the SCOPED_INCLUDES experiment is enabled, variables from included
Taskfiles are no longer merged globally. They remain in their original
Taskfile within the DAG.

Exception: flatten includes still merge variables globally to allow
sharing common variables across multiple Taskfiles.
2026-01-25 19:53:04 +01:00
Valentin Maerten
da927ad5fe feat(graph): add Root() helper method
Add Root() method to TaskfileGraph to get the root vertex (entrypoint
Taskfile). This will be used for lazy variable resolution.

Note: Tasks already have Location.Taskfile which can be used to find
their source Taskfile in the graph, so GetVertexByNamespace is not
needed.
2026-01-25 19:49:16 +01:00
Valentin Maerten
9732f7e08b feat(executor): store TaskfileGraph for lazy resolution
Store the TaskfileGraph in the Executor so it can be used for lazy
variable resolution when SCOPED_INCLUDES experiment is enabled.

The graph is now preserved after reading, before merging into the
final Taskfile. This allows traversing the DAG at runtime to resolve
variables from the correct scope.
2026-01-25 19:49:16 +01:00
Valentin Maerten
1b418409d1 feat(experiments): add SCOPED_INCLUDES experiment
Add new experiment flag TASK_X_SCOPED_INCLUDES for scoped variable
resolution in included Taskfiles. When enabled, variables from included
Taskfiles will be isolated rather than merged globally.

This is the first step towards implementing lazy DAG-based variable
resolution with strict isolation between includes.
2026-01-25 19:49:16 +01:00
Valentin Maerten
026c899d90 feat: support self-signed certificates for remote taskfiles (#2537) 2026-01-25 18:51:30 +01:00
Timothy Rule
f6720760b4 fix(includes): propagate silent mode from included Taskfiles to tasks (#2640) 2026-01-25 16:33:52 +01:00
Valentin Maerten
065236f076 chore: changelog for #2635 2026-01-25 16:08:11 +01:00
Timothy Rule
1bd5aa6bd5 fix: correct the value of ROOT_TASKFILE when no entrypoint (#2635) 2026-01-25 16:06:13 +01:00
Valentin Maerten
c3fd3c4b5e chore: add website/.netlify to gitignore 2026-01-25 14:26:53 +01:00
Valentin Maerten
299232ee7d fix(website): improve SEO with favicons, structured data and robots.txt (#2657) 2026-01-25 14:16:23 +01:00
53 changed files with 1471 additions and 53 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@@ -174,6 +174,8 @@ func run() error {
// Merge CLI variables first (e.g. FOO=bar) so they take priority over Taskfile defaults
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
cliArgsPostDashQuoted, err := args.ToQuotedString(cliArgsPostDash)

View File

@@ -9,6 +9,7 @@ import (
"strings"
"sync"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
@@ -25,6 +26,8 @@ type Compiler struct {
TaskfileEnv *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
@@ -44,8 +47,236 @@ func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error)
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) {
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)
if err != nil {
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 {
return func(k string, v ast.Var) error {
cache := &templater.Cache{Vars: result}
// Replace values
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 {
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
return nil
}
// If the variable should not be evaluated and it is set, we can set it and return
if !evaluateShVars {
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
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 {
return err
}
// If the variable is already set, we can set it and return
if newVar.Value != nil || newVar.Sh == nil {
result.Set(k, ast.Var{Value: newVar.Value})
return nil
}
// If the variable is dynamic, we need to resolve it first
static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
if err != nil {
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
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}
dir := templater.Replace(t.Dir, cache)
if err := cache.Err(); err != nil {
@@ -114,6 +335,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
return nil, err
}
}
if t != nil {
for k, v := range t.IncludeVars.All() {
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()
defer c.muDynamicCache.Unlock()
// If the variable is not dynamic or it is empty, return an empty string
if v.Sh == nil || *v.Sh == "" {
return "", nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -364,6 +364,7 @@ func TestSpecialVars(t *testing.T) {
// Root
"print-task",
"print-root-dir",
"print-root-taskfile",
"print-taskfile",
"print-taskfile-dir",
"print-task-dir",
@@ -1059,6 +1060,18 @@ func TestIncludeChecksum(t *testing.T) {
)
}
func TestIncludeSilent(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("include-taskfile-silent"),
WithExecutorOptions(
task.WithDir("testdata/includes_silent"),
),
WithTask("default"),
)
}
func TestFailfast(t *testing.T) {
t.Parallel()
@@ -1167,3 +1180,114 @@ func TestIf(t *testing.T) {
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"),
)
})
}

View File

@@ -19,6 +19,7 @@ var (
GentleForce Experiment
RemoteTaskfiles Experiment
EnvPrecedence Experiment
ScopedTaskfiles Experiment
)
// 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)
RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1)
EnvPrecedence = New("ENV_PRECEDENCE", config, 1)
ScopedTaskfiles = New("SCOPED_TASKFILES", config, 1)
AnyVariables = New("ANY_VARIABLES", config)
MapVariables = New("MAP_VARIABLES", config)
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import (
"github.com/sajari/fuzzy"
"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/execext"
"github.com/go-task/task/v3/internal/filepathext"
@@ -55,7 +56,11 @@ func (e *Executor) Setup() error {
}
func (e *Executor) getRootNode() (taskfile.Node, error) {
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout)
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout,
taskfile.WithCACert(e.CACert),
taskfile.WithCert(e.Cert),
taskfile.WithCertKey(e.CertKey),
)
if os.IsNotExist(err) {
return nil, errors.TaskfileNotFoundError{
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
@@ -67,6 +72,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
return nil, err
}
e.Dir = node.Dir()
e.Entrypoint = node.Location()
return node, err
}
@@ -86,6 +92,9 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
taskfile.WithTrustedHosts(e.TrustedHosts),
taskfile.WithTempDir(e.TempDir.Remote),
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
taskfile.WithReaderCACert(e.CACert),
taskfile.WithReaderCert(e.Cert),
taskfile.WithReaderCertKey(e.CertKey),
taskfile.WithDebugFunc(debugFunc),
taskfile.WithPromptFunc(promptFunc),
)
@@ -96,7 +105,8 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
}
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 nil
@@ -218,6 +228,7 @@ func (e *Executor) setupCompiler() error {
UserWorkingDir: e.UserWorkingDir,
TaskfileEnv: e.Taskfile.Env,
TaskfileVars: e.Taskfile.Vars,
Graph: e.Graph,
Logger: e.Logger,
}
return nil

View File

@@ -228,7 +228,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
}
if upToDate && preCondMet {
if e.Verbose || (!call.Silent && !t.Silent && !e.Taskfile.Silent && !e.Silent) {
if e.Verbose || (!call.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) {
name := t.Name()
if e.OutputStyle.Name == "prefixed" {
name = t.Prefix
@@ -383,7 +383,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
return nil
}
if e.Verbose || (!call.Silent && !cmd.Silent && !t.Silent && !e.Taskfile.Silent && !e.Silent) {
if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) {
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd)
}

View File

@@ -45,7 +45,21 @@ func (tfg *TaskfileGraph) Visualize(filename string) error {
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)
if err != nil {
return nil, err
@@ -92,6 +106,7 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) {
if err := vertex.Taskfile.Merge(
includedVertex.Taskfile,
include,
skipVarsMerge,
); err != nil {
return err
}

View File

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

View File

@@ -36,8 +36,9 @@ type Taskfile struct {
Interval time.Duration
}
// Merge merges the second Taskfile into the first
func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error {
// Merge merges the second Taskfile into the first.
// 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) {
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 {
t1.Tasks = NewTasks()
}
t1.Vars.Merge(t2.Vars, include)
t1.Env.Merge(t2.Env, include)
if t2.Silent {
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)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,9 @@ type (
trustedHosts []string
tempDir string
cacheExpiryDuration time.Duration
caCert string
cert string
certKey string
debugFunc DebugFunc
promptFunc PromptFunc
promptMutex sync.Mutex
@@ -199,6 +202,45 @@ func (o *promptFuncOption) ApplyToReader(r *Reader) {
r.promptFunc = o.promptFunc
}
// WithReaderCACert sets the path to a custom CA certificate for TLS connections.
func WithReaderCACert(caCert string) ReaderOption {
return &readerCACertOption{caCert: caCert}
}
type readerCACertOption struct {
caCert string
}
func (o *readerCACertOption) ApplyToReader(r *Reader) {
r.caCert = o.caCert
}
// WithReaderCert sets the path to a client certificate for TLS connections.
func WithReaderCert(cert string) ReaderOption {
return &readerCertOption{cert: cert}
}
type readerCertOption struct {
cert string
}
func (o *readerCertOption) ApplyToReader(r *Reader) {
r.cert = o.cert
}
// WithReaderCertKey sets the path to a client certificate key for TLS connections.
func WithReaderCertKey(certKey string) ReaderOption {
return &readerCertKeyOption{certKey: certKey}
}
type readerCertKeyOption struct {
certKey string
}
func (o *readerCertKeyOption) ApplyToReader(r *Reader) {
r.certKey = o.certKey
}
// Read will read the Taskfile defined by the [Reader]'s [Node] and recurse
// through any [ast.Includes] it finds, reading each included Taskfile and
// building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be
@@ -314,6 +356,9 @@ func (r *Reader) include(ctx context.Context, node Node) error {
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
WithParent(node),
WithChecksum(include.Checksum),
WithCACert(r.caCert),
WithCert(r.cert),
WithCertKey(r.certKey),
)
if err != nil {
if include.Optional {

View File

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

View File

@@ -28,6 +28,9 @@ type Remote struct {
CacheExpiry *time.Duration `yaml:"cache-expiry"`
CacheDir *string `yaml:"cache-dir"`
TrustedHosts []string `yaml:"trusted-hosts"`
CACert *string `yaml:"cacert"`
Cert *string `yaml:"cert"`
CertKey *string `yaml:"cert-key"`
}
// Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC.
@@ -55,6 +58,9 @@ func (t *TaskRC) Merge(other *TaskRC) {
slices.Sort(merged)
t.Remote.TrustedHosts = slices.Compact(merged)
}
t.Remote.CACert = cmp.Or(other.Remote.CACert, t.Remote.CACert)
t.Remote.Cert = cmp.Or(other.Remote.Cert, t.Remote.Cert)
t.Remote.CertKey = cmp.Or(other.Remote.CertKey, t.Remote.CertKey)
t.Verbose = cmp.Or(other.Verbose, t.Verbose)
t.Color = cmp.Or(other.Color, t.Color)

View File

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

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

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

View File

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

57
testdata/scoped_taskfiles/Taskfile.yml vendored Normal file
View 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}}"

View 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}}"

View 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}}"

View 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}}"

View File

@@ -0,0 +1 @@
A:UNIQUE_B=only_in_b

View 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

View File

@@ -0,0 +1 @@
NAME=from_caller

View File

@@ -0,0 +1 @@
A:UNIQUE_B=

View 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

View File

@@ -0,0 +1,3 @@
ROOT_ENV=env_from_root
SHARED_ENV=shared_from_root
PATH_EXISTS=yes

View File

@@ -0,0 +1 @@
ROOT_ENV_AT_ROOT=

View File

@@ -0,0 +1,3 @@
A:INC_A_ENV=env_from_a
A:ROOT_ENV=env_from_root
A:SHARED_ENV=shared_from_a

View File

@@ -0,0 +1,2 @@
A:UNIQUE_A=only_in_a
A:ROOT_VAR=from_root

View File

@@ -0,0 +1,2 @@
B:UNIQUE_B=only_in_b
B:ROOT_VAR=from_root

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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