Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
}
53 changes: 53 additions & 0 deletions setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 2 additions & 3 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions taskfile/ast/dotenv.go
Original file line number Diff line number Diff line change
@@ -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(),
}
}
4 changes: 4 additions & 0 deletions taskfile/ast/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type Task struct {
Namespace string `hash:"ignore"`
IncludeVars *Vars
IncludedTaskfileVars *Vars
DotenvScope *DotenvScope
IncludedDotenvEnv *Vars

FullName string `hash:"ignore"`
}
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 0 additions & 6 deletions taskfile/ast/taskfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions taskfile/ast/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions taskfile/dotenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions taskfile/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions testdata/dotenv/included_dynamic_cache/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions testdata/dotenv/included_dynamic_cache/config-a.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DOTENV_CACHE_VALUE=a
1 change: 1 addition & 0 deletions testdata/dotenv/included_dynamic_cache/config-b.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DOTENV_CACHE_VALUE=b
6 changes: 6 additions & 0 deletions testdata/dotenv/included_dynamic_cache/included.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: '3'

dotenv: ['{{.CONFIG}}-{{.LOCAL}}.env']

tasks:
inspect: 'echo {{.DOTENV_CACHE_VALUE}}'
1 change: 1 addition & 0 deletions testdata/dotenv/included_dynamic_cache/root.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DOTENV_CACHE_ROOT=root
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
a
a-evaluated
b
b-evaluated
global-evaluated
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
a
a-evaluated
b
b-evaluated
global-evaluated
27 changes: 27 additions & 0 deletions testdata/dotenv/included_dynamic_cache/with-root.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions testdata/dotenv/included_errors/broken.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
INVALID="unterminated
Loading