diff --git a/internal/files/yaml.go b/internal/files/yaml.go index 317071f..33283cb 100644 --- a/internal/files/yaml.go +++ b/internal/files/yaml.go @@ -68,6 +68,14 @@ func UpdateYAMLFile(cfg VersionFileConfig, currentVersion, newVersion string) er // Embedded version found - replace just the version portion oldValue = valueAtPath newValue = strings.Replace(valueAtPath, oldVersionStr, newVersionStr, 1) + } else if embeddedVersion := findEmbeddedVersion(valueAtPath, cfg.Prefix); embeddedVersion != "" { + // Value contains an embedded version, but it doesn't match currentVersion + // This indicates a version mismatch that should be fixed before releasing + return fmt.Errorf("version mismatch in %s at path %s: "+ + "expected to find %q but found %q in value %q. "+ + "This usually means the file was not updated in a previous release. "+ + "Please manually update the version in this file to %q before running releaseo", + cfg.File, cfg.Path, oldVersionStr, embeddedVersion, valueAtPath, oldVersionStr) } else { // No embedded version - replace the entire value (original behavior) oldValue = valueAtPath @@ -168,6 +176,33 @@ func surgicalReplace(data []byte, oldValue, newValue string) ([]byte, error) { return nil, fmt.Errorf("could not find value %q to replace", oldValue) } +// findEmbeddedVersion looks for a version pattern in the value and returns it if found. +// It detects patterns like ":v1.2.3", ":1.2.3", or prefix followed by semver at end of string. +// Returns empty string if no embedded version is detected. +func findEmbeddedVersion(value, prefix string) string { + // Pattern to match versions: optional prefix + semver (major.minor.patch with optional prerelease) + // Looks for versions after ":" (common in image tags) or at end of string + patterns := []string{ + // Image tag style: repo:v1.2.3 or repo:1.2.3 + `:` + regexp.QuoteMeta(prefix) + `(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)`, + // Version at end of string with prefix + regexp.QuoteMeta(prefix) + `(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)$`, + } + + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + if matches := re.FindStringSubmatch(value); matches != nil { + // Return the full match including prefix (reconstruct it) + if strings.HasPrefix(matches[0], ":") { + return matches[0][1:] // Remove leading ":" + } + return matches[0] + } + } + + return "" +} + // convertToYAMLPath converts a dot notation path to YAML path format. // Examples: // diff --git a/internal/files/yaml_test.go b/internal/files/yaml_test.go index c87768a..6eaca72 100644 --- a/internal/files/yaml_test.go +++ b/internal/files/yaml_test.go @@ -328,6 +328,143 @@ func TestUpdateYAMLFile_PreservesQuotes(t *testing.T) { } } +func TestUpdateYAMLFile_VersionMismatch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + config VersionFileConfig + currentVersion string + newVersion string + wantErrContain string + }{ + { + name: "image tag version mismatch", + input: `operator: + image: ghcr.io/stacklok/toolhive/operator:v0.8.1 +`, + config: VersionFileConfig{Path: "operator.image", Prefix: "v"}, + currentVersion: "0.8.2", + newVersion: "0.8.3", + wantErrContain: "version mismatch", + }, + { + name: "image tag version mismatch shows found version", + input: `toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.7.1 +`, + config: VersionFileConfig{Path: "toolhiveRunnerImage", Prefix: "v"}, + currentVersion: "0.8.0", + newVersion: "0.8.1", + wantErrContain: "v0.7.1", + }, + { + name: "image tag version mismatch shows expected version", + input: `image: registry.io/app:v1.0.0 +`, + config: VersionFileConfig{Path: "image", Prefix: "v"}, + currentVersion: "2.0.0", + newVersion: "2.0.1", + wantErrContain: "v2.0.0", + }, + { + name: "version mismatch without prefix", + input: `image: myregistry.io/app:1.0.0 +`, + config: VersionFileConfig{Path: "image"}, + currentVersion: "2.0.0", + newVersion: "2.0.1", + wantErrContain: "version mismatch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpPath := createTempFile(t, tt.input, "yaml-test-*.yaml") + + cfg := tt.config + cfg.File = tmpPath + + err := UpdateYAMLFile(cfg, tt.currentVersion, tt.newVersion) + if err == nil { + t.Error("UpdateYAMLFile() expected error for version mismatch") + return + } + + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("UpdateYAMLFile() error should contain %q, got: %v", tt.wantErrContain, err) + } + }) + } +} + +func TestFindEmbeddedVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + prefix string + want string + }{ + { + name: "image tag with v prefix", + value: "ghcr.io/stacklok/toolhive/operator:v0.8.1", + prefix: "v", + want: "v0.8.1", + }, + { + name: "image tag without prefix", + value: "myregistry.io/app:1.0.0", + prefix: "", + want: "1.0.0", + }, + { + name: "image tag with prerelease", + value: "registry.io/app:v1.0.0-alpha.1", + prefix: "v", + want: "v1.0.0-alpha.1", + }, + { + name: "simple version at end", + value: "v1.2.3", + prefix: "v", + want: "v1.2.3", + }, + { + name: "no version found", + value: "some-random-string", + prefix: "v", + want: "", + }, + { + name: "no version in plain text", + value: "hello world", + prefix: "", + want: "", + }, + { + name: "version in URL path (not tag)", + value: "https://example.com/v1/api", + prefix: "v", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := findEmbeddedVersion(tt.value, tt.prefix) + if got != tt.want { + t.Errorf("findEmbeddedVersion(%q, %q) = %q, want %q", tt.value, tt.prefix, got, tt.want) + } + }) + } +} + func TestUpdateYAMLFile_PreservesComments(t *testing.T) { t.Parallel()