diff --git a/cli/internal/env/env.go b/cli/internal/env/env.go new file mode 100644 index 0000000000000..408974472e69e --- /dev/null +++ b/cli/internal/env/env.go @@ -0,0 +1,86 @@ +package env + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/vercel/turborepo/cli/internal/util" +) + +// Prefixes for common framework variables that we always include +var envVarPrefixes = []string{ + "GATSBY_", + "NEXT_PUBLIC_", + "NUXT_ENV_", + "PUBLIC_", + "REACT_APP_", + "REDWOOD_ENV_", + "SANITY_STUDIO_", + "VITE_", + "VUE_APP_", +} + +func getEnvMap() map[string]string { + envMap := make(map[string]string) + for _, envVar := range os.Environ() { + if i := strings.Index(envVar, "="); i >= 0 { + parts := strings.SplitN(envVar, "=", 2) + envMap[parts[0]] = strings.Join(parts[1:], "") + } + } + return envMap +} + +// getEnvPairsFromKeys returns a slice of key=value pairs for all env var keys specified in envKeys +func getEnvPairsFromKeys(envKeys []string, allEnvVars map[string]string) []string { + hashableConfigEnvPairs := []string{} + for _, envVar := range envKeys { + hashableConfigEnvPairs = append(hashableConfigEnvPairs, fmt.Sprintf("%v=%v", envVar, allEnvVars[envVar])) + } + + return hashableConfigEnvPairs +} + +// getFrameworkEnvPairs returns a slice of all key=value pairs that match the given prefix +func getEnvVarsFromPrefix(prefix string, allEnvVars map[string]string) []string { + hashableFrameworkEnvPairs := []string{} + for k, v := range allEnvVars { + if strings.HasPrefix(k, prefix) { + hashableFrameworkEnvPairs = append(hashableFrameworkEnvPairs, fmt.Sprintf("%v=%v", k, v)) + } + } + return hashableFrameworkEnvPairs +} + +// getEnvPairsFromPrefixes returns a slice containing key=value pairs for all frameworks +func getEnvPairsFromPrefixes(prefixes []string, allEnvVars map[string]string) []string { + allHashableFrameworkEnvPairs := []string{} + for _, frameworkEnvPrefix := range envVarPrefixes { + hashableFrameworkEnvPairs := getEnvVarsFromPrefix(frameworkEnvPrefix, allEnvVars) + allHashableFrameworkEnvPairs = append(allHashableFrameworkEnvPairs, hashableFrameworkEnvPairs...) + + } + return allHashableFrameworkEnvPairs +} + +// GetHashableEnvPairs returns all sorted key=value env var pairs for both frameworks and from envKeys +func GetHashableEnvPairs(envKeys []string) []string { + allEnvVars := getEnvMap() + hashableEnvFromKeys := getEnvPairsFromKeys(envKeys, allEnvVars) + hashableEnvFromPrefixes := getEnvPairsFromPrefixes(envVarPrefixes, allEnvVars) + + // convert to set to eliminate duplicates, then cast back to slice to sort for stable hashing + uniqueHashableEnvPairs := make(util.Set, len(hashableEnvFromKeys)+len(hashableEnvFromPrefixes)) + for _, pair := range hashableEnvFromKeys { + uniqueHashableEnvPairs.Add(pair) + } + for _, pair := range hashableEnvFromPrefixes { + uniqueHashableEnvPairs.Add(pair) + } + + allHashableEnvPairs := uniqueHashableEnvPairs.UnsafeListOfStrings() + sort.Strings(allHashableEnvPairs) + return allHashableEnvPairs +} diff --git a/cli/internal/env/env_test.go b/cli/internal/env/env_test.go new file mode 100644 index 0000000000000..69a075d888b1c --- /dev/null +++ b/cli/internal/env/env_test.go @@ -0,0 +1,183 @@ +package env + +import ( + "os" + "reflect" + "strings" + "testing" +) + +func setEnvs(envVars []string) { + for _, envVar := range envVars { + parts := strings.SplitN(envVar, "=", 2) + err := os.Setenv(parts[0], strings.Join(parts[1:], "")) + if err != nil { + panic(err) + } + } +} + +func TestGetHashableEnvPairs(t *testing.T) { + type args struct { + envKeys []string + } + tests := []struct { + env []string + name string + args args + want []string + }{ + { + env: []string{"lowercase=stillcool", "MY_TEST_VAR=cool", "12345=numbers"}, + name: "no framework env vars, no env values", + args: args{ + envKeys: []string{"myval"}, + }, + want: []string{"myval="}, + }, + { + env: []string{"lowercase=stillcool", "MY_TEST_VAR=cool", "12345=numbers"}, + name: "no framework env vars, one env value", + args: args{ + envKeys: []string{"lowercase"}, + }, + want: []string{"lowercase=stillcool"}, + }, + { + env: []string{"lowercase=stillcool", "MY_TEST_VAR=cool", "lowercase=notcool"}, + name: "no framework env vars, duplicate env value", + args: args{ + envKeys: []string{"lowercase"}, + }, + want: []string{"lowercase=notcool"}, + }, + { + env: []string{"lowercase=stillcool", "MY_TEST_VAR=cool", "12345=numbers"}, + name: "no framework env vars, multiple env values", + args: args{ + envKeys: []string{"lowercase", "MY_TEST_VAR"}, + }, + want: []string{"MY_TEST_VAR=cool", "lowercase=stillcool"}, + }, + { + env: []string{"lowercase=stillcool", "MY_TEST_VAR=cool", "12345=numbers", "NEXT_PUBLIC_MY_COOL_VAR=cool"}, + name: "one framework env var, multiple env values", + args: args{ + envKeys: []string{"lowercase", "MY_TEST_VAR"}, + }, + want: []string{"MY_TEST_VAR=cool", "NEXT_PUBLIC_MY_COOL_VAR=cool", "lowercase=stillcool"}, + }, + { + env: []string{"NEXT_PUBLIC_MY_COOL_VAR=cool"}, + name: "duplicate framework env var and env values", + args: args{ + envKeys: []string{"NEXT_PUBLIC_MY_COOL_VAR"}, + }, + want: []string{"NEXT_PUBLIC_MY_COOL_VAR=cool"}, + }, + { + env: []string{"a=1", "b=2", "c=3", "PUBLIC_myvar=4"}, + name: "sorts correctly", + args: args{ + envKeys: []string{"a", "b", "c"}, + }, + want: []string{"PUBLIC_myvar=4", "a=1", "b=2", "c=3"}, + }, + { + env: []string{"a=1=2", "NEXT_PUBLIC_VALUE_TEST=do=not=do=this"}, + name: "parses env values correctly", + args: args{ + envKeys: []string{"a"}, + }, + want: []string{"NEXT_PUBLIC_VALUE_TEST=do=not=do=this", "a=1=2"}, + }, + { + env: []string{"a=1", "NEXT_PUBLIC_=weird"}, + name: "parses prefix with no ending", + args: args{ + envKeys: []string{"a"}, + }, + want: []string{"NEXT_PUBLIC_=weird", "a=1"}, + }, + { + env: []string{"NEXT_PUBLIC_EMOJI=😋"}, + name: "parses unicode env value", + args: args{ + envKeys: []string{}, + }, + want: []string{"NEXT_PUBLIC_EMOJI=😋"}, + }, + { + env: []string{"zero=0", "null=null", "nil=nil"}, + name: "parses corner case env values", + args: args{ + envKeys: []string{"zero", "null", "nil"}, + }, + want: []string{"nil=nil", "null=null", "zero=0"}, + }, + { + env: []string{"GATSBY_custom=GATSBY", + "NEXT_PUBLIC_custom=NEXT_PUBLIC", + "NUXT_ENV_custom=NUXT_ENV", + "PUBLIC_custom=PUBLIC", + "REACT_APP_custom=REACT_APP", + "REDWOOD_ENV_custom=REDWOOD_ENV", + "SANITY_STUDIO_custom=SANITY_STUDIO", + "VITE_custom=VITE", + "VUE_APP_custom=VUE_APP"}, + name: "all framework vars with no env keys", + args: args{ + envKeys: []string{}, + }, + want: []string{"GATSBY_custom=GATSBY", + "NEXT_PUBLIC_custom=NEXT_PUBLIC", + "NUXT_ENV_custom=NUXT_ENV", + "PUBLIC_custom=PUBLIC", + "REACT_APP_custom=REACT_APP", + "REDWOOD_ENV_custom=REDWOOD_ENV", + "SANITY_STUDIO_custom=SANITY_STUDIO", + "VITE_custom=VITE", + "VUE_APP_custom=VUE_APP"}, + }, + { + env: []string{"GATSBY_custom=GATSBY", + "NEXT_PUBLIC_custom=NEXT_PUBLIC", + "NUXT_ENV_custom=NUXT_ENV", + "PUBLIC_custom=PUBLIC", + "REACT_APP_custom=REACT_APP", + "REDWOOD_ENV_custom=REDWOOD_ENV", + "SANITY_STUDIO_custom=SANITY_STUDIO", + "VITE_custom=VITE", + "CUSTOM=cool", + "ANOTHER=neat", + "FINAL=great", + "VITE_custom=VITE", + "VUE_APP_custom=VUE_APP"}, + name: "all framework vars with env keys", + args: args{ + envKeys: []string{"FINAL", "CUSTOM", "ANOTHER"}, + }, + want: []string{"ANOTHER=neat", "CUSTOM=cool", "FINAL=great", "GATSBY_custom=GATSBY", + "NEXT_PUBLIC_custom=NEXT_PUBLIC", + "NUXT_ENV_custom=NUXT_ENV", + "PUBLIC_custom=PUBLIC", + "REACT_APP_custom=REACT_APP", + "REDWOOD_ENV_custom=REDWOOD_ENV", + "SANITY_STUDIO_custom=SANITY_STUDIO", + "VITE_custom=VITE", + "VUE_APP_custom=VUE_APP"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // set the env vars + setEnvs(tt.env) + // test + if got := GetHashableEnvPairs(tt.args.envKeys); !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetHashableEnvPairs() = %v, want %v", got, tt.want) + } + // clean up the env for the next run + os.Clearenv() + }) + } +} diff --git a/cli/internal/taskhash/taskhash.go b/cli/internal/taskhash/taskhash.go index 2b896021864ba..e1d879daad566 100644 --- a/cli/internal/taskhash/taskhash.go +++ b/cli/internal/taskhash/taskhash.go @@ -5,7 +5,6 @@ package taskhash import ( "fmt" - "os" "sort" "strings" "sync" @@ -13,6 +12,7 @@ import ( "github.com/pyr-sh/dag" gitignore "github.com/sabhiram/go-gitignore" "github.com/vercel/turborepo/cli/internal/doublestar" + "github.com/vercel/turborepo/cli/internal/env" "github.com/vercel/turborepo/cli/internal/fs" "github.com/vercel/turborepo/cli/internal/nodes" "github.com/vercel/turborepo/cli/internal/turbopath" @@ -258,12 +258,8 @@ func (th *Tracker) CalculateTaskHash(pt *nodes.PackageTask, dependencySet dag.Se if !ok { return "", fmt.Errorf("cannot find package-file hash for %v", pkgFileHashKey) } + hashableEnvPairs := env.GetHashableEnvPairs(pt.TaskDefinition.EnvVarDependencies) outputs := pt.HashableOutputs() - hashableEnvPairs := []string{} - for _, envVar := range pt.TaskDefinition.EnvVarDependencies { - hashableEnvPairs = append(hashableEnvPairs, fmt.Sprintf("%v=%v", envVar, os.Getenv(envVar))) - } - sort.Strings(hashableEnvPairs) taskDependencyHashes, err := th.calculateDependencyHashes(dependencySet) if err != nil { return "", err