From 30f0c4230f6eafde9f141ed1bc6b0786df4411a3 Mon Sep 17 00:00:00 2001
From: Gabriel Melillo <gabriel@melillo.me>
Date: Sun, 2 Feb 2020 08:35:26 +0100
Subject: [PATCH] feat: helmfile manager (#5257)

---
 docs/usage/configuration-options.md           |   2 +
 lib/config/definitions.ts                     |  15 ++
 lib/constants/managers.ts                     |   1 +
 lib/manager/helmfile/extract.ts               |  85 +++++++++
 lib/manager/helmfile/index.ts                 |   2 +
 lib/manager/helmfile/update.ts                |  74 ++++++++
 lib/manager/index.ts                          |   2 +
 renovate-schema.json                          |  12 ++
 .../__snapshots__/validation.spec.ts.snap     |   2 +-
 .../__snapshots__/extract.spec.ts.snap        | 104 +++++++++++
 .../__snapshots__/update.spec.ts.snap         |  46 +++++
 test/manager/helmfile/extract.spec.ts         | 171 ++++++++++++++++++
 test/manager/helmfile/update.spec.ts          | 137 ++++++++++++++
 .../extract/__snapshots__/index.spec.ts.snap  |   3 +
 14 files changed, 655 insertions(+), 1 deletion(-)
 create mode 100644 lib/manager/helmfile/extract.ts
 create mode 100644 lib/manager/helmfile/index.ts
 create mode 100644 lib/manager/helmfile/update.ts
 create mode 100644 test/manager/helmfile/__snapshots__/extract.spec.ts.snap
 create mode 100644 test/manager/helmfile/__snapshots__/update.spec.ts.snap
 create mode 100644 test/manager/helmfile/extract.spec.ts
 create mode 100644 test/manager/helmfile/update.spec.ts

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 7d8f9a4f79..a4fd039f83 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -483,6 +483,8 @@ Note: you shouldn't usually need to configure this unless you really care about
 
 Renovate supports updating Helm Chart references within `requirements.yaml` files. If your Helm charts make use of Aliases then you will need to configure an `aliases` object in your config to tell Renovate where to look for them.
 
+## helmfile
+
 ## homebrew
 
 ## hostRules
diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts
index 1409b3c9d2..9bf118b65f 100644
--- a/lib/config/definitions.ts
+++ b/lib/config/definitions.ts
@@ -1714,6 +1714,21 @@ const options: RenovateOptions[] = [
     mergeable: true,
     cli: false,
   },
+  {
+    name: 'helmfile',
+    description: 'Configuration object for helmfile helmfile.yaml files.',
+    stage: 'package',
+    type: 'object',
+    default: {
+      aliases: {
+        stable: 'https://kubernetes-charts.storage.googleapis.com/',
+      },
+      commitMessageTopic: 'helm chart {{depName}}',
+      fileMatch: ['(^|/)helmfile.yaml$'],
+    },
+    mergeable: true,
+    cli: false,
+  },
   {
     name: 'circleci',
     description:
diff --git a/lib/constants/managers.ts b/lib/constants/managers.ts
index 8a9855d402..557a157065 100644
--- a/lib/constants/managers.ts
+++ b/lib/constants/managers.ts
@@ -18,6 +18,7 @@ export const MANAGER_GO_MOD = 'gomod';
 export const MANAGER_GRADLE = 'gradle';
 export const MANAGER_GRADLE_WRAPPER = 'gradle-wrapper';
 export const MANAGER_HELM_REQUIREMENTS = 'helm-requirements';
+export const MANAGER_HELMFILE = 'helmfile';
 export const MANAGER_HOMEBREW = 'homebrew';
 export const MANAGER_KUBERNETES = 'kubernetes';
 export const MANAGER_LEININGEN = 'leiningen';
diff --git a/lib/manager/helmfile/extract.ts b/lib/manager/helmfile/extract.ts
new file mode 100644
index 0000000000..efc02a4a2e
--- /dev/null
+++ b/lib/manager/helmfile/extract.ts
@@ -0,0 +1,85 @@
+import is from '@sindresorhus/is';
+import yaml from 'js-yaml';
+
+import { logger } from '../../logger';
+import { PackageFile, PackageDependency, ExtractConfig } from '../common';
+
+const isValidChartName = (name: string): boolean => {
+  return name.match(/[!@#$%^&*(),.?":{}/|<>A-Z]/) === null;
+};
+
+export function extractPackageFile(
+  content: string,
+  fileName: string,
+  config: ExtractConfig
+): PackageFile {
+  let deps = [];
+  let doc;
+  const aliases: Record<string, string> = {};
+  try {
+    doc = yaml.safeLoad(content, { json: true });
+  } catch (err) {
+    logger.debug({ err, fileName }, 'Failed to parse helmfile helmfile.yaml');
+    return null;
+  }
+  if (!(doc && is.array(doc.releases))) {
+    logger.debug({ fileName }, 'helmfile.yaml has no releases');
+    return null;
+  }
+
+  if (doc.repositories) {
+    for (let i = 0; i < doc.repositories.length; i += 1) {
+      aliases[doc.repositories[i].name] = doc.repositories[i].url;
+    }
+  }
+  logger.debug({ aliases }, 'repositories discovered.');
+
+  deps = doc.releases.map(dep => {
+    let depName = dep.chart;
+    let repoName = null;
+
+    // If starts with ./ is for sure a local path
+    if (dep.chart.startsWith('./')) {
+      return {
+        depName,
+        skipReason: 'local-chart',
+      } as PackageDependency;
+    }
+
+    if (dep.chart.includes('/')) {
+      const v = dep.chart.split('/');
+      repoName = v.shift();
+      depName = v.join('/');
+    } else {
+      repoName = dep.chart;
+    }
+
+    const res: PackageDependency = {
+      depName,
+      currentValue: dep.version,
+      registryUrls: [aliases[repoName]]
+        .concat([config.aliases[repoName]])
+        .filter(Boolean),
+    };
+
+    // If version is null is probably a local chart
+    if (!res.currentValue) {
+      res.skipReason = 'local-chart';
+    }
+
+    // By definition on helm the chart name should be lowecase letter + number + -
+    // However helmfile support templating of that field
+    if (!isValidChartName(res.depName)) {
+      res.skipReason = 'unsupported-chart-type';
+    }
+
+    // Skip in case we cannot locate the registry
+    if (is.emptyArray(res.registryUrls)) {
+      res.skipReason = 'unknown-registry';
+    }
+
+    return res;
+  });
+
+  return { deps, datasource: 'helm' } as PackageFile;
+}
diff --git a/lib/manager/helmfile/index.ts b/lib/manager/helmfile/index.ts
new file mode 100644
index 0000000000..730b5b60ba
--- /dev/null
+++ b/lib/manager/helmfile/index.ts
@@ -0,0 +1,2 @@
+export { extractPackageFile } from './extract';
+export { updateDependency } from './update';
diff --git a/lib/manager/helmfile/update.ts b/lib/manager/helmfile/update.ts
new file mode 100644
index 0000000000..a62e3f2134
--- /dev/null
+++ b/lib/manager/helmfile/update.ts
@@ -0,0 +1,74 @@
+import _ from 'lodash';
+import yaml from 'js-yaml';
+import is from '@sindresorhus/is';
+
+import { logger } from '../../logger';
+import { Upgrade } from '../common';
+
+// Return true if the match string is found at index in content
+function matchAt(content: string, index: number, match: string): boolean {
+  return content.substring(index, index + match.length) === match;
+}
+
+// Replace oldString with newString at location index of content
+function replaceAt(
+  content: string,
+  index: number,
+  oldString: string,
+  newString: string
+): string {
+  logger.debug(`Replacing ${oldString} with ${newString} at index ${index}`);
+  return (
+    content.substr(0, index) +
+    newString +
+    content.substr(index + oldString.length)
+  );
+}
+
+export function updateDependency(
+  fileContent: string,
+  upgrade: Upgrade
+): string | null {
+  logger.trace({ config: upgrade }, 'updateDependency()');
+  if (!upgrade || !upgrade.depName || !upgrade.newValue) {
+    logger.debug('Failed to update dependency, invalid upgrade');
+    return fileContent;
+  }
+  const doc = yaml.safeLoad(fileContent, { json: true });
+  if (!doc || !is.array(doc.releases)) {
+    logger.debug('Failed to update dependency, invalid helmfile.yaml file');
+    return fileContent;
+  }
+  const { depName, newValue } = upgrade;
+  const oldVersion = doc.releases.filter(
+    dep => dep.chart.split('/')[1] === depName
+  )[0].version;
+  doc.releases = doc.releases.map(dep =>
+    dep.chart.split('/')[1] === depName ? { ...dep, version: newValue } : dep
+  );
+  const searchString = `${oldVersion}`;
+  const newString = `${newValue}`;
+  let newFileContent = fileContent;
+
+  let searchIndex = newFileContent.indexOf('releases') + 'releases'.length;
+  for (; searchIndex < newFileContent.length; searchIndex += 1) {
+    // First check if we have a hit for the old version
+    if (matchAt(newFileContent, searchIndex, searchString)) {
+      logger.trace(`Found match at index ${searchIndex}`);
+      // Now test if the result matches
+      newFileContent = replaceAt(
+        newFileContent,
+        searchIndex,
+        searchString,
+        newString
+      );
+    }
+  }
+  // Compare the parsed yaml structure of old and new
+  if (!_.isEqual(doc, yaml.safeLoad(newFileContent, { json: true }))) {
+    logger.trace(`Mismatched replace: ${newFileContent}`);
+    newFileContent = fileContent;
+  }
+
+  return newFileContent;
+}
diff --git a/lib/manager/index.ts b/lib/manager/index.ts
index b67aa180aa..c64b9ce80a 100644
--- a/lib/manager/index.ts
+++ b/lib/manager/index.ts
@@ -42,6 +42,7 @@ import {
   MANAGER_GRADLE,
   MANAGER_GRADLE_WRAPPER,
   MANAGER_HELM_REQUIREMENTS,
+  MANAGER_HELMFILE,
   MANAGER_HOMEBREW,
   MANAGER_KUBERNETES,
   MANAGER_LEININGEN,
@@ -84,6 +85,7 @@ const managerList = [
   MANAGER_GRADLE,
   MANAGER_GRADLE_WRAPPER,
   MANAGER_HELM_REQUIREMENTS,
+  MANAGER_HELMFILE,
   MANAGER_HOMEBREW,
   MANAGER_KUBERNETES,
   MANAGER_LEININGEN,
diff --git a/renovate-schema.json b/renovate-schema.json
index 751aa48248..4a952be5df 100644
--- a/renovate-schema.json
+++ b/renovate-schema.json
@@ -1123,6 +1123,18 @@
       },
       "$ref": "#"
     },
+    "helmfile": {
+      "description": "Configuration object for helmfile helmfile.yaml files.",
+      "type": "object",
+      "default": {
+        "aliases": {
+          "stable": "https://kubernetes-charts.storage.googleapis.com/"
+        },
+        "commitMessageTopic": "helm chart {{depName}}",
+        "fileMatch": ["(^|/)helmfile.yaml$"]
+      },
+      "$ref": "#"
+    },
     "circleci": {
       "description": "Configuration object for CircleCI yml renovation. Also inherits settings from `docker` object.",
       "type": "object",
diff --git a/test/config/__snapshots__/validation.spec.ts.snap b/test/config/__snapshots__/validation.spec.ts.snap
index 0b0e95f72d..b8f36dbfb9 100644
--- a/test/config/__snapshots__/validation.spec.ts.snap
+++ b/test/config/__snapshots__/validation.spec.ts.snap
@@ -96,7 +96,7 @@ Array [
     "depName": "Configuration Error",
     "message": "packageRules:
         You have included an unsupported manager in a package rule. Your list: foo.
-        Supported managers are: (ansible, bazel, buildkite, bundler, cargo, cdnurl, circleci, composer, deps-edn, docker-compose, dockerfile, droneci, git-submodules, github-actions, gitlabci, gitlabci-include, gomod, gradle, gradle-wrapper, helm-requirements, homebrew, kubernetes, leiningen, maven, meteor, mix, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, poetry, pub, sbt, swift, terraform, travis, ruby-version).",
+        Supported managers are: (ansible, bazel, buildkite, bundler, cargo, cdnurl, circleci, composer, deps-edn, docker-compose, dockerfile, droneci, git-submodules, github-actions, gitlabci, gitlabci-include, gomod, gradle, gradle-wrapper, helm-requirements, helmfile, homebrew, kubernetes, leiningen, maven, meteor, mix, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, poetry, pub, sbt, swift, terraform, travis, ruby-version).",
   },
 ]
 `;
diff --git a/test/manager/helmfile/__snapshots__/extract.spec.ts.snap b/test/manager/helmfile/__snapshots__/extract.spec.ts.snap
new file mode 100644
index 0000000000..5a093a75ea
--- /dev/null
+++ b/test/manager/helmfile/__snapshots__/extract.spec.ts.snap
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`lib/manager/helmfile/extract extractPackageFile() skip chart that does not have specified version 1`] = `
+Object {
+  "datasource": "helm",
+  "deps": Array [
+    Object {
+      "currentValue": undefined,
+      "depName": "example",
+      "registryUrls": Array [
+        "https://kubernetes-charts.storage.googleapis.com/",
+      ],
+      "skipReason": "local-chart",
+    },
+  ],
+}
+`;
+
+exports[`lib/manager/helmfile/extract extractPackageFile() skip chart with special character in the name 1`] = `
+Object {
+  "datasource": "helm",
+  "deps": Array [
+    Object {
+      "currentValue": "1.0.0",
+      "depName": "example/example",
+      "registryUrls": Array [
+        "https://kiwigrid.github.io",
+      ],
+      "skipReason": "unsupported-chart-type",
+    },
+    Object {
+      "currentValue": "1.0.0",
+      "depName": "example?example",
+      "registryUrls": Array [
+        "https://kiwigrid.github.io",
+      ],
+      "skipReason": "unsupported-chart-type",
+    },
+  ],
+}
+`;
+
+exports[`lib/manager/helmfile/extract extractPackageFile() skip chart with unknown repository 1`] = `
+Object {
+  "datasource": "helm",
+  "deps": Array [
+    Object {
+      "currentValue": "1.0.0",
+      "depName": "example",
+      "registryUrls": Array [],
+      "skipReason": "unknown-registry",
+    },
+  ],
+}
+`;
+
+exports[`lib/manager/helmfile/extract extractPackageFile() skip if repository details are not specified 1`] = `
+Object {
+  "datasource": "helm",
+  "deps": Array [
+    Object {
+      "currentValue": "1.0.0",
+      "depName": "example",
+      "registryUrls": Array [],
+      "skipReason": "unknown-registry",
+    },
+  ],
+}
+`;
+
+exports[`lib/manager/helmfile/extract extractPackageFile() skip local charts 1`] = `
+Object {
+  "datasource": "helm",
+  "deps": Array [
+    Object {
+      "depName": "./charts/example",
+      "skipReason": "local-chart",
+    },
+  ],
+}
+`;
+
+exports[`lib/manager/helmfile/extract extractPackageFile() skip templetized release with invalid characters 1`] = `
+Object {
+  "datasource": "helm",
+  "deps": Array [
+    Object {
+      "currentValue": "1.0.0",
+      "depName": "{{\`{{ .Release.Name }}\`}}",
+      "registryUrls": Array [
+        "https://kubernetes-charts.storage.googleapis.com/",
+      ],
+      "skipReason": "unsupported-chart-type",
+    },
+    Object {
+      "currentValue": "1.0.0",
+      "depName": "example",
+      "registryUrls": Array [
+        "https://kubernetes-charts.storage.googleapis.com/",
+      ],
+    },
+  ],
+}
+`;
diff --git a/test/manager/helmfile/__snapshots__/update.spec.ts.snap b/test/manager/helmfile/__snapshots__/update.spec.ts.snap
new file mode 100644
index 0000000000..5336665539
--- /dev/null
+++ b/test/manager/helmfile/__snapshots__/update.spec.ts.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`lib/manager/helmfile/extract updateDependency() upgrades dependency if chart is repeated 1`] = `
+"
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+
+      releases:
+        - name: fluentd-elasticsearch-internal
+          version: 5.3.1
+          chart: kiwigrid/fluentd-elasticsearch
+
+        - name: nginx-ingress
+          version: 1.3.0
+          chart: stable/nginx-ingress
+
+        - name: fluentd-elasticsearch-external
+          version: 5.3.1
+          chart: kiwigrid/fluentd-elasticsearch
+      "
+`;
+
+exports[`lib/manager/helmfile/extract updateDependency() upgrades dependency if valid upgrade 1`] = `
+"
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: fluentd-elasticsearch
+          version: 5.3.1
+          chart: kiwigrid/fluentd-elasticsearch
+      "
+`;
+
+exports[`lib/manager/helmfile/extract updateDependency() upgrades dependency if version field comes before name field 1`] = `
+"
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - version: 5.3.1
+          name: fluentd-elasticsearch
+          chart: kiwigrid/fluentd-elasticsearch
+      "
+`;
diff --git a/test/manager/helmfile/extract.spec.ts b/test/manager/helmfile/extract.spec.ts
new file mode 100644
index 0000000000..1360aa8a9f
--- /dev/null
+++ b/test/manager/helmfile/extract.spec.ts
@@ -0,0 +1,171 @@
+import { extractPackageFile } from '../../../lib/manager/helmfile/extract';
+
+describe('lib/manager/helmfile/extract', () => {
+  describe('extractPackageFile()', () => {
+    beforeEach(() => {
+      jest.resetAllMocks();
+    });
+
+    it('returns null if no releases', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).toBeNull();
+    });
+
+    it('do not crash on invalid helmfile.yaml', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+
+      releases: [
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).toBeNull();
+    });
+
+    it('skip if repository details are not specified', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: example
+          version: 1.0.0
+          chart: experimental/example
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).not.toBeNull();
+      expect(result).toMatchSnapshot();
+      expect(result.deps.every(dep => dep.skipReason));
+    });
+
+    it('skip templetized release with invalid characters', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: example
+          version: 1.0.0
+          chart: stable/{{\`{{ .Release.Name }}\`}}
+        - name: example-internal
+          version: 1.0.0
+          chart: stable/example
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).not.toBeNull();
+      expect(result).toMatchSnapshot();
+    });
+
+    it('skip local charts', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: example
+          version: 1.0.0
+          chart: ./charts/example
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).not.toBeNull();
+      expect(result).toMatchSnapshot();
+      expect(result.deps.every(dep => dep.skipReason));
+    });
+
+    it('skip chart with unknown repository', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: example
+          version: 1.0.0
+          chart: example
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).not.toBeNull();
+      expect(result).toMatchSnapshot();
+      expect(result.deps.every(dep => dep.skipReason));
+    });
+
+    it('skip chart with special character in the name', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: example
+          version: 1.0.0
+          chart: kiwigrid/example/example
+        - name: example2
+          version: 1.0.0
+          chart: kiwigrid/example?example
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).not.toBeNull();
+      expect(result).toMatchSnapshot();
+      expect(result.deps.every(dep => dep.skipReason));
+    });
+
+    it('skip chart that does not have specified version', async () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: example
+          chart: stable/example
+      `;
+      const fileName = 'helmfile.yaml';
+      const result = await extractPackageFile(content, fileName, {
+        aliases: {
+          stable: 'https://kubernetes-charts.storage.googleapis.com/',
+        },
+      });
+      expect(result).not.toBeNull();
+      expect(result).toMatchSnapshot();
+      expect(result.deps.every(dep => dep.skipReason));
+    });
+  });
+});
diff --git a/test/manager/helmfile/update.spec.ts b/test/manager/helmfile/update.spec.ts
new file mode 100644
index 0000000000..06cd8df1c5
--- /dev/null
+++ b/test/manager/helmfile/update.spec.ts
@@ -0,0 +1,137 @@
+import { updateDependency } from '../../../lib/manager/helmfile/update';
+
+describe('lib/manager/helmfile/extract', () => {
+  describe('updateDependency()', () => {
+    it('returns the same fileContent for undefined upgrade', () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: fluentd-elasticsearch
+          version: 5.3.0
+          chart: kiwigrid/fluentd-elasticsearch
+      `;
+      const upgrade = undefined;
+
+      expect(updateDependency(content, upgrade)).toBe(content);
+    });
+
+    it('returns the same fileContent for invalid helmfile.yaml file', () => {
+      const content = `
+       Invalid helmfile.yaml content.
+      `;
+      const upgrade = {
+        depName: 'fluentd-elasticsearch',
+        newValue: '5.3.0',
+        repository: 'https://kiwigrid.github.io',
+      };
+      expect(updateDependency(content, upgrade)).toBe(content);
+    });
+
+    it('returns the same fileContent for empty upgrade', () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: fluentd-elasticsearch
+          version: 5.3.0
+          chart: kiwigrid/fluentd-elasticsearch
+      `;
+      const upgrade = {};
+      expect(updateDependency(content, upgrade)).toBe(content);
+    });
+
+    it('upgrades dependency if valid upgrade', () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - name: fluentd-elasticsearch
+          version: 5.3.0
+          chart: kiwigrid/fluentd-elasticsearch
+      `;
+      const upgrade = {
+        depName: 'fluentd-elasticsearch',
+        newValue: '5.3.1',
+        repository: 'https://kiwigrid.github.io',
+      };
+      expect(updateDependency(content, upgrade)).not.toBe(content);
+      expect(updateDependency(content, upgrade)).toMatchSnapshot();
+    });
+
+    it('upgrades dependency if version field comes before name field', () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+      releases:
+        - version: 5.3.0
+          name: fluentd-elasticsearch
+          chart: kiwigrid/fluentd-elasticsearch
+      `;
+      const upgrade = {
+        depName: 'fluentd-elasticsearch',
+        newValue: '5.3.1',
+        repository: 'https://kiwigrid.github.io',
+      };
+      expect(updateDependency(content, upgrade)).not.toBe(content);
+      expect(updateDependency(content, upgrade)).toMatchSnapshot();
+    });
+
+    it('upgrades dependency if chart is repeated', () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+
+      releases:
+        - name: fluentd-elasticsearch-internal
+          version: 5.3.0
+          chart: kiwigrid/fluentd-elasticsearch
+
+        - name: nginx-ingress
+          version: 1.3.0
+          chart: stable/nginx-ingress
+
+        - name: fluentd-elasticsearch-external
+          version: 5.3.0
+          chart: kiwigrid/fluentd-elasticsearch
+      `;
+      const upgrade = {
+        depName: 'fluentd-elasticsearch',
+        newValue: '5.3.1',
+        repository: 'https://kiwigrid.github.io',
+      };
+      expect(updateDependency(content, upgrade)).not.toBe(content);
+      expect(updateDependency(content, upgrade)).toMatchSnapshot();
+    });
+
+    it('Not fail if same version in multiple package', () => {
+      const content = `
+      repositories:
+        - name: kiwigrid
+          url: https://kiwigrid.github.io
+
+      releases:
+        - name: fluentd-elasticsearch-internal
+          version: 5.3.0
+          chart: kiwigrid/fluentd-elasticsearch
+        - name: nginx-ingress
+          version: 5.3.0
+          chart: stable/nginx-ingress
+        - name: fluentd-elasticsearch-external
+          version: 5.3.0
+          chart: kiwigrid/fluentd-elasticsearch
+      `;
+      const upgrade = {
+        depName: 'fluentd-elasticsearch',
+        newValue: '5.3.1',
+        repository: 'https://kiwigrid.github.io',
+      };
+      expect(updateDependency(content, upgrade)).toBe(content);
+    });
+  });
+});
diff --git a/test/workers/repository/extract/__snapshots__/index.spec.ts.snap b/test/workers/repository/extract/__snapshots__/index.spec.ts.snap
index d693641f9e..d63407073d 100644
--- a/test/workers/repository/extract/__snapshots__/index.spec.ts.snap
+++ b/test/workers/repository/extract/__snapshots__/index.spec.ts.snap
@@ -62,6 +62,9 @@ Object {
   "helm-requirements": Array [
     Object {},
   ],
+  "helmfile": Array [
+    Object {},
+  ],
   "homebrew": Array [
     Object {},
   ],
-- 
GitLab