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
26 changes: 20 additions & 6 deletions internal/files/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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), &currentValue); 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)
}
Expand Down
153 changes: 90 additions & 63 deletions internal/files/yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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,
},
}

Expand All @@ -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
Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 '.'")
}
Expand Down Expand Up @@ -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)
}

Expand All @@ -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",
},
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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)
}

Expand Down
Loading