Compare commits

...

8 Commits

Author SHA1 Message Date
Valentin Maerten
cf9d6b73a5 feat(homebrew): migrate from Formula to Cask
Switch GoReleaser config from `brews` to `homebrew_casks` and update
documentation links to reference Casks/go-task.rb instead of
Formula/go-task.rb.
2026-06-30 23:12:39 +02:00
Valentin Maerten
48b215db0a ci: enable indirect Go deps and group action digest updates (#2902) 2026-06-30 21:01:24 +00:00
Valentin Maerten
0aa0496f85 ci: fix golangci-lint custom manager and enable OSV alerts in Renovate (#2901) 2026-06-30 20:50:31 +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
14 changed files with 275 additions and 29 deletions

17
.github/renovate.json vendored
View File

@@ -6,20 +6,31 @@
"schedule:weekly", "schedule:weekly",
":semanticCommitTypeAll(chore)" ":semanticCommitTypeAll(chore)"
], ],
"mode": "full",
"addLabels":["area: dependencies"], "addLabels":["area: dependencies"],
"osvVulnerabilityAlerts": true,
"postUpdateOptions": ["gomodTidy"],
"customManagers": [ "customManagers": [
{ {
"customType": "regex", "customType": "regex",
"fileMatch": ["^\\.github/workflows/.*\\.ya?ml$"], "managerFilePatterns": ["/^\\.github/workflows/.*\\.ya?ml$/"],
"matchStrings": [ "matchStrings": [
"uses:\\s*golangci/golangci-lint-action@\\S+\\s+with:\\s+version:\\s*(?<currentValue>v[\\d.]+)" "uses:\\s*golangci/golangci-lint-action@\\S+(?:\\s*#[^\\n]*)?\\s+with:\\s+version:\\s*(?<currentValue>v[\\d.]+)"
], ],
"datasourceTemplate": "github-releases", "datasourceTemplate": "github-releases",
"depNameTemplate": "golangci/golangci-lint" "depNameTemplate": "golangci/golangci-lint"
} }
], ],
"packageRules": [ "packageRules": [
{
"matchManagers": ["gomod"],
"matchDepTypes": ["indirect"],
"enabled": true
},
{
"matchUpdateTypes": ["digest", "pinDigest"],
"groupName": "all non-major dependencies",
"groupSlug": "all-minor-patch"
},
{ {
"matchManagers": ["github-actions"], "matchManagers": ["github-actions"],
"addLabels": ["area: github actions"] "addLabels": ["area: github actions"]

View File

@@ -80,22 +80,21 @@ nfpms:
- src: completion/zsh/_task - src: completion/zsh/_task
dst: /usr/local/share/zsh/site-functions/_task dst: /usr/local/share/zsh/site-functions/_task
brews: homebrew_casks:
- name: go-task - name: go-task
description: A fast, cross-platform build tool inspired by Make, designed for modern workflows. description: A fast, cross-platform build tool inspired by Make, designed for modern workflows.
license: MIT
homepage: https://taskfile.dev homepage: https://taskfile.dev
directory: Formula binaries:
- task
completions:
bash: completion/bash/task.bash
zsh: completion/zsh/_task
fish: completion/fish/task.fish
directory: Casks
repository: repository:
owner: go-task owner: go-task
name: homebrew-tap name: homebrew-tap
token: "{{.Env.GH_GORELEASER_TOKEN}}" # So that it runs as the task-bot user token: "{{.Env.GH_GORELEASER_TOKEN}}" # So that it runs as the task-bot user
test: system "#{bin}/task", "--help"
install: |-
bin.install "task"
bash_completion.install "completion/bash/task.bash" => "task"
zsh_completion.install "completion/zsh/_task" => "_task"
fish_completion.install "completion/fish/task.fish"
commit_author: commit_author:
name: task-bot name: task-bot
email: 106601941+task-bot@users.noreply.github.com email: 106601941+task-bot@users.noreply.github.com

View File

@@ -10,11 +10,11 @@
by @Legimity). by @Legimity).
- PowerShell completions now work with aliases of the `task` command, not just - PowerShell completions now work with aliases of the `task` command, not just
the `task` binary itself (#2852 by @kojiishi). the `task` binary itself (#2852 by @kojiishi).
- Fixed task and namespace aliases not being completed by the Zsh completion. - Fixed task and namespace aliases not being completed by the Zsh completion. A
A `show-aliases` zstyle can turn this off (#2865, #2864 by @vmaerten). `show-aliases` zstyle can turn this off (#2865, #2864 by @vmaerten).
- Fixed task names containing certain characters (e.g. `\`, `_`, `^`) leaking - Fixed task names containing certain characters (e.g. `\`, `_`, `^`) leaking
into checksum/timestamp filenames, breaking `sources:`/`generates:` into checksum/timestamp filenames, breaking `sources:`/`generates:` up-to-date
up-to-date detection (#2886 by @s3onghyun). detection (#2886 by @s3onghyun).
- Fixed `for: matrix:` loops using `ref:` rows producing wrong values when the - 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 same task was run concurrently (e.g. by parallel `deps`) with different vars
(#2890, #2894 by @amitmishra11). (#2890, #2894 by @amitmishra11).
@@ -23,13 +23,15 @@
- Added the `use_gitignore` setting (global or per-task) to skip files matched - Added the `use_gitignore` setting (global or per-task) to skip files matched
by your `.gitignore` when fingerprinting `sources`/`generates` and when by your `.gitignore` when fingerprinting `sources`/`generates` and when
watching (#2773 by @vmaerten). watching (#2773 by @vmaerten).
- Added support for configuring output flags (`--output`, `--output-group-begin`, - Added support for configuring output flags (`--output`,
`--output-group-end`, `--output-group-error-only`) via the `TASK_OUTPUT*` `--output-group-begin`, `--output-group-end`, `--output-group-error-only`) via
environment variables (#2873 by @liiight). the `TASK_OUTPUT*` environment variables (#2873 by @liiight).
- Added a `--temp-dir` flag (with `TASK_TEMP_DIR` env var and `temp-dir` taskrc - 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 config) to customise the directory where Task stores temporary files such as
checksums. Relative paths are resolved against the root Taskfile (#2891 by checksums. Relative paths are resolved against the root Taskfile (#2891 by
@kjasn). @kjasn).
- Defined environment variable behavior for remote taskfiles (#2267, #2847 by
@vmaerten).
## v3.51.1 - 2026-05-16 ## 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/logger"
"github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/internal/version" "github.com/go-task/task/v3/internal/version"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/taskfile/ast" "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 // Use filepath.ToSlash for all paths to ensure consistent forward slashes
// across platforms. This prevents issues with backslashes being interpreted // across platforms. This prevents issues with backslashes being interpreted
// as escape sequences when paths are used in shell commands on Windows. // 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{ allVars := map[string]string{
"TASK_EXE": filepath.ToSlash(os.Args[0]), "TASK_EXE": filepath.ToSlash(os.Args[0]),
"ROOT_TASKFILE": filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint)), "ROOT_TASKFILE": rootTaskfile,
"ROOT_DIR": filepath.ToSlash(c.Dir), "ROOT_DIR": rootDir,
"USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir), "USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir),
"TASK_VERSION": version.GetVersion(), "TASK_VERSION": version.GetVersion(),
"PATH_LIST_SEPARATOR": string(os.PathListSeparator), "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 { if t != nil {
allVars["TASK"] = t.Task allVars["TASK"] = t.Task
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["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.Dir, t.Dir))
allVars["TASKFILE"] = filepath.ToSlash(t.Location.Taskfile) allVars["TASKFILE"] = filepath.ToSlash(t.Location.Taskfile)
allVars["TASKFILE_DIR"] = filepath.ToSlash(filepath.Dir(t.Location.Taskfile)) allVars["TASKFILE_DIR"] = filepath.ToSlash(filepath.Dir(t.Location.Taskfile))
}
} else { } else {
allVars["TASK"] = "" allVars["TASK"] = ""
allVars["TASK_DIR"] = "" 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")
})
}
}

View File

@@ -98,6 +98,9 @@ func (vars *Vars) Values() iter.Seq[Var] {
// ToCacheMap converts Vars to an unordered map containing only the static // ToCacheMap converts Vars to an unordered map containing only the static
// variables // variables
func (vars *Vars) ToCacheMap() (m map[string]any) { func (vars *Vars) ToCacheMap() (m map[string]any) {
if vars == nil || vars.om == nil {
return nil
}
defer vars.mutex.RUnlock() defer vars.mutex.RUnlock()
vars.mutex.RLock() vars.mutex.RLock()
m = make(map[string]any, vars.Len()) 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 return node, err
} }
func isRemoteEntrypoint(entrypoint string) bool { func IsRemoteEntrypoint(entrypoint string) bool {
scheme, _ := getScheme(entrypoint) scheme, _ := getScheme(entrypoint)
switch scheme { switch scheme {
case "git", "http", "https": case "git", "http", "https":

View File

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

View File

@@ -42,7 +42,7 @@ func (node *StdinNode) Read() ([]byte, error) {
func (node *StdinNode) ResolveEntrypoint(entrypoint string) (string, error) { func (node *StdinNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path // If the file is remote, we don't need to resolve the path
if isRemoteEntrypoint(entrypoint) { if IsRemoteEntrypoint(entrypoint) {
return entrypoint, nil 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 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 ## Security
### Automatic checksums ### Automatic checksums

View File

@@ -75,7 +75,7 @@ apk add task
### [Homebrew](https://brew.sh) ![macOS](https://img.shields.io/badge/MacOS-000000?logo=apple&logoColor=F0F0F0) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) {#homebrew} ### [Homebrew](https://brew.sh) ![macOS](https://img.shields.io/badge/MacOS-000000?logo=apple&logoColor=F0F0F0) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) {#homebrew}
Task is available via our official Homebrew tap Task is available via our official Homebrew tap
[[source](https://github.com/go-task/homebrew-tap/blob/main/Formula/go-task.rb)]: [[source](https://github.com/go-task/homebrew-tap/blob/main/Casks/go-task.rb)]:
```shell ```shell
brew install go-task/tap/go-task brew install go-task/tap/go-task

View File

@@ -565,6 +565,9 @@ tasks:
- echo "{{.MULTILINE | catLines}}" # Replace newlines with spaces - 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 #### Shell Argument Parsing
```yaml ```yaml