diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go index 58d2276437..1fb0d3979c 100644 --- a/internal/experiments/experiments.go +++ b/internal/experiments/experiments.go @@ -6,8 +6,11 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" + "gopkg.in/yaml.v3" + "github.com/Ladicle/tabwriter" "github.com/joho/godotenv" "github.com/spf13/pflag" @@ -17,10 +20,19 @@ import ( const envPrefix = "TASK_X_" +var defaultConfigFilenames = []string{ + ".taskrc.yml", + ".taskrc.yaml", +} + +type experimentConfigFile struct { + Experiments map[string]int `yaml:"experiments"` +} + type Experiment struct { Name string Enabled bool - Value string + Value int } // A list of experiments. @@ -32,20 +44,29 @@ var ( EnvPrecedence Experiment ) +var experimentConfig experimentConfigFile + func init() { readDotEnv() + experimentConfig = readConfig() GentleForce = New("GENTLE_FORCE") RemoteTaskfiles = New("REMOTE_TASKFILES") - AnyVariables = New("ANY_VARIABLES", "1", "2") - MapVariables = New("MAP_VARIABLES", "1", "2") + AnyVariables = New("ANY_VARIABLES", 1, 2) + MapVariables = New("MAP_VARIABLES", 1, 2) EnvPrecedence = New("ENV_PRECEDENCE") } -func New(xName string, enabledValues ...string) Experiment { +func New(xName string, enabledValues ...int) Experiment { if len(enabledValues) == 0 { - enabledValues = []string{"1"} + enabledValues = []int{1} } - value := getEnv(xName) + + value := experimentConfig.Experiments[xName] + + if value == 0 { + value, _ = strconv.Atoi(getEnv(xName)) + } + return Experiment{ Name: xName, Enabled: slices.Contains(enabledValues, value), @@ -55,7 +76,7 @@ func New(xName string, enabledValues ...string) Experiment { func (x Experiment) String() string { if x.Enabled { - return fmt.Sprintf("on (%s)", x.Value) + return fmt.Sprintf("on (%d)", x.Value) } return "off" } @@ -65,7 +86,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 @@ -76,18 +97,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) { @@ -96,6 +117,30 @@ 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 +} + func printExperiment(w io.Writer, l *logger.Logger, x Experiment) { l.FOutf(w, logger.Yellow, "* ") l.FOutf(w, logger.Green, x.Name) diff --git a/task_test.go b/task_test.go index a0f1e7bfff..4fda73e22c 100644 --- a/task_test.go +++ b/task_test.go @@ -1184,7 +1184,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" @@ -1338,7 +1338,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) @@ -3191,7 +3191,7 @@ func TestReference(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 diff --git a/taskfile/ast/var.go b/taskfile/ast/var.go index b2d24977d0..9296c096d6 100644 --- a/taskfile/ast/var.go +++ b/taskfile/ast/var.go @@ -178,7 +178,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) @@ -199,7 +199,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 diff --git a/website/docs/experiments/experiments.mdx b/website/docs/experiments/experiments.mdx index 8a665ce8be..f8850ebf69 100644 --- a/website/docs/experiments/experiments.mdx +++ b/website/docs/experiments/experiments.mdx @@ -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. - - ```shell title=".env" - TASK_X_FEATURE=1 - ``` +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. + + + + ```yaml title=".taskrc.yml" + experiments: + FEATURE: 1 + ``` + + + + ```shell title=".env" + TASK_X_FEATURE=1 + ``` + + ## Workflow diff --git a/website/static/schema-taskrc.json b/website/static/schema-taskrc.json new file mode 100644 index 0000000000..4ed35be313 --- /dev/null +++ b/website/static/schema-taskrc.json @@ -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 +}