Compare commits

...

6 Commits

Author SHA1 Message Date
renovate[bot]
888a2c4626 chore(deps): update mvdan.cc/sh/v3 digest to 2255122 2026-06-30 20:39:38 +00:00
otjdiepluong
d601746d4b fix: handle nil Vars in ToCacheMap (#2842) 2026-06-30 22:32:07 +02:00
Valentin Maerten
e415d7135e fix(docs): wrap join example in v-pre to fix VitePress build
The inline `{{join " " .WORDS}}` example was parsed as a Vue
interpolation, which broke the website build. Wrap it in <span v-pre>
so it renders literally, matching the existing escaped example in the
same file.
2026-06-30 22:25:27 +02:00
Pete Davison
a61f8ade36 feat: update changelog for #2847 2026-06-29 18:27:59 +00:00
Valentin Maerten
9910c33557 fix(remote): define special variables behavior (#2847) 2026-06-29 19:25:25 +01:00
Markus
b93897a6c3 docs: clarify join argument order in templating reference (#2887)
Co-authored-by: Markus Stark <markusstark@MacBook-Air-von-Markus.local>
2026-06-29 17:08:44 +02:00
13 changed files with 255 additions and 17 deletions

View File

@@ -10,11 +10,11 @@
by @Legimity).
- PowerShell completions now work with aliases of the `task` command, not just
the `task` binary itself (#2852 by @kojiishi).
- Fixed task and namespace aliases not being completed by the Zsh completion.
A `show-aliases` zstyle can turn this off (#2865, #2864 by @vmaerten).
- Fixed task and namespace aliases not being completed by the Zsh completion. A
`show-aliases` zstyle can turn this off (#2865, #2864 by @vmaerten).
- Fixed task names containing certain characters (e.g. `\`, `_`, `^`) leaking
into checksum/timestamp filenames, breaking `sources:`/`generates:`
up-to-date detection (#2886 by @s3onghyun).
into checksum/timestamp filenames, breaking `sources:`/`generates:` up-to-date
detection (#2886 by @s3onghyun).
- Fixed `for: matrix:` loops using `ref:` rows producing wrong values when the
same task was run concurrently (e.g. by parallel `deps`) with different vars
(#2890, #2894 by @amitmishra11).
@@ -23,13 +23,15 @@
- Added the `use_gitignore` setting (global or per-task) to skip files matched
by your `.gitignore` when fingerprinting `sources`/`generates` and when
watching (#2773 by @vmaerten).
- Added support for configuring output flags (`--output`, `--output-group-begin`,
`--output-group-end`, `--output-group-error-only`) via the `TASK_OUTPUT*`
environment variables (#2873 by @liiight).
- Added support for configuring output flags (`--output`,
`--output-group-begin`, `--output-group-end`, `--output-group-error-only`) via
the `TASK_OUTPUT*` environment variables (#2873 by @liiight).
- Added a `--temp-dir` flag (with `TASK_TEMP_DIR` env var and `temp-dir` taskrc
config) to customise the directory where Task stores temporary files such as
checksums. Relative paths are resolved against the root Taskfile (#2891 by
@kjasn).
- Defined environment variable behavior for remote taskfiles (#2267, #2847 by
@vmaerten).
## v3.51.1 - 2026-05-16

View File

@@ -15,6 +15,7 @@ import (
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/internal/version"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/taskfile/ast"
)
@@ -206,10 +207,19 @@ func (c *Compiler) getSpecialVars(t *ast.Task, call *Call) (map[string]string, e
// Use filepath.ToSlash for all paths to ensure consistent forward slashes
// across platforms. This prevents issues with backslashes being interpreted
// as escape sequences when paths are used in shell commands on Windows.
var rootTaskfile, rootDir string
if taskfile.IsRemoteEntrypoint(c.Entrypoint) {
rootTaskfile = c.Entrypoint
rootDir = ""
} else {
rootTaskfile = filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint))
rootDir = filepath.ToSlash(c.Dir)
}
allVars := map[string]string{
"TASK_EXE": filepath.ToSlash(os.Args[0]),
"ROOT_TASKFILE": filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint)),
"ROOT_DIR": filepath.ToSlash(c.Dir),
"ROOT_TASKFILE": rootTaskfile,
"ROOT_DIR": rootDir,
"USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir),
"TASK_VERSION": version.GetVersion(),
"PATH_LIST_SEPARATOR": string(os.PathListSeparator),
@@ -217,9 +227,22 @@ func (c *Compiler) getSpecialVars(t *ast.Task, call *Call) (map[string]string, e
}
if t != nil {
allVars["TASK"] = t.Task
allVars["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.Dir, t.Dir))
allVars["TASKFILE"] = filepath.ToSlash(t.Location.Taskfile)
allVars["TASKFILE_DIR"] = filepath.ToSlash(filepath.Dir(t.Location.Taskfile))
if taskfile.IsRemoteEntrypoint(t.Location.Taskfile) {
allVars["TASKFILE"] = t.Location.Taskfile
allVars["TASKFILE_DIR"] = ""
switch {
case t.Dir == "":
allVars["TASK_DIR"] = filepath.ToSlash(c.UserWorkingDir)
case filepath.IsAbs(t.Dir):
allVars["TASK_DIR"] = filepath.ToSlash(t.Dir)
default:
allVars["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.UserWorkingDir, t.Dir))
}
} else {
allVars["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.Dir, t.Dir))
allVars["TASKFILE"] = filepath.ToSlash(t.Location.Taskfile)
allVars["TASKFILE_DIR"] = filepath.ToSlash(filepath.Dir(t.Location.Taskfile))
}
} else {
allVars["TASK"] = ""
allVars["TASK_DIR"] = ""

135
compiler_internal_test.go Normal file
View File

@@ -0,0 +1,135 @@
package task
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/taskfile/ast"
)
func TestGetSpecialVarsRemote(t *testing.T) {
t.Parallel()
uwd := t.TempDir()
uwdSlash := filepath.ToSlash(uwd)
localProj := filepath.Join(uwd, "proj")
localProjSlash := filepath.ToSlash(localProj)
localTaskfile := filepath.Join(localProj, "Taskfile.yml")
localTaskfileSlash := filepath.ToSlash(localTaskfile)
absTaskDir := filepath.Join(uwd, "opt", "work")
absTaskDirSlash := filepath.ToSlash(absTaskDir)
tests := []struct {
name string
entrypoint string
compilerDir string
taskDir string
taskfileLocation string
wantRootTaskfile string
wantRootDir string
wantTaskfile string
wantTaskfileDir string
wantTaskDir string
}{
{
name: "local entrypoint, local task",
entrypoint: localTaskfile,
compilerDir: localProj,
taskDir: "",
taskfileLocation: localTaskfile,
wantRootTaskfile: localTaskfileSlash,
wantRootDir: localProjSlash,
wantTaskfile: localTaskfileSlash,
wantTaskfileDir: localProjSlash,
wantTaskDir: localProjSlash,
},
{
name: "https entrypoint, empty task.dir",
entrypoint: "https://taskfile.dev/Taskfile.yml",
compilerDir: "",
taskDir: "",
taskfileLocation: "https://taskfile.dev/Taskfile.yml",
wantRootTaskfile: "https://taskfile.dev/Taskfile.yml",
wantRootDir: "",
wantTaskfile: "https://taskfile.dev/Taskfile.yml",
wantTaskfileDir: "",
wantTaskDir: uwdSlash,
},
{
name: "https entrypoint, relative task.dir",
entrypoint: "https://taskfile.dev/Taskfile.yml",
compilerDir: "",
taskDir: "subdir",
taskfileLocation: "https://taskfile.dev/Taskfile.yml",
wantRootTaskfile: "https://taskfile.dev/Taskfile.yml",
wantRootDir: "",
wantTaskfile: "https://taskfile.dev/Taskfile.yml",
wantTaskfileDir: "",
wantTaskDir: filepath.ToSlash(filepathext.SmartJoin(uwd, "subdir")),
},
{
name: "https entrypoint, absolute task.dir",
entrypoint: "https://taskfile.dev/Taskfile.yml",
compilerDir: "",
taskDir: absTaskDir,
taskfileLocation: "https://taskfile.dev/Taskfile.yml",
wantRootTaskfile: "https://taskfile.dev/Taskfile.yml",
wantRootDir: "",
wantTaskfile: "https://taskfile.dev/Taskfile.yml",
wantTaskfileDir: "",
wantTaskDir: absTaskDirSlash,
},
{
name: "git entrypoint",
entrypoint: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
compilerDir: "",
taskDir: "",
taskfileLocation: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
wantRootTaskfile: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
wantRootDir: "",
wantTaskfile: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
wantTaskfileDir: "",
wantTaskDir: uwdSlash,
},
{
name: "local root, remote included task",
entrypoint: localTaskfile,
compilerDir: localProj,
taskDir: "",
taskfileLocation: "https://taskfile.dev/included.yml",
wantRootTaskfile: localTaskfileSlash,
wantRootDir: localProjSlash,
wantTaskfile: "https://taskfile.dev/included.yml",
wantTaskfileDir: "",
wantTaskDir: uwdSlash,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := &Compiler{
Dir: tt.compilerDir,
Entrypoint: tt.entrypoint,
UserWorkingDir: uwd,
}
task := &ast.Task{
Task: "mytask",
Dir: tt.taskDir,
Location: &ast.Location{Taskfile: tt.taskfileLocation},
}
vars, err := c.getSpecialVars(task, nil)
assert.NoError(t, err)
assert.Equal(t, tt.wantRootTaskfile, vars["ROOT_TASKFILE"], "ROOT_TASKFILE")
assert.Equal(t, tt.wantRootDir, vars["ROOT_DIR"], "ROOT_DIR")
assert.Equal(t, tt.wantTaskfile, vars["TASKFILE"], "TASKFILE")
assert.Equal(t, tt.wantTaskfileDir, vars["TASKFILE_DIR"], "TASKFILE_DIR")
assert.Equal(t, tt.wantTaskDir, vars["TASK_DIR"], "TASK_DIR")
})
}
}

2
go.mod
View File

@@ -31,7 +31,7 @@ require (
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997
mvdan.cc/sh/v3 v3.13.2-0.20260510185049-f5c6e2779117
mvdan.cc/sh/v3 v3.13.2-0.20260613075524-2255122b577b
)
require (

2
go.sum
View File

@@ -333,3 +333,5 @@ mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 h1:3bbJwtPFh98dJ6lxRdR
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997/go.mod h1:Qy/zdaMDxq9sT72Gi43K3gsV+TtTohyDO3f1cyBVwuo=
mvdan.cc/sh/v3 v3.13.2-0.20260510185049-f5c6e2779117 h1:BfzdGSjcnJBb8sPNLudpzTml8zFUxS1N0N/v9IIS6tQ=
mvdan.cc/sh/v3 v3.13.2-0.20260510185049-f5c6e2779117/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
mvdan.cc/sh/v3 v3.13.2-0.20260613075524-2255122b577b h1:NREoadYF42Gu7127VIccx/SRia+Bz8wpKBaqmXKiGXE=
mvdan.cc/sh/v3 v3.13.2-0.20260613075524-2255122b577b/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=

View File

@@ -98,6 +98,9 @@ func (vars *Vars) Values() iter.Seq[Var] {
// ToCacheMap converts Vars to an unordered map containing only the static
// variables
func (vars *Vars) ToCacheMap() (m map[string]any) {
if vars == nil || vars.om == nil {
return nil
}
defer vars.mutex.RUnlock()
vars.mutex.RLock()
m = make(map[string]any, vars.Len())

55
taskfile/ast/vars_test.go Normal file
View File

@@ -0,0 +1,55 @@
package ast
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestVars_ToCacheMap(t *testing.T) {
t.Parallel()
t.Run("nil receiver returns nil", func(t *testing.T) {
t.Parallel()
var vars *Vars
assert.Nil(t, vars.ToCacheMap())
})
t.Run("empty vars returns empty map", func(t *testing.T) {
t.Parallel()
vars := NewVars()
m := vars.ToCacheMap()
assert.NotNil(t, m)
assert.Empty(t, m)
})
t.Run("static values are included", func(t *testing.T) {
t.Parallel()
vars := NewVars(
&VarElement{Key: "FOO", Value: Var{Value: "bar"}},
&VarElement{Key: "NUM", Value: Var{Value: 42}},
)
m := vars.ToCacheMap()
assert.Equal(t, map[string]any{"FOO": "bar", "NUM": 42}, m)
})
t.Run("live values take precedence over static values", func(t *testing.T) {
t.Parallel()
vars := NewVars(
&VarElement{Key: "FOO", Value: Var{Value: "bar", Live: "live-bar"}},
)
m := vars.ToCacheMap()
assert.Equal(t, map[string]any{"FOO": "live-bar"}, m)
})
t.Run("dynamic variables are excluded", func(t *testing.T) {
t.Parallel()
sh := "echo hello"
vars := NewVars(
&VarElement{Key: "STATIC", Value: Var{Value: "ok"}},
&VarElement{Key: "DYNAMIC", Value: Var{Sh: &sh}},
)
m := vars.ToCacheMap()
assert.Equal(t, map[string]any{"STATIC": "ok"}, m)
})
}

View File

@@ -73,7 +73,7 @@ func NewNode(
return node, err
}
func isRemoteEntrypoint(entrypoint string) bool {
func IsRemoteEntrypoint(entrypoint string) bool {
scheme, _ := getScheme(entrypoint)
switch scheme {
case "git", "http", "https":

View File

@@ -60,7 +60,7 @@ func (node *FileNode) Read() ([]byte, error) {
func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path
if isRemoteEntrypoint(entrypoint) {
if IsRemoteEntrypoint(entrypoint) {
return entrypoint, nil
}

View File

@@ -188,7 +188,7 @@ func (node *GitNode) ReadContext(ctx context.Context) ([]byte, error) {
func (node *GitNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path
if isRemoteEntrypoint(entrypoint) {
if IsRemoteEntrypoint(entrypoint) {
return entrypoint, nil
}

View File

@@ -42,7 +42,7 @@ func (node *StdinNode) Read() ([]byte, error) {
func (node *StdinNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path
if isRemoteEntrypoint(entrypoint) {
if IsRemoteEntrypoint(entrypoint) {
return entrypoint, nil
}

View File

@@ -190,6 +190,21 @@ includes:
my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml
```
## Special Variables
The file-path [special variables](../reference/templating.md#file-paths) behave
differently when a Taskfile is loaded from a remote source, because there is no
local file or directory that corresponds 1:1 to the Taskfile:
| Variable | Value when loaded remotely |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `TASKFILE` / `ROOT_TASKFILE` | The original URL, unchanged |
| `TASKFILE_DIR` / `ROOT_DIR` | Empty string — a directory variable cannot point to a URL |
| `TASK_DIR` | Resolved against `USER_WORKING_DIR` (relative `dir:` → joined with `USER_WORKING_DIR`, empty `dir:` → `USER_WORKING_DIR`, absolute `dir:` → kept as-is) |
If a remote Taskfile includes a local Taskfile (or vice-versa), each variable
reflects the source of the Taskfile it refers to.
## Security
### Automatic checksums

View File

@@ -565,6 +565,9 @@ tasks:
- echo "{{.MULTILINE | catLines}}" # Replace newlines with spaces
```
In pipeline form, `join` receives the list from the left-hand side. The
equivalent non-pipeline form is <span v-pre>`{{join " " .WORDS}}`</span>.
#### Shell Argument Parsing
```yaml