Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,43 @@ 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
```

:::note
Use `\\` to represent a literal backslash in keys. For example, `path\\to.file`
results in the path `["path\to", "file"]`.
:::

## Examples

### Common Usage
Expand Down
41 changes: 39 additions & 2 deletions pkg/yaml/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key 0>.<key 1>...<key n>.
// the list of Updates. Keys are of the form <key 0>.<key 1>...<key n>.
// 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -162,3 +162,40 @@ 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.
//
// 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"]
// - "path\\to.file" → ["path\to", "file"]
func splitKeyPath(key string) []string {
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
}
150 changes: 150 additions & 0 deletions pkg/yaml/yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,153 @@ 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: "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 {
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)
}