1
0
mirror of https://github.com/go-task/task.git synced 2025-06-23 00:38:19 +02:00

feat: add a new .taskrc.yml to enable experiments (#1982)

This commit is contained in:
Valentin Maerten
2025-02-23 10:51:59 +01:00
committed by GitHub
parent 8a35033abc
commit 461714a899
10 changed files with 235 additions and 52 deletions

View File

@ -2,13 +2,16 @@ package experiments
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"github.com/go-task/task/v3/internal/slicesext"
) )
type InvalidValueError struct { type InvalidValueError struct {
Name string Name string
AllowedValues []string AllowedValues []int
Value string Value int
} }
func (err InvalidValueError) Error() string { func (err InvalidValueError) Error() string {
@ -16,7 +19,7 @@ func (err InvalidValueError) Error() string {
"task: Experiment %q has an invalid value %q (allowed values: %s)", "task: Experiment %q has an invalid value %q (allowed values: %s)",
err.Name, err.Name,
err.Value, err.Value,
strings.Join(err.AllowedValues, ", "), strings.Join(slicesext.Convert(err.AllowedValues, strconv.Itoa), ", "),
) )
} }

View File

@ -3,18 +3,24 @@ package experiments
import ( import (
"fmt" "fmt"
"slices" "slices"
"strconv"
) )
type Experiment struct { type Experiment struct {
Name string // The name of the experiment. Name string // The name of the experiment.
AllowedValues []string // The values that can enable this experiment. AllowedValues []int // The values that can enable this experiment.
Value string // The version of the experiment that is enabled. Value int // The version of the experiment that is enabled.
} }
// New creates a new experiment with the given name and sets the values that can // New creates a new experiment with the given name and sets the values that can
// enable it. // enable it.
func New(xName string, allowedValues ...string) Experiment { func New(xName string, allowedValues ...int) Experiment {
value := getEnv(xName) value := experimentConfig.Experiments[xName]
if value == 0 {
value, _ = strconv.Atoi(getEnv(xName))
}
x := Experiment{ x := Experiment{
Name: xName, Name: xName,
AllowedValues: allowedValues, AllowedValues: allowedValues,
@ -24,21 +30,21 @@ func New(xName string, allowedValues ...string) Experiment {
return x return x
} }
func (x *Experiment) Enabled() bool { func (x Experiment) Enabled() bool {
return slices.Contains(x.AllowedValues, x.Value) return slices.Contains(x.AllowedValues, x.Value)
} }
func (x *Experiment) Active() bool { func (x Experiment) Active() bool {
return len(x.AllowedValues) > 0 return len(x.AllowedValues) > 0
} }
func (x Experiment) Valid() error { func (x Experiment) Valid() error {
if !x.Active() && x.Value != "" { if !x.Active() && x.Value != 0 {
return &InactiveError{ return &InactiveError{
Name: x.Name, Name: x.Name,
} }
} }
if !x.Enabled() && x.Value != "" { if !x.Enabled() && x.Value != 0 {
return &InvalidValueError{ return &InvalidValueError{
Name: x.Name, Name: x.Name,
AllowedValues: x.AllowedValues, AllowedValues: x.AllowedValues,
@ -50,7 +56,7 @@ func (x Experiment) Valid() error {
func (x Experiment) String() string { func (x Experiment) String() string {
if x.Enabled() { if x.Enabled() {
return fmt.Sprintf("on (%s)", x.Value) return fmt.Sprintf("on (%d)", x.Value)
} }
return "off" return "off"
} }

View File

@ -1,6 +1,7 @@
package experiments_test package experiments_test
import ( import (
"strconv"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -15,8 +16,8 @@ func TestNew(t *testing.T) {
) )
tests := []struct { tests := []struct {
name string name string
allowedValues []string allowedValues []int
value string value int
wantEnabled bool wantEnabled bool
wantActive bool wantActive bool
wantValid error wantValid error
@ -28,7 +29,7 @@ func TestNew(t *testing.T) {
}, },
{ {
name: `[] allowed, value="1"`, name: `[] allowed, value="1"`,
value: "1", value: 1,
wantEnabled: false, wantEnabled: false,
wantActive: false, wantActive: false,
wantValid: &experiments.InactiveError{ wantValid: &experiments.InactiveError{
@ -37,33 +38,33 @@ func TestNew(t *testing.T) {
}, },
{ {
name: `[1] allowed, value=""`, name: `[1] allowed, value=""`,
allowedValues: []string{"1"}, allowedValues: []int{1},
wantEnabled: false, wantEnabled: false,
wantActive: true, wantActive: true,
}, },
{ {
name: `[1] allowed, value="1"`, name: `[1] allowed, value="1"`,
allowedValues: []string{"1"}, allowedValues: []int{1},
value: "1", value: 1,
wantEnabled: true, wantEnabled: true,
wantActive: true, wantActive: true,
}, },
{ {
name: `[1] allowed, value="2"`, name: `[1] allowed, value="2"`,
allowedValues: []string{"1"}, allowedValues: []int{1},
value: "2", value: 2,
wantEnabled: false, wantEnabled: false,
wantActive: true, wantActive: true,
wantValid: &experiments.InvalidValueError{ wantValid: &experiments.InvalidValueError{
Name: exampleExperiment, Name: exampleExperiment,
AllowedValues: []string{"1"}, AllowedValues: []int{1},
Value: "2", Value: 2,
}, },
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Setenv(exampleExperimentEnv, tt.value) t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.value))
x := experiments.New(exampleExperiment, tt.allowedValues...) x := experiments.New(exampleExperiment, tt.allowedValues...)
assert.Equal(t, exampleExperiment, x.Name) assert.Equal(t, exampleExperiment, x.Name)
assert.Equal(t, tt.wantEnabled, x.Enabled()) assert.Equal(t, tt.wantEnabled, x.Enabled())

View File

@ -6,13 +6,24 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/Masterminds/semver/v3"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"gopkg.in/yaml.v3"
) )
const envPrefix = "TASK_X_" const envPrefix = "TASK_X_"
// A set of experiments that can be enabled or disabled. var defaultConfigFilenames = []string{
".taskrc.yml",
".taskrc.yaml",
}
type experimentConfigFile struct {
Experiments map[string]int `yaml:"experiments"`
Version *semver.Version
}
var ( var (
GentleForce Experiment GentleForce Experiment
RemoteTaskfiles Experiment RemoteTaskfiles Experiment
@ -22,15 +33,19 @@ var (
) )
// An internal list of all the initialized experiments used for iterating. // An internal list of all the initialized experiments used for iterating.
var xList []Experiment var (
xList []Experiment
experimentConfig experimentConfigFile
)
func init() { func init() {
readDotEnv() readDotEnv()
GentleForce = New("GENTLE_FORCE", "1") experimentConfig = readConfig()
RemoteTaskfiles = New("REMOTE_TASKFILES", "1") GentleForce = New("GENTLE_FORCE", 1)
RemoteTaskfiles = New("REMOTE_TASKFILES", 1)
AnyVariables = New("ANY_VARIABLES") AnyVariables = New("ANY_VARIABLES")
MapVariables = New("MAP_VARIABLES", "1", "2") MapVariables = New("MAP_VARIABLES", 1, 2)
EnvPrecedence = New("ENV_PRECEDENCE", "1") EnvPrecedence = New("ENV_PRECEDENCE", 1)
} }
// Validate checks if any experiments have been enabled while being inactive. // Validate checks if any experiments have been enabled while being inactive.
@ -53,7 +68,7 @@ func getEnv(xName string) string {
return os.Getenv(envName) return os.Getenv(envName)
} }
func getEnvFilePath() string { func getFilePath(filename string) string {
// Parse the CLI flags again to get the directory/taskfile being run // Parse the CLI flags again to get the directory/taskfile being run
// We use a flagset here so that we can parse a subset of flags without exiting on error. // We use a flagset here so that we can parse a subset of flags without exiting on error.
var dir, taskfile string var dir, taskfile string
@ -64,18 +79,18 @@ func getEnvFilePath() string {
_ = fs.Parse(os.Args[1:]) _ = fs.Parse(os.Args[1:])
// If the directory is set, find a .env file in that directory. // If the directory is set, find a .env file in that directory.
if dir != "" { if dir != "" {
return filepath.Join(dir, ".env") return filepath.Join(dir, filename)
} }
// If the taskfile is set, find a .env file in the directory containing the Taskfile. // If the taskfile is set, find a .env file in the directory containing the Taskfile.
if taskfile != "" { if taskfile != "" {
return filepath.Join(filepath.Dir(taskfile), ".env") return filepath.Join(filepath.Dir(taskfile), filename)
} }
// Otherwise just use the current working directory. // Otherwise just use the current working directory.
return ".env" return filename
} }
func readDotEnv() { func readDotEnv() {
env, _ := godotenv.Read(getEnvFilePath()) env, _ := godotenv.Read(getFilePath(".env"))
// If the env var is an experiment, set it. // If the env var is an experiment, set it.
for key, value := range env { for key, value := range env {
if strings.HasPrefix(key, envPrefix) { if strings.HasPrefix(key, envPrefix) {
@ -83,3 +98,27 @@ func readDotEnv() {
} }
} }
} }
func readConfig() experimentConfigFile {
var cfg experimentConfigFile
var content []byte
var err error
for _, filename := range defaultConfigFilenames {
path := getFilePath(filename)
content, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return experimentConfigFile{}
}
if err := yaml.Unmarshal(content, &cfg); err != nil {
return experimentConfigFile{}
}
return cfg
}

View File

@ -18,3 +18,15 @@ func UniqueJoin[T cmp.Ordered](ss ...[]T) []T {
slices.Sort(r) slices.Sort(r)
return slices.Compact(r) return slices.Compact(r)
} }
func Convert[T, U any](s []T, f func(T) U) []U {
// Create a new slice with the same length as the input slice
result := make([]U, len(s))
// Convert each element using the provided function
for i, v := range s {
result[i] = f(v)
}
return result
}

View File

@ -0,0 +1,86 @@
package slicesext
import (
"math"
"strconv"
"testing"
)
func TestConvertIntToString(t *testing.T) {
t.Parallel()
input := []int{1, 2, 3, 4, 5}
expected := []string{"1", "2", "3", "4", "5"}
result := Convert(input, strconv.Itoa)
if len(result) != len(expected) {
t.Errorf("Expected length %d, got %d", len(expected), len(result))
}
for i := range expected {
if result[i] != expected[i] {
t.Errorf("At index %d: expected %v, got %v", i, expected[i], result[i])
}
}
}
func TestConvertStringToInt(t *testing.T) {
t.Parallel()
input := []string{"1", "2", "3", "4", "5"}
expected := []int{1, 2, 3, 4, 5}
result := Convert(input, func(s string) int {
n, _ := strconv.Atoi(s)
return n
})
if len(result) != len(expected) {
t.Errorf("Expected length %d, got %d", len(expected), len(result))
}
for i := range expected {
if result[i] != expected[i] {
t.Errorf("At index %d: expected %v, got %v", i, expected[i], result[i])
}
}
}
func TestConvertFloatToInt(t *testing.T) {
t.Parallel()
input := []float64{1.1, 2.2, 3.7, 4.5, 5.9}
expected := []int{1, 2, 4, 5, 6}
result := Convert(input, func(f float64) int {
return int(math.Round(f))
})
if len(result) != len(expected) {
t.Errorf("Expected length %d, got %d", len(expected), len(result))
}
for i := range expected {
if result[i] != expected[i] {
t.Errorf("At index %d: expected %v, got %v", i, expected[i], result[i])
}
}
}
func TestConvertEmptySlice(t *testing.T) {
t.Parallel()
input := []int{}
result := Convert(input, strconv.Itoa)
if len(result) != 0 {
t.Errorf("Expected empty slice, got length %d", len(result))
}
}
func TestConvertNilSlice(t *testing.T) {
t.Parallel()
var input []int
result := Convert(input, strconv.Itoa)
if result == nil {
t.Error("Expected non-nil empty slice, got nil")
}
if len(result) != 0 {
t.Errorf("Expected empty slice, got length %d", len(result))
}
}

View File

@ -135,7 +135,7 @@ func TestEnv(t *testing.T) {
}, },
} }
tt.Run(t) tt.Run(t)
enableExperimentForTest(t, &experiments.EnvPrecedence, "1") enableExperimentForTest(t, &experiments.EnvPrecedence, 1)
ttt := fileContentTest{ ttt := fileContentTest{
Dir: "testdata/env", Dir: "testdata/env",
Target: "overridden", Target: "overridden",
@ -1215,7 +1215,7 @@ func TestIncludesMultiLevel(t *testing.T) {
} }
func TestIncludesRemote(t *testing.T) { func TestIncludesRemote(t *testing.T) {
enableExperimentForTest(t, &experiments.RemoteTaskfiles, "1") enableExperimentForTest(t, &experiments.RemoteTaskfiles, 1)
dir := "testdata/includes_remote" dir := "testdata/includes_remote"
@ -1373,7 +1373,7 @@ func TestIncludesEmptyMain(t *testing.T) {
} }
func TestIncludesHttp(t *testing.T) { func TestIncludesHttp(t *testing.T) {
enableExperimentForTest(t, &experiments.RemoteTaskfiles, "1") enableExperimentForTest(t, &experiments.RemoteTaskfiles, 1)
dir, err := filepath.Abs("testdata/includes_http") dir, err := filepath.Abs("testdata/includes_http")
require.NoError(t, err) require.NoError(t, err)
@ -3224,7 +3224,7 @@ func TestReference(t *testing.T) {
} }
func TestVarInheritance(t *testing.T) { func TestVarInheritance(t *testing.T) {
enableExperimentForTest(t, &experiments.EnvPrecedence, "1") enableExperimentForTest(t, &experiments.EnvPrecedence, 1)
tests := []struct { tests := []struct {
name string name string
want string want string
@ -3332,12 +3332,12 @@ func TestVarInheritance(t *testing.T) {
// //
// Typically experiments are controlled via TASK_X_ env vars, but we cannot use those in tests // Typically experiments are controlled via TASK_X_ env vars, but we cannot use those in tests
// because the experiment settings are parsed during experiments.init(), before any tests run. // because the experiment settings are parsed during experiments.init(), before any tests run.
func enableExperimentForTest(t *testing.T, e *experiments.Experiment, val string) { func enableExperimentForTest(t *testing.T, e *experiments.Experiment, val int) {
t.Helper() t.Helper()
prev := *e prev := *e
*e = experiments.Experiment{ *e = experiments.Experiment{
Name: prev.Name, Name: prev.Name,
AllowedValues: []string{val}, AllowedValues: []int{val},
Value: val, Value: val,
} }
t.Cleanup(func() { *e = prev }) t.Cleanup(func() { *e = prev })

View File

@ -22,7 +22,7 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error {
if experiments.MapVariables.Enabled() { if experiments.MapVariables.Enabled() {
// This implementation is not backwards-compatible and replaces the 'sh' key with map variables // This implementation is not backwards-compatible and replaces the 'sh' key with map variables
if experiments.MapVariables.Value == "1" { if experiments.MapVariables.Value == 1 {
var value any var value any
if err := node.Decode(&value); err != nil { if err := node.Decode(&value); err != nil {
return errors.NewTaskfileDecodeError(err, node) return errors.NewTaskfileDecodeError(err, node)
@ -43,7 +43,7 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error {
} }
// This implementation IS backwards-compatible and keeps the 'sh' key and allows map variables to be added under the `map` key // This implementation IS backwards-compatible and keeps the 'sh' key and allows map variables to be added under the `map` key
if experiments.MapVariables.Value == "2" { if experiments.MapVariables.Value == 2 {
switch node.Kind { switch node.Kind {
case yaml.MappingNode: case yaml.MappingNode:
key := node.Content[0].Value key := node.Content[0].Value

View File

@ -3,6 +3,9 @@ slug: /experiments/
sidebar_position: 6 sidebar_position: 6
--- ---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Experiments # Experiments
:::caution :::caution
@ -39,7 +42,7 @@ Which method you use depends on how you intend to use the experiment:
1. Prefixing your task commands with the relevant environment variable(s). For 1. Prefixing your task commands with the relevant environment variable(s). For
example, `TASK_X_{FEATURE}=1 task {my-task}`. This is intended for one-off example, `TASK_X_{FEATURE}=1 task {my-task}`. This is intended for one-off
invocations of Task to test out experimental features. invocations of Task to test out experimental features.
1. Adding the relevant environment variable(s) in your "dotfiles" (e.g. 2. Adding the relevant environment variable(s) in your "dotfiles" (e.g.
`.bashrc`, `.zshrc` etc.). This will permanently enable experimental features `.bashrc`, `.zshrc` etc.). This will permanently enable experimental features
for your personal environment. for your personal environment.
@ -47,15 +50,33 @@ Which method you use depends on how you intend to use the experiment:
export TASK_X_FEATURE=1 export TASK_X_FEATURE=1
``` ```
1. Creating a `.env` file in the same directory as your root Taskfile that 3. Creating a `.env` or a `.task-experiments.yml` file in the same directory as
contains the relevant environment variable(s). This allows you to enable an your root Taskfile.\
experimental feature at a project level. If you commit the `.env` file to The `.env` file should contain the relevant environment
source control then other users of your project will also have these variable(s), while the `.task-experiments.yml` file should use a YAML format
experiments enabled. where each experiment is defined as a key with a corresponding value.
This allows you to enable an experimental feature at a project level. If you
commit this file to source control, then other users of your project will
also have these experiments enabled.
If both files are present, the values in the `.task-experiments.yml` file
will take precedence.
<Tabs values={[ {label: '.task-experiments.yml', value: 'yaml'}, {label: '.env', value: 'env'}]}>
<TabItem value="yaml">
```yaml title=".taskrc.yml"
experiments:
FEATURE: 1
```
</TabItem>
<TabItem value="env">
```shell title=".env" ```shell title=".env"
TASK_X_FEATURE=1 TASK_X_FEATURE=1
``` ```
</TabItem>
</Tabs>
## Workflow ## Workflow

View File

@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Taskrc YAML Schema",
"description": "Schema for .taskrc files.",
"type": "object",
"properties": {
"experiments": {
"type": "object",
"additionalProperties": {
"type": "integer"
}
}
},
"additionalProperties": false
}