Repository: argoproj-labs/argocd-image-updater

Issue:
Image Updater overrides Helm values with empty file in multi-source applications
**Describe the bug**
When updating an image in a Helm chart deployed via multi-source application (see below) using the `helmvalues` write-back-target, the image updater overrides the specified values file with an empty file (or creates an empty file if it doesn't exist) instead of writing image names/tags.

**To Reproduce**

Create an application that deploys _both_ a Helm chart and extra resources via kustomize. Have the image updater update an image used in the helm chart using the Git write-back-method and a Helm values write-back-target.

In my minimal reproduction case, I'm using the [Codecentric Keycloakx](https://github.com/codecentric/helm-charts/tree/master/charts/keycloakx) chart with an init container for the image to update. The directory structure in the GitOps repo is this:

```
apps/keycloak-playground/overlays/dev
 |- config.yaml
 |- kustomization.yaml
 |- cm.yaml
 |- values.yaml
```

The application manifest is generated from the `config.yaml` file using an application set (resulting manifest below).

<details>
<summary>Application manifest</summary>

```yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  annotations:
    argocd-image-updater.argoproj.io/git-repository: https://redacted.example.com/gitops.git
    argocd-image-updater.argoproj.io/image-list: theme=docker.io/hello-world:latest
    argocd-image-updater.argoproj.io/theme.helm.image-name: theme-image.name
    argocd-image-updater.argoproj.io/theme.helm.image-tag: theme-image.tag
    argocd-image-updater.argoproj.io/theme.update-strategy: digest
    argocd-image-updater.argoproj.io/write-back-method: git
    argocd-image-updater.argoproj.io/write-back-target: helmvalues:/apps/keycloak-playground/overlays/dev/theme-version.yaml
  name: keycloak-playground-dev
  namespace: argocd
spec:
  destination:
    namespace: dev
    server: https://kubernetes.default.svc
  project: dev
  sources:
    - path: apps/keycloak-playground/overlays/dev
      repoURL: https://redacted.example.com/gitops.git
      targetRevision: master
    - ref: appGitops
      repoURL: https://redacted.example.com/gitops.git
      targetRevision: master
    - chart: keycloakx
      helm:
        valueFiles:
          - $appGitops/apps/keycloak-playground/overlays/dev/values.yaml
      repoURL: ghcr.io/codecentric/helm-charts
      targetRevision: 7.1.4
```

</details>

<details>
<summary>kustomization.yaml</summary>

```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - cm.yaml
```

</details>

<details>
<summary>cm.yaml</summary>

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: keycloak-test-config
data:
  key: val
```

</details>

<details>
<summary>values.yaml</summary>

```yaml
extraInitContainers: |
  - name: theme
    image: docker.io/hello-world
  
args:
  - start-dev
```

</details>

**Expected behavior**
I expect the image updater to create/update the `theme-version.yaml` file with an image name and tag.

**Additional context**
Add any other context about the problem here.

**Version**
Image Updater: 0.17.0 (chart version 0.13.0)
Argo CD: 3.0.12

**Logs**

This happens on every update cycle:

```
time="2025-10-22T12:47:49Z" level=info msg="Starting configmap/secret informers"
time="2025-10-22T12:47:49Z" level=info msg="Configmap/secret informer synced"
time="2025-10-22T12:49:54Z" level=info msg="Initializing https://redacted.example.com/gitops.git to /tmp/git-keycloak-playground-dev3539967519"
time="2025-10-22T12:49:54Z" level=info msg="secrets informer cancelled"
time="2025-10-22T12:49:54Z" level=info msg="configmap informer cancelled"
time="2025-10-22T12:49:54Z" level=info msg="git fetch origin master --force --prune --depth 1" dir=/tmp/git-keycloak-playground-dev3539967519 execID=275c3
time="2025-10-22T12:49:54Z" level=info msg=Trace args="[git fetch origin master --force --prune --depth 1]" dir=/tmp/git-keycloak-playground-dev3539967519 operation_name="exec git" time_ms=578.967579
time="2025-10-22T12:49:54Z" level=info msg="git checkout --force master" dir=/tmp/git-keycloak-playground-dev3539967519 execID=0ecd9
time="2025-10-22T12:49:54Z" level=info msg=Trace args="[git checkout --force master]" dir=/tmp/git-keycloak-playground-dev3539967519 operation_name="exec git" time_ms=8.943865
time="2025-10-22T12:49:54Z" level=info msg="git clean -ffdx" dir=/tmp/git-keycloak-playground-dev3539967519 execID=391ef
time="2025-10-22T12:49:54Z" level=info msg=Trace args="[git clean -ffdx]" dir=/tmp/git-keycloak-playground-dev3539967519 operation_name="exec git" time_ms=1.4543100000000002
time="2025-10-22T12:49:54Z" level=info msg="Successfully updated the live application spec" application=keycloak-playground-dev
```

No errors/warnings.


Hints from issue comments:
@DeZin7: @JohnnyJayJay Had the same issue and couldnt figure out a solution if using version 0.17.0 which forced me to rollback to 0.14.0 and it started to work properly.  Looking at the v0.17.0 [code](https://github.com/argoproj-labs/argocd-image-updater/blob/master/pkg/argocd/update.go), the issue is likely in how the yaml.Node is being marshaled back. The yaml.Node structure might be getting corrupted or the marshal is producing empty output.

Solution patch:
diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go
index 65c79c0..c7e0873 100644
--- a/pkg/argocd/update.go
+++ b/pkg/argocd/update.go
@@ -455,6 +455,21 @@ func marshalParamsOverride(app *v1alpha1.Application, originalData []byte) ([]by
 	appType := GetApplicationType(app)
 	appSource := getApplicationSource(app)
 
+	// Special handling for multi-source applications with helmvalues write-back target
+	// We need to ensure we get the Helm source, not the Kustomize source
+	if strings.HasPrefix(app.Annotations[common.WriteBackTargetAnnotation], common.HelmPrefix) {
+		// For helmvalues write-back, we need the Helm source specifically
+		if app.Spec.HasMultipleSources() {
+			for _, s := range app.Spec.Sources {
+				if s.Helm != nil {
+					appSource = &s
+					appType = ApplicationTypeHelm
+					break
+				}
+			}
+		}
+	}
+
 	switch appType {
 	case ApplicationTypeKustomize:
 		if appSource.Kustomize == nil {



Test patch:
diff --git a/pkg/argocd/update_test.go b/pkg/argocd/update_test.go
index ea83400..32a9275 100644
--- a/pkg/argocd/update_test.go
+++ b/pkg/argocd/update_test.go
@@ -2242,6 +2242,82 @@ replicas: 1
 		assert.Error(t, err)
 	})
 
+	t.Run("Multi-source application with Kustomize first and Helm values write-back", func(t *testing.T) {
+		// This reproduces issue #1289 where getApplicationSource returns the Kustomize source
+		// instead of the Helm source for multi-source applications
+		expected := `
+# auto generated by argocd image updater
+
+theme:
+  image:
+    tag: latest
+    name: docker.io/hello-world
+`
+		app := v1alpha1.Application{
+			ObjectMeta: v1.ObjectMeta{
+				Name: "multi-source-app",
+				Annotations: map[string]string{
+					"argocd-image-updater.argoproj.io/image-list":            "theme=docker.io/hello-world:latest",
+					"argocd-image-updater.argoproj.io/write-back-method":     "git",
+					"argocd-image-updater.argoproj.io/write-back-target":     "helmvalues:/apps/test/theme-version.yaml",
+					"argocd-image-updater.argoproj.io/theme.helm.image-name": "theme.image.name",
+					"argocd-image-updater.argoproj.io/theme.helm.image-tag":  "theme.image.tag",
+				},
+			},
+			Spec: v1alpha1.ApplicationSpec{
+				Sources: []v1alpha1.ApplicationSource{
+					{
+						// First source: Kustomize (this gets picked up by getApplicationSource!)
+						Path:      "apps/test/overlays/dev",
+						RepoURL:   "https://example.com/gitops.git",
+						Kustomize: &v1alpha1.ApplicationSourceKustomize{
+							// Empty kustomize
+						},
+					},
+					{
+						// Second source: Helm chart (this is what we want!)
+						Chart:   "my-chart",
+						RepoURL: "https://example.com/charts",
+						Helm: &v1alpha1.ApplicationSourceHelm{
+							Parameters: []v1alpha1.HelmParameter{
+								{
+									Name:        "theme.image.name",
+									Value:       "docker.io/hello-world",
+									ForceString: true,
+								},
+								{
+									Name:        "theme.image.tag",
+									Value:       "latest",
+									ForceString: true,
+								},
+							},
+						},
+					},
+				},
+			},
+			Status: v1alpha1.ApplicationStatus{
+				SourceTypes: []v1alpha1.ApplicationSourceType{
+					v1alpha1.ApplicationSourceTypeKustomize,
+					v1alpha1.ApplicationSourceTypeHelm,
+				},
+				Summary: v1alpha1.ApplicationSummary{
+					Images: []string{
+						"docker.io/hello-world:latest",
+					},
+				},
+			},
+		}
+
+		// Simulate creating a new file (nil originalData)
+		yamlOutput, err := marshalParamsOverride(&app, nil)
+		require.NoError(t, err)
+		assert.NotEmpty(t, yamlOutput, "output should not be empty")
+
+		// The output should contain the helm values, not be empty
+		yamlStr := string(yamlOutput)
+		assert.Equal(t, strings.TrimSpace(strings.ReplaceAll(expected, "\t", "  ")), strings.TrimSpace(yamlStr))
+	})
+
 	t.Run("Whitespace only values file from helm source does not cause error", func(t *testing.T) {
 		expected := `
 # auto generated by argocd image updater
@@ -2302,10 +2378,22 @@ nginx:
 
 		originalData := []byte(`
 `)
-		yaml, err := marshalParamsOverride(&app, originalData)
+		yamlOutput, err := marshalParamsOverride(&app, originalData)
 		require.NoError(t, err)
-		assert.NotEmpty(t, yaml)
-		assert.Equal(t, strings.TrimSpace(strings.ReplaceAll(expected, "\t", "  ")), strings.TrimSpace(string(yaml)))
+		assert.NotEmpty(t, yamlOutput)
+		assert.Equal(t, strings.TrimSpace(strings.ReplaceAll(expected, "\t", "  ")), strings.TrimSpace(string(yamlOutput)))
+
+		// Verify round-trip: marshal → unmarshal → marshal
+		// This ensures the generated YAML structure can be read back correctly
+		var roundTripNode yaml.Node
+		err = yaml.Unmarshal(yamlOutput, &roundTripNode)
+		require.NoError(t, err, "should be able to unmarshal the generated YAML")
+
+		roundTripYaml, err := yaml.Marshal(&roundTripNode)
+		require.NoError(t, err, "should be able to re-marshal the unmarshaled YAML")
+		assert.NotEmpty(t, roundTripYaml, "round-tripped YAML should not be empty")
+
+		assert.YAMLEq(t, string(yamlOutput), string(roundTripYaml), "round-tripped YAML should be semantically equivalent")
 	})
 }