diff --git a/lib/modules/versioning/ruby/index.spec.ts b/lib/modules/versioning/ruby/index.spec.ts
index fec50cc579eca8c9a7dd6e9f2668335b3866b9e8..474111a83f7042ab4e999d6638fe7ea3ce21d75b 100644
--- a/lib/modules/versioning/ruby/index.spec.ts
+++ b/lib/modules/versioning/ruby/index.spec.ts
@@ -265,24 +265,44 @@ describe('modules/versioning/ruby/index', () => {
     ${"'>= 3.0.5', '< 3.2'"} | ${'replace'}         | ${'3.1.5'}     | ${'3.2.1'}   | ${"'>= 3.0.5', '< 3.3'"}
     ${"'0.0.10'"}            | ${'auto'}            | ${'0.0.10'}    | ${'0.0.11'}  | ${"'0.0.11'"}
     ${"'0.0.10'"}            | ${'replace'}         | ${'0.0.10'}    | ${'0.0.11'}  | ${"'0.0.11'"}
+    ${'>= 3.2, < 5.0'}       | ${'bump'}            | ${'4.0.2'}     | ${'6.0.1'}   | ${'>= 6.0.1, < 6.0.2'}
+    ${'~> 5.2, >= 5.2.5'}    | ${'bump'}            | ${'5.3.0'}     | ${'6.0.0'}   | ${'~> 6.0'}
+    ${'~> 5.2, >= 5.2.5'}    | ${'bump'}            | ${'5.3.0'}     | ${'6.0.1'}   | ${'~> 6.0, >= 6.0.1'}
+    ${'~> 5.2.0, >= 5.2.5'}  | ${'bump'}            | ${'5.2.5'}     | ${'5.3.1'}   | ${'~> 5.3.1'}
+    ${'4.2.0'}               | ${'bump'}            | ${'4.2.0'}     | ${'4.2.5.1'} | ${'4.2.5.1'}
+    ${'4.2.5.1'}             | ${'bump'}            | ${'0.1'}       | ${'4.3.0'}   | ${'4.3.0'}
+    ${'~> 1'}                | ${'bump'}            | ${'1.2.0'}     | ${'2.0.3'}   | ${'~> 2, >= 2.0.3'}
+    ${'= 5.2.2'}             | ${'bump'}            | ${'5.2.2'}     | ${'5.2.2.1'} | ${'= 5.2.2.1'}
     ${'1.0.3'}               | ${'bump'}            | ${'1.0.3'}     | ${'1.2.3'}   | ${'1.2.3'}
     ${'v1.0.3'}              | ${'bump'}            | ${'1.0.3'}     | ${'1.2.3'}   | ${'v1.2.3'}
     ${'= 1.0.3'}             | ${'bump'}            | ${'1.0.3'}     | ${'1.2.3'}   | ${'= 1.2.3'}
-    ${'!= 1.0.3'}            | ${'bump'}            | ${'1.0.0'}     | ${'1.2.3'}   | ${'!= 1.0.3'}
+    ${'!= 1.0.3'}            | ${'bump'}            | ${'1.0.0'}     | ${'1.2.3'}   | ${'>= 1.2.3'}
+    ${'!= 1.0.3'}            | ${'bump'}            | ${'1.0.0'}     | ${'1.0.2'}   | ${'!= 1.0.3'}
+    ${'!= 1.0.3'}            | ${'bump'}            | ${'1.0.0'}     | ${'1.0.3'}   | ${'!= 1.0.3'}
     ${'> 1.0.3'}             | ${'bump'}            | ${'1.0.4'}     | ${'1.2.3'}   | ${'> 1.2.2'}
-    ${'> 1.2.3'}             | ${'bump'}            | ${'1.0.0'}     | ${'1.0.3'}   | ${'> 1.2.3'}
+    ${'> 1.2.3'}             | ${'bump'}            | ${'1.0.0'}     | ${'1.0.3'}   | ${'> 1.0.2'}
     ${'< 1.0.3'}             | ${'bump'}            | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
     ${'< 1.2.3'}             | ${'bump'}            | ${'1.0.0'}     | ${'1.0.3'}   | ${'< 1.2.3'}
     ${'< 1.2.2'}             | ${'bump'}            | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
     ${'< 1.2.3'}             | ${'bump'}            | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
     ${'< 1.2'}               | ${'bump'}            | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.3'}
     ${'< 1'}                 | ${'bump'}            | ${'0.9.0'}     | ${'1.2.3'}   | ${'< 2'}
+    ${'< 1.2.3'}             | ${'bump'}            | ${'1.0.0'}     | ${'1.2.2'}   | ${'< 1.2.3'}
     ${'>= 1.0.3'}            | ${'bump'}            | ${'1.0.3'}     | ${'1.2.3'}   | ${'>= 1.2.3'}
+    ${'>= 1.0.3'}            | ${'bump'}            | ${'1.0.3'}     | ${'1.0.2'}   | ${'>= 1.0.2'}
     ${'<= 1.0.3'}            | ${'bump'}            | ${'1.0.3'}     | ${'1.2.3'}   | ${'<= 1.2.3'}
-    ${'~> 1.0.3'}            | ${'bump'}            | ${'1.0.3'}     | ${'1.2.3'}   | ${'~> 1.2.0'}
-    ${'~> 1.0.3'}            | ${'bump'}            | ${'1.0.3'}     | ${'1.0.4'}   | ${'~> 1.0.0'}
-    ${'~> 4.7, >= 4.7.4'}    | ${'bump'}            | ${'4.7.5'}     | ${'4.7.9'}   | ${'~> 4.7.0, >= 4.7.9'}
+    ${'<= 1.0.3'}            | ${'bump'}            | ${'1.0.0'}     | ${'1.0.2'}   | ${'<= 1.0.3'}
+    ${'~> 1.0.3'}            | ${'bump'}            | ${'1.0.3'}     | ${'1.2.3'}   | ${'~> 1.2.3'}
+    ${'~> 1.0.3'}            | ${'bump'}            | ${'1.0.3'}     | ${'1.0.4'}   | ${'~> 1.0.4'}
+    ${'~> 4.7, >= 4.7.4'}    | ${'bump'}            | ${'4.7.5'}     | ${'4.7.9'}   | ${'~> 4.7, >= 4.7.9'}
+    ${'~> 4.7, >= 4.7.4'}    | ${'bump'}            | ${'4.7.5'}     | ${'4.8.0'}   | ${'~> 4.8'}
+    ${'>= 2.0.0, <= 2.15'}   | ${'bump'}            | ${'2.15.0'}    | ${'2.20.1'}  | ${'>= 2.20.1, <= 2.20.1'}
+    ${'~> 5.2.0'}            | ${'bump'}            | ${'5.2.4.1'}   | ${'6.0.2.1'} | ${'~> 6.0.2, >= 6.0.2.1'}
+    ${'~> 4.0, < 5'}         | ${'bump'}            | ${'4.7.5'}     | ${'5.0.0'}   | ${'~> 5.0, < 6'}
+    ${'~> 4.0, < 5'}         | ${'bump'}            | ${'4.7.5'}     | ${'5.0.1'}   | ${'~> 5.0, >= 5.0.1, < 6'}
+    ${'~> 4.0, < 5'}         | ${'bump'}            | ${'4.7.5'}     | ${'5.1.0'}   | ${'~> 5.1, < 6'}
     ${'>= 3.2, < 5.0'}       | ${'replace'}         | ${'4.0.2'}     | ${'6.0.1'}   | ${'>= 3.2, < 6.0.2'}
+    ${'~> 5.2, >= 5.2.5'}    | ${'replace'}         | ${'5.3.0'}     | ${'6.0.0'}   | ${'~> 6.0, >= 6.0.0'}
     ${'~> 5.2, >= 5.2.5'}    | ${'replace'}         | ${'5.3.0'}     | ${'6.0.1'}   | ${'~> 6.0, >= 6.0.1'}
     ${'~> 5.2.0, >= 5.2.5'}  | ${'replace'}         | ${'5.2.5'}     | ${'5.3.1'}   | ${'~> 5.3.0, >= 5.3.1'}
     ${'4.2.0'}               | ${'replace'}         | ${'4.2.0'}     | ${'4.2.5.1'} | ${'4.2.5.1'}
@@ -293,23 +313,66 @@ describe('modules/versioning/ruby/index', () => {
     ${'v1.0.3'}              | ${'replace'}         | ${'1.0.3'}     | ${'1.2.3'}   | ${'v1.2.3'}
     ${'= 1.0.3'}             | ${'replace'}         | ${'1.0.3'}     | ${'1.2.3'}   | ${'= 1.2.3'}
     ${'!= 1.0.3'}            | ${'replace'}         | ${'1.0.0'}     | ${'1.2.3'}   | ${'!= 1.0.3'}
+    ${'!= 1.0.3'}            | ${'replace'}         | ${'1.0.0'}     | ${'1.0.2'}   | ${'!= 1.0.3'}
+    ${'!= 1.0.3'}            | ${'replace'}         | ${'1.0.0'}     | ${'1.0.3'}   | ${'!= 1.0.3'}
+    ${'> 1.0.3'}             | ${'replace'}         | ${'1.0.4'}     | ${'1.2.3'}   | ${'> 1.0.3'}
+    ${'> 1.2.3'}             | ${'replace'}         | ${'1.0.0'}     | ${'1.0.3'}   | ${'> 1.0.2'}
     ${'< 1.0.3'}             | ${'replace'}         | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
+    ${'< 1.2.3'}             | ${'replace'}         | ${'1.0.0'}     | ${'1.0.3'}   | ${'< 1.2.3'}
     ${'< 1.2.2'}             | ${'replace'}         | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
     ${'< 1.2.3'}             | ${'replace'}         | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
     ${'< 1.2'}               | ${'replace'}         | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.3'}
     ${'< 1'}                 | ${'replace'}         | ${'0.9.0'}     | ${'1.2.3'}   | ${'< 2'}
     ${'< 1.2.3'}             | ${'replace'}         | ${'1.0.0'}     | ${'1.2.2'}   | ${'< 1.2.3'}
     ${'>= 1.0.3'}            | ${'replace'}         | ${'1.0.3'}     | ${'1.2.3'}   | ${'>= 1.0.3'}
+    ${'>= 1.0.3'}            | ${'replace'}         | ${'1.0.3'}     | ${'1.0.2'}   | ${'>= 1.0.2'}
     ${'<= 1.0.3'}            | ${'replace'}         | ${'1.0.0'}     | ${'1.2.3'}   | ${'<= 1.2.3'}
     ${'<= 1.0.3'}            | ${'replace'}         | ${'1.0.0'}     | ${'1.0.2'}   | ${'<= 1.0.3'}
     ${'~> 1.0.3'}            | ${'replace'}         | ${'1.0.0'}     | ${'1.2.3'}   | ${'~> 1.2.0'}
     ${'~> 1.0.3'}            | ${'replace'}         | ${'1.0.0'}     | ${'1.0.4'}   | ${'~> 1.0.3'}
     ${'~> 4.7, >= 4.7.4'}    | ${'replace'}         | ${'1.0.0'}     | ${'4.7.9'}   | ${'~> 4.7, >= 4.7.4'}
+    ${'~> 4.7, >= 4.7.4'}    | ${'replace'}         | ${'4.7.5'}     | ${'4.8.0'}   | ${'~> 4.7, >= 4.7.4'}
     ${'>= 2.0.0, <= 2.15'}   | ${'replace'}         | ${'2.15.0'}    | ${'2.20.1'}  | ${'>= 2.0.0, <= 2.20.1'}
     ${'~> 5.2.0'}            | ${'replace'}         | ${'5.2.4.1'}   | ${'6.0.2.1'} | ${'~> 6.0.0'}
     ${'~> 4.0, < 5'}         | ${'replace'}         | ${'4.7.5'}     | ${'5.0.0'}   | ${'~> 5.0, < 6'}
     ${'~> 4.0, < 5'}         | ${'replace'}         | ${'4.7.5'}     | ${'5.0.1'}   | ${'~> 5.0, < 6'}
-    ${'~> 4.0, < 5'}         | ${'replace'}         | ${'4.7.5'}     | ${'5.1.0'}   | ${'~> 5.1, < 6'}
+    ${'~> 4.0, < 5'}         | ${'replace'}         | ${'4.7.5'}     | ${'5.1.0'}   | ${'~> 5.0, < 6'}
+    ${'>= 3.2, < 5.0'}       | ${'widen'}           | ${'4.0.2'}     | ${'6.0.1'}   | ${'>= 3.2, < 6.0.2'}
+    ${'~> 5.2, >= 5.2.5'}    | ${'widen'}           | ${'5.3.0'}     | ${'6.0.0'}   | ${'>= 5.2.5, < 7'}
+    ${'~> 5.2, >= 5.2.5'}    | ${'widen'}           | ${'5.3.0'}     | ${'6.0.1'}   | ${'>= 5.2.5, < 7'}
+    ${'~> 5.2.0, >= 5.2.5'}  | ${'widen'}           | ${'5.2.5'}     | ${'5.3.1'}   | ${'>= 5.2.5, < 5.4'}
+    ${'4.2.0'}               | ${'widen'}           | ${'4.2.0'}     | ${'4.2.5.1'} | ${'4.2.5.1'}
+    ${'4.2.5.1'}             | ${'widen'}           | ${'0.1'}       | ${'4.3.0'}   | ${'4.3.0'}
+    ${'~> 1'}                | ${'widen'}           | ${'1.2.0'}     | ${'2.0.3'}   | ${'>= 1, < 3'}
+    ${'= 5.2.2'}             | ${'widen'}           | ${'5.2.2'}     | ${'5.2.2.1'} | ${'= 5.2.2.1'}
+    ${'1.0.3'}               | ${'widen'}           | ${'1.0.3'}     | ${'1.2.3'}   | ${'1.2.3'}
+    ${'v1.0.3'}              | ${'widen'}           | ${'1.0.3'}     | ${'1.2.3'}   | ${'v1.2.3'}
+    ${'= 1.0.3'}             | ${'widen'}           | ${'1.0.3'}     | ${'1.2.3'}   | ${'= 1.2.3'}
+    ${'!= 1.0.3'}            | ${'widen'}           | ${'1.0.0'}     | ${'1.2.3'}   | ${'!= 1.0.3'}
+    ${'!= 1.0.3'}            | ${'widen'}           | ${'1.0.0'}     | ${'1.0.2'}   | ${'!= 1.0.3'}
+    ${'!= 1.0.3'}            | ${'widen'}           | ${'1.0.0'}     | ${'1.0.3'}   | ${'!= 1.0.3'}
+    ${'> 1.0.3'}             | ${'widen'}           | ${'1.0.4'}     | ${'1.2.3'}   | ${'> 1.0.3'}
+    ${'> 1.2.3'}             | ${'widen'}           | ${'1.0.0'}     | ${'1.0.3'}   | ${'> 1.0.2'}
+    ${'< 1.0.3'}             | ${'widen'}           | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
+    ${'< 1.2.3'}             | ${'widen'}           | ${'1.0.0'}     | ${'1.0.3'}   | ${'< 1.2.3'}
+    ${'< 1.2.2'}             | ${'widen'}           | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
+    ${'< 1.2.3'}             | ${'widen'}           | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.2.4'}
+    ${'< 1.2'}               | ${'widen'}           | ${'1.0.0'}     | ${'1.2.3'}   | ${'< 1.3'}
+    ${'< 1'}                 | ${'widen'}           | ${'0.9.0'}     | ${'1.2.3'}   | ${'< 2'}
+    ${'< 1.2.3'}             | ${'widen'}           | ${'1.0.0'}     | ${'1.2.2'}   | ${'< 1.2.3'}
+    ${'>= 1.0.3'}            | ${'widen'}           | ${'1.0.3'}     | ${'1.2.3'}   | ${'>= 1.0.3'}
+    ${'>= 1.0.3'}            | ${'widen'}           | ${'1.0.3'}     | ${'1.0.2'}   | ${'>= 1.0.2'}
+    ${'<= 1.0.3'}            | ${'widen'}           | ${'1.0.0'}     | ${'1.2.3'}   | ${'<= 1.2.3'}
+    ${'<= 1.0.3'}            | ${'widen'}           | ${'1.0.0'}     | ${'1.0.2'}   | ${'<= 1.0.3'}
+    ${'~> 1.0.3'}            | ${'widen'}           | ${'1.0.0'}     | ${'1.2.3'}   | ${'>= 1.0.3, < 1.2.4'}
+    ${'~> 1.0.3'}            | ${'widen'}           | ${'1.0.0'}     | ${'1.0.4'}   | ${'~> 1.0.3'}
+    ${'~> 4.7, >= 4.7.4'}    | ${'widen'}           | ${'1.0.0'}     | ${'4.7.9'}   | ${'~> 4.7, >= 4.7.4'}
+    ${'~> 4.7, >= 4.7.4'}    | ${'widen'}           | ${'4.7.5'}     | ${'4.8.0'}   | ${'~> 4.7, >= 4.7.4'}
+    ${'>= 2.0.0, <= 2.15'}   | ${'widen'}           | ${'2.15.0'}    | ${'2.20.1'}  | ${'>= 2.0.0, <= 2.20.1'}
+    ${'~> 5.2.0'}            | ${'widen'}           | ${'5.2.4.1'}   | ${'6.0.2.1'} | ${'>= 5.2.0, < 6.0.3'}
+    ${'~> 4.0, < 5'}         | ${'widen'}           | ${'4.7.5'}     | ${'5.0.0'}   | ${'>= 4.0, < 6, < 6'}
+    ${'~> 4.0, < 5'}         | ${'widen'}           | ${'4.7.5'}     | ${'5.0.1'}   | ${'>= 4.0, < 6, < 6'}
+    ${'~> 4.0, < 5'}         | ${'widen'}           | ${'4.7.5'}     | ${'5.1.0'}   | ${'>= 4.0, < 6, < 6'}
     ${'< 1.0.3'}             | ${'auto'}            | ${'1.0.3'}     | ${'1.2.4'}   | ${'< 1.2.5'}
     ${'< 1.0.3'}             | ${'replace'}         | ${'1.0.3'}     | ${'1.2.4'}   | ${'< 1.2.5'}
     ${'< 1.0.3'}             | ${'widen'}           | ${'1.0.3'}     | ${'1.2.4'}   | ${'< 1.2.5'}
diff --git a/lib/modules/versioning/ruby/index.ts b/lib/modules/versioning/ruby/index.ts
index 9be31daea9d9397ab7cb052a8304679c8fb330a3..7c5aebf13f59a738582d1687bd131e5d08098089 100644
--- a/lib/modules/versioning/ruby/index.ts
+++ b/lib/modules/versioning/ruby/index.ts
@@ -12,7 +12,7 @@ import { regEx } from '../../../util/regex';
 import type { NewValueConfig, VersioningApi } from '../types';
 import { isSingleOperator, isValidOperator } from './operator';
 import { ltr, parse as parseRange } from './range';
-import { bump, pin, replace } from './strategies';
+import { bump, pin, replace, widen } from './strategies';
 import { parse as parseVersion } from './version';
 
 export const id = 'ruby';
@@ -127,13 +127,18 @@ const getNewValue = ({
         newValue = bump({ range: vtrim(currentValue), to: vtrim(newVersion) });
         break;
       case 'auto':
-      case 'widen':
       case 'replace':
         newValue = replace({
           range: vtrim(currentValue),
           to: vtrim(newVersion),
         });
         break;
+      case 'widen':
+        newValue = widen({
+          range: vtrim(currentValue),
+          to: vtrim(newVersion),
+        });
+        break;
       // istanbul ignore next
       default:
         logger.warn(`Unsupported strategy ${rangeStrategy}`);
diff --git a/lib/modules/versioning/ruby/range.ts b/lib/modules/versioning/ruby/range.ts
index 8e7533460f90702245bff188037b9546864f5e5d..02f8a0a1b778830b945094db2754ee5eda56990e 100644
--- a/lib/modules/versioning/ruby/range.ts
+++ b/lib/modules/versioning/ruby/range.ts
@@ -1,3 +1,4 @@
+import { satisfies } from '@renovatebot/ruby-semver';
 import { parse as _parse } from '@renovatebot/ruby-semver/dist/ruby/requirement.js';
 import { Version, create } from '@renovatebot/ruby-semver/dist/ruby/version.js';
 import { logger } from '../../../logger';
@@ -8,6 +9,14 @@ export interface Range {
   version: string;
   operator: string;
   delimiter: string;
+  /**
+   * If the range is `~>` and immediately followed by `>=`,
+   * the latter range is considered the former's companion
+   * and assigned here instead of being an independent range.
+   *
+   * Example: `'~> 6.2', '>= 6.2.1'`
+   */
+  companion?: Range;
 }
 
 const parse = (range: string): Range => {
@@ -30,6 +39,60 @@ const parse = (range: string): Range => {
   };
 };
 
+/** Wrapper for {@link satisfies} for {@link Range} record. */
+export function satisfiesRange(ver: string, range: Range): boolean {
+  if (range.companion) {
+    return (
+      satisfies(ver, `${range.operator}${range.version}`) &&
+      satisfiesRange(ver, range.companion)
+    );
+  } else {
+    return satisfies(ver, `${range.operator}${range.version}`);
+  }
+}
+
+/**
+ * Parses a comma-delimited list of range parts,
+ * with special treatment for a pair of `~>` and `>=` parts.
+ */
+export function parseRanges(range: string): Range[] {
+  const originalRanges = range.split(',').map(parse);
+  const ranges: Range[] = [];
+  for (let i = 0; i < originalRanges.length; ) {
+    if (
+      i + 1 < originalRanges.length &&
+      originalRanges[i].operator === PGTE &&
+      originalRanges[i + 1].operator === GTE
+    ) {
+      ranges.push({
+        ...originalRanges[i],
+        companion: originalRanges[i + 1],
+      });
+      i += 2;
+    } else {
+      ranges.push(originalRanges[i]);
+      i++;
+    }
+  }
+  return ranges;
+}
+
+/**
+ * Stringifies a list of range parts into a comma-separated string,
+ * with special treatment for a pair of `~>` and `>=` parts.
+ */
+export function stringifyRanges(ranges: Range[]): string {
+  return ranges
+    .map((r) => {
+      if (r.companion) {
+        return `${r.operator}${r.delimiter}${r.version}, ${r.companion.operator}${r.companion.delimiter}${r.companion.version}`;
+      } else {
+        return `${r.operator}${r.delimiter}${r.version}`;
+      }
+    })
+    .join(', ');
+}
+
 type GemRequirement = [string, Version];
 
 const ltr = (version: string, range: string): boolean => {
diff --git a/lib/modules/versioning/ruby/strategies/bump.ts b/lib/modules/versioning/ruby/strategies/bump.ts
index 5db8cb2456a555e5f2d136baadab07e2c18d5685..0167dc9edec598ec84f4da9bc29e33aceba26daa 100644
--- a/lib/modules/versioning/ruby/strategies/bump.ts
+++ b/lib/modules/versioning/ruby/strategies/bump.ts
@@ -1,35 +1,45 @@
-import { gte, lte } from '@renovatebot/ruby-semver';
-import { logger } from '../../../../logger';
-import { EQUAL, GT, GTE, LT, LTE, NOT_EQUAL, PGTE } from '../operator';
-import { parse as parseRange } from '../range';
-import { decrement, floor, increment } from '../version';
+import { gt, gte, lt } from '@renovatebot/ruby-semver';
+import { GTE, LT, LTE, NOT_EQUAL, PGTE } from '../operator';
+import { Range, parseRanges, stringifyRanges } from '../range';
+import { adapt, trimZeroes } from '../version';
+import { replacePart } from './replace';
 
 export default ({ range, to }: { range: string; to: string }): string => {
-  const ranges = range.split(',').map(parseRange);
-  const results = ranges.map(({ operator, version: ver, delimiter }) => {
+  const parts = parseRanges(range).map((part): Range => {
+    const { operator, version: ver } = part;
     switch (operator) {
-      case GT:
-        return lte(to, ver)
-          ? `${GT}${delimiter}${ver}`
-          : `${GT}${delimiter}${decrement(to)}`;
+      // Update upper bound (`<` and `<=`) ranges only if the new version violates them
       case LT:
-        return gte(to, ver)
-          ? `${LT}${delimiter}${increment(ver, to)}`
-          : `${LT}${delimiter}${ver}`;
-      case PGTE:
-        return `${operator}${delimiter}${floor(to)}`;
-      case GTE:
+        return gte(to, ver) ? replacePart(part, to) : part;
       case LTE:
-      case EQUAL:
-        return `${operator}${delimiter}${to}`;
+        return gt(to, ver) ? replacePart(part, to) : part;
+      // `~>` ranges.
+      case PGTE: {
+        // Try to add / remove extra `>=` constraint.
+        const trimmed = adapt(to, ver);
+        if (trimZeroes(trimmed) === trimZeroes(to)) {
+          // E.g. `'~> 5.2', '>= 5.2.0'`. In this case the latter is redundant.
+          return { ...part, version: trimmed, companion: undefined };
+        } else {
+          // E.g. `'~> 5.2', '>= 5.2.1'`.
+          return {
+            ...part,
+            version: trimmed,
+            companion: { operator: GTE, delimiter: ' ', version: to },
+          };
+        }
+      }
       case NOT_EQUAL:
-        return `${NOT_EQUAL}${delimiter}${ver}`;
-      // istanbul ignore next
+        if (lt(ver, to)) {
+          // The version to exclude is now out of range.
+          return { ...part, operator: GTE, version: to };
+        }
+        return part;
       default:
-        logger.warn(`Unsupported operator '${operator}'`);
-        return null;
+        // For `=` and lower bound ranges, always keep it stick to the new version.
+        return replacePart(part, to);
     }
   });
 
-  return results.join(', ');
+  return stringifyRanges(parts);
 };
diff --git a/lib/modules/versioning/ruby/strategies/index.ts b/lib/modules/versioning/ruby/strategies/index.ts
index e30fd6d6652dfdb321a70daffd312082f21487ff..c08882ed2830d49ba29a6c63e1c1468e805ff7db 100644
--- a/lib/modules/versioning/ruby/strategies/index.ts
+++ b/lib/modules/versioning/ruby/strategies/index.ts
@@ -1,5 +1,6 @@
 import bump from './bump';
 import pin from './pin';
 import replace from './replace';
+import widen from './widen';
 
-export { pin, bump, replace };
+export { pin, bump, replace, widen };
diff --git a/lib/modules/versioning/ruby/strategies/replace.ts b/lib/modules/versioning/ruby/strategies/replace.ts
index acc6e859c021f72867d77bd8c374a1948eea6872..3b8f06bc2ea0e74e3c0a6fda8b29df4266d9bfa8 100644
--- a/lib/modules/versioning/ruby/strategies/replace.ts
+++ b/lib/modules/versioning/ruby/strategies/replace.ts
@@ -1,103 +1,52 @@
-import { satisfies } from '@renovatebot/ruby-semver';
 import { logger } from '../../../../logger';
-import bump from './bump';
+import { EQUAL, GT, GTE, LT, LTE, NOT_EQUAL, PGTE } from '../operator';
+import { Range, parseRanges, satisfiesRange, stringifyRanges } from '../range';
+import { adapt, decrement, floor, increment } from '../version';
 
-function countInstancesOf(str: string, char: string): number {
-  return str.split(char).length - 1;
-}
-
-function isMajorRange(range: string): boolean {
-  const splitRange = range.split(',').map((part) => part.trim());
-  return (
-    splitRange.length === 1 &&
-    splitRange[0]?.startsWith('~>') &&
-    countInstancesOf(splitRange[0], '.') === 0
-  );
-}
-
-function isCommonRubyMajorRange(range: string): boolean {
-  const splitRange = range.split(',').map((part) => part.trim());
-  return (
-    splitRange.length === 2 &&
-    splitRange[0]?.startsWith('~>') &&
-    countInstancesOf(splitRange[0], '.') === 1 &&
-    splitRange[1]?.startsWith('>=')
-  );
-}
-
-function isCommonRubyMinorRange(range: string): boolean {
-  const splitRange = range.split(',').map((part) => part.trim());
-  return (
-    splitRange.length === 2 &&
-    splitRange[0]?.startsWith('~>') &&
-    countInstancesOf(splitRange[0], '.') === 2 &&
-    splitRange[1]?.startsWith('>=')
-  );
-}
-
-function reduceOnePrecision(version: string): string {
-  const versionParts = version.split('.');
-  // istanbul ignore if
-  if (versionParts.length === 1) {
-    return version;
+// Common logic for replace, widen, and bump strategies
+// It basically makes the range stick to the new version.
+export function replacePart(part: Range, to: string): Range {
+  const { operator, version: ver, companion } = part;
+  switch (operator) {
+    case LT:
+      return { ...part, version: increment(ver, to) };
+    case LTE:
+      return { ...part, version: to };
+    case PGTE:
+      if (companion) {
+        return {
+          ...part,
+          version: floor(adapt(to, ver)),
+          companion: { ...companion, version: to },
+        };
+      } else {
+        return { ...part, version: floor(adapt(to, ver)) };
+      }
+    case GT:
+      return { ...part, version: decrement(to) };
+    case GTE:
+    case EQUAL:
+      return { ...part, version: to };
+    case NOT_EQUAL:
+      return part;
+    // istanbul ignore next
+    default:
+      logger.warn(`Unsupported operator '${operator}'`);
+      return { operator: '', delimiter: ' ', version: '' };
   }
-  versionParts.pop();
-  return versionParts.join('.');
 }
 
-export function matchPrecision(existing: string, next: string): string {
-  let res = next;
-  while (res.split('.').length > existing.split('.').length) {
-    res = reduceOnePrecision(res);
-  }
-  return res;
-}
-
-export default ({ to, range }: { range: string; to: string }): string => {
-  if (satisfies(to, range)) {
-    return range;
-  }
-  let newRange;
-  if (isCommonRubyMajorRange(range)) {
-    const firstPart = reduceOnePrecision(to);
-    newRange = `~> ${firstPart}, >= ${to}`;
-  } else if (isCommonRubyMinorRange(range)) {
-    const firstPart = reduceOnePrecision(to) + '.0';
-    newRange = `~> ${firstPart}, >= ${to}`;
-  } else if (isMajorRange(range)) {
-    const majorPart = to.split('.')[0];
-    newRange = '~>' + (range.includes(' ') ? ' ' : '') + majorPart;
-  } else {
-    const lastPart = range
-      .split(',')
-      .map((part) => part.trim())
-      .slice(-1)
-      .join();
-    const lastPartPrecision = lastPart.split('.').length;
-    const toPrecision = to.split('.').length;
-    let massagedTo: string = to;
-    if (!lastPart.startsWith('<') && toPrecision > lastPartPrecision) {
-      massagedTo = to.split('.').slice(0, lastPartPrecision).join('.');
-    }
-    const newLastPart = bump({ to: massagedTo, range: lastPart });
-    newRange = range.replace(lastPart, newLastPart);
-    const firstPart = range
-      .split(',')
-      .map((part) => part.trim())
-      .shift();
-    if (firstPart && !satisfies(to, firstPart)) {
-      let newFirstPart = bump({ to: massagedTo, range: firstPart });
-      newFirstPart = matchPrecision(firstPart, newFirstPart);
-      newRange = newRange.replace(firstPart, newFirstPart);
+export default ({ range, to }: { range: string; to: string }): string => {
+  const parts = parseRanges(range).map((part): Range => {
+    if (satisfiesRange(to, part)) {
+      // The new version satisfies the range. Keep it as-is.
+      // Note that consecutive `~>` and `>=` parts are combined into one Range object,
+      // therefore both parts are updated if the new version violates one of them.
+      return part;
     }
-  }
-  // istanbul ignore if
-  if (!satisfies(to, newRange)) {
-    logger.warn(
-      { range, to, newRange },
-      'Ruby versioning getNewValue problem: to version is not satisfied by new range'
-    );
-    return range;
-  }
-  return newRange;
+
+    return replacePart(part, to);
+  });
+
+  return stringifyRanges(parts);
 };
diff --git a/lib/modules/versioning/ruby/strategies/widen.ts b/lib/modules/versioning/ruby/strategies/widen.ts
new file mode 100644
index 0000000000000000000000000000000000000000..05ad8f0c032c9b643431044c4ab0265d8741ddeb
--- /dev/null
+++ b/lib/modules/versioning/ruby/strategies/widen.ts
@@ -0,0 +1,31 @@
+import { GTE, LT, PGTE } from '../operator';
+import { Range, parseRanges, satisfiesRange, stringifyRanges } from '../range';
+import { increment, pgteUpperBound } from '../version';
+import { replacePart } from './replace';
+
+export default ({ range, to }: { range: string; to: string }): string => {
+  const parts = parseRanges(range).flatMap((part): Range[] => {
+    if (satisfiesRange(to, part)) {
+      return [part];
+    }
+
+    const { operator, version: ver, companion } = part;
+    switch (operator) {
+      // `~>` works as both lower bound and upper bound.
+      // We need to decompose it to get wider range.
+      case PGTE: {
+        // Prefer constraints from `>=`
+        const baseVersion = companion ? companion.version : ver;
+        const limit = increment(pgteUpperBound(ver), to);
+        return [
+          { operator: GTE, delimiter: ' ', version: baseVersion },
+          { operator: LT, delimiter: ' ', version: limit },
+        ];
+      }
+      default:
+        return [replacePart(part, to)];
+    }
+  });
+
+  return stringifyRanges(parts);
+};
diff --git a/lib/modules/versioning/ruby/version.ts b/lib/modules/versioning/ruby/version.ts
index 3e279fe8a9266a88bd2f90d5b1ae025ec2aeb959..cd421a6fea0a67132bc89742fa1dbe8e2f765ba9 100644
--- a/lib/modules/versioning/ruby/version.ts
+++ b/lib/modules/versioning/ruby/version.ts
@@ -28,11 +28,34 @@ const parse = (version: string): RubyVersion => ({
   prerelease: prerelease(version),
 });
 
+const floor = (version: string): string => {
+  const segments = releaseSegments(version);
+  if (segments.length <= 1) {
+    // '~> 2' is equivalent to '~> 2.0', thus no need to floor
+    return segments.join('.');
+  }
+  return [...segments.slice(0, -1), 0].join('.');
+};
+
 const adapt = (left: string, right: string): string =>
   left.split('.').slice(0, right.split('.').length).join('.');
 
-const floor = (version: string): string =>
-  [...releaseSegments(version).slice(0, -1), 0].join('.');
+const trimZeroes = (version: string): string => {
+  const segments = version.split('.');
+  while (segments.length > 0 && segments[segments.length - 1] === '0') {
+    segments.pop();
+  }
+  return segments.join('.');
+};
+
+// Returns the upper bound of `~>` operator.
+const pgteUpperBound = (version: string): string => {
+  const segments = releaseSegments(version);
+  if (segments.length > 1) {
+    segments.pop();
+  }
+  return incrementLastSegment(segments.join('.'));
+};
 
 // istanbul ignore next
 const incrementLastSegment = (version: string): string => {
@@ -119,4 +142,12 @@ const decrement = (version: string): string => {
   return nextSegments.reverse().join('.');
 };
 
-export { parse, floor, increment, decrement };
+export {
+  parse,
+  adapt,
+  floor,
+  trimZeroes,
+  pgteUpperBound,
+  increment,
+  decrement,
+};