From 9c61fe352f731652e8fa5e3ba0c4662c7849ec30 Mon Sep 17 00:00:00 2001 From: nitishfy Date: Fri, 23 Jan 2026 13:44:46 +0530 Subject: [PATCH 1/4] add split func Signed-off-by: nitishfy --- pkg/yaml/yaml.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go index 00bae5d882..9e3ae4500c 100644 --- a/pkg/yaml/yaml.go +++ b/pkg/yaml/yaml.go @@ -28,7 +28,7 @@ type Update struct { } // SetValuesInFile overwrites the specified file with the changes specified by -// the the list of Updates. Keys are of the form ..... +// the list of Updates. Keys are of the form ..... // Integers may be used as keys in cases where a specific node needs to be // selected from a sequence. An error is returned for any attempted update to a // key that does not exist or does not address a scalar node. Importantly, all @@ -79,7 +79,7 @@ func SetValuesInBytes(inBytes []byte, updates []Update) ([]byte, error) { } changesByLine := map[int]change{} for _, update := range updates { - keyPath := strings.Split(update.Key, ".") + keyPath := splitKeyPath(update.Key) line, col, err := findScalarNode(doc, keyPath) if err != nil { return nil, fmt.Errorf("error finding key %s: %w", update.Key, err) @@ -162,3 +162,25 @@ func findScalarNode(node *yaml.Node, keyPath []string) (int, int, error) { } return 0, 0, fmt.Errorf("key path not found") } + +// splitKeyPath splits a key string into path elements for traversal. +// +// Rules: +// 1. "." is treated as a path separator (original behavior). +// 2. "\." is treated as a literal dot in the key name (new behavior). +// +// Examples: +// - "image.tag" -> ["image", "tag"] +// - "example\.com/version" -> ["example.com", "version"] +func splitKeyPath(key string) []string { + placeholder := "__DOT__" + // Replace all escaped dots with a placeholder + key = strings.ReplaceAll(key, `\.`, placeholder) + // Split on unescaped dots + parts := strings.Split(key, ".") + // Finally,restore literal dots in each part + for i := range parts { + parts[i] = strings.ReplaceAll(parts[i], placeholder, ".") + } + return parts +} From 9750a469d78155a5c7afcb3246f65f77ee3ff8de Mon Sep 17 00:00:00 2001 From: nitishfy Date: Fri, 23 Jan 2026 13:44:57 +0530 Subject: [PATCH 2/4] add test Signed-off-by: nitishfy --- pkg/yaml/yaml_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/pkg/yaml/yaml_test.go b/pkg/yaml/yaml_test.go index d39d66a4e3..35e185e816 100644 --- a/pkg/yaml/yaml_test.go +++ b/pkg/yaml/yaml_test.go @@ -199,3 +199,141 @@ characters: }) } } + +func TestSplitKeyPath(t *testing.T) { + testCases := []struct { + input string + expected []string + }{ + { + input: "image.tag", + expected: []string{"image", "tag"}, + }, + { + input: "example\\.com/version", + expected: []string{"example.com/version"}, + }, + { + input: "configs.example\\.com/feature", + expected: []string{"configs", "example.com/feature"}, + }, + { + input: "containers.0.image", + expected: []string{"containers", "0", "image"}, + }, + { + input: "nested.key.with\\.dots.and\\.more", + expected: []string{"nested", "key", "with.dots", "and.more"}, + }, + } + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got := splitKeyPath(tc.input) + require.Equal(t, tc.expected, got) + }) + } +} + +func TestSetValuesInBytesWithEscapedDots(t *testing.T) { + yamlBytes := []byte(` +example.com/version: v1.0.0 +image: + tag: v1.0.0 +configs: + example.com/feature: false +containers: + - name: my-app + image: my-app:v1.0 +`) + updates := []Update{ + { + Key: "example\\.com/version", + Value: "v2.0.0", + }, + { + Key: "image.tag", + Value: "v2.0.0", + }, + { + Key: "configs.example\\.com/feature", + Value: true, + }, + { + Key: "containers.0.image", + Value: "my-app:v2.0.0", + }, + } + + expected := []byte(` +example.com/version: v2.0.0 +image: + tag: v2.0.0 +configs: + example.com/feature: true +containers: + - name: my-app + image: my-app:v2.0.0 +`) + + out, err := SetValuesInBytes(yamlBytes, updates) + require.NoError(t, err) + require.Equal(t, expected, out) +} + +func TestSetValuesInBytesWithEscapedDotNested(t *testing.T) { + yamlBytes := []byte(` +configs: + example.com/feature: + enabled: false +`) + + updates := []Update{ + { + Key: "configs.example\\.com/feature.enabled", + Value: true, + }, + } + + expected := []byte(` +configs: + example.com/feature: + enabled: true +`) + + out, err := SetValuesInBytes(yamlBytes, updates) + require.NoError(t, err) + require.Equal(t, expected, out) +} + +func TestSequenceAndLiteralDotTogether(t *testing.T) { + yamlBytes := []byte(` +services: + - name: api.example.com + port: 8080 + - name: web + port: 80 +`) + + updates := []Update{ + { + Key: "services.0.name", + Value: "api.newdomain.com", + }, + { + Key: "services.0.port", + Value: 9090, + }, + } + + expected := []byte(` +services: + - name: api.newdomain.com + port: 9090 + - name: web + port: 80 +`) + + out, err := SetValuesInBytes(yamlBytes, updates) + require.NoError(t, err) + require.Equal(t, expected, out) +} From fe13aee46d5433a9160fcf5d90795fea35e2155e Mon Sep 17 00:00:00 2001 From: nitishfy Date: Fri, 23 Jan 2026 13:45:05 +0530 Subject: [PATCH 3/4] add docs Signed-off-by: nitishfy --- .../30-promotion-steps/yaml-update.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md index f9504eec80..769965fd64 100644 --- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md @@ -24,6 +24,39 @@ followed by a [`helm-template` step](helm-template.md). |------|------|-------------| | `commitMessage` | `string` | A description of the change(s) applied by this step. Typically, a subsequent [`git-commit` step](git-commit.md) will reference this output and aggregate this commit message fragment with others like it to build a comprehensive commit message that describes all changes. | + +## Writing Keys + +1. **Nested keys:** +```yaml +image: + tag: v1.0.0 +``` +Update key: `image.tag` + +2**Keys with literal dots:** + +```yaml +example.com/version: v1.0.0 +``` +Update key: `example\.com/version` + +3. **Sequences/arrays:** +```yaml +containers: + - name: my-app + image: my-app:v1.0 +``` +Update key: `containers.0.image` + +4. **Combination of literal dots and nested maps:** +```yaml +configs: + example.com/feature: + enabled: false +``` +Update key: `configs.example\.com/feature.enabled` + ## Examples ### Common Usage From d763076e93a84f63bae7d3fe1ac873a355055c30 Mon Sep 17 00:00:00 2001 From: nitishfy Date: Tue, 3 Feb 2026 08:49:36 +0900 Subject: [PATCH 4/4] resolve comments Signed-off-by: nitishfy --- .../30-promotion-steps/yaml-update.md | 8 +++- pkg/yaml/yaml.go | 41 +++++++++++++------ pkg/yaml/yaml_test.go | 16 +++++++- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md index 769965fd64..e518abd386 100644 --- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md @@ -34,7 +34,7 @@ image: ``` Update key: `image.tag` -2**Keys with literal dots:** +2. **Keys with literal dots:** ```yaml example.com/version: v1.0.0 @@ -55,7 +55,11 @@ configs: example.com/feature: enabled: false ``` -Update key: `configs.example\.com/feature.enabled` + +:::note +Use `\\` to represent a literal backslash in keys. For example, `path\\to.file` +results in the path `["path\to", "file"]`. +::: ## Examples diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go index 9e3ae4500c..6396522612 100644 --- a/pkg/yaml/yaml.go +++ b/pkg/yaml/yaml.go @@ -165,22 +165,37 @@ func findScalarNode(node *yaml.Node, keyPath []string) (int, int, error) { // splitKeyPath splits a key string into path elements for traversal. // -// Rules: -// 1. "." is treated as a path separator (original behavior). -// 2. "\." is treated as a literal dot in the key name (new behavior). +// Escape sequences: +// - \. → literal dot (not a separator) +// - \\ → literal backslash +// - \x → x (any other escaped char becomes itself) // // Examples: -// - "image.tag" -> ["image", "tag"] -// - "example\.com/version" -> ["example.com", "version"] +// - "image.tag" → ["image", "tag"] +// - "example\.com/version" → ["example.com/version"] +// - "path\\to.file" → ["path\to", "file"] func splitKeyPath(key string) []string { - placeholder := "__DOT__" - // Replace all escaped dots with a placeholder - key = strings.ReplaceAll(key, `\.`, placeholder) - // Split on unescaped dots - parts := strings.Split(key, ".") - // Finally,restore literal dots in each part - for i := range parts { - parts[i] = strings.ReplaceAll(parts[i], placeholder, ".") + var parts []string + var current strings.Builder + escaped := false + for _, r := range key { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '.': + parts = append(parts, current.String()) + current.Reset() + default: + current.WriteRune(r) + } + } + // Trailing backslash is preserved literally + if escaped { + current.WriteRune('\\') } + parts = append(parts, current.String()) return parts } diff --git a/pkg/yaml/yaml_test.go b/pkg/yaml/yaml_test.go index 35e185e816..09df38415c 100644 --- a/pkg/yaml/yaml_test.go +++ b/pkg/yaml/yaml_test.go @@ -222,8 +222,20 @@ func TestSplitKeyPath(t *testing.T) { expected: []string{"containers", "0", "image"}, }, { - input: "nested.key.with\\.dots.and\\.more", - expected: []string{"nested", "key", "with.dots", "and.more"}, + input: "foo\\\\bar", + expected: []string{"foo\\bar"}, + }, + { + input: "foo\\\\.bar", + expected: []string{"foo\\", "bar"}, + }, + { + input: "foo\\", + expected: []string{"foo\\"}, + }, + { + input: "foo\\.\\.bar", + expected: []string{"foo..bar"}, }, } for _, tc := range testCases {