diff --git a/CHANGELOG.md b/CHANGELOG.md index 719d3414..7e66853e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add task-level `dotenv` support + ([#389](https://github.com/go-task/task/issues/389), [#904](https://github.com/go-task/task/pull/904)). - It's now possible to use global level variables on `includes` ([#942](https://github.com/go-task/task/issues/942), [#943](https://github.com/go-task/task/pull/943)). - The website got a brand new [translation to Chinese](https://task-zh.readthedocs.io/zh_CN/latest/) diff --git a/docs/docs/api_reference.md b/docs/docs/api_reference.md index 19a476b1..a356085b 100644 --- a/docs/docs/api_reference.md +++ b/docs/docs/api_reference.md @@ -130,6 +130,7 @@ includes: | `dir` | `string` | | The directory in which this task should run. Defaults to the current working directory. | | `vars` | [`map[string]Variable`](#variable) | | A set of variables that can be used in the task. | | `env` | [`map[string]Variable`](#variable) | | A set of environment variables that will be made available to shell commands. | +| `dotenv` | `[]string` | | A list of `.env` file paths to be parsed. | | `silent` | `bool` | `false` | Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`. When combined with the `--list` flag, task descriptions will be hidden. | | `interactive` | `bool` | `false` | Tells task that the command is interactive. | | `internal` | `bool` | `false` | Stops a task from being callable on the command line. It will also be omitted from the output when used with `--list`. | diff --git a/docs/docs/usage.md b/docs/docs/usage.md index c58a5119..dd7b5eb6 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -118,6 +118,45 @@ tasks: - echo "Using $KEYNAME and endpoint $ENDPOINT" ``` +Dotenv files can also be specified at the task level: + +```yaml +version: '3' + +env: + ENV: testing + +tasks: + greet: + dotenv: ['.env', '{{.ENV}}/.env.', '{{.HOME}}/.env'] + cmds: + - echo "Using $KEYNAME and endpoint $ENDPOINT" +``` + +Environment variables specified explicitly at the task-level will override +variables defined in dotfiles: + +```yaml +version: '3' + +env: + ENV: testing + +tasks: + greet: + dotenv: ['.env', '{{.ENV}}/.env.', '{{.HOME}}/.env'] + env: + KEYNAME: DIFFERENT_VALUE + cmds: + - echo "Using $KEYNAME and endpoint $ENDPOINT" +``` + +:::info + +Please note that you are not currently able to use the `dotenv` key inside included Taskfiles. + +::: + ## Including other Taskfiles If you want to share tasks between different projects (Taskfiles), you can use diff --git a/docs/static/schema.json b/docs/static/schema.json index f0e52b89..5a40b8ed 100644 --- a/docs/static/schema.json +++ b/docs/static/schema.json @@ -116,6 +116,13 @@ "description": "A set of environment variables that will be made available to shell commands.", "$ref": "#/definitions/3/env" }, + "dotenv": { + "description": "A list of `.env` file paths to be parsed.", + "type": "array", + "items": { + "type": "string" + } + }, "silent": { "description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`. When combined with the `--list` flag, task descriptions will be hidden.", "type": "boolean", diff --git a/task_test.go b/task_test.go index 27cc6aab..8132a00c 100644 --- a/task_test.go +++ b/task_test.go @@ -1410,6 +1410,54 @@ func TestDotenvHasEnvVarInPath(t *testing.T) { tt.Run(t) } +func TestTaskDotenv(t *testing.T) { + tt := fileContentTest{ + Dir: "testdata/dotenv_task/default", + Target: "dotenv", + TrimSpace: true, + Files: map[string]string{ + "dotenv.txt": "foo", + }, + } + tt.Run(t) +} + +func TestTaskDotenvFail(t *testing.T) { + tt := fileContentTest{ + Dir: "testdata/dotenv_task/default", + Target: "no-dotenv", + TrimSpace: true, + Files: map[string]string{ + "no-dotenv.txt": "global", + }, + } + tt.Run(t) +} + +func TestTaskDotenvOverriddenByEnv(t *testing.T) { + tt := fileContentTest{ + Dir: "testdata/dotenv_task/default", + Target: "dotenv-overridden-by-env", + TrimSpace: true, + Files: map[string]string{ + "dotenv-overridden-by-env.txt": "overridden", + }, + } + tt.Run(t) +} + +func TestTaskDotenvWithVarName(t *testing.T) { + tt := fileContentTest{ + Dir: "testdata/dotenv_task/default", + Target: "dotenv-with-var-name", + TrimSpace: true, + Files: map[string]string{ + "dotenv-with-var-name.txt": "foo", + }, + } + tt.Run(t) +} + func TestExitImmediately(t *testing.T) { const dir = "testdata/exit_immediately" diff --git a/taskfile/task.go b/taskfile/task.go index fd5c3937..581253a3 100644 --- a/taskfile/task.go +++ b/taskfile/task.go @@ -19,6 +19,7 @@ type Task struct { Dir string Vars *Vars Env *Vars + Dotenv []string Silent bool Interactive bool Internal bool @@ -65,6 +66,7 @@ func (t *Task) UnmarshalYAML(unmarshal func(interface{}) error) error { Dir string Vars *Vars Env *Vars + Dotenv []string Silent bool Interactive bool Internal bool @@ -89,6 +91,7 @@ func (t *Task) UnmarshalYAML(unmarshal func(interface{}) error) error { t.Dir = task.Dir t.Vars = task.Vars t.Env = task.Env + t.Dotenv = task.Dotenv t.Silent = task.Silent t.Interactive = task.Interactive t.Internal = task.Internal @@ -117,6 +120,7 @@ func (t *Task) DeepCopy() *Task { Dir: t.Dir, Vars: t.Vars.DeepCopy(), Env: t.Env.DeepCopy(), + Dotenv: deepCopySlice(t.Dotenv), Silent: t.Silent, Interactive: t.Interactive, Internal: t.Internal, diff --git a/testdata/dotenv_task/default/.env b/testdata/dotenv_task/default/.env new file mode 100644 index 00000000..2afc0350 --- /dev/null +++ b/testdata/dotenv_task/default/.env @@ -0,0 +1 @@ +FOO=foo diff --git a/testdata/dotenv_task/default/.gitignore b/testdata/dotenv_task/default/.gitignore new file mode 100644 index 00000000..2211df63 --- /dev/null +++ b/testdata/dotenv_task/default/.gitignore @@ -0,0 +1 @@ +*.txt diff --git a/testdata/dotenv_task/default/Taskfile.yml b/testdata/dotenv_task/default/Taskfile.yml new file mode 100644 index 00000000..3adbc5be --- /dev/null +++ b/testdata/dotenv_task/default/Taskfile.yml @@ -0,0 +1,28 @@ +version: '3' + +env: + FOO: global + +tasks: + dotenv: + dotenv: ['.env'] + cmds: + - echo "$FOO" > dotenv.txt + + dotenv-overridden-by-env: + dotenv: ['.env'] + env: + FOO: overridden + cmds: + - echo "$FOO" > dotenv-overridden-by-env.txt + + dotenv-with-var-name: + vars: + DOTENV: .env + dotenv: ['{{.DOTENV}}'] + cmds: + - echo "$FOO" > dotenv-with-var-name.txt + + no-dotenv: + cmds: + - echo "$FOO" > no-dotenv.txt diff --git a/variables.go b/variables.go index 872b81ed..ea41d1ac 100644 --- a/variables.go +++ b/variables.go @@ -1,8 +1,11 @@ package task import ( + "os" "strings" + "github.com/joho/godotenv" + "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/status" @@ -55,6 +58,7 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf Dir: r.Replace(origTask.Dir), Vars: nil, Env: nil, + Dotenv: r.ReplaceSlice(origTask.Dotenv), Silent: origTask.Silent, Interactive: origTask.Interactive, Internal: origTask.Internal, @@ -76,8 +80,28 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf new.Prefix = new.Task } + dotenvEnvs := &taskfile.Vars{} + if len(new.Dotenv) > 0 { + for _, dotEnvPath := range new.Dotenv { + dotEnvPath = filepathext.SmartJoin(new.Dir, dotEnvPath) + if _, err := os.Stat(dotEnvPath); os.IsNotExist(err) { + continue + } + envs, err := godotenv.Read(dotEnvPath) + if err != nil { + return nil, err + } + for key, value := range envs { + if _, ok := dotenvEnvs.Mapping[key]; !ok { + dotenvEnvs.Set(key, taskfile.Var{Static: value}) + } + } + } + } + new.Env = &taskfile.Vars{} new.Env.Merge(r.ReplaceVars(e.Taskfile.Env)) + new.Env.Merge(r.ReplaceVars(dotenvEnvs)) new.Env.Merge(r.ReplaceVars(origTask.Env)) if evaluateShVars { err = new.Env.Range(func(k string, v taskfile.Var) error {