diff --git a/lib/modules/manager/bundler/artifacts.spec.ts b/lib/modules/manager/bundler/artifacts.spec.ts
index a4611a22d843a81c2b045d2561bda84703a53f1d..497d293a06b5527938b1106e3e06268e5059b23f 100644
--- a/lib/modules/manager/bundler/artifacts.spec.ts
+++ b/lib/modules/manager/bundler/artifacts.spec.ts
@@ -126,7 +126,10 @@ describe('modules/manager/bundler/artifacts', () => {
 
     it('works for default binarySource', async () => {
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.readLocalFile.mockResolvedValueOnce(null);
+      fs.readLocalFile.mockResolvedValueOnce(null); // .ruby-version
+      fs.readLocalFile.mockResolvedValueOnce(null); // .tool-versions
+      fs.localPathExists.mockResolvedValueOnce(true); // Gemfile.lock
+      fs.readLocalFile.mockResolvedValueOnce(null); // Gemfile.lock
       const execSnapshots = mockExecAll();
       git.getRepoStatus.mockResolvedValueOnce(
         partial<StatusResult>({
@@ -150,7 +153,10 @@ describe('modules/manager/bundler/artifacts', () => {
     it('works explicit global binarySource', async () => {
       GlobalConfig.set({ ...adminConfig, binarySource: 'global' });
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.readLocalFile.mockResolvedValueOnce(null);
+      fs.readLocalFile.mockResolvedValueOnce(null); // .ruby-version
+      fs.readLocalFile.mockResolvedValueOnce(null); // .tool-versions
+      fs.localPathExists.mockResolvedValueOnce(true); // Gemfile.lock
+      fs.readLocalFile.mockResolvedValueOnce(null); // Gemfile.lock
       const execSnapshots = mockExecAll();
       git.getRepoStatus.mockResolvedValueOnce(
         partial<StatusResult>({
@@ -173,7 +179,10 @@ describe('modules/manager/bundler/artifacts', () => {
 
     it('supports conservative mode and updateType option', async () => {
       fs.readLocalFile.mockResolvedValueOnce('Current Gemfile.lock');
-      fs.readLocalFile.mockResolvedValueOnce(null);
+      fs.readLocalFile.mockResolvedValueOnce(null); // .ruby-version
+      fs.readLocalFile.mockResolvedValueOnce(null); // .tool-versions
+      fs.localPathExists.mockResolvedValueOnce(true); // Gemfile.lock
+      fs.readLocalFile.mockResolvedValueOnce(null); // Gemfile.lock
       const execSnapshots = mockExecAll();
       git.getRepoStatus.mockResolvedValueOnce(
         partial<StatusResult>({
diff --git a/lib/modules/manager/bundler/common.spec.ts b/lib/modules/manager/bundler/common.spec.ts
index f7b7aeb8705ec66c0777e166d586a6612ba9c0c8..ced2e45fbbbc114528843115ed7386a5813c197d 100644
--- a/lib/modules/manager/bundler/common.spec.ts
+++ b/lib/modules/manager/bundler/common.spec.ts
@@ -68,7 +68,7 @@ describe('modules/manager/bundler/common', () => {
       expect(version).toBe('2.1.0');
     });
 
-    it('extracts from lockfile', async () => {
+    it('extracts from gemfile', async () => {
       const config = partial<UpdateArtifact>({
         packageFileName: 'Gemfile',
         newPackageFileContent: gemfile,
@@ -84,9 +84,37 @@ describe('modules/manager/bundler/common', () => {
         newPackageFileContent: '',
         config: {},
       });
-      fs.readLocalFile.mockResolvedValueOnce('ruby-1.2.3');
+      fs.readLocalFile.mockResolvedValueOnce('2.7.8');
+      const version = await getRubyConstraint(config);
+      expect(version).toBe('2.7.8');
+    });
+
+    it('extracts from .tool-versions', async () => {
+      const config = partial<UpdateArtifact>({
+        packageFileName: 'Gemfile',
+        newPackageFileContent: '',
+        config: {},
+      });
+      fs.readLocalFile
+        .mockResolvedValueOnce(null)
+        .mockResolvedValueOnce('python\t3.8.10\nruby\t3.3.4\n');
+      const version = await getRubyConstraint(config);
+      expect(version).toBe('3.3.4');
+    });
+
+    it('extracts from lockfile', async () => {
+      const config = partial<UpdateArtifact>({
+        packageFileName: 'Gemfile',
+        newPackageFileContent: '',
+        config: {},
+      });
+      fs.localPathExists.mockResolvedValueOnce(true);
+      fs.readLocalFile
+        .mockResolvedValueOnce(null)
+        .mockResolvedValueOnce(null)
+        .mockResolvedValueOnce(Fixtures.get('Gemfile.rubyci.lock'));
       const version = await getRubyConstraint(config);
-      expect(version).toBe('1.2.3');
+      expect(version).toBe('2.6.5');
     });
 
     it('returns null', async () => {
diff --git a/lib/modules/manager/bundler/common.ts b/lib/modules/manager/bundler/common.ts
index cf85fee10eda70a1ff968fb84ffc81289e272fd0..0b6c4fdb7410fcd19518ebc2e948dfa9dbd1052e 100644
--- a/lib/modules/manager/bundler/common.ts
+++ b/lib/modules/manager/bundler/common.ts
@@ -34,17 +34,24 @@ export async function getRubyConstraint(
       logger.debug('Using ruby version from gemfile');
       return rubyMatch;
     }
-    const rubyVersionFile = getSiblingFileName(
-      packageFileName,
-      '.ruby-version',
-    );
-    const rubyVersionFileContent = await readLocalFile(rubyVersionFile, 'utf8');
-    if (rubyVersionFileContent) {
-      logger.debug('Using ruby version specified in .ruby-version');
-      return rubyVersionFileContent
-        .replace(regEx(/^ruby-/), '')
-        .replace(regEx(/\n/g), '')
-        .trim();
+    for (const file of ['.ruby-version', '.tool-versions']) {
+      const rubyVersion = (
+        await readLocalFile(getSiblingFileName(packageFileName, file), 'utf8')
+      )?.match(regEx(/^(?:ruby(?:-|\s+))?(\d[\d.]*)/m))?.[1];
+      if (rubyVersion) {
+        logger.debug(`Using ruby version specified in ${file}`);
+        return rubyVersion;
+      }
+    }
+    const lockFile = await getLockFilePath(packageFileName);
+    if (lockFile) {
+      const rubyVersion = (await readLocalFile(lockFile, 'utf8'))?.match(
+        regEx(/^ {3}ruby (\d[\d.]*)(?:[a-z]|\s|$)/m),
+      )?.[1];
+      if (rubyVersion) {
+        logger.debug(`Using ruby version specified in lock file`);
+        return rubyVersion;
+      }
     }
   }
   return null;