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:
@ -2,13 +2,16 @@ package experiments
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-task/task/v3/internal/slicesext"
|
||||
)
|
||||
|
||||
type InvalidValueError struct {
|
||||
Name string
|
||||
AllowedValues []string
|
||||
Value string
|
||||
AllowedValues []int
|
||||
Value int
|
||||
}
|
||||
|
||||
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)",
|
||||
err.Name,
|
||||
err.Value,
|
||||
strings.Join(err.AllowedValues, ", "),
|
||||
strings.Join(slicesext.Convert(err.AllowedValues, strconv.Itoa), ", "),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -3,18 +3,24 @@ package experiments
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Experiment struct {
|
||||
Name string // The name of the experiment.
|
||||
AllowedValues []string // The values that can enable this experiment.
|
||||
Value string // The version of the experiment that is enabled.
|
||||
Name string // The name of the experiment.
|
||||
AllowedValues []int // The values that can enable this experiment.
|
||||
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
|
||||
// enable it.
|
||||
func New(xName string, allowedValues ...string) Experiment {
|
||||
value := getEnv(xName)
|
||||
func New(xName string, allowedValues ...int) Experiment {
|
||||
value := experimentConfig.Experiments[xName]
|
||||
|
||||
if value == 0 {
|
||||
value, _ = strconv.Atoi(getEnv(xName))
|
||||
}
|
||||
|
||||
x := Experiment{
|
||||
Name: xName,
|
||||
AllowedValues: allowedValues,
|
||||
@ -24,21 +30,21 @@ func New(xName string, allowedValues ...string) Experiment {
|
||||
return x
|
||||
}
|
||||
|
||||
func (x *Experiment) Enabled() bool {
|
||||
func (x Experiment) Enabled() bool {
|
||||
return slices.Contains(x.AllowedValues, x.Value)
|
||||
}
|
||||
|
||||
func (x *Experiment) Active() bool {
|
||||
func (x Experiment) Active() bool {
|
||||
return len(x.AllowedValues) > 0
|
||||
}
|
||||
|
||||
func (x Experiment) Valid() error {
|
||||
if !x.Active() && x.Value != "" {
|
||||
if !x.Active() && x.Value != 0 {
|
||||
return &InactiveError{
|
||||
Name: x.Name,
|
||||
}
|
||||
}
|
||||
if !x.Enabled() && x.Value != "" {
|
||||
if !x.Enabled() && x.Value != 0 {
|
||||
return &InvalidValueError{
|
||||
Name: x.Name,
|
||||
AllowedValues: x.AllowedValues,
|
||||
@ -50,7 +56,7 @@ func (x Experiment) Valid() error {
|
||||
|
||||
func (x Experiment) String() string {
|
||||
if x.Enabled() {
|
||||
return fmt.Sprintf("on (%s)", x.Value)
|
||||
return fmt.Sprintf("on (%d)", x.Value)
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package experiments_test
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -15,8 +16,8 @@ func TestNew(t *testing.T) {
|
||||
)
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedValues []string
|
||||
value string
|
||||
allowedValues []int
|
||||
value int
|
||||
wantEnabled bool
|
||||
wantActive bool
|
||||
wantValid error
|
||||
@ -28,7 +29,7 @@ func TestNew(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: `[] allowed, value="1"`,
|
||||
value: "1",
|
||||
value: 1,
|
||||
wantEnabled: false,
|
||||
wantActive: false,
|
||||
wantValid: &experiments.InactiveError{
|
||||
@ -37,33 +38,33 @@ func TestNew(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, value=""`,
|
||||
allowedValues: []string{"1"},
|
||||
allowedValues: []int{1},
|
||||
wantEnabled: false,
|
||||
wantActive: true,
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, value="1"`,
|
||||
allowedValues: []string{"1"},
|
||||
value: "1",
|
||||
allowedValues: []int{1},
|
||||
value: 1,
|
||||
wantEnabled: true,
|
||||
wantActive: true,
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, value="2"`,
|
||||
allowedValues: []string{"1"},
|
||||
value: "2",
|
||||
allowedValues: []int{1},
|
||||
value: 2,
|
||||
wantEnabled: false,
|
||||
wantActive: true,
|
||||
wantValid: &experiments.InvalidValueError{
|
||||
Name: exampleExperiment,
|
||||
AllowedValues: []string{"1"},
|
||||
Value: "2",
|
||||
AllowedValues: []int{1},
|
||||
Value: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
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...)
|
||||
assert.Equal(t, exampleExperiment, x.Name)
|
||||
assert.Equal(t, tt.wantEnabled, x.Enabled())
|
||||
|
@ -6,13 +6,24 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/pflag"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
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 (
|
||||
GentleForce Experiment
|
||||
RemoteTaskfiles Experiment
|
||||
@ -22,15 +33,19 @@ var (
|
||||
)
|
||||
|
||||
// An internal list of all the initialized experiments used for iterating.
|
||||
var xList []Experiment
|
||||
var (
|
||||
xList []Experiment
|
||||
experimentConfig experimentConfigFile
|
||||
)
|
||||
|
||||
func init() {
|
||||
readDotEnv()
|
||||
GentleForce = New("GENTLE_FORCE", "1")
|
||||
RemoteTaskfiles = New("REMOTE_TASKFILES", "1")
|
||||
experimentConfig = readConfig()
|
||||
GentleForce = New("GENTLE_FORCE", 1)
|
||||
RemoteTaskfiles = New("REMOTE_TASKFILES", 1)
|
||||
AnyVariables = New("ANY_VARIABLES")
|
||||
MapVariables = New("MAP_VARIABLES", "1", "2")
|
||||
EnvPrecedence = New("ENV_PRECEDENCE", "1")
|
||||
MapVariables = New("MAP_VARIABLES", 1, 2)
|
||||
EnvPrecedence = New("ENV_PRECEDENCE", 1)
|
||||
}
|
||||
|
||||
// Validate checks if any experiments have been enabled while being inactive.
|
||||
@ -53,7 +68,7 @@ func getEnv(xName string) string {
|
||||
return os.Getenv(envName)
|
||||
}
|
||||
|
||||
func getEnvFilePath() string {
|
||||
func getFilePath(filename string) string {
|
||||
// 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.
|
||||
var dir, taskfile string
|
||||
@ -64,18 +79,18 @@ func getEnvFilePath() string {
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
// If the directory is set, find a .env file in that directory.
|
||||
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 taskfile != "" {
|
||||
return filepath.Join(filepath.Dir(taskfile), ".env")
|
||||
return filepath.Join(filepath.Dir(taskfile), filename)
|
||||
}
|
||||
// Otherwise just use the current working directory.
|
||||
return ".env"
|
||||
return filename
|
||||
}
|
||||
|
||||
func readDotEnv() {
|
||||
env, _ := godotenv.Read(getEnvFilePath())
|
||||
env, _ := godotenv.Read(getFilePath(".env"))
|
||||
// If the env var is an experiment, set it.
|
||||
for key, value := range env {
|
||||
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
|
||||
}
|
||||
|
@ -18,3 +18,15 @@ func UniqueJoin[T cmp.Ordered](ss ...[]T) []T {
|
||||
slices.Sort(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
|
||||
}
|
||||
|
86
internal/slicesext/slicesext_test.go
Normal file
86
internal/slicesext/slicesext_test.go
Normal 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))
|
||||
}
|
||||
}
|
12
task_test.go
12
task_test.go
@ -135,7 +135,7 @@ func TestEnv(t *testing.T) {
|
||||
},
|
||||
}
|
||||
tt.Run(t)
|
||||
enableExperimentForTest(t, &experiments.EnvPrecedence, "1")
|
||||
enableExperimentForTest(t, &experiments.EnvPrecedence, 1)
|
||||
ttt := fileContentTest{
|
||||
Dir: "testdata/env",
|
||||
Target: "overridden",
|
||||
@ -1215,7 +1215,7 @@ func TestIncludesMultiLevel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIncludesRemote(t *testing.T) {
|
||||
enableExperimentForTest(t, &experiments.RemoteTaskfiles, "1")
|
||||
enableExperimentForTest(t, &experiments.RemoteTaskfiles, 1)
|
||||
|
||||
dir := "testdata/includes_remote"
|
||||
|
||||
@ -1373,7 +1373,7 @@ func TestIncludesEmptyMain(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")
|
||||
require.NoError(t, err)
|
||||
@ -3224,7 +3224,7 @@ func TestReference(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVarInheritance(t *testing.T) {
|
||||
enableExperimentForTest(t, &experiments.EnvPrecedence, "1")
|
||||
enableExperimentForTest(t, &experiments.EnvPrecedence, 1)
|
||||
tests := []struct {
|
||||
name 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
|
||||
// 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()
|
||||
prev := *e
|
||||
*e = experiments.Experiment{
|
||||
Name: prev.Name,
|
||||
AllowedValues: []string{val},
|
||||
AllowedValues: []int{val},
|
||||
Value: val,
|
||||
}
|
||||
t.Cleanup(func() { *e = prev })
|
||||
|
@ -22,7 +22,7 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error {
|
||||
if experiments.MapVariables.Enabled() {
|
||||
|
||||
// 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
|
||||
if err := node.Decode(&value); err != nil {
|
||||
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
|
||||
if experiments.MapVariables.Value == "2" {
|
||||
if experiments.MapVariables.Value == 2 {
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
key := node.Content[0].Value
|
||||
|
@ -3,6 +3,9 @@ slug: /experiments/
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# Experiments
|
||||
|
||||
:::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
|
||||
example, `TASK_X_{FEATURE}=1 task {my-task}`. This is intended for one-off
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
1. Creating a `.env` file in the same directory as your root Taskfile that
|
||||
contains the relevant environment variable(s). This allows you to enable an
|
||||
experimental feature at a project level. If you commit the `.env` file to
|
||||
source control then other users of your project will also have these
|
||||
experiments enabled.
|
||||
3. Creating a `.env` or a `.task-experiments.yml` file in the same directory as
|
||||
your root Taskfile.\
|
||||
The `.env` file should contain the relevant environment
|
||||
variable(s), while the `.task-experiments.yml` file should use a YAML format
|
||||
where each experiment is defined as a key with a corresponding value.
|
||||
|
||||
```shell title=".env"
|
||||
TASK_X_FEATURE=1
|
||||
```
|
||||
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"
|
||||
TASK_X_FEATURE=1
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Workflow
|
||||
|
||||
|
15
website/static/schema-taskrc.json
Normal file
15
website/static/schema-taskrc.json
Normal 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
|
||||
}
|
Reference in New Issue
Block a user