diff --git a/lib/modules/manager/bundler/__snapshots__/artifacts.spec.ts.snap b/lib/modules/manager/bundler/__snapshots__/artifacts.spec.ts.snap
deleted file mode 100644
index 65b76e989d69257cf2ab76291ec2e076e4d867e6..0000000000000000000000000000000000000000
--- a/lib/modules/manager/bundler/__snapshots__/artifacts.spec.ts.snap
+++ /dev/null
@@ -1,393 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`modules/manager/bundler/artifacts Docker .ruby-version 1`] = `
-Array [
-  Object {
-    "cmd": "docker pull renovate/ruby:1.2.0",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker ps --filter name=renovate_ruby -aq",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker run --rm --name=renovate_ruby --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/cache\\":\\"/tmp/cache\\" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w \\"/tmp/github/some/repo\\" renovate/ruby:1.2.0 bash -l -c \\"install-tool bundler 2.3.5 && ruby --version && bundler lock --update foo bar\\"",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "BUILDPACK_CACHE_DIR": "/tmp/cache/buildpack",
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts Docker constraints options 1`] = `
-Array [
-  Object {
-    "cmd": "docker pull renovate/ruby:latest",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker ps --filter name=renovate_ruby -aq",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker run --rm --name=renovate_ruby --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/cache\\":\\"/tmp/cache\\" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w \\"/tmp/github/some/repo\\" renovate/ruby:latest bash -l -c \\"install-tool bundler 3.2.1 && ruby --version && bundler lock --update foo bar\\"",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "BUILDPACK_CACHE_DIR": "/tmp/cache/buildpack",
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts Docker injects bundler host configuration as command with bundler < 2 1`] = `
-Array [
-  Object {
-    "cmd": "docker pull renovate/ruby:1.2.0",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker ps --filter name=renovate_ruby -aq",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker run --rm --name=renovate_ruby --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/cache\\":\\"/tmp/cache\\" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w \\"/tmp/github/some/repo\\" renovate/ruby:1.2.0 bash -l -c \\"install-tool bundler 1.2 && ruby --version && bundler config --local gems-private.com some-user:some-password && bundler lock --update foo bar\\"",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "BUILDPACK_CACHE_DIR": "/tmp/cache/buildpack",
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts Docker injects bundler host configuration as command with bundler == latest 1`] = `
-Array [
-  Object {
-    "cmd": "docker pull renovate/ruby:1.2.0",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker ps --filter name=renovate_ruby -aq",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker run --rm --name=renovate_ruby --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/cache\\":\\"/tmp/cache\\" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w \\"/tmp/github/some/repo\\" renovate/ruby:1.2.0 bash -l -c \\"install-tool bundler 2.3.5 && ruby --version && bundler config set --local gems-private.com some-user:some-password && bundler lock --update foo bar\\"",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "BUILDPACK_CACHE_DIR": "/tmp/cache/buildpack",
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts Docker injects bundler host configuration as command with bundler >= 2 1`] = `
-Array [
-  Object {
-    "cmd": "docker pull renovate/ruby:1.2.0",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker ps --filter name=renovate_ruby -aq",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker run --rm --name=renovate_ruby --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/cache\\":\\"/tmp/cache\\" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w \\"/tmp/github/some/repo\\" renovate/ruby:1.2.0 bash -l -c \\"install-tool bundler 2.1 && ruby --version && bundler config set --local gems-private.com some-user:some-password && bundler lock --update foo bar\\"",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "BUILDPACK_CACHE_DIR": "/tmp/cache/buildpack",
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts Docker injects bundler host configuration environment variables 1`] = `
-Array [
-  Object {
-    "cmd": "docker pull renovate/ruby:1.2.0",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker ps --filter name=renovate_ruby -aq",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker run --rm --name=renovate_ruby --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/cache\\":\\"/tmp/cache\\" -e BUNDLE_GEMS__PRIVATE__COM -e GEM_HOME -e BUILDPACK_CACHE_DIR -w \\"/tmp/github/some/repo\\" renovate/ruby:1.2.0 bash -l -c \\"install-tool bundler 2.3.5 && ruby --version && bundler lock --update foo bar\\"",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "BUILDPACK_CACHE_DIR": "/tmp/cache/buildpack",
-        "BUNDLE_GEMS__PRIVATE__COM": "some-user:some-password",
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts Docker invalid constraints options 1`] = `
-Array [
-  Object {
-    "cmd": "docker pull renovate/ruby:latest",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker ps --filter name=renovate_ruby -aq",
-    "options": Object {
-      "encoding": "utf-8",
-    },
-  },
-  Object {
-    "cmd": "docker run --rm --name=renovate_ruby --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/cache\\":\\"/tmp/cache\\" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w \\"/tmp/github/some/repo\\" renovate/ruby:latest bash -l -c \\"install-tool bundler 2.3.5 && ruby --version && bundler lock --update foo bar\\"",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "BUILDPACK_CACHE_DIR": "/tmp/cache/buildpack",
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts performs lockFileMaintenance 1`] = `
-Array [
-  Object {
-    "cmd": "bundler lock --update",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts returns error when failing in lockFileMaintenance true 1`] = `
-Array [
-  Object {
-    "artifactError": Object {
-      "lockFile": "Gemfile.lock",
-      "stderr": " foo was resolved to
-",
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts returns error when failing in lockFileMaintenance true 2`] = `
-Array [
-  Object {
-    "cmd": "bundler lock --update",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts returns null if Gemfile.lock was not changed 1`] = `
-Array [
-  Object {
-    "cmd": "bundler lock --update foo bar",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts works explicit global binarySource 1`] = `
-Array [
-  Object {
-    "cmd": "bundler lock --update foo bar",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
-
-exports[`modules/manager/bundler/artifacts works for default binarySource 1`] = `
-Array [
-  Object {
-    "cmd": "bundler lock --update foo bar",
-    "options": Object {
-      "cwd": "/tmp/github/some/repo",
-      "encoding": "utf-8",
-      "env": Object {
-        "GEM_HOME": "/tmp/cache/others/gem",
-        "HOME": "/home/user",
-        "HTTPS_PROXY": "https://example.com",
-        "HTTP_PROXY": "http://example.com",
-        "LANG": "en_US.UTF-8",
-        "LC_ALL": "en_US",
-        "NO_PROXY": "localhost",
-        "PATH": "/tmp/path",
-      },
-      "maxBuffer": 10485760,
-      "timeout": 900000,
-    },
-  },
-]
-`;
diff --git a/lib/modules/manager/bundler/artifacts.spec.ts b/lib/modules/manager/bundler/artifacts.spec.ts
index c86b6f2e65f0075aa97ff93ff0d78f26df6d1bf8..9c4d799b6b2c88dfe3ef9ae06111113cb8bff89d 100644
--- a/lib/modules/manager/bundler/artifacts.spec.ts
+++ b/lib/modules/manager/bundler/artifacts.spec.ts
@@ -1,9 +1,18 @@
 import { join } from 'upath';
-import { envMock, mockExecAll } from '../../../../test/exec-util';
+import {
+  envMock,
+  mockExecAll,
+  mockExecSequence,
+} from '../../../../test/exec-util';
 import { env, fs, git, mocked } from '../../../../test/util';
 import { GlobalConfig } from '../../../config/global';
 import type { RepoGlobalConfig } from '../../../config/types';
+import {
+  BUNDLER_INVALID_CREDENTIALS,
+  TEMPORARY_ERROR,
+} from '../../../constants/error-messages';
 import * as docker from '../../../util/exec/docker';
+import { ExecError } from '../../../util/exec/exec-error';
 import type { StatusResult } from '../../../util/git/types';
 import * as _datasource from '../../datasource';
 import type { UpdateArtifactsConfig } from '../types';
@@ -74,7 +83,7 @@ describe('modules/manager/bundler/artifacts', () => {
     git.getRepoStatus.mockResolvedValueOnce({
       modified: [] as string[],
     } as StatusResult);
-    fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+    fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
     expect(
       await updateArtifacts({
         packageFileName: 'Gemfile',
@@ -83,12 +92,13 @@ describe('modules/manager/bundler/artifacts', () => {
         config,
       })
     ).toBeNull();
-    expect(execSnapshots).toMatchSnapshot();
+    expect(execSnapshots).toMatchObject([
+      { cmd: 'bundler lock --update foo bar' },
+    ]);
   });
 
   it('works for default binarySource', async () => {
     fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-    fs.writeLocalFile.mockResolvedValueOnce();
     fs.readLocalFile.mockResolvedValueOnce(null);
     const execSnapshots = mockExecAll();
     git.getRepoStatus.mockResolvedValueOnce({
@@ -103,13 +113,14 @@ describe('modules/manager/bundler/artifacts', () => {
         config,
       })
     ).toEqual([updatedGemfileLock]);
-    expect(execSnapshots).toMatchSnapshot();
+    expect(execSnapshots).toMatchObject([
+      { cmd: 'bundler lock --update foo bar' },
+    ]);
   });
 
   it('works explicit global binarySource', async () => {
     GlobalConfig.set({ ...adminConfig, binarySource: 'global' });
     fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-    fs.writeLocalFile.mockResolvedValueOnce();
     fs.readLocalFile.mockResolvedValueOnce(null);
     const execSnapshots = mockExecAll();
     git.getRepoStatus.mockResolvedValueOnce({
@@ -124,12 +135,13 @@ describe('modules/manager/bundler/artifacts', () => {
         config,
       })
     ).toEqual([updatedGemfileLock]);
-    expect(execSnapshots).toMatchSnapshot();
+    expect(execSnapshots).toMatchObject([
+      { cmd: 'bundler lock --update foo bar' },
+    ]);
   });
 
   it('supports conservative mode', async () => {
     fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-    fs.writeLocalFile.mockResolvedValueOnce();
     fs.readLocalFile.mockResolvedValueOnce(null);
     const execSnapshots = mockExecAll();
     git.getRepoStatus.mockResolvedValueOnce({
@@ -167,7 +179,6 @@ describe('modules/manager/bundler/artifacts', () => {
 
     it('.ruby-version', async () => {
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.writeLocalFile.mockResolvedValueOnce(null as never);
       fs.readLocalFile.mockResolvedValueOnce('1.2.0');
       datasource.getPkgReleases.mockResolvedValueOnce({
         releases: [{ version: '1.17.2' }, { version: '2.3.5' }],
@@ -183,7 +194,7 @@ describe('modules/manager/bundler/artifacts', () => {
       git.getRepoStatus.mockResolvedValueOnce({
         modified: ['Gemfile.lock'],
       } as StatusResult);
-      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
@@ -192,13 +203,18 @@ describe('modules/manager/bundler/artifacts', () => {
           config,
         })
       ).toEqual([updatedGemfileLock]);
-      expect(execSnapshots).toMatchSnapshot();
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'docker pull renovate/ruby:1.2.0' },
+        { cmd: 'docker ps --filter name=renovate_ruby -aq' },
+        {
+          cmd: 'docker run --rm --name=renovate_ruby --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w "/tmp/github/some/repo" renovate/ruby:1.2.0 bash -l -c "install-tool bundler 2.3.5 && ruby --version && bundler lock --update foo bar"',
+        },
+      ]);
     });
 
     it('constraints options', async () => {
       GlobalConfig.set({ ...adminConfig, binarySource: 'docker' });
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.writeLocalFile.mockResolvedValueOnce(null as never);
       datasource.getPkgReleases.mockResolvedValueOnce({
         releases: [{ version: '1.17.2' }, { version: '2.3.5' }],
       });
@@ -213,7 +229,7 @@ describe('modules/manager/bundler/artifacts', () => {
       git.getRepoStatus.mockResolvedValueOnce({
         modified: ['Gemfile.lock'],
       } as StatusResult);
-      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
@@ -228,13 +244,18 @@ describe('modules/manager/bundler/artifacts', () => {
           },
         })
       ).toEqual([updatedGemfileLock]);
-      expect(execSnapshots).toMatchSnapshot();
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'docker pull renovate/ruby:latest' },
+        { cmd: 'docker ps --filter name=renovate_ruby -aq' },
+        {
+          cmd: 'docker run --rm --name=renovate_ruby --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w "/tmp/github/some/repo" renovate/ruby:latest bash -l -c "install-tool bundler 3.2.1 && ruby --version && bundler lock --update foo bar"',
+        },
+      ]);
     });
 
     it('invalid constraints options', async () => {
       GlobalConfig.set({ ...adminConfig, binarySource: 'docker' });
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.writeLocalFile.mockResolvedValueOnce(null as never);
       datasource.getPkgReleases.mockResolvedValueOnce({
         releases: [{ version: '1.17.2' }, { version: '2.3.5' }],
       });
@@ -249,7 +270,7 @@ describe('modules/manager/bundler/artifacts', () => {
       git.getRepoStatus.mockResolvedValueOnce({
         modified: ['Gemfile.lock'],
       } as StatusResult);
-      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
@@ -264,13 +285,18 @@ describe('modules/manager/bundler/artifacts', () => {
           },
         })
       ).toEqual([updatedGemfileLock]);
-      expect(execSnapshots).toMatchSnapshot();
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'docker pull renovate/ruby:latest' },
+        { cmd: 'docker ps --filter name=renovate_ruby -aq' },
+        {
+          cmd: 'docker run --rm --name=renovate_ruby --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w "/tmp/github/some/repo" renovate/ruby:latest bash -l -c "install-tool bundler 2.3.5 && ruby --version && bundler lock --update foo bar"',
+        },
+      ]);
     });
 
     it('injects bundler host configuration environment variables', async () => {
       GlobalConfig.set({ ...adminConfig, binarySource: 'docker' });
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.writeLocalFile.mockResolvedValueOnce(null as never);
       fs.readLocalFile.mockResolvedValueOnce('1.2.0');
       datasource.getPkgReleases.mockResolvedValueOnce({
         releases: [{ version: '1.17.2' }, { version: '2.3.5' }],
@@ -298,7 +324,7 @@ describe('modules/manager/bundler/artifacts', () => {
       git.getRepoStatus.mockResolvedValueOnce({
         modified: ['Gemfile.lock'],
       } as StatusResult);
-      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
@@ -307,13 +333,18 @@ describe('modules/manager/bundler/artifacts', () => {
           config,
         })
       ).toEqual([updatedGemfileLock]);
-      expect(execSnapshots).toMatchSnapshot();
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'docker pull renovate/ruby:1.2.0' },
+        { cmd: 'docker ps --filter name=renovate_ruby -aq' },
+        {
+          cmd: 'docker run --rm --name=renovate_ruby --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e BUNDLE_GEMS__PRIVATE__COM -e GEM_HOME -e BUILDPACK_CACHE_DIR -w "/tmp/github/some/repo" renovate/ruby:1.2.0 bash -l -c "install-tool bundler 2.3.5 && ruby --version && bundler lock --update foo bar"',
+        },
+      ]);
     });
 
     it('injects bundler host configuration as command with bundler < 2', async () => {
       GlobalConfig.set({ ...adminConfig, binarySource: 'docker' });
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.writeLocalFile.mockResolvedValueOnce(null as never);
       fs.readLocalFile.mockResolvedValueOnce('1.2.0');
       datasource.getPkgReleases.mockResolvedValueOnce({
         releases: [
@@ -338,7 +369,7 @@ describe('modules/manager/bundler/artifacts', () => {
       git.getRepoStatus.mockResolvedValueOnce({
         modified: ['Gemfile.lock'],
       } as StatusResult);
-      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
@@ -352,13 +383,18 @@ describe('modules/manager/bundler/artifacts', () => {
           },
         })
       ).toEqual([updatedGemfileLock]);
-      expect(execSnapshots).toMatchSnapshot();
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'docker pull renovate/ruby:1.2.0' },
+        { cmd: 'docker ps --filter name=renovate_ruby -aq' },
+        {
+          cmd: 'docker run --rm --name=renovate_ruby --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w "/tmp/github/some/repo" renovate/ruby:1.2.0 bash -l -c "install-tool bundler 1.2 && ruby --version && bundler config --local gems-private.com some-user:some-password && bundler lock --update foo bar"',
+        },
+      ]);
     });
 
     it('injects bundler host configuration as command with bundler >= 2', async () => {
       GlobalConfig.set({ ...adminConfig, binarySource: 'docker' });
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.writeLocalFile.mockResolvedValueOnce(null as never);
       fs.readLocalFile.mockResolvedValueOnce('1.2.0');
       datasource.getPkgReleases.mockResolvedValueOnce({
         releases: [
@@ -383,7 +419,7 @@ describe('modules/manager/bundler/artifacts', () => {
       git.getRepoStatus.mockResolvedValueOnce({
         modified: ['Gemfile.lock'],
       } as StatusResult);
-      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+      fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
@@ -397,13 +433,18 @@ describe('modules/manager/bundler/artifacts', () => {
           },
         })
       ).toEqual([updatedGemfileLock]);
-      expect(execSnapshots).toMatchSnapshot();
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'docker pull renovate/ruby:1.2.0' },
+        { cmd: 'docker ps --filter name=renovate_ruby -aq' },
+        {
+          cmd: 'docker run --rm --name=renovate_ruby --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w "/tmp/github/some/repo" renovate/ruby:1.2.0 bash -l -c "install-tool bundler 2.1 && ruby --version && bundler config set --local gems-private.com some-user:some-password && bundler lock --update foo bar"',
+        },
+      ]);
     });
 
     it('injects bundler host configuration as command with bundler == latest', async () => {
       GlobalConfig.set({ ...adminConfig, binarySource: 'docker' });
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.writeLocalFile.mockResolvedValueOnce(null as never);
       fs.readLocalFile.mockResolvedValueOnce('1.2.0');
       datasource.getPkgReleases.mockResolvedValueOnce({
         releases: [{ version: '1.17.2' }, { version: '2.3.5' }],
@@ -440,14 +481,23 @@ describe('modules/manager/bundler/artifacts', () => {
           config,
         })
       ).toEqual([updatedGemfileLock]);
-      expect(execSnapshots).toMatchSnapshot();
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'docker pull renovate/ruby:1.2.0' },
+        { cmd: 'docker ps --filter name=renovate_ruby -aq' },
+        {
+          cmd: 'docker run --rm --name=renovate_ruby --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e GEM_HOME -e BUILDPACK_CACHE_DIR -w "/tmp/github/some/repo" renovate/ruby:1.2.0 bash -l -c "install-tool bundler 2.3.5 && ruby --version && bundler config set --local gems-private.com some-user:some-password && bundler lock --update foo bar"',
+        },
+      ]);
     });
   });
 
   it('returns error when failing in lockFileMaintenance true', async () => {
-    const execError = new Error();
-    (execError as any).stdout = ' foo was resolved to';
-    (execError as any).stderr = '';
+    const execError = new ExecError('Exec error', {
+      cmd: '',
+      stdout: ' foo was resolved to',
+      stderr: '',
+      options: { encoding: 'utf8' },
+    });
     fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
     fs.writeLocalFile.mockResolvedValueOnce(null as never);
     const execSnapshots = mockExecAll(execError);
@@ -464,24 +514,18 @@ describe('modules/manager/bundler/artifacts', () => {
           isLockFileMaintenance: true,
         },
       })
-    ).toMatchSnapshot([
-      {
-        artifactError: {
-          lockFile: 'Gemfile.lock',
-        },
-      },
-    ]);
-    expect(execSnapshots).toMatchSnapshot();
+    ).toMatchObject([{ artifactError: { lockFile: 'Gemfile.lock' } }]);
+    expect(execSnapshots).toMatchObject([{ cmd: 'bundler lock --update' }]);
   });
 
   it('performs lockFileMaintenance', async () => {
     fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-    fs.writeLocalFile.mockResolvedValueOnce(null as never);
+    fs.writeLocalFile.mockResolvedValueOnce();
     const execSnapshots = mockExecAll();
     git.getRepoStatus.mockResolvedValueOnce({
       modified: ['Gemfile.lock'],
     } as StatusResult);
-    fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+    fs.readLocalFile.mockResolvedValueOnce('Updated Gemfile.lock');
     expect(
       await updateArtifacts({
         packageFileName: 'Gemfile',
@@ -493,6 +537,139 @@ describe('modules/manager/bundler/artifacts', () => {
         },
       })
     ).not.toBeNull();
-    expect(execSnapshots).toMatchSnapshot();
+    expect(execSnapshots).toMatchObject([{ cmd: 'bundler lock --update' }]);
+  });
+
+  describe('Error handling', () => {
+    it('returns error when failing in lockFileMaintenance true', async () => {
+      const execError = new ExecError('Exec error', {
+        cmd: '',
+        stdout: ' foo was resolved to',
+        stderr: '',
+        options: { encoding: 'utf8' },
+      });
+      fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
+      const execSnapshots = mockExecAll(execError);
+      git.getRepoStatus.mockResolvedValueOnce({
+        modified: ['Gemfile.lock'],
+      } as StatusResult);
+      expect(
+        await updateArtifacts({
+          packageFileName: 'Gemfile',
+          updatedDeps: [],
+          newPackageFileContent: '{}',
+          config: {
+            ...config,
+            isLockFileMaintenance: true,
+          },
+        })
+      ).toMatchObject([
+        {
+          artifactError: {
+            lockFile: 'Gemfile.lock',
+          },
+        },
+      ]);
+      expect(execSnapshots).toMatchObject([{ cmd: 'bundler lock --update' }]);
+    });
+
+    it('rethrows for temporary error', async () => {
+      const execError = new ExecError(TEMPORARY_ERROR, {
+        cmd: '',
+        stdout: '',
+        stderr: '',
+        options: { encoding: 'utf8' },
+      });
+      fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
+      mockExecAll(execError);
+      await expect(
+        updateArtifacts({
+          packageFileName: 'Gemfile',
+          updatedDeps: [],
+          newPackageFileContent: '{}',
+          config: {
+            ...config,
+            isLockFileMaintenance: true,
+          },
+        })
+      ).rejects.toThrow(TEMPORARY_ERROR);
+    });
+
+    it('handles "Could not parse object" error', async () => {
+      const execError = new ExecError('fatal: Could not parse object', {
+        cmd: '',
+        stdout: 'but that version could not be found',
+        stderr: '',
+        options: { encoding: 'utf8' },
+      });
+      fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
+      mockExecAll(execError);
+      expect(
+        await updateArtifacts({
+          packageFileName: 'Gemfile',
+          updatedDeps: [],
+          newPackageFileContent: '{}',
+          config: {
+            ...config,
+            isLockFileMaintenance: true,
+          },
+        })
+      ).toMatchObject([{ artifactError: { lockFile: 'Gemfile.lock' } }]);
+    });
+
+    it('throws on authentication errors', async () => {
+      const execError = new ExecError('Exec error', {
+        cmd: '',
+        stdout: 'Please supply credentials for this source',
+        stderr: 'Please make sure you have the correct access rights',
+        options: { encoding: 'utf8' },
+      });
+      fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
+      mockExecAll(execError);
+      await expect(
+        updateArtifacts({
+          packageFileName: 'Gemfile',
+          updatedDeps: [],
+          newPackageFileContent: '{}',
+          config: {
+            ...config,
+            isLockFileMaintenance: true,
+          },
+        })
+      ).rejects.toThrow(BUNDLER_INVALID_CREDENTIALS);
+    });
+
+    it('handles recursive resolved dependencies', async () => {
+      const execError = new ExecError('Exec error', {
+        cmd: '',
+        stdout: 'foo was resolved to foo',
+        stderr: 'bar was resolved to bar',
+        options: { encoding: 'utf8' },
+      });
+      fs.readLocalFile.mockResolvedValue('Current Gemfile.lock');
+      const execSnapshots = mockExecSequence([
+        execError,
+        { stdout: '', stderr: '' },
+      ]);
+      git.getRepoStatus.mockResolvedValueOnce({
+        modified: ['Gemfile.lock'],
+      } as StatusResult);
+
+      const res = await updateArtifacts({
+        packageFileName: 'Gemfile',
+        updatedDeps: [{ depName: 'foo' }],
+        newPackageFileContent: '{}',
+        config: {
+          ...config,
+          isLockFileMaintenance: false,
+        },
+      });
+
+      expect(res).toMatchObject([{ file: { path: 'Gemfile.lock' } }]);
+      expect(execSnapshots).toMatchObject([
+        { cmd: 'bundler lock --update foo' },
+        { cmd: 'bundler lock --update foo bar' },
+      ]);
+    });
   });
 });
diff --git a/lib/modules/manager/bundler/artifacts.ts b/lib/modules/manager/bundler/artifacts.ts
index 495260a1c5dfd3154d4d182514b6f8ecb6118db4..ace9f6860fef7e98c96cb45836b17e94e795fe6a 100644
--- a/lib/modules/manager/bundler/artifacts.ts
+++ b/lib/modules/manager/bundler/artifacts.ts
@@ -16,7 +16,7 @@ import {
   writeLocalFile,
 } from '../../../util/fs';
 import { getRepoStatus } from '../../../util/git';
-import { regEx } from '../../../util/regex';
+import { newlineRegex, regEx } from '../../../util/regex';
 import { addSecretForSanitizing } from '../../../util/sanitize';
 import { isValid } from '../../versioning/ruby';
 import type { UpdateArtifact, UpdateArtifactsResult } from '../types';
@@ -43,8 +43,27 @@ function buildBundleHostVariable(hostRule: HostRule): Record<string, string> {
   };
 }
 
+const resolvedPkgRegex = regEx(
+  /(?<pkg>\S+)(?:\s*\([^)]+\)\s*)? was resolved to/
+);
+
+function getResolvedPackages(input: string): string[] {
+  const lines = input.split(newlineRegex);
+  const result: string[] = [];
+  for (const line of lines) {
+    const resolveMatchGroups = line.match(resolvedPkgRegex)?.groups;
+    if (resolveMatchGroups) {
+      const { pkg } = resolveMatchGroups;
+      result.push(pkg);
+    }
+  }
+
+  return [...new Set(result)];
+}
+
 export async function updateArtifacts(
-  updateArtifact: UpdateArtifact
+  updateArtifact: UpdateArtifact,
+  recursionLimit = 10
 ): Promise<UpdateArtifactsResult[] | null> {
   const { packageFileName, updatedDeps, newPackageFileContent, config } =
     updateArtifact;
@@ -68,6 +87,10 @@ export async function updateArtifacts(
     '--update',
   ].filter(is.nonEmptyString);
 
+  const updatedDepNames = updatedDeps
+    .map(({ depName }) => depName)
+    .filter(is.nonEmptyStringAndNotWhitespace);
+
   try {
     await writeLocalFile(packageFileName, newPackageFileContent);
 
@@ -76,8 +99,7 @@ export async function updateArtifacts(
     if (config.isLockFileMaintenance) {
       cmd = 'bundler lock --update';
     } else {
-      cmd = `bundler lock ${args.join(' ')} ${updatedDeps
-        .map((dep) => `${dep.depName}`)
+      cmd = `bundler lock ${args.join(' ')} ${updatedDepNames
         .filter((dep) => dep !== 'ruby')
         .map(quote)
         .join(' ')}`;
@@ -174,7 +196,7 @@ export async function updateArtifacts(
         },
       },
     ];
-  } catch (err) /* istanbul ignore next */ {
+  } catch (err) {
     if (err.message === TEMPORARY_ERROR) {
       throw err;
     }
@@ -207,44 +229,36 @@ export async function updateArtifacts(
       memCache.set('bundlerArtifactsError', BUNDLER_INVALID_CREDENTIALS);
       throw new Error(BUNDLER_INVALID_CREDENTIALS);
     }
-    const resolveMatchRe = regEx('\\s+(.*) was resolved to', 'g');
-    if (output.match(resolveMatchRe) && !config.isLockFileMaintenance) {
-      logger.debug({ err }, 'Bundler has a resolve error');
-      // TODO: see below
-      const resolveMatches: any[] = [];
-      let resolveMatch: RegExpExecArray | null;
-      do {
-        resolveMatch = resolveMatchRe.exec(output);
-        if (resolveMatch) {
-          resolveMatches.push(resolveMatch[1].split(' ').shift());
-        }
-      } while (resolveMatch);
-      // TODO: fixme `updatedDeps.includes(match)` is never true, as updatedDeps is `PackageDependency[]`
-      if (resolveMatches.some((match) => !updatedDeps.includes(match))) {
-        logger.debug(
-          { resolveMatches, updatedDeps },
-          'Found new resolve matches - reattempting recursively'
-        );
-        const newUpdatedDeps = [
-          ...new Set([...updatedDeps, ...resolveMatches]),
-        ];
-        return updateArtifacts({
+    const resolveMatches: string[] = getResolvedPackages(output).filter(
+      (depName) => !updatedDepNames.includes(depName)
+    );
+    if (
+      recursionLimit > 0 &&
+      resolveMatches.length &&
+      !config.isLockFileMaintenance
+    ) {
+      logger.debug(
+        { resolveMatches, updatedDeps },
+        'Found new resolve matches - reattempting recursively'
+      );
+      const newUpdatedDeps = [
+        ...new Set([
+          ...updatedDeps,
+          ...resolveMatches.map((match) => ({ depName: match })),
+        ]),
+      ];
+      return updateArtifacts(
+        {
           packageFileName,
           updatedDeps: newUpdatedDeps,
           newPackageFileContent,
           config,
-        });
-      }
-      logger.debug(
-        { err },
-        'Gemfile.lock update failed due to incompatible packages'
-      );
-    } else {
-      logger.info(
-        { err },
-        'Gemfile.lock update failed due to an unknown reason'
+        },
+        recursionLimit - 1
       );
     }
+
+    logger.info({ err }, 'Gemfile.lock update failed due to an unknown reason');
     return [
       {
         artifactError: {