diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 409b6a49dc1b42f4fb23efcb478658830af0f517..bc41af3d0a649fd1fb098baf0c89cd0f8704d6d7 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2026,19 +2026,31 @@ Example setting source URL for package "dummy": ### replacementName -This config option only works with the `npm` manager. +This config option only works with some managers. We're working to support more managers, subscribe to issue [renovatebot/renovate#14149](https://github.com/renovatebot/renovate/issues/14149) to follow our progress. -Use this field to define the name of a replacement package. +Managers which do not support replacement: + +- `bazel` +- `git-submodules` +- `gomod` +- `gradle` +- `hermit` +- `homebrew` +- `maven` +- `regex` + +Use the `replacementName` config option to set the name of a replacement package. Must be used with `replacementVersion` (see example below). You can suggest a new community package rule by editing [the `replacements.ts` file on the Renovate repository](https://github.com/renovatebot/renovate/blob/main/lib/config/presets/internal/replacements.ts) and opening a pull request. ### replacementVersion -This config option only works with the `npm` manager. +This config option only works with some managers. We're working to support more managers, subscribe to issue [renovatebot/renovate#14149](https://github.com/renovatebot/renovate/issues/14149) to follow our progress. +For a list of managers which do not support replacement read the `replacementName` config option docs. -Use this field to define the version of a replacement package. +Use the `replacementVersion` config option to set the version of a replacement package. Must be used with `replacementName`. For example to replace the npm package `jade` with version `2.0.0` of the package `pug`: diff --git a/lib/workers/repository/update/branch/auto-replace.spec.ts b/lib/workers/repository/update/branch/auto-replace.spec.ts index 79ded8c0964a8b34edb0dfe9dab0faea3bc4d330..4a1abc13f01151710259408e47e9afc3d82a1492 100644 --- a/lib/workers/repository/update/branch/auto-replace.spec.ts +++ b/lib/workers/repository/update/branch/auto-replace.spec.ts @@ -1,5 +1,6 @@ +import { codeBlock } from 'common-tags'; import { Fixtures } from '../../../../../test/fixtures'; -import { getConfig, partial } from '../../../../../test/util'; +import { getConfig } from '../../../../../test/util'; import { GlobalConfig } from '../../../../config/global'; import { WORKER_FILE_UPDATE_FAILED } from '../../../../constants/error-messages'; import { extractPackageFile } from '../../../../modules/manager/html'; @@ -25,12 +26,10 @@ describe('workers/repository/update/branch/auto-replace', () => { }); beforeEach(() => { - upgrade = partial<BranchUpgradeConfig>({ - // TODO: fix types (#7154) - ...(getConfig() as any), - manager: 'html', - packageFile: 'test', - }); + // TODO: fix types (#7154) + upgrade = getConfig() as BranchUpgradeConfig; + upgrade.packageFile = 'test'; + upgrade.manager = 'html'; reuseExistingBranch = false; }); @@ -233,5 +232,811 @@ describe('workers/repository/update/branch/auto-replace', () => { const res = doAutoReplace(upgrade, yml, reuseExistingBranch); await expect(res).rejects.toThrow(WORKER_FILE_UPDATE_FAILED); }); + + it('updates with docker replacement', async () => { + const dockerfile = 'FROM bitnami/redis:6.0.8'; + upgrade.manager = 'dockerfile'; + upgrade.updateType = 'replacement'; + upgrade.depName = 'bitnami/redis'; + upgrade.newName = 'mcr.microsoft.com/oss/bitnami/redis'; + upgrade.replaceString = 'bitnami/redis:6.0.8'; + upgrade.packageFile = 'Dockerfile'; + upgrade.depIndex = 0; + upgrade.currentValue = '6.0.8'; + const res = await doAutoReplace(upgrade, dockerfile, reuseExistingBranch); + expect(res).toBe(dockerfile.replace(upgrade.depName, upgrade.newName)); + }); + + it('handles already replaced', async () => { + const dockerfile = 'FROM library/ubuntu:20.04'; + upgrade.manager = 'dockerfile'; + upgrade.updateType = 'replacement'; + upgrade.depName = 'library/alpine'; + upgrade.newName = 'library/ubuntu'; + upgrade.packageFile = 'Dockerfile'; + const res = await doAutoReplace(upgrade, dockerfile, reuseExistingBranch); + expect(res).toBe(dockerfile); + }); + + it('handles replacement with depName===newName when replaceString exists', async () => { + const yml = + 'image: "1111111111.dkr.ecr.us-east-1.amazonaws.com/my-repository:1"\n\n'; + upgrade.manager = 'regex'; + upgrade.updateType = 'replacement'; + upgrade.depName = + '1111111111.dkr.ecr.us-east-1.amazonaws.com/my-repository'; + upgrade.currentValue = '1'; + upgrade.newName = + '1111111111.dkr.ecr.us-east-1.amazonaws.com/my-repository'; + upgrade.depIndex = 0; + upgrade.replaceString = + 'image: "1111111111.dkr.ecr.us-east-1.amazonaws.com/my-repository:1"\n\n'; + upgrade.packageFile = 'k8s/base/defaults.yaml'; + upgrade.matchStrings = [ + 'image:\\s*\\\'?\\"?(?<depName>[^:]+):(?<currentValue>[^\\s\\\'\\"]+)\\\'?\\"?\\s*', + ]; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe(yml); + }); + + it('updates with terraform replacement', async () => { + const hcl = codeBlock` + module "foo" { + source = "github.com/hashicorp/example?ref=v1.0.0" + } + `; + upgrade.manager = 'terraform'; + upgrade.updateType = 'replacement'; + upgrade.depName = 'github.com/hashicorp/example'; + upgrade.newName = 'github.com/hashicorp/new-example'; + upgrade.currentValue = 'v1.0.0'; + upgrade.depIndex = 0; + upgrade.packageFile = 'modules.tf'; + const res = await doAutoReplace(upgrade, hcl, reuseExistingBranch); + expect(res).toBe(hcl.replace(upgrade.depName, upgrade.newName)); + }); + + it('updates with ansible replacement', async () => { + const yml = codeBlock` + - name: Container present + docker_container: + name: mycontainer + state: present + image: ubuntu:14.04 + command: sleep infinity + `; + upgrade.manager = 'ansible'; + upgrade.depName = 'ubuntu'; + upgrade.currentValue = '14.04'; + upgrade.replaceString = 'ubuntu:14.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'tasks/main.yaml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with ansible-galaxy roles replacement', async () => { + const yml = codeBlock` + roles + - name: geerlingguy.java + version: 1.9.6 + `; + upgrade.manager = 'ansible-galaxy'; + upgrade.depName = 'geerlingguy.java'; + upgrade.currentValue = '1.9.6'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'cloudalchemy.node_exporter'; + upgrade.newValue = '1.0.0'; + upgrade.packageFile = 'requirements.yaml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with azure-pipeline image replacement', async () => { + const yml = codeBlock` + resources: + containers: + - container: linux + image: ubuntu:16.04 + `; + upgrade.manager = 'azure-pipelines'; + upgrade.depName = 'ubuntu'; + upgrade.currentValue = '16.04'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'azure-pipeline.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with batect image replacement', async () => { + const yml = codeBlock` + containers: + my-container: + image: ubuntu:16.04 + `; + upgrade.manager = 'batect'; + upgrade.depName = 'ubuntu'; + upgrade.currentValue = '16.04'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'batect.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with bitbucket-pipelines image replacement', async () => { + const yml = 'image: ubuntu:16.04\n'; + upgrade.manager = 'bitbucket-pipelines'; + upgrade.depName = 'ubuntu'; + upgrade.currentValue = '16.04'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'bitbucket-pipelines.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with buildkite plugin replacement', async () => { + const yml = codeBlock` + steps: + - command: test.sh + plugins: + - docker-compose#v3.10.0: + `; + upgrade.manager = 'buildkite'; + upgrade.depName = 'docker-compose'; + upgrade.currentValue = 'v3.10.0'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'buildpipe'; + upgrade.newValue = 'v0.10.1'; + upgrade.packageFile = 'buildkite.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with bundler gem replacement', async () => { + const gemfile = codeBlock` + source 'https://rubygems.org' + + gem 'rails', '~>7.0' + `; + upgrade.manager = 'bundler'; + upgrade.depName = 'rails'; + upgrade.currentValue = "'~>7.0'"; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'rack'; + upgrade.newValue = "'~>2.2'"; + upgrade.packageFile = 'Gemfile'; + const res = await doAutoReplace(upgrade, gemfile, reuseExistingBranch); + expect(res).toBe( + gemfile + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with cake #addin replacement', async () => { + const build = + '#addin nuget:?package=Microsoft.Extensions.Logging&version=7.0.0-preview.7.22375.6&prerelease\n'; + upgrade.manager = 'cake'; + upgrade.depName = 'Microsoft.Extensions.Logging'; + upgrade.currentValue = '7.0.0-preview.7.22375.6'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'Newtonsoft.Json'; + upgrade.newValue = '13.0.2-beta1'; + upgrade.packageFile = 'build.cake'; + const res = await doAutoReplace(upgrade, build, reuseExistingBranch); + expect(res).toBe( + build + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with cargo dependency replacement', async () => { + const cargo = codeBlock` + [dependencies] + rand = "0.8.4" + `; + upgrade.manager = 'cargo'; + upgrade.depName = 'rand'; + upgrade.currentValue = '0.8.4'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'syn'; + upgrade.newValue = '1.0.99'; + upgrade.packageFile = 'Cargo.toml'; + const res = await doAutoReplace(upgrade, cargo, reuseExistingBranch); + expect(res).toBe( + cargo + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with cloudbuild replacement', async () => { + const yml = codeBlock` + steps: + - name: gcr.io/cloud-builders/docker + - name: ubuntu:16.04 + `; + upgrade.manager = 'cloudbuild'; + upgrade.depName = 'ubuntu'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.currentValue = '16.04'; + upgrade.depIndex = 1; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'cloudbuild.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with podfile pod replacement', async () => { + const podfile = "pod 'GoogleAnalytics', '3.20.0'"; + upgrade.manager = 'cocoapods'; + upgrade.depName = 'GoogleAnalytics'; + upgrade.currentValue = '3.20.0'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'Docker'; + upgrade.newValue = '1.3.11'; + upgrade.packageFile = 'Podfile'; + const res = await doAutoReplace(upgrade, podfile, reuseExistingBranch); + expect(res).toBe( + podfile + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with composer require replacement', async () => { + const json = codeBlock` + { + "require": { + "psr/log": "3.0.0" + } + } + `; + upgrade.manager = 'composer'; + upgrade.depName = 'psr/log'; + upgrade.currentValue = '3.0.0'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'symfony/console'; + upgrade.newValue = 'v6.1.3'; + upgrade.packageFile = 'composer.json'; + const res = await doAutoReplace(upgrade, json, reuseExistingBranch); + expect(res).toBe( + json + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with edn deps replacement', async () => { + const edn = codeBlock` + {:deps + {com.taoensso/timbre {:mvn/version "5.2.1"}} + } + `; + upgrade.manager = 'deps-edn'; + upgrade.depName = 'com.taoensso/timbre'; + upgrade.currentValue = '5.2.1'; + upgrade.depIndex = 0; + upgrade.replaceString = '{:mvn/version \\"5.2.1\\"}'; + upgrade.updateType = 'replacement'; + upgrade.newName = 'org.clojure-android/tools.nrepl'; + upgrade.newValue = '0.2.6-lollipop'; + upgrade.packageFile = 'deps.edn'; + const res = await doAutoReplace(upgrade, edn, reuseExistingBranch); + expect(res).toBe( + edn + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with docker-compose image replacement', async () => { + const yml = codeBlock` + services: + test: + image: "ubuntu:16.04" + `; + upgrade.manager = 'docker-compose'; + upgrade.depName = 'ubuntu'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.currentValue = '16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'docker-compose.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with Dockerfile image replacement', async () => { + const dockerfile = 'FROM ubuntu:16.04\n'; + upgrade.manager = 'dockerfile'; + upgrade.depName = 'ubuntu'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.currentValue = '16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'Dockerfile'; + const res = await doAutoReplace(upgrade, dockerfile, reuseExistingBranch); + expect(res).toBe( + dockerfile + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with Dockerfile image replacement with digest', async () => { + const dockerfile = 'FROM ubuntu:16.04@q1w2e3r4t5z6u7i8o9p0\n'; + upgrade.manager = 'dockerfile'; + upgrade.depName = 'ubuntu'; + upgrade.replaceString = 'ubuntu:16.04@q1w2e3r4t5z6u7i8o9p0'; + upgrade.currentValue = '16.04'; + upgrade.currentDigest = 'q1w2e3r4t5z6u7i8o9p0'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.newDigest = 'p0o9i8u7z6t5r4e3w2q1'; + upgrade.packageFile = 'Dockerfile'; + const res = await doAutoReplace(upgrade, dockerfile, reuseExistingBranch); + expect(res).toBe( + dockerfile + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + .replace(upgrade.currentDigest, upgrade.newDigest) + ); + }); + + it('updates with droneci image replacement', async () => { + const yml = codeBlock` + steps: + - name: test + image: ubuntu:16.04 + `; + upgrade.manager = 'droneci'; + upgrade.depName = 'ubuntu'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.currentValue = '16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = '.drone.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with gitlabci image replacement', async () => { + const yml = 'image: "ubuntu:16.04"\n'; + upgrade.manager = 'gitlabci'; + upgrade.depName = 'ubuntu'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.currentValue = '16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = '.gitlab-ci.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with helm value image/repository replacement', async () => { + const yml = codeBlock` + parser: + image: + repository: docker.io/securecodebox/parser-nmap + tag: 3.14.3 + `; + upgrade.manager = 'helm-values'; + upgrade.depName = 'docker.io/securecodebox/parser-nmap'; + upgrade.replaceString = '3.14.3'; + upgrade.currentValue = '3.14.3'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'iteratec/juice-balancer'; + upgrade.newValue = 'v5.1.0'; + upgrade.packageFile = 'values.yml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with jenkins plugin replacement', async () => { + const txt = 'script-security:1175\n'; + upgrade.manager = 'jenkins'; + upgrade.depName = 'script-security'; + upgrade.currentValue = '1175'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'Mailer'; + upgrade.newValue = '438.v02c7f0a_12fa_4'; + upgrade.packageFile = 'plugins.txt'; + const res = await doAutoReplace(upgrade, txt, reuseExistingBranch); + expect(res).toBe( + txt + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with meteor npm.depends replacement', async () => { + const js = codeBlock` + Package.describe({ + 'name': 'test', + }); + + Npm.depends({ + 'xml2js': '0.2.0' + });' + `; + upgrade.manager = 'meteor'; + upgrade.depName = 'xml2js'; + upgrade.currentValue = '0.2.0'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'connect'; + upgrade.newValue = '2.7.10'; + upgrade.packageFile = 'package.js'; + const res = await doAutoReplace(upgrade, js, reuseExistingBranch); + expect(res).toBe( + js + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('checks for replaceWithoutReplaceString double update', async () => { + const js = codeBlock` + Package.describe({ + 'name': 'test', + }); + + Npm.depends({ + 'xml2js': '0.2.0', + 'xml2js': '0.2.0' + }); + `; + upgrade.manager = 'meteor'; + upgrade.depName = 'xml2js'; + upgrade.currentValue = '0.2.0'; + upgrade.depIndex = 1; + upgrade.updateType = 'replacement'; + upgrade.newName = 'connect'; + upgrade.newValue = '2.7.10'; + upgrade.packageFile = 'package.js'; + const res = await doAutoReplace(upgrade, js, reuseExistingBranch); + expect(res).toBe( + codeBlock` + Package.describe({ + 'name': 'test', + }); + + Npm.depends({ + 'xml2js': '0.2.0', + 'connect': '2.7.10' + }); + ` + ); + }); + + it('updates with mix deps replacement', async () => { + const exs = codeBlock` + defmodule MyProject.MixProject do + use Mix.Project + + def project() do + [ + app: :my_project, + version: "0.0.1", + elixir: "~> 1.0", + deps: deps(), + ] + end + + def application() do + [] + end + + defp deps() do + [ + {:postgrex, "~> 0.8.1"} + ] + end + end + `; + upgrade.manager = 'mix'; + upgrade.depName = 'postgrex'; + upgrade.currentValue = '~> 0.8.1'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'jason'; + upgrade.newValue = '~> 1.3.0'; + upgrade.packageFile = 'mix.exs'; + const res = await doAutoReplace(upgrade, exs, reuseExistingBranch); + expect(res).toBe( + exs + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with nuget tools replacement', async () => { + const json = codeBlock` + { + "version": 1, + "isRoot": true, + "tools": { + "Microsoft.Extensions.Logging": { + "version": "7.0.0-preview.7.22375.6" + } + } + } + `; + upgrade.manager = 'nuget'; + upgrade.depName = 'Microsoft.Extensions.Logging'; + upgrade.currentValue = '7.0.0-preview.7.22375.6'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'Newtonsoft.Json'; + upgrade.newValue = '13.0.2-beta1'; + upgrade.packageFile = 'dotnet-tools.json'; + const res = await doAutoReplace(upgrade, json, reuseExistingBranch); + expect(res).toBe( + json + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with pre-commit repo replacement', async () => { + const yml = codeBlock` + repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + `; + upgrade.manager = 'pre-commit'; + upgrade.depName = 'pre-commit/pre-commit-hooks'; + upgrade.currentValue = 'v4.3.0'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'pre-commit/pygrep-hooks'; + upgrade.newValue = 'v1.9.0'; + upgrade.packageFile = '.pre-commit-config.yaml'; + const res = await doAutoReplace(upgrade, yml, reuseExistingBranch); + expect(res).toBe( + yml + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with terraform image replacement', async () => { + const tf = codeBlock` + resource "docker_image" "image" { + name = "ubuntu:16.04" + } + `; + upgrade.manager = 'terraform'; + upgrade.depName = 'ubuntu'; + upgrade.replaceString = 'ubuntu:16.04'; + upgrade.currentValue = '16.04'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'test.tf'; + const res = await doAutoReplace(upgrade, tf, reuseExistingBranch); + expect(res).toBe( + tf + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with terraform module replacement', async () => { + const tf = codeBlock` + module "vpc" { + source = "terraform-aws-modules/vpc/aws" + version = "3.14.2" + } + `; + upgrade.manager = 'terraform'; + upgrade.depName = 'terraform-aws-modules/vpc/aws'; + upgrade.currentValue = '3.14.2'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'cloudposse/label/null'; + upgrade.newValue = '0.25.0'; + upgrade.packageFile = 'module-test.tf'; + const res = await doAutoReplace(upgrade, tf, reuseExistingBranch); + expect(res).toBe( + tf + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with setup-cfg replacement', async () => { + const tf = codeBlock` + [options] + install_requires = sphinx ~=5.1.0 + `; + upgrade.manager = 'setup-cfg'; + upgrade.depName = 'sphinx'; + upgrade.currentValue = '~=5.1.0'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'postgres'; + upgrade.newValue = '~=4.0.0'; + upgrade.packageFile = 'setup.cfg'; + const res = await doAutoReplace(upgrade, tf, reuseExistingBranch); + expect(res).toBe( + tf + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with nvm version replacement', async () => { + const tf = '12.3.4'; + upgrade.manager = 'nvm'; + upgrade.depName = 'node'; + upgrade.currentValue = '12.3.4'; + upgrade.depIndex = 0; + upgrade.updateType = 'replacement'; + upgrade.newName = 'node'; + upgrade.newValue = '16.5.4'; + upgrade.packageFile = '.nvmrc'; + const res = await doAutoReplace(upgrade, tf, reuseExistingBranch); + expect(res).toBe( + tf + .replace(upgrade.depName, upgrade.newName) + .replace(upgrade.currentValue, upgrade.newValue) + ); + }); + + it('updates with multiple same name replacement without replaceString', async () => { + const dockerfile = codeBlock` + FROM ubuntu:16.04 + FROM ubuntu:20.04 + FROM ubuntu:18.04 + `; + upgrade.manager = 'dockerfile'; + upgrade.depName = 'ubuntu'; + upgrade.currentValue = '18.04'; + upgrade.depIndex = 2; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'Dockerfile'; + const res = await doAutoReplace(upgrade, dockerfile, reuseExistingBranch); + expect(res).toBe( + codeBlock` + FROM ubuntu:16.04 + FROM ubuntu:20.04 + FROM alpine:3.16 + ` + ); + }); + + it('updates with multiple same name replacement without replaceString 2', async () => { + const dockerfile = codeBlock` + FROM ubuntu:16.04 + FROM ubuntu:20.04 + FROM ubuntu:18.04 + `; + upgrade.manager = 'dockerfile'; + upgrade.depName = 'ubuntu'; + upgrade.currentValue = '20.04'; + upgrade.depIndex = 1; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'Dockerfile'; + const res = await doAutoReplace(upgrade, dockerfile, reuseExistingBranch); + expect(res).toBe( + codeBlock` + FROM ubuntu:16.04 + FROM alpine:3.16 + FROM ubuntu:18.04 + ` + ); + }); + + it('updates with multiple same version replacement without replaceString', async () => { + const dockerfile = codeBlock` + FROM notUbuntu:18.04 + FROM alsoNotUbuntu:18.04 + FROM ubuntu:18.04 + `; + upgrade.manager = 'dockerfile'; + upgrade.depName = 'ubuntu'; + upgrade.currentValue = '18.04'; + upgrade.depIndex = 2; + upgrade.updateType = 'replacement'; + upgrade.newName = 'alpine'; + upgrade.newValue = '3.16'; + upgrade.packageFile = 'Dockerfile'; + const res = await doAutoReplace(upgrade, dockerfile, reuseExistingBranch); + expect(res).toBe( + codeBlock` + FROM notUbuntu:18.04 + FROM alsoNotUbuntu:18.04 + FROM alpine:3.16 + ` + ); + }); }); }); diff --git a/lib/workers/repository/update/branch/auto-replace.ts b/lib/workers/repository/update/branch/auto-replace.ts index 1b92ed94b95491eec511f83f16c1bb07f4a5082e..c13490eab68b9ec95f9d06916075af10675dfae7 100644 --- a/lib/workers/repository/update/branch/auto-replace.ts +++ b/lib/workers/repository/update/branch/auto-replace.ts @@ -1,4 +1,5 @@ // TODO #7154 +import is from '@sindresorhus/is'; import { WORKER_FILE_UPDATE_FAILED } from '../../../../constants/error-messages'; import { logger } from '../../../../logger'; import { get } from '../../../../modules/manager'; @@ -45,7 +46,10 @@ export async function confirmIfDepUpdated( return false; } - if (upgrade.depName !== newUpgrade.depName) { + if ( + upgrade.depName !== newUpgrade.depName && + upgrade.newName !== newUpgrade.depName + ) { logger.debug( { manager, @@ -139,18 +143,36 @@ export async function doAutoReplace( const { packageFile, depName, + newName, currentValue, newValue, currentDigest, newDigest, autoReplaceStringTemplate, } = upgrade; + /* + If replacement support for more managers is added, + please also update the list in docs/usage/configuration-options.md + at replacementName and replacementVersion + */ if (reuseExistingBranch) { return await checkExistingBranch(upgrade, existingContent); } + const replaceWithoutReplaceString = + is.string(newName) && + newName !== depName && + (is.undefined(upgrade.replaceString) || + !upgrade.replaceString?.includes(depName!)); const replaceString = upgrade.replaceString ?? currentValue; logger.trace({ depName, replaceString }, 'autoReplace replaceString'); - let searchIndex = existingContent.indexOf(replaceString!); + let searchIndex: number; + if (replaceWithoutReplaceString) { + const depIndex = existingContent.indexOf(depName!); + const valIndex = existingContent.indexOf(currentValue!); + searchIndex = depIndex < valIndex ? depIndex : valIndex; + } else { + searchIndex = existingContent.indexOf(replaceString!); + } if (searchIndex === -1) { logger.info( { packageFile, depName, existingContent, replaceString }, @@ -160,7 +182,7 @@ export async function doAutoReplace( } try { let newString: string; - if (autoReplaceStringTemplate) { + if (autoReplaceStringTemplate && !newName) { newString = compile(autoReplaceStringTemplate, upgrade, false); } else { newString = replaceString!; @@ -170,6 +192,12 @@ export async function doAutoReplace( newValue ); } + if (depName && newName) { + newString = newString.replace( + regEx(escapeRegExp(depName), 'g'), + newName + ); + } if (currentDigest && newDigest) { newString = newString.replace( regEx(escapeRegExp(currentDigest), 'g'), @@ -189,10 +217,60 @@ export async function doAutoReplace( `Starting search at index ${searchIndex}` ); let newContent = existingContent; + let nameReplaced = !newName; + let valueReplaced = !newValue; + let startIndex = searchIndex; // Iterate through the rest of the file for (; searchIndex < newContent.length; searchIndex += 1) { // First check if we have a hit for the old version - if (matchAt(existingContent, searchIndex, replaceString!)) { + if (replaceWithoutReplaceString) { + // look for depName and currentValue + if (newName && matchAt(newContent, searchIndex, depName!)) { + logger.debug( + { packageFile, depName }, + `Found depName at index ${searchIndex}` + ); + if (nameReplaced) { + startIndex += 1; + searchIndex = startIndex; + await writeLocalFile(upgrade.packageFile!, existingContent); + newContent = existingContent; + nameReplaced = false; + valueReplaced = false; + continue; + } + // replace with newName + newContent = replaceAt(newContent, searchIndex, depName!, newName); + await writeLocalFile(upgrade.packageFile!, newContent); + nameReplaced = true; + } else if ( + newValue && + matchAt(newContent, searchIndex, currentValue!) + ) { + logger.debug( + { packageFile, currentValue }, + `Found currentValue at index ${searchIndex}` + ); + // Now test if the result matches + newContent = replaceAt( + newContent, + searchIndex, + currentValue!, + newValue + ); + await writeLocalFile(upgrade.packageFile!, newContent); + valueReplaced = true; + } + if (nameReplaced && valueReplaced) { + if (await confirmIfDepUpdated(upgrade, newContent)) { + return newContent; + } + await writeLocalFile(upgrade.packageFile!, existingContent); + newContent = existingContent; + nameReplaced = false; + valueReplaced = false; + } + } else if (matchAt(newContent, searchIndex, replaceString!)) { logger.debug( { packageFile, depName }, `Found match at index ${searchIndex}` diff --git a/lib/workers/repository/update/branch/get-updated.spec.ts b/lib/workers/repository/update/branch/get-updated.spec.ts index cb7fe72479ec9a8f94036fba6bcaddaf431bc96c..0f335cf14321546a9b1e9ad17c3b6b8d6c0d01a5 100644 --- a/lib/workers/repository/update/branch/get-updated.spec.ts +++ b/lib/workers/repository/update/branch/get-updated.spec.ts @@ -505,6 +505,22 @@ describe('workers/repository/update/branch/get-updated', () => { }); }); + it('handles replacement', async () => { + config.upgrades.push({ + packageFile: 'index.html', + manager: 'html', + updateType: 'replacement', + branchName: undefined!, + }); + autoReplace.doAutoReplace.mockResolvedValueOnce('my-new-dep:1.0.0'); + const res = await getUpdatedPackageFiles(config); + expect(res).toMatchObject({ + updatedPackageFiles: [ + { path: 'index.html', contents: 'my-new-dep:1.0.0' }, + ], + }); + }); + describe('when some artifacts have changed and others have not', () => { const pushGemUpgrade = (opts: Partial<BranchUpgradeConfig>) => config.upgrades.push({