From 74af1ee54ab12f8db4cfdbda2791fbe0127f380d Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:55:30 +0000 Subject: [PATCH] Support embedded versions in YAML values (e.g., image tags) When the current version (with prefix) is found within a YAML value, only that portion is replaced instead of the entire value. Example: `ghcr.io/stacklok/toolhive/proxyrunner:v0.7.1` with prefix "v" and current version "0.7.1" will update to `ghcr.io/stacklok/toolhive/proxyrunner:v0.8.0` This allows updating version references embedded in container image tags without needing regex patterns. Co-Authored-By: Claude Opus 4.5 --- internal/files/yaml.go | 26 ++++-- internal/files/yaml_test.go | 153 +++++++++++++++++++++--------------- main.go | 21 ++--- 3 files changed, 121 insertions(+), 79 deletions(-) diff --git a/internal/files/yaml.go b/internal/files/yaml.go index 2819ee4..e3e41b3 100644 --- a/internal/files/yaml.go +++ b/internal/files/yaml.go @@ -33,7 +33,8 @@ type VersionFileConfig struct { // UpdateYAMLFile updates a specific path in a YAML file with a new version. // It uses surgical text replacement to preserve the original file formatting. -func UpdateYAMLFile(cfg VersionFileConfig, version string) error { +// The currentVersion is used to find embedded versions within larger values (e.g., image tags). +func UpdateYAMLFile(cfg VersionFileConfig, currentVersion, newVersion string) error { // Read the file content data, err := os.ReadFile(cfg.File) if err != nil { @@ -52,16 +53,29 @@ func UpdateYAMLFile(cfg VersionFileConfig, version string) error { return fmt.Errorf("creating path %s: %w", yamlPath, err) } - var currentValue string - if err := path.Read(bytes.NewReader(data), ¤tValue); err != nil { + var valueAtPath string + if err := path.Read(bytes.NewReader(data), &valueAtPath); err != nil { return fmt.Errorf("path %s not found in %s: %w", cfg.Path, cfg.File, err) } - // Apply prefix (empty prefix just results in version) - newValue := cfg.Prefix + version + // Build the old and new version strings with prefix + oldVersionStr := cfg.Prefix + currentVersion + newVersionStr := cfg.Prefix + newVersion + + // Determine what to replace: either the embedded version or the entire value + var oldValue, newValue string + if strings.Contains(valueAtPath, oldVersionStr) { + // Embedded version found - replace just the version portion + oldValue = valueAtPath + newValue = strings.Replace(valueAtPath, oldVersionStr, newVersionStr, 1) + } else { + // No embedded version - replace the entire value (original behavior) + oldValue = valueAtPath + newValue = newVersionStr + } // Perform surgical replacement - find and replace only the value - newData, err := surgicalReplace(data, currentValue, newValue) + newData, err := surgicalReplace(data, oldValue, newValue) if err != nil { return fmt.Errorf("replacing value at path %s: %w", cfg.Path, err) } diff --git a/internal/files/yaml_test.go b/internal/files/yaml_test.go index 3752ad3..21627f0 100644 --- a/internal/files/yaml_test.go +++ b/internal/files/yaml_test.go @@ -23,12 +23,13 @@ func TestUpdateYAMLFile(t *testing.T) { t.Parallel() tests := []struct { - name string - input string - config VersionFileConfig - version string - wantContain string - wantErr bool + name string + input string + config VersionFileConfig + currentVersion string + newVersion string + wantContain string + wantErr bool }{ { name: "simple path", @@ -37,9 +38,10 @@ metadata: name: test version: 1.0.0 `, - config: VersionFileConfig{Path: "metadata.version"}, - version: "2.0.0", - wantContain: "version: 2.0.0", + config: VersionFileConfig{Path: "metadata.version"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantContain: "version: 2.0.0", }, { name: "nested path", @@ -49,66 +51,78 @@ metadata: image: tag: v1.0.0 `, - config: VersionFileConfig{Path: "spec.template.spec.image.tag"}, - version: "2.0.0", - wantContain: "tag: 2.0.0", + config: VersionFileConfig{Path: "spec.template.spec.image.tag", Prefix: "v"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantContain: "tag: v2.0.0", }, { name: "with prefix", input: `image: tag: v1.0.0 `, - config: VersionFileConfig{Path: "image.tag", Prefix: "v"}, - version: "2.0.0", - wantContain: "tag: v2.0.0", + config: VersionFileConfig{Path: "image.tag", Prefix: "v"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantContain: "tag: v2.0.0", }, { name: "without prefix", input: `image: tag: 1.0.0 `, - config: VersionFileConfig{Path: "image.tag"}, - version: "2.0.0", - wantContain: "tag: 2.0.0", + config: VersionFileConfig{Path: "image.tag"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantContain: "tag: 2.0.0", + }, + { + name: "embedded version in image tag", + input: `toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.7.1 +`, + config: VersionFileConfig{Path: "toolhiveRunnerImage", Prefix: "v"}, + currentVersion: "0.7.1", + newVersion: "0.8.0", + wantContain: "toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.8.0", }, { - name: "array index", - input: `containers: - - name: app - image: myapp:v1.0.0 - - name: sidecar - image: sidecar:v1.0.0 + name: "embedded version without prefix", + input: `image: myregistry.io/app:1.0.0-alpine `, - config: VersionFileConfig{Path: "containers[0].image"}, - version: "myapp:v2.0.0", - wantContain: "image: myapp:v2.0.0", + config: VersionFileConfig{Path: "image"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantContain: "image: myregistry.io/app:2.0.0-alpine", }, { name: "top level key", input: `version: 1.0.0 name: myapp `, - config: VersionFileConfig{Path: "version"}, - version: "2.0.0", - wantContain: "version: 2.0.0", + config: VersionFileConfig{Path: "version"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantContain: "version: 2.0.0", }, { name: "key not found", input: `metadata: name: test `, - config: VersionFileConfig{Path: "metadata.version"}, - version: "2.0.0", - wantErr: true, + config: VersionFileConfig{Path: "metadata.version"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantErr: true, }, { name: "invalid path - missing parent", input: `metadata: name: test `, - config: VersionFileConfig{Path: "spec.version"}, - version: "2.0.0", - wantErr: true, + config: VersionFileConfig{Path: "spec.version"}, + currentVersion: "1.0.0", + newVersion: "2.0.0", + wantErr: true, }, } @@ -121,7 +135,7 @@ name: myapp cfg := tt.config cfg.File = tmpPath - err := UpdateYAMLFile(cfg, tt.version) + err := UpdateYAMLFile(cfg, tt.currentVersion, tt.newVersion) if (err != nil) != tt.wantErr { t.Errorf("UpdateYAMLFile() error = %v, wantErr %v", err, tt.wantErr) return @@ -147,7 +161,7 @@ func TestUpdateYAMLFile_FileNotFound(t *testing.T) { Path: "version", } - err := UpdateYAMLFile(cfg, "1.0.0") + err := UpdateYAMLFile(cfg, "0.9.0", "1.0.0") if err == nil { t.Error("UpdateYAMLFile() expected error for nonexistent file") } @@ -177,7 +191,7 @@ data: Path: "data.version", } - if err := UpdateYAMLFile(cfg, "2.0.0"); err != nil { + if err := UpdateYAMLFile(cfg, "1.0.0", "2.0.0"); err != nil { t.Fatalf("UpdateYAMLFile() error = %v", err) } @@ -250,7 +264,7 @@ func TestUpdateYAMLFile_InvalidPath(t *testing.T) { Path: ".image.tag", } - err := UpdateYAMLFile(cfg, "2.0.0") + err := UpdateYAMLFile(cfg, "1.0.0", "2.0.0") if err == nil { t.Error("UpdateYAMLFile() expected error for path starting with '.'") } @@ -297,11 +311,12 @@ func TestUpdateYAMLFile_PreservesQuotes(t *testing.T) { tmpPath := createTempFile(t, tt.input, "yaml-test-*.yaml") cfg := VersionFileConfig{ - File: tmpPath, - Path: "image.tag", + File: tmpPath, + Path: "image.tag", + Prefix: "v", } - if err := UpdateYAMLFile(cfg, "v2.0.0"); err != nil { + if err := UpdateYAMLFile(cfg, "1.0.0", "2.0.0"); err != nil { t.Fatalf("UpdateYAMLFile() error = %v", err) } @@ -317,19 +332,23 @@ func TestUpdateYAMLFile_PreservesComments(t *testing.T) { t.Parallel() tests := []struct { - name string - input string - path string - version string - wantContains []string + name string + input string + path string + prefix string + currentVersion string + newVersion string + wantContains []string }{ { name: "preserves inline comment after value", input: `image: tag: v1.0.0 # current version `, - path: "image.tag", - version: "v2.0.0", + path: "image.tag", + prefix: "v", + currentVersion: "1.0.0", + newVersion: "2.0.0", wantContains: []string{ "tag: v2.0.0 # current version", }, @@ -340,8 +359,10 @@ func TestUpdateYAMLFile_PreservesComments(t *testing.T) { # This is the image tag tag: v1.0.0 `, - path: "image.tag", - version: "v2.0.0", + path: "image.tag", + prefix: "v", + currentVersion: "1.0.0", + newVersion: "2.0.0", wantContains: []string{ "# This is the image tag", "tag: v2.0.0", @@ -356,8 +377,9 @@ func TestUpdateYAMLFile_PreservesComments(t *testing.T) { # Author information author: test `, - path: "metadata.version", - version: "2.0.0", + path: "metadata.version", + currentVersion: "1.0.0", + newVersion: "2.0.0", wantContains: []string{ "# Version information", "version: 2.0.0", @@ -373,8 +395,9 @@ apiVersion: v1 metadata: version: 1.0.0 `, - path: "metadata.version", - version: "2.0.0", + path: "metadata.version", + currentVersion: "1.0.0", + newVersion: "2.0.0", wantContains: []string{ "# This file is auto-generated", "# Do not edit manually", @@ -389,8 +412,10 @@ metadata: tag: v1.0.0 # image version repo: myrepo # image repository `, - path: "spec.image.tag", - version: "v2.0.0", + path: "spec.image.tag", + prefix: "v", + currentVersion: "1.0.0", + newVersion: "2.0.0", wantContains: []string{ "replicas: 3 # number of replicas", "tag: v2.0.0 # image version", @@ -408,8 +433,9 @@ app: database: host: localhost `, - path: "app.version", - version: "2.0.0", + path: "app.version", + currentVersion: "1.0.0", + newVersion: "2.0.0", wantContains: []string{ "# ============================================", "# Application Configuration", @@ -426,11 +452,12 @@ app: tmpPath := createTempFile(t, tt.input, "yaml-test-*.yaml") cfg := VersionFileConfig{ - File: tmpPath, - Path: tt.path, + File: tmpPath, + Path: tt.path, + Prefix: tt.prefix, } - if err := UpdateYAMLFile(cfg, tt.version); err != nil { + if err := UpdateYAMLFile(cfg, tt.currentVersion, tt.newVersion); err != nil { t.Fatalf("UpdateYAMLFile() error = %v", err) } diff --git a/main.go b/main.go index 6e9498f..ad91324 100644 --- a/main.go +++ b/main.go @@ -53,13 +53,13 @@ func main() { func run(ctx context.Context, cfg Config) error { // Bump version - newVersion, err := bumpVersion(cfg) + currentVersion, newVersion, err := bumpVersion(cfg) if err != nil { return err } // Update all files - helmDocsFiles := updateAllFiles(cfg, newVersion.String()) + helmDocsFiles := updateAllFiles(cfg, currentVersion, newVersion.String()) // Create the release PR pr, err := createReleasePR(ctx, cfg, newVersion.String(), helmDocsFiles) @@ -76,34 +76,35 @@ func run(ctx context.Context, cfg Config) error { } // bumpVersion reads the current version and bumps it according to the bump type. -func bumpVersion(cfg Config) (*version.Version, error) { +// Returns the current version string and the new version. +func bumpVersion(cfg Config) (string, *version.Version, error) { currentVersion, err := files.ReadVersion(cfg.VersionFile) if err != nil { - return nil, fmt.Errorf("reading version: %w", err) + return "", nil, fmt.Errorf("reading version: %w", err) } fmt.Printf("Current version: %s\n", currentVersion) v, err := version.Parse(currentVersion) if err != nil { - return nil, fmt.Errorf("parsing version: %w", err) + return "", nil, fmt.Errorf("parsing version: %w", err) } newVersion, err := v.Bump(cfg.BumpType) if err != nil { - return nil, fmt.Errorf("bumping version: %w", err) + return "", nil, fmt.Errorf("bumping version: %w", err) } fmt.Printf("New version: %s (%s bump)\n", newVersion, cfg.BumpType) if !version.IsGreater(newVersion.String(), currentVersion) { - return nil, fmt.Errorf("new version %s is not greater than current %s", newVersion, currentVersion) + return "", nil, fmt.Errorf("new version %s is not greater than current %s", newVersion, currentVersion) } - return newVersion, nil + return currentVersion, newVersion, nil } // updateAllFiles updates the VERSION file, custom version files, and runs helm-docs. // Returns the list of files modified by helm-docs. -func updateAllFiles(cfg Config, newVersion string) []string { +func updateAllFiles(cfg Config, currentVersion, newVersion string) []string { // Update VERSION file if err := files.WriteVersion(cfg.VersionFile, newVersion); err != nil { fmt.Printf("Warning: could not write version file: %v\n", err) @@ -113,7 +114,7 @@ func updateAllFiles(cfg Config, newVersion string) []string { // Update custom version files for _, vf := range cfg.VersionFiles { - if err := files.UpdateYAMLFile(vf, newVersion); err != nil { + if err := files.UpdateYAMLFile(vf, currentVersion, newVersion); err != nil { fmt.Printf("Warning: could not update %s at %s: %v\n", vf.File, vf.Path, err) } else { fmt.Printf("Updated %s at path %s\n", vf.File, vf.Path)