Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 57 additions & 12 deletions internal/experiments/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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),
Expand All @@ -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"
}
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions taskfile/ast/var.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
41 changes: 31 additions & 10 deletions website/docs/experiments/experiments.mdx
vmaerten marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ slug: /experiments/
sidebar_position: 6
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# Experiments

:::caution
Expand Down Expand Up @@ -39,23 +42,41 @@ 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.
vmaerten marked this conversation as resolved.
Show resolved Hide resolved
`.bashrc`, `.zshrc` etc.). This will permanently enable experimental features
for your personal environment.

```shell title="~/.bashrc"
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.

<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

Expand Down
15 changes: 15 additions & 0 deletions website/static/schema-taskrc.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading