diff --git a/compiler.go b/compiler.go index 2c2e56f632..65d2686118 100644 --- a/compiler.go +++ b/compiler.go @@ -34,18 +34,18 @@ type Compiler struct { } func (c *Compiler) GetTaskfileVariables() (*ast.Vars, error) { - return c.getVariables(nil, nil, true) + return c.getVariables(nil, nil, c.TaskfileEnv, true) } func (c *Compiler) GetVariables(t *ast.Task, call *Call) (*ast.Vars, error) { - return c.getVariables(t, call, true) + return c.getVariables(t, call, c.TaskfileEnv, true) } func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error) { - return c.getVariables(t, call, false) + return c.getVariables(t, call, c.TaskfileEnv, false) } -func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { +func (c *Compiler) getVariables(t *ast.Task, call *Call, taskfileEnv *ast.Vars, evaluateShVars bool) (*ast.Vars, error) { result := env.GetEnviron() specialVars, err := c.getSpecialVars(t, call) if err != nil { @@ -105,11 +105,18 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* taskRangeFunc = getRangeFunc(dir) } - for k, v := range c.TaskfileEnv.All() { + for k, v := range taskfileEnv.All() { if err := rangeFunc(k, v); err != nil { return nil, err } } + if t != nil { + for k, v := range t.IncludedDotenvEnv.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + } for k, v := range c.TaskfileVars.All() { if err := rangeFunc(k, v); err != nil { return nil, err diff --git a/executor_test.go b/executor_test.go index e0ce4e2786..9ec528a502 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1289,3 +1289,74 @@ func TestIf(t *testing.T) { NewExecutorTest(t, opts...) } } + +func TestIncludedDotenvScopes(t *testing.T) { + t.Parallel() + + const dir = "testdata/dotenv/included_scopes" + for _, test := range []struct { + name, dir, entrypoint, task string + }{ + {name: "root isolation", task: "inspect"}, + {name: "include directory and file order", task: "app:inspect"}, + {name: "standalone", dir: "app", task: "inspect"}, + {name: "standalone root remains global", dir: "app", task: "common:inspect"}, + {name: "no dotenv inherited without root", entrypoint: "without-root.yml", task: "app:common:inspect"}, + {name: "without root dotenv", entrypoint: "without-root.yml", task: "app:inspect"}, + {name: "nested dotenv is independent", task: "app:child:inspect"}, + {name: "common only inherits root", task: "app:common:inspect"}, + {name: "sibling isolation", task: "sibling:inspect"}, + {name: "common via another parent", task: "sibling:common:inspect"}, + {name: "include vars a", task: "a:inspect"}, + {name: "include vars b", task: "b:inspect"}, + {name: "flatten", task: "flattened"}, + {name: "task env priority", task: "app:task-env"}, + {name: "task dotenv priority", task: "app:task-dotenv"}, + } { + entrypoint := "" + if test.entrypoint != "" { + entrypoint = filepath.Join(dir, test.entrypoint) + } + NewExecutorTest(t, + WithName(test.name), + WithExecutorOptions( + task.WithDir(filepath.Join(dir, test.dir)), + task.WithEntrypoint(entrypoint), + task.WithSilent(true), + ), + WithTask(test.task), + ) + } +} + +func TestIncludedDotenvErrors(t *testing.T) { + t.Parallel() + + for _, name := range []string{"parse", "template"} { + NewExecutorTest(t, + WithName(name), + WithExecutorOptions( + task.WithDir("testdata/dotenv/included_errors"), + task.WithEntrypoint(filepath.Join("testdata/dotenv/included_errors", name+"-root.yml")), + ), + WithSetupError(), + WithFixtureTemplating(), + ) + } +} + +func TestIncludedDotenvDynamicCache(t *testing.T) { + t.Parallel() + + for _, entrypoint := range []string{"Taskfile.yml", "with-root.yml"} { + NewExecutorTest(t, + WithName(entrypoint), + WithExecutorOptions( + task.WithDir("testdata/dotenv/included_dynamic_cache"), + task.WithEntrypoint(filepath.Join("testdata/dotenv/included_dynamic_cache", entrypoint)), + task.WithSilent(true), + ), + WithPostProcessFn(PPSortedLines), + ) + } +} diff --git a/setup.go b/setup.go index e92848417a..2db9c995da 100644 --- a/setup.go +++ b/setup.go @@ -42,9 +42,13 @@ func (e *Executor) Setup() error { if err := e.setupCompiler(); err != nil { return err } + explicitEnv := e.Taskfile.Env.DeepCopy() if err := e.readDotEnvFiles(); err != nil { return err } + if err := e.readIncludedDotEnvFiles(explicitEnv); err != nil { + return err + } if err := e.doVersionChecks(); err != nil { return err } @@ -250,6 +254,55 @@ func (e *Executor) readDotEnvFiles() error { return err } +func (e *Executor) readIncludedDotEnvFiles(explicitEnv *ast.Vars) error { + if e.Taskfile.Version.LessThan(ast.V3) { + return nil + } + // Cache by include ancestry: two includes of the same file may supply + // different variables for its dotenv paths. + type scopeKey struct{ namespace, location string } + loadedScopes := make(map[scopeKey]*ast.Vars) + for t := range e.Taskfile.Tasks.Values(nil) { + scope := t.DotenvScope + if scope == nil { + continue + } + key := scopeKey{scope.Namespace, t.Location.Taskfile} + loaded, ok := loadedScopes[key] + if !ok { + env := e.Taskfile.Env.DeepCopy() + env.Merge(scope.Env, nil) + dir := e.Dir + if !taskfile.IsRemoteEntrypoint(t.Location.Taskfile) { + dir = filepath.Dir(t.Location.Taskfile) + } + contextTask := &ast.Task{ + Dir: dir, + Location: t.Location, + IncludeVars: scope.IncludeVars, + IncludedTaskfileVars: scope.Vars, + } + // Reuse the executor's dynamic cache while resolving this scope's environment. + vars, err := e.Compiler.getVariables(contextTask, nil, env, true) + if err != nil { + return err + } + loaded, err = taskfile.Dotenv(vars, &ast.Taskfile{Dotenv: scope.Files}, dir) + if err != nil { + return err + } + loadedScopes[key] = loaded + } + t.IncludedDotenvEnv = ast.NewVars() + for k, v := range loaded.All() { + if _, explicit := explicitEnv.Get(k); !explicit { + t.IncludedDotenvEnv.Set(k, v) + } + } + } + return nil +} + func (e *Executor) setupDefaults() { if e.Taskfile.Method == "" { e.Taskfile.Method = "checksum" diff --git a/task_test.go b/task_test.go index 540e7ba8de..884ba53041 100644 --- a/task_test.go +++ b/task_test.go @@ -2142,7 +2142,7 @@ func TestDotenvShouldIncludeAllEnvFiles(t *testing.T) { }) } -func TestDotenvShouldErrorWhenIncludingDependantDotenvs(t *testing.T) { +func TestDotenvShouldAllowIncludedDotenvs(t *testing.T) { t.Parallel() var buff bytes.Buffer @@ -2154,8 +2154,7 @@ func TestDotenvShouldErrorWhenIncludingDependantDotenvs(t *testing.T) { ) err := e.Setup() - require.Error(t, err) - assert.Contains(t, err.Error(), "move the dotenv") + require.NoError(t, err) } func TestDotenvShouldAllowMissingEnv(t *testing.T) { diff --git a/taskfile/ast/dotenv.go b/taskfile/ast/dotenv.go new file mode 100644 index 0000000000..be33ba98ba --- /dev/null +++ b/taskfile/ast/dotenv.go @@ -0,0 +1,25 @@ +package ast + +import "github.com/go-task/task/v3/internal/deepcopy" + +// DotenvScope preserves a declaration's context while Taskfiles are merged. +type DotenvScope struct { + Namespace string + Files []string + Vars *Vars + Env *Vars + IncludeVars *Vars +} + +func (d *DotenvScope) DeepCopy() *DotenvScope { + if d == nil { + return nil + } + return &DotenvScope{ + Namespace: d.Namespace, + Files: deepcopy.Slice(d.Files), + Vars: d.Vars.DeepCopy(), + Env: d.Env.DeepCopy(), + IncludeVars: d.IncludeVars.DeepCopy(), + } +} diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 9465c77770..6c1d71accf 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -49,6 +49,8 @@ type Task struct { Namespace string `hash:"ignore"` IncludeVars *Vars IncludedTaskfileVars *Vars + DotenvScope *DotenvScope + IncludedDotenvEnv *Vars FullName string `hash:"ignore"` } @@ -246,6 +248,8 @@ func (t *Task) DeepCopy() *Task { Run: t.Run, IncludeVars: t.IncludeVars.DeepCopy(), IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(), + DotenvScope: t.DotenvScope.DeepCopy(), + IncludedDotenvEnv: t.IncludedDotenvEnv.DeepCopy(), Platforms: deepcopy.Slice(t.Platforms), If: t.If, Location: t.Location.DeepCopy(), diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 20b4476cd5..964b2dfa04 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -15,9 +15,6 @@ const NamespaceSeparator = ":" var V3 = semver.MustParse("3") -// ErrIncludedTaskfilesCantHaveDotenvs is returned when a included Taskfile contains dotenvs -var ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles can't have dotenv declarations. Please, move the dotenv declaration to the main Taskfile") - // Taskfile is the abstract syntax tree for a Taskfile type Taskfile struct { Location string @@ -42,9 +39,6 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) 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) } - if len(t2.Dotenv) > 0 { - return ErrIncludedTaskfilesCantHaveDotenvs - } if t2.Output.IsSet() { t1.Output = t2.Output } diff --git a/taskfile/ast/tasks.go b/taskfile/ast/tasks.go index 6ce298fd92..ffa5462c79 100644 --- a/taskfile/ast/tasks.go +++ b/taskfile/ast/tasks.go @@ -125,6 +125,10 @@ func (t1 *Tasks) Merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars) // We do a deep copy of the task struct here to ensure that no data can // be changed elsewhere once the taskfile is merged. task := v.DeepCopy() + if scope := task.DotenvScope; scope != nil { + scope.Namespace = include.Namespace + NamespaceSeparator + scope.Namespace + scope.IncludeVars.ReverseMerge(include.Vars, nil) + } // Set the task to internal if EITHER the included task or the included // taskfile are marked as internal task.Internal = task.Internal || (include != nil && include.Internal) diff --git a/taskfile/dotenv.go b/taskfile/dotenv.go index a86a9eed1e..b10dd6b731 100644 --- a/taskfile/dotenv.go +++ b/taskfile/dotenv.go @@ -17,6 +17,9 @@ func Dotenv(vars *ast.Vars, tf *ast.Taskfile, dir string) (*ast.Vars, error) { for _, dotEnvPath := range tf.Dotenv { dotEnvPath = templater.Replace(dotEnvPath, cache) + if err := cache.Err(); err != nil { + return nil, err + } if dotEnvPath == "" { continue } diff --git a/taskfile/reader.go b/taskfile/reader.go index fc5d6d30af..cabacce8cc 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -431,6 +431,17 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) // Set the taskfile/task's locations tf.Location = node.Location() + // Preserve the included Taskfile's context before merging. Root dotenv + // keeps its existing global scope. + var dotenvScope *ast.DotenvScope + if node.Parent() != nil && len(tf.Dotenv) > 0 { + dotenvScope = &ast.DotenvScope{ + Files: tf.Dotenv, + Vars: tf.Vars.DeepCopy(), + Env: tf.Env.DeepCopy(), + IncludeVars: ast.NewVars(), + } + } for task := range tf.Tasks.Values(nil) { // If the task is not defined, create a new one if task == nil { @@ -440,6 +451,7 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) if task.Location.Taskfile == "" { task.Location.Taskfile = tf.Location } + task.DotenvScope = dotenvScope.DeepCopy() } return &tf, nil diff --git a/testdata/dotenv/included_dynamic_cache/Taskfile.yml b/testdata/dotenv/included_dynamic_cache/Taskfile.yml new file mode 100644 index 0000000000..e55ebc6807 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/Taskfile.yml @@ -0,0 +1,25 @@ +version: '3' + +vars: + CONFIG: + sh: 'echo global-evaluated >&2; echo config' + +includes: + a: + taskfile: included.yml + vars: + FLAVOR: a + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + b: + taskfile: included.yml + vars: + FLAVOR: b + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + +tasks: + default: + cmds: + - task: a:inspect + - task: b:inspect diff --git a/testdata/dotenv/included_dynamic_cache/config-a.env b/testdata/dotenv/included_dynamic_cache/config-a.env new file mode 100644 index 0000000000..17161747ee --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/config-a.env @@ -0,0 +1 @@ +DOTENV_CACHE_VALUE=a diff --git a/testdata/dotenv/included_dynamic_cache/config-b.env b/testdata/dotenv/included_dynamic_cache/config-b.env new file mode 100644 index 0000000000..6b5a17ac66 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/config-b.env @@ -0,0 +1 @@ +DOTENV_CACHE_VALUE=b diff --git a/testdata/dotenv/included_dynamic_cache/included.yml b/testdata/dotenv/included_dynamic_cache/included.yml new file mode 100644 index 0000000000..3ded6d9d3e --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/included.yml @@ -0,0 +1,6 @@ +version: '3' + +dotenv: ['{{.CONFIG}}-{{.LOCAL}}.env'] + +tasks: + inspect: 'echo {{.DOTENV_CACHE_VALUE}}' diff --git a/testdata/dotenv/included_dynamic_cache/root.env b/testdata/dotenv/included_dynamic_cache/root.env new file mode 100644 index 0000000000..3ae2a1dc98 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/root.env @@ -0,0 +1 @@ +DOTENV_CACHE_ROOT=root diff --git a/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-Taskfile.yml.golden b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-Taskfile.yml.golden new file mode 100644 index 0000000000..1495832f13 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-Taskfile.yml.golden @@ -0,0 +1,5 @@ +a +a-evaluated +b +b-evaluated +global-evaluated diff --git a/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-with-root.yml.golden b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-with-root.yml.golden new file mode 100644 index 0000000000..1495832f13 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-with-root.yml.golden @@ -0,0 +1,5 @@ +a +a-evaluated +b +b-evaluated +global-evaluated diff --git a/testdata/dotenv/included_dynamic_cache/with-root.yml b/testdata/dotenv/included_dynamic_cache/with-root.yml new file mode 100644 index 0000000000..41691ffb21 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/with-root.yml @@ -0,0 +1,27 @@ +version: '3' + +dotenv: [root.env] + +vars: + CONFIG: + sh: 'echo global-evaluated >&2; echo config' + +includes: + a: + taskfile: included.yml + vars: + FLAVOR: a + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + b: + taskfile: included.yml + vars: + FLAVOR: b + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + +tasks: + default: + cmds: + - task: a:inspect + - task: b:inspect diff --git a/testdata/dotenv/included_errors/broken.env b/testdata/dotenv/included_errors/broken.env new file mode 100644 index 0000000000..ccee1ea8ee --- /dev/null +++ b/testdata/dotenv/included_errors/broken.env @@ -0,0 +1 @@ +INVALID="unterminated \ No newline at end of file diff --git a/testdata/dotenv/included_errors/parse-root.yml b/testdata/dotenv/included_errors/parse-root.yml new file mode 100644 index 0000000000..69255d339a --- /dev/null +++ b/testdata/dotenv/included_errors/parse-root.yml @@ -0,0 +1,4 @@ +version: '3' + +includes: + app: parse.yml diff --git a/testdata/dotenv/included_errors/parse.yml b/testdata/dotenv/included_errors/parse.yml new file mode 100644 index 0000000000..b83ccc1a75 --- /dev/null +++ b/testdata/dotenv/included_errors/parse.yml @@ -0,0 +1,6 @@ +version: '3' + +dotenv: [broken.env] + +tasks: + default: echo test diff --git a/testdata/dotenv/included_errors/template-root.yml b/testdata/dotenv/included_errors/template-root.yml new file mode 100644 index 0000000000..2fa170c71a --- /dev/null +++ b/testdata/dotenv/included_errors/template-root.yml @@ -0,0 +1,4 @@ +version: '3' + +includes: + app: template.yml diff --git a/testdata/dotenv/included_errors/template.yml b/testdata/dotenv/included_errors/template.yml new file mode 100644 index 0000000000..72177a2520 --- /dev/null +++ b/testdata/dotenv/included_errors/template.yml @@ -0,0 +1,6 @@ +version: '3' + +dotenv: ['{{fail "invalid dotenv path"}}'] + +tasks: + default: echo test diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse-err-setup.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse-err-setup.golden new file mode 100644 index 0000000000..ee4f679b1a --- /dev/null +++ b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse-err-setup.golden @@ -0,0 +1 @@ +error reading env file {{.TEST_DIR}}/testdata/dotenv/included_errors/broken.env: unterminated quoted value "unterminated \ No newline at end of file diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template-err-setup.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template-err-setup.golden new file mode 100644 index 0000000000..464e72565a --- /dev/null +++ b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template-err-setup.golden @@ -0,0 +1 @@ +template: :1:2: executing "" at : error calling fail: invalid dotenv path \ No newline at end of file diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/dotenv/included_scopes/Taskfile.yml b/testdata/dotenv/included_scopes/Taskfile.yml new file mode 100644 index 0000000000..280721853c --- /dev/null +++ b/testdata/dotenv/included_scopes/Taskfile.yml @@ -0,0 +1,23 @@ +version: '3' +dotenv: [root.env] +env: + DOTENV_EXPLICIT: explicit +includes: + app: + taskfile: app/Taskfile.yml + dir: work + sibling: sibling/Taskfile.yml + a: + taskfile: shared/Taskfile.yml + vars: {FLAVOR: a} + b: + taskfile: shared/Taskfile.yml + vars: {FLAVOR: b} + flat: + taskfile: flat/Taskfile.yml + flatten: true +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/app/Taskfile.yml b/testdata/dotenv/included_scopes/app/Taskfile.yml new file mode 100644 index 0000000000..0ef7f95c9e --- /dev/null +++ b/testdata/dotenv/included_scopes/app/Taskfile.yml @@ -0,0 +1,20 @@ +version: '3' +vars: + CONFIG: config +env: + CONFIG_ENV: config +dotenv: [override.env, '{{.CONFIG}}/base.env', '{{.CONFIG_ENV}}/env-path.env', missing.env] +includes: + child: child/Taskfile.yml + common: ../common/Taskfile.yml +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' + task-env: + env: {DOTENV_CHOICE: task} + cmds: ['echo "$DOTENV_CHOICE"', 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"'] + task-dotenv: + dotenv: ['{{.TASKFILE_DIR}}/task.env'] + cmds: ['echo "$DOTENV_CHOICE"', 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"'] diff --git a/testdata/dotenv/included_scopes/app/child/Taskfile.yml b/testdata/dotenv/included_scopes/app/child/Taskfile.yml new file mode 100644 index 0000000000..2d2f377890 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/child/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' +dotenv: [child.env] +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/app/child/child.env b/testdata/dotenv/included_scopes/app/child/child.env new file mode 100644 index 0000000000..9a54ccf0cf --- /dev/null +++ b/testdata/dotenv/included_scopes/app/child/child.env @@ -0,0 +1 @@ +DOTENV_CHOICE=child diff --git a/testdata/dotenv/included_scopes/app/config/base.env b/testdata/dotenv/included_scopes/app/config/base.env new file mode 100644 index 0000000000..12aa8dbb68 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/config/base.env @@ -0,0 +1,2 @@ +DOTENV_CHOICE=base +DOTENV_BASE=base diff --git a/testdata/dotenv/included_scopes/app/config/env-path.env b/testdata/dotenv/included_scopes/app/config/env-path.env new file mode 100644 index 0000000000..9083a19127 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/config/env-path.env @@ -0,0 +1 @@ +DOTENV_ENV_PATH=env-path diff --git a/testdata/dotenv/included_scopes/app/override.env b/testdata/dotenv/included_scopes/app/override.env new file mode 100644 index 0000000000..00c4cddc84 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/override.env @@ -0,0 +1,3 @@ +DOTENV_CHOICE=app +DOTENV_APP=app +DOTENV_EXPLICIT=dotenv diff --git a/testdata/dotenv/included_scopes/app/task.env b/testdata/dotenv/included_scopes/app/task.env new file mode 100644 index 0000000000..3a307bee53 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/task.env @@ -0,0 +1 @@ +DOTENV_CHOICE=task-dotenv diff --git a/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone.golden b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone.golden new file mode 100644 index 0000000000..53d31b5f9f --- /dev/null +++ b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=dotenv root= base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone_root_remains_global.golden b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone_root_remains_global.golden new file mode 100644 index 0000000000..53d31b5f9f --- /dev/null +++ b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone_root_remains_global.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=dotenv root= base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/common/Taskfile.yml b/testdata/dotenv/included_scopes/common/Taskfile.yml new file mode 100644 index 0000000000..98931c832f --- /dev/null +++ b/testdata/dotenv/included_scopes/common/Taskfile.yml @@ -0,0 +1,6 @@ +version: '3' +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE | default ""}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/flat/Taskfile.yml b/testdata/dotenv/included_scopes/flat/Taskfile.yml new file mode 100644 index 0000000000..b3360f9624 --- /dev/null +++ b/testdata/dotenv/included_scopes/flat/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' +dotenv: ['{{.TASKFILE_DIR}}/flat.env'] +tasks: + flattened: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/flat/flat.env b/testdata/dotenv/included_scopes/flat/flat.env new file mode 100644 index 0000000000..81bbaeb803 --- /dev/null +++ b/testdata/dotenv/included_scopes/flat/flat.env @@ -0,0 +1 @@ +DOTENV_CHOICE=flat diff --git a/testdata/dotenv/included_scopes/root.env b/testdata/dotenv/included_scopes/root.env new file mode 100644 index 0000000000..3f00fde7a8 --- /dev/null +++ b/testdata/dotenv/included_scopes/root.env @@ -0,0 +1,2 @@ +DOTENV_CHOICE=root +DOTENV_ROOT=root diff --git a/testdata/dotenv/included_scopes/shared/Taskfile.yml b/testdata/dotenv/included_scopes/shared/Taskfile.yml new file mode 100644 index 0000000000..be73751b0e --- /dev/null +++ b/testdata/dotenv/included_scopes/shared/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' +dotenv: ['{{.FLAVOR}}.env', '{{.FLAVOR}}.env'] +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/shared/a.env b/testdata/dotenv/included_scopes/shared/a.env new file mode 100644 index 0000000000..00ff1dde99 --- /dev/null +++ b/testdata/dotenv/included_scopes/shared/a.env @@ -0,0 +1 @@ +DOTENV_CHOICE=a diff --git a/testdata/dotenv/included_scopes/shared/b.env b/testdata/dotenv/included_scopes/shared/b.env new file mode 100644 index 0000000000..1659922416 --- /dev/null +++ b/testdata/dotenv/included_scopes/shared/b.env @@ -0,0 +1 @@ +DOTENV_CHOICE=b diff --git a/testdata/dotenv/included_scopes/sibling/Taskfile.yml b/testdata/dotenv/included_scopes/sibling/Taskfile.yml new file mode 100644 index 0000000000..d431b06bb7 --- /dev/null +++ b/testdata/dotenv/included_scopes/sibling/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' +env: + CONFIG_ENV: nonexistent +dotenv: [sibling.env] +includes: + common: ../common/Taskfile.yml +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/sibling/sibling.env b/testdata/dotenv/included_scopes/sibling/sibling.env new file mode 100644 index 0000000000..5bdb48f1aa --- /dev/null +++ b/testdata/dotenv/included_scopes/sibling/sibling.env @@ -0,0 +1 @@ +DOTENV_CHOICE=sibling diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_only_inherits_root.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_only_inherits_root.golden new file mode 100644 index 0000000000..59ae49405c --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_only_inherits_root.golden @@ -0,0 +1,2 @@ +root|root| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_via_another_parent.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_via_another_parent.golden new file mode 100644 index 0000000000..59ae49405c --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_via_another_parent.golden @@ -0,0 +1,2 @@ +root|root| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-flatten.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-flatten.golden new file mode 100644 index 0000000000..b9dc53cf07 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-flatten.golden @@ -0,0 +1,2 @@ +flat|flat| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_directory_and_file_order.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_directory_and_file_order.golden new file mode 100644 index 0000000000..e06354d173 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_directory_and_file_order.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=explicit root=root base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_a.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_a.golden new file mode 100644 index 0000000000..c1eeeda173 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_a.golden @@ -0,0 +1,2 @@ +a|a| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_b.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_b.golden new file mode 100644 index 0000000000..79d487c3df --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_b.golden @@ -0,0 +1,2 @@ +b|b| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-nested_dotenv_is_independent.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-nested_dotenv_is_independent.golden new file mode 100644 index 0000000000..98913f7136 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-nested_dotenv_is_independent.golden @@ -0,0 +1,2 @@ +child|child| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-no_dotenv_inherited_without_root.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-no_dotenv_inherited_without_root.golden new file mode 100644 index 0000000000..88cfd6c5e0 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-no_dotenv_inherited_without_root.golden @@ -0,0 +1,2 @@ +|| +explicit= root= base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-root_isolation.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-root_isolation.golden new file mode 100644 index 0000000000..59ae49405c --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-root_isolation.golden @@ -0,0 +1,2 @@ +root|root| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-sibling_isolation.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-sibling_isolation.golden new file mode 100644 index 0000000000..76cd2d25f1 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-sibling_isolation.golden @@ -0,0 +1,2 @@ +sibling|sibling| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_dotenv_priority.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_dotenv_priority.golden new file mode 100644 index 0000000000..2bb2381c66 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_dotenv_priority.golden @@ -0,0 +1,2 @@ +task-dotenv +explicit=explicit root=root base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_env_priority.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_env_priority.golden new file mode 100644 index 0000000000..86d2bf47a0 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_env_priority.golden @@ -0,0 +1,2 @@ +task +explicit=explicit root=root base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-without_root_dotenv.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-without_root_dotenv.golden new file mode 100644 index 0000000000..53d31b5f9f --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-without_root_dotenv.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=dotenv root= base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/without-root.yml b/testdata/dotenv/included_scopes/without-root.yml new file mode 100644 index 0000000000..6b3b9d85fb --- /dev/null +++ b/testdata/dotenv/included_scopes/without-root.yml @@ -0,0 +1,3 @@ +version: '3' +includes: + app: app/Taskfile.yml diff --git a/testdata/dotenv/included_scopes/work/override.env b/testdata/dotenv/included_scopes/work/override.env new file mode 100644 index 0000000000..a9eb0c992e --- /dev/null +++ b/testdata/dotenv/included_scopes/work/override.env @@ -0,0 +1 @@ +DOTENV_CHOICE=wrong-directory diff --git a/variables.go b/variables.go index c2085bd1ea..532bad07f9 100644 --- a/variables.go +++ b/variables.go @@ -191,6 +191,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err new.Env = ast.NewVars() new.Env.Merge(templater.ReplaceVars(e.Taskfile.Env, cache), nil) + new.Env.Merge(templater.ReplaceVars(origTask.IncludedDotenvEnv, cache), nil) new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil) new.Env.Merge(templater.ReplaceVars(origTask.Env, cache), nil) if evaluateShVars { diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index f9243013d7..854232b149 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -259,8 +259,22 @@ tasks: ::: info -Please note that you are not currently able to use the `dotenv` key inside -included Taskfiles. +Included Taskfiles can also declare `dotenv`. These variables are available only +to tasks defined in that Taskfile. Nested includes use their own dotenv values. +For example, a common Taskfile included by an application does not inherit the +application's dotenv values. The root Taskfile's dotenv remains available to all +tasks for compatibility. + +Relative paths are resolved from the Taskfile declaring `dotenv`, independently +of the include's `dir`. Path templates can use that Taskfile's variables and the +variables supplied by its include. Remote Taskfiles read dotenv files locally +relative to the root execution directory. + +An included Taskfile's dotenv values take precedence over the root's values for +its own tasks, and the first file in each list takes precedence. Explicit +Taskfile `env` values override dotenv values; task-level `dotenv` and `env` +retain their existing precedence. +The existing merge behavior of `vars` and `env` declarations is unchanged. :::