Skip to content
Merged
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
35 changes: 35 additions & 0 deletions internal/files/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
//
Expand Down
137 changes: 137 additions & 0 deletions internal/files/yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down