From 3885df559a39332f7d061a26c81eeca2c0044674 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Fri, 16 Jan 2026 20:48:57 -0500 Subject: [PATCH 01/10] Read JSON schemas --- fakeserver/server_test.go | 34 +++++++++++++++++++++++++++++++++- schema/schema.go | 28 +++++++++++++++++++++------- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/fakeserver/server_test.go b/fakeserver/server_test.go index 72e32a0..a0a554a 100644 --- a/fakeserver/server_test.go +++ b/fakeserver/server_test.go @@ -33,6 +33,17 @@ splits: treatment: 40 ` +var otherTestSchema = `{ + "serializer_version": 1, + "schema_version": "2020011774023", + "splits": [ + { + "name": "test.json_experiment", + "weights": { "control": 50, "treatment": 50 } + } + ] +}` + var testAssignments = ` something_something_enabled: "true" ` @@ -52,7 +63,12 @@ func TestMain(m *testing.M) { } schemaContent := []byte(testSchema) - if err := os.WriteFile(filepath.Join(schemasDir, "test.yml"), schemaContent, 0644); err != nil { + if err := os.WriteFile(filepath.Join(schemasDir, "a.yml"), schemaContent, 0644); err != nil { + log.Fatal(err) + } + + otherSchemaContent := []byte(otherTestSchema) + if err := os.WriteFile(filepath.Join(schemasDir, "b.json"), otherSchemaContent, 0644); err != nil { log.Fatal(err) } @@ -140,6 +156,22 @@ func TestSplitRegistry(t *testing.T) { require.Equal(t, 40, treatment.Weight) require.Equal(t, false, split.FeatureGate) }) + + t.Run("it loads JSON schemas from home directory", func(t *testing.T) { + w := httptest.NewRecorder() + h := createHandler() + + h.ServeHTTP(w, httptest.NewRequest("GET", "/api/v2/split_registry", nil)) + + require.Equal(t, http.StatusOK, w.Code) + + registry := v2SplitRegistry{} + err := json.Unmarshal(w.Body.Bytes(), ®istry) + require.Nil(t, err) + + require.Equal(t, 50, registry.Splits["test.json_experiment"].Weights["control"]) + require.Equal(t, 50, registry.Splits["test.json_experiment"].Weights["treatment"]) + }) } func TestVisitorConfig(t *testing.T) { diff --git a/schema/schema.go b/schema/schema.go index cdc66e0..98a8e0e 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -15,12 +15,24 @@ import ( "gopkg.in/yaml.v2" ) +// Finds the path to the schema file (preferring JSON), or returns an error if neither exists +func findSchemaPath() (string, error) { + if _, err := os.Stat("testtrack/schema.json"); err == nil { + return "testtrack/schema.json", nil + } + if _, err := os.Stat("testtrack/schema.yml"); err == nil { + return "testtrack/schema.yml", nil + } + return "", errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first") +} + // Read a schema from disk or generate one func Read() (*serializers.Schema, error) { - if _, err := os.Stat("testtrack/schema.yml"); os.IsNotExist(err) { + schemaPath, err := findSchemaPath() + if err != nil { return Generate() } - schemaBytes, err := os.ReadFile("testtrack/schema.yml") + schemaBytes, err := os.ReadFile(schemaPath) if err != nil { return nil, err } @@ -68,8 +80,9 @@ func Write(schema *serializers.Schema) error { // Link a schema to the user's home dir func Link(force bool) error { - if _, err := os.Stat("testtrack/schema.yml"); os.IsNotExist(err) { - return errors.New("testtrack/schema.yml does not exist. Are you in your app root dir? If so, call testtrack init_project first") + schemaPath, err := findSchemaPath() + if err != nil { + return err } dir, err := os.Getwd() if err != nil { @@ -84,11 +97,12 @@ func Link(force bool) error { if err != nil { return err } - path := fmt.Sprintf("%s/schemas/%s.yml", *configDir, dirname) + ext := filepath.Ext(schemaPath) + path := fmt.Sprintf("%s/schemas/%s%s", *configDir, dirname, ext) if force { os.Remove(path) // If this fails it might just not exist, we'll error on the next line if something else is up } - return os.Symlink(dir+"/testtrack/schema.yml", path) + return os.Symlink(dir+"/"+schemaPath, path) } // ReadMerged merges schemas linked at ~/testtrack/schemas into a single virtual schema @@ -97,7 +111,7 @@ func ReadMerged() (*serializers.Schema, error) { if err != nil { return nil, err } - paths, err := filepath.Glob(*configDir + "/schemas/*.yml") + paths, err := filepath.Glob(*configDir + "/schemas/*.*") if err != nil { return nil, err } From 2bea3de57dc39ef84b1f10800c485d78c7b958b0 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Tue, 20 Jan 2026 11:07:44 -0500 Subject: [PATCH 02/10] Separate validation from parsing --- splits/weights.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/splits/weights.go b/splits/weights.go index a27be76..3cea07d 100644 --- a/splits/weights.go +++ b/splits/weights.go @@ -10,10 +10,25 @@ import ( // Weights represents the weightings of a split type Weights map[string]int +// NewWeights creates a Weights instance from a map, validating that weights sum to 100 +func NewWeights(weights map[string]int) (*Weights, error) { + cumulativeWeight := 0 + for _, weight := range weights { + if weight < 0 { + return nil, fmt.Errorf("weight %d is less than zero", weight) + } + cumulativeWeight += weight + } + if cumulativeWeight != 100 { + return nil, fmt.Errorf("weights must sum to 100, got %d", cumulativeWeight) + } + w := Weights(weights) + return &w, nil +} + // WeightsFromYAML converts YAML-serializable weights to a weights map func WeightsFromYAML(yamlWeights yaml.MapSlice) (*Weights, error) { - weights := make(Weights) - cumulativeWeight := 0 + weights := make(map[string]int) for _, item := range yamlWeights { variant, ok := item.Key.(string) if !ok { @@ -23,16 +38,9 @@ func WeightsFromYAML(yamlWeights yaml.MapSlice) (*Weights, error) { if !ok { return nil, fmt.Errorf("weighting %v is not an int", item.Value) } - if weight < 0 { - return nil, fmt.Errorf("weight %d is less than zero", weight) - } - cumulativeWeight += weight weights[variant] = weight } - if cumulativeWeight != 100 { - return nil, fmt.Errorf("weights must sum to 100, got %d", cumulativeWeight) - } - return &weights, nil + return NewWeights(weights) } // ToYAML converts weights to a YAML-serializable representation From 2f167a8c4c5215fdc3a822fe97cbd2e3494dba86 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Tue, 20 Jan 2026 11:12:15 -0500 Subject: [PATCH 03/10] Stop using yaml.MapSlice --- cmds/sync.go | 2 +- fakeserver/routes.go | 6 +++--- schema/schema.go | 6 +++--- schemaloaders/schemaloaders.go | 2 +- serializers/serializers.go | 14 +++++++------- splitdecisions/splitdecisions.go | 6 +++--- splitretirements/splitretirements.go | 2 +- splits/splits.go | 12 ++++++------ validations/validations.go | 6 +----- 9 files changed, 26 insertions(+), 30 deletions(-) diff --git a/cmds/sync.go b/cmds/sync.go index b3491e2..0977b58 100644 --- a/cmds/sync.go +++ b/cmds/sync.go @@ -48,7 +48,7 @@ func Sync() error { remoteSplit, exists := splitRegistry.Splits[localSplit.Name] if exists { remoteWeights := splits.Weights(remoteSplit.Weights) - localSchema.Splits[ind].Weights = remoteWeights.ToYAML() + localSchema.Splits[ind].Weights = remoteWeights } } diff --git a/fakeserver/routes.go b/fakeserver/routes.go index 4730be6..49acea7 100644 --- a/fakeserver/routes.go +++ b/fakeserver/routes.go @@ -192,7 +192,7 @@ func getV1SplitRegistry() (interface{}, error) { } splitRegistry := map[string]*splits.Weights{} for _, split := range schema.Splits { - splitRegistry[split.Name], err = splits.WeightsFromYAML(split.Weights) + splitRegistry[split.Name], err = splits.NewWeights(split.Weights) if err != nil { return nil, err } @@ -208,7 +208,7 @@ func getV2PlusSplitRegistry() (interface{}, error) { splitRegistry := map[string]*v2Split{} for _, split := range schema.Splits { isFeatureGate := splits.IsFeatureGateFromName(split.Name) - weights, err := splits.WeightsFromYAML(split.Weights) + weights, err := splits.NewWeights(split.Weights) if err != nil { return nil, err } @@ -231,7 +231,7 @@ func getV4SplitRegistry() (interface{}, error) { v4Splits := make([]v4Split, 0, len(schema.Splits)) for _, split := range schema.Splits { isFeatureGate := splits.IsFeatureGateFromName(split.Name) - weights, err := splits.WeightsFromYAML(split.Weights) + weights, err := splits.NewWeights(split.Weights) if err != nil { return nil, err } diff --git a/schema/schema.go b/schema/schema.go index 98a8e0e..e3d3d09 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -170,18 +170,18 @@ func mergeLegacySchema(schema *serializers.Schema) error { if !ok { return fmt.Errorf("expected split name, got %v", mapSlice.Key) } - weightsYAML, ok := mapSlice.Value.(yaml.MapSlice) + weightsYAML, ok := mapSlice.Value.(map[string]int) if !ok { return fmt.Errorf("expected weights, got %v", mapSlice.Value) } - weights, err := splits.WeightsFromYAML(weightsYAML) + weights, err := splits.NewWeights(weightsYAML) if err != nil { return err } schema.Splits = append(schema.Splits, serializers.SchemaSplit{ Name: name, - Weights: weights.ToYAML(), + Weights: *weights, Decided: false, }) } diff --git a/schemaloaders/schemaloaders.go b/schemaloaders/schemaloaders.go index 222ce08..cbf5d22 100644 --- a/schemaloaders/schemaloaders.go +++ b/schemaloaders/schemaloaders.go @@ -98,7 +98,7 @@ func schemaSplitMigrations(schemaSplit serializers.SchemaSplit) ([]migrations.IM if schemaSplit.Decided { var decision *string - weights, err := splits.WeightsFromYAML(schemaSplit.Weights) + weights, err := splits.NewWeights(schemaSplit.Weights) if err != nil { return nil, fmt.Errorf("schema split %s invalid: %w", schemaSplit.Name, err) } diff --git a/serializers/serializers.go b/serializers/serializers.go index 7188161..7a70a30 100644 --- a/serializers/serializers.go +++ b/serializers/serializers.go @@ -40,9 +40,9 @@ type RemoteKill struct { // SplitYAML is the YAML-marshalable representation of a Split type SplitYAML struct { - Name string `yaml:"name"` - Weights yaml.MapSlice `yaml:"weights"` - Owner string `yaml:"owner,omitempty"` + Name string `yaml:"name"` + Weights map[string]int `yaml:"weights"` + Owner string `yaml:"owner,omitempty"` } // SplitJSON is the JSON-marshalabe representation of a Split @@ -80,10 +80,10 @@ type IdentifierType struct { // SchemaSplit is the schema-file YAML-marshalable representation of a split's state type SchemaSplit struct { - Name string `yaml:"name"` - Weights yaml.MapSlice `yaml:"weights"` - Decided bool `yaml:"decided,omitempty"` - Owner string `yaml:"owner,omitempty"` + Name string `yaml:"name"` + Weights map[string]int `yaml:"weights"` + Decided bool `yaml:"decided,omitempty"` + Owner string `yaml:"owner,omitempty"` } // Schema is the YAML-marshalable representation of the TestTrack schema for diff --git a/splitdecisions/splitdecisions.go b/splitdecisions/splitdecisions.go index cf8dfe1..3d9fb8d 100644 --- a/splitdecisions/splitdecisions.go +++ b/splitdecisions/splitdecisions.go @@ -97,7 +97,7 @@ func (s *SplitDecision) ApplyToSchema(schema *serializers.Schema, migrationRepo for i, candidate := range schema.Splits { if candidate.Name == *s.split { schema.Splits[i].Decided = true - weights, err := splits.WeightsFromYAML(candidate.Weights) + weights, err := splits.NewWeights(candidate.Weights) if err != nil { return err } @@ -105,7 +105,7 @@ func (s *SplitDecision) ApplyToSchema(schema *serializers.Schema, migrationRepo if err != nil { return fmt.Errorf("in split %s in schema: %w", *s.split, err) } - schema.Splits[i].Weights = weights.ToYAML() + schema.Splits[i].Weights = *weights return nil } } @@ -119,7 +119,7 @@ func (s *SplitDecision) ApplyToSchema(schema *serializers.Schema, migrationRepo } schema.Splits = append(schema.Splits, serializers.SchemaSplit{ Name: *s.split, - Weights: weights.ToYAML(), + Weights: *weights, Decided: true, }) return nil diff --git a/splitretirements/splitretirements.go b/splitretirements/splitretirements.go index a0f18fc..c9c5b2e 100644 --- a/splitretirements/splitretirements.go +++ b/splitretirements/splitretirements.go @@ -96,7 +96,7 @@ func (s *SplitRetirement) SameResourceAs(other migrations.IMigration) bool { func (s *SplitRetirement) ApplyToSchema(schema *serializers.Schema, _ migrations.Repository, _idempotently bool) error { for i, candidate := range schema.Splits { if candidate.Name == *s.split { - weights, err := splits.WeightsFromYAML(candidate.Weights) + weights, err := splits.NewWeights(candidate.Weights) if err != nil { return err } diff --git a/splits/splits.go b/splits/splits.go index 703d8ba..d471f9b 100644 --- a/splits/splits.go +++ b/splits/splits.go @@ -83,7 +83,7 @@ func IsFeatureGateFromName(name string) bool { // FromFile reifies a migration from the yaml serializable representation func FromFile(migrationVersion *string, serializable *serializers.SplitYAML) (migrations.IMigration, error) { - weights, err := WeightsFromYAML(serializable.Weights) + weights, err := NewWeights(serializable.Weights) if err != nil { return nil, err } @@ -111,7 +111,7 @@ func (s *Split) File() *serializers.MigrationFile { SerializerVersion: serializers.SerializerVersion, Split: &serializers.SplitYAML{ Name: *s.name, - Weights: s.weights.ToYAML(), + Weights: *s.weights, Owner: *s.owner, }, } @@ -152,13 +152,13 @@ func (s *Split) SameResourceAs(other migrations.IMigration) bool { func (s *Split) ApplyToSchema(schema *serializers.Schema, migrationRepo migrations.Repository, _idempotently bool) error { for i, candidate := range schema.Splits { // Replace if candidate.Name == *s.name { - schemaWeights, err := WeightsFromYAML(candidate.Weights) + schemaWeights, err := NewWeights(candidate.Weights) if err != nil { return err } schemaWeights.Merge(*s.weights) schema.Splits[i].Decided = false - schema.Splits[i].Weights = schemaWeights.ToYAML() + schema.Splits[i].Weights = *schemaWeights return nil } } @@ -169,7 +169,7 @@ func (s *Split) ApplyToSchema(schema *serializers.Schema, migrationRepo migratio weights.Merge(*s.weights) schema.Splits = append(schema.Splits, serializers.SchemaSplit{ Name: *s.name, - Weights: weights.ToYAML(), + Weights: *weights, Decided: false, }) return nil @@ -177,7 +177,7 @@ func (s *Split) ApplyToSchema(schema *serializers.Schema, migrationRepo migratio } schemaSplit := serializers.SchemaSplit{ // Create Name: *s.name, - Weights: s.weights.ToYAML(), + Weights: *s.weights, Decided: false, Owner: *s.owner, } diff --git a/validations/validations.go b/validations/validations.go index 79e4185..7000995 100644 --- a/validations/validations.go +++ b/validations/validations.go @@ -259,11 +259,7 @@ func VariantExistsInSchema(paramName string, variant *string, split string, sche } for _, schemaSplit := range schema.Splits { if schemaSplit.Name == split { - for _, item := range schemaSplit.Weights { - v, ok := item.Key.(string) - if !ok { - return fmt.Errorf("variant %v is not a string", item.Key) - } + for v := range schemaSplit.Weights { if v == *variant { return nil } From 39f8ca872bb1f2e94eeae99b5e9f9e8d5185e221 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Tue, 20 Jan 2026 11:12:40 -0500 Subject: [PATCH 04/10] Remove WeightsFromYAML and ToYAML --- splits/weights.go | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/splits/weights.go b/splits/weights.go index 3cea07d..d4f9931 100644 --- a/splits/weights.go +++ b/splits/weights.go @@ -2,9 +2,6 @@ package splits import ( "fmt" - "sort" - - "gopkg.in/yaml.v2" ) // Weights represents the weightings of a split @@ -26,37 +23,6 @@ func NewWeights(weights map[string]int) (*Weights, error) { return &w, nil } -// WeightsFromYAML converts YAML-serializable weights to a weights map -func WeightsFromYAML(yamlWeights yaml.MapSlice) (*Weights, error) { - weights := make(map[string]int) - for _, item := range yamlWeights { - variant, ok := item.Key.(string) - if !ok { - return nil, fmt.Errorf("variant %v is not a string", item.Key) - } - weight, ok := item.Value.(int) - if !ok { - return nil, fmt.Errorf("weighting %v is not an int", item.Value) - } - weights[variant] = weight - } - return NewWeights(weights) -} - -// ToYAML converts weights to a YAML-serializable representation -func (w *Weights) ToYAML() yaml.MapSlice { - var variants = make([]string, 0, len(*w)) - for variant := range *w { - variants = append(variants, variant) - } - sort.Strings(variants) - weightsYaml := make(yaml.MapSlice, 0, len(variants)) - for _, variant := range variants { - weightsYaml = append(weightsYaml, yaml.MapItem{Key: variant, Value: (*w)[variant]}) - } - return weightsYaml -} - // Merge newWeights over weights func (w *Weights) Merge(newWeights Weights) { for variant := range *w { From 60c986e60e0cb9a24f8bd853e7f4fb1c7919e772 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Tue, 20 Jan 2026 11:26:48 -0500 Subject: [PATCH 05/10] Write JSON by default, but continue writing YAML if yaml exists --- schema/schema.go | 18 ++++++++++++++---- serializers/serializers.go | 20 ++++++++++---------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/schema/schema.go b/schema/schema.go index e3d3d09..2a78428 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -1,6 +1,7 @@ package schema import ( + "encoding/json" "errors" "fmt" "os" @@ -15,7 +16,7 @@ import ( "gopkg.in/yaml.v2" ) -// Finds the path to the schema file (preferring JSON), or returns an error if neither exists +// Finds the path to the schema file (preferring JSON), or returns testtrack/schema.json and an error if neither exists func findSchemaPath() (string, error) { if _, err := os.Stat("testtrack/schema.json"); err == nil { return "testtrack/schema.json", nil @@ -23,7 +24,7 @@ func findSchemaPath() (string, error) { if _, err := os.Stat("testtrack/schema.yml"); err == nil { return "testtrack/schema.yml", nil } - return "", errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first") + return "testtrack/schema.json", errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first") } // Read a schema from disk or generate one @@ -65,12 +66,21 @@ func Generate() (*serializers.Schema, error) { // Write a schema to disk after alpha-sorting its resources func Write(schema *serializers.Schema) error { SortAlphabetically(schema) - out, err := yaml.Marshal(schema) + + schemaPath, _ := findSchemaPath() + + var out []byte + var err error + if filepath.Ext(schemaPath) == ".yml" { + out, err = yaml.Marshal(schema) + } else { + out, err = json.MarshalIndent(schema, "", " ") + } if err != nil { return err } - err = os.WriteFile("testtrack/schema.yml", out, 0644) + err = os.WriteFile(schemaPath, out, 0644) if err != nil { return err } diff --git a/serializers/serializers.go b/serializers/serializers.go index 7a70a30..34f06ae 100644 --- a/serializers/serializers.go +++ b/serializers/serializers.go @@ -80,21 +80,21 @@ type IdentifierType struct { // SchemaSplit is the schema-file YAML-marshalable representation of a split's state type SchemaSplit struct { - Name string `yaml:"name"` - Weights map[string]int `yaml:"weights"` - Decided bool `yaml:"decided,omitempty"` - Owner string `yaml:"owner,omitempty"` + Name string `yaml:"name" json:"name"` + Weights map[string]int `yaml:"weights" json:"weights"` + Decided bool `yaml:"decided,omitempty" json:"decided,omitempty"` + Owner string `yaml:"owner,omitempty" json:"owner,omitempty"` } // Schema is the YAML-marshalable representation of the TestTrack schema for // migration validation and bootstrapping of new ecosystems type Schema struct { - SerializerVersion int `yaml:"serializer_version"` - SchemaVersion string `yaml:"schema_version"` - Splits []SchemaSplit `yaml:"splits,omitempty"` - IdentifierTypes []IdentifierType `yaml:"identifier_types,omitempty"` - RemoteKills []RemoteKill `yaml:"remote_kills,omitempty"` - FeatureCompletions []FeatureCompletion `yaml:"feature_completions,omitempty"` + SerializerVersion int `yaml:"serializer_version" json:"serializer_version"` + SchemaVersion string `yaml:"schema_version" json:"schema_version"` + Splits []SchemaSplit `yaml:"splits,omitempty" json:"splits,omitempty"` + IdentifierTypes []IdentifierType `yaml:"identifier_types,omitempty" json:"identifier_types,omitempty"` + RemoteKills []RemoteKill `yaml:"remote_kills,omitempty" json:"remote_kills,omitempty"` + FeatureCompletions []FeatureCompletion `yaml:"feature_completions,omitempty" json:"feature_completions,omitempty"` } // LegacySchema represents the Rails migration-piggybacked testtrack schema files of old From e25bd6f1337b0b891a7a8da33069ddf164ea8f80 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Tue, 20 Jan 2026 12:30:06 -0500 Subject: [PATCH 06/10] Bump the version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fa3f87f..8ce3795 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL = /bin/sh -VERSION=1.7.1 +VERSION=1.8.0 BUILD=`git rev-parse HEAD` LDFLAGS=-ldflags "-w -s \ From ecb6eef40a7807ceb38457522a9da5b34ea9bf0d Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Wed, 21 Jan 2026 12:16:35 -0500 Subject: [PATCH 07/10] Fix segfault in `testtrack schema generate` (unrelated fix) --- splits/splits.go | 1 + 1 file changed, 1 insertion(+) diff --git a/splits/splits.go b/splits/splits.go index d471f9b..ddd6316 100644 --- a/splits/splits.go +++ b/splits/splits.go @@ -90,6 +90,7 @@ func FromFile(migrationVersion *string, serializable *serializers.SplitYAML) (mi return &Split{ migrationVersion: migrationVersion, name: &serializable.Name, + owner: &serializable.Owner, weights: weights, }, nil } From 188e546ddf43f28dd79fa865ec10b030dfa3ab44 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Wed, 21 Jan 2026 12:36:42 -0500 Subject: [PATCH 08/10] Fix broken symlink detection logic --- schema/schema.go | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/schema/schema.go b/schema/schema.go index 2a78428..2ff00a4 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -127,22 +127,14 @@ func ReadMerged() (*serializers.Schema, error) { } var mergedSchema serializers.Schema for _, path := range paths { - // Deref symlink - fi, err := os.Lstat(path) - if err != nil { - return nil, err - } - if fi.Mode()&os.ModeSymlink != 0 { - path, err = os.Readlink(path) - if err != nil { - continue // It's OK if this symlink isn't traversable (e.g. app was uninstalled), we'll just skip it. - } - } - // Read file schemaBytes, err := os.ReadFile(path) if err != nil { + if os.IsNotExist(err) { + continue // It's OK if this file doesn't exist (e.g. broken symlink, app was uninstalled), we'll just skip it. + } return nil, err } + var schema serializers.Schema err = yaml.Unmarshal(schemaBytes, &schema) if err != nil { From 3cff4bb461aaba8ac812db383a7a74e1e4c4f396 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Wed, 21 Jan 2026 12:53:53 -0500 Subject: [PATCH 09/10] Change the signature for `findSchemaPath` --- schema/schema.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/schema/schema.go b/schema/schema.go index 2ff00a4..9916da8 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -16,21 +16,21 @@ import ( "gopkg.in/yaml.v2" ) -// Finds the path to the schema file (preferring JSON), or returns testtrack/schema.json and an error if neither exists -func findSchemaPath() (string, error) { +// Finds the path to the schema file (preferring JSON), or returns testtrack/schema.json +func findSchemaPath() (string, bool) { if _, err := os.Stat("testtrack/schema.json"); err == nil { - return "testtrack/schema.json", nil + return "testtrack/schema.json", true } if _, err := os.Stat("testtrack/schema.yml"); err == nil { - return "testtrack/schema.yml", nil + return "testtrack/schema.yml", true } - return "testtrack/schema.json", errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first") + return "testtrack/schema.json", false } // Read a schema from disk or generate one func Read() (*serializers.Schema, error) { - schemaPath, err := findSchemaPath() - if err != nil { + schemaPath, exists := findSchemaPath() + if !exists { return Generate() } schemaBytes, err := os.ReadFile(schemaPath) @@ -90,9 +90,9 @@ func Write(schema *serializers.Schema) error { // Link a schema to the user's home dir func Link(force bool) error { - schemaPath, err := findSchemaPath() - if err != nil { - return err + schemaPath, exists := findSchemaPath() + if !exists { + return errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first") } dir, err := os.Getwd() if err != nil { From 49bb2d8353b6f2b5bb34bf365aeca0b5cfc63258 Mon Sep 17 00:00:00 2001 From: Ray Zane Date: Wed, 21 Jan 2026 12:56:27 -0500 Subject: [PATCH 10/10] Update docs/help information to include schema.json --- README.md | 2 +- cmds/schema_generate.go | 6 +++--- cmds/schema_load.go | 4 ++-- cmds/server.go | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b4b14b8..ceeef01 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ If you have a large organization, you may wish to tag ownership of splits to a s ### Syncing split assignments -If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL= testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.yml` file. +If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL= testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.{json,yml}` file. ## How to Contribute diff --git a/cmds/schema_generate.go b/cmds/schema_generate.go index 845e964..2f73897 100644 --- a/cmds/schema_generate.go +++ b/cmds/schema_generate.go @@ -7,10 +7,10 @@ import ( var schemaGenerateDoc = ` Reads the migrations in testtrack/migrate and writes the resulting schema state -to testtrack/schema.yml, overwriting the file if it already exists. Generate +to testtrack/schema.{json,yml}, overwriting the file if it already exists. Generate makes no TestTrack API calls. -In addition to refreshing a schema.yml file that may have been corrupted due to +In addition to refreshing a schema file that may have been corrupted due to a bad merge or bug that produced incorrect schema state, 'schema generate' will also validate that migrations merged from multiple development branches don't logically conflict, or else it will fail with errors. @@ -30,7 +30,7 @@ func init() { var schemaGenerateCmd = &cobra.Command{ Use: "generate", - Short: "Generate schema.yml from migration files", + Short: "Generate schema.{json,yml} from migration files", Long: schemaGenerateDoc, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/cmds/schema_load.go b/cmds/schema_load.go index 11b54e0..87fbb46 100644 --- a/cmds/schema_load.go +++ b/cmds/schema_load.go @@ -6,7 +6,7 @@ import ( ) var schemaLoadDoc = ` -Loads the testtrack/schema.yml state into TestTrack server. This operation is +Loads the testtrack/schema.{json,yml} state into TestTrack server. This operation is idempotent with a valid, consistent schema file, though might fail if your schema file became invalid due to a bad merge or a bug. @@ -27,7 +27,7 @@ func init() { var schemaLoadCmd = &cobra.Command{ Use: "load", - Short: "Load schema.yml state into TestTrack server", + Short: "Load schema.{json,yml} state into TestTrack server", Long: schemaLoadDoc, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/cmds/server.go b/cmds/server.go index 9504dc8..9e70971 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -6,7 +6,7 @@ import ( ) var serverDoc = ` -Run a fake TestTrack server for local development, backed by schema.yml files +Run a fake TestTrack server for local development, backed by schema.{json,yml} files and nonsense. `