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 (
"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), ", "),
)
}

View File

@ -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.
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"
}

View File

@ -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())

View File

@ -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
}

View File

@ -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
}

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)
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 })

View File

@ -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

View File

@ -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.
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

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
}