diff --git a/lib/modules/versioning/api.ts b/lib/modules/versioning/api.ts
index 764529bf40c69fd49cbd681e0394e85d931e549b..211c3f8740fe292367bb8286afae0564a25b6fc6 100644
--- a/lib/modules/versioning/api.ts
+++ b/lib/modules/versioning/api.ts
@@ -26,6 +26,7 @@ import * as nuget from './nuget';
 import * as pep440 from './pep440';
 import * as perl from './perl';
 import * as poetry from './poetry';
+import * as pvp from './pvp';
 import * as python from './python';
 import * as redhat from './redhat';
 import * as regex from './regex';
@@ -71,6 +72,7 @@ api.set(nuget.id, nuget.api);
 api.set(pep440.id, pep440.api);
 api.set(perl.id, perl.api);
 api.set(poetry.id, poetry.api);
+api.set(pvp.id, pvp.api);
 api.set(python.id, python.api);
 api.set(redhat.id, redhat.api);
 api.set(regex.id, regex.api);
diff --git a/lib/modules/versioning/pvp/index.spec.ts b/lib/modules/versioning/pvp/index.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..900baa3ae7ef8ebba8f2db37622a4750f94b6e78
--- /dev/null
+++ b/lib/modules/versioning/pvp/index.spec.ts
@@ -0,0 +1,265 @@
+import pvp from '.';
+
+describe('modules/versioning/pvp/index', () => {
+  describe('.isGreaterThan(version, other)', () => {
+    it.each`
+      first       | second     | expected
+      ${'1.23.1'} | ${'1.9.6'} | ${true}
+      ${'4.0.0'}  | ${'3.0.0'} | ${true}
+      ${'3.0.1'}  | ${'3.0.0'} | ${true}
+      ${'4.10'}   | ${'4.1'}   | ${true}
+      ${'1.0.0'}  | ${'1.0'}   | ${true}
+      ${'2.0.2'}  | ${'3.1.0'} | ${false}
+      ${'3.0.0'}  | ${'3.0.0'} | ${false}
+      ${'4.1'}    | ${'4.10'}  | ${false}
+      ${'1.0'}    | ${'1.0.0'} | ${false}
+      ${''}       | ${'1.0'}   | ${false}
+      ${'1.0'}    | ${''}      | ${false}
+    `('pvp.isGreaterThan($first, $second)', ({ first, second, expected }) => {
+      expect(pvp.isGreaterThan(first, second)).toBe(expected);
+    });
+  });
+
+  describe('.getMajor(version)', () => {
+    it.each`
+      version    | expected
+      ${'1.0.0'} | ${1.0}
+      ${'1.0.1'} | ${1.0}
+      ${'1.1.1'} | ${1.1}
+      ${''}      | ${null}
+    `('pvp.getMajor("$version") === $expected', ({ version, expected }) => {
+      expect(pvp.getMajor(version)).toBe(expected);
+    });
+  });
+
+  describe('.getMinor(version)', () => {
+    it.each`
+      version    | expected
+      ${'1.0'}   | ${null}
+      ${'1.0.0'} | ${0}
+      ${'1.0.1'} | ${1}
+      ${'1.1.2'} | ${2}
+    `('pvp.getMinor("$version") === $expected', ({ version, expected }) => {
+      expect(pvp.getMinor(version)).toBe(expected);
+    });
+  });
+
+  describe('.getPatch(version)', () => {
+    it.each`
+      version         | expected
+      ${'1.0.0'}      | ${null}
+      ${'1.0.0.5.1'}  | ${5.1}
+      ${'1.0.1.6'}    | ${6}
+      ${'1.1.2.7'}    | ${7}
+      ${'0.0.0.0.1'}  | ${0.1}
+      ${'0.0.0.0.10'} | ${0.1}
+    `('pvp.getPatch("$version") === $expected', ({ version, expected }) => {
+      expect(pvp.getPatch(version)).toBe(expected);
+    });
+  });
+
+  describe('.matches(version, range)', () => {
+    it.each`
+      version    | range                 | expected
+      ${'1.0.1'} | ${'>=1.0 && <1.1'}    | ${true}
+      ${'4.1'}   | ${'>=4.0 && <4.10'}   | ${true}
+      ${'4.1'}   | ${'>=4.1 && <4.10'}   | ${true}
+      ${'4.1.0'} | ${'>=4.1 && <4.10'}   | ${true}
+      ${'4.1.0'} | ${'<4.10 && >=4.1'}   | ${true}
+      ${'4.10'}  | ${'>=4.1 && <4.10.0'} | ${true}
+      ${'4.10'}  | ${'>=4.0 && <4.10.1'} | ${true}
+      ${'1.0.0'} | ${'>=2.0 && <2.1'}    | ${false}
+      ${'4'}     | ${'>=4.0 && <4.10'}   | ${false}
+      ${'4.10'}  | ${'>=4.1 && <4.10'}   | ${false}
+      ${'4'}     | ${'gibberish'}        | ${false}
+      ${''}      | ${'>=1.0 && <1.1'}    | ${false}
+    `(
+      'pvp.matches("$version", "$range") === $expected',
+      ({ version, range, expected }) => {
+        expect(pvp.matches(version, range)).toBe(expected);
+      },
+    );
+  });
+
+  describe('.getSatisfyingVersion(versions, range)', () => {
+    it.each`
+      versions                                | range              | expected
+      ${['1.0.0', '1.0.4', '1.3.0', '2.0.0']} | ${'>=1.0 && <1.1'} | ${'1.0.4'}
+      ${['2.0.0', '1.0.0', '1.0.4', '1.3.0']} | ${'>=1.0 && <1.1'} | ${'1.0.4'}
+      ${['1.0.0', '1.0.4', '1.3.0', '2.0.0']} | ${'>=3.0 && <4.0'} | ${null}
+    `(
+      'pvp.getSatisfyingVersion($versions, "$range") === $expected',
+      ({ versions, range, expected }) => {
+        expect(pvp.getSatisfyingVersion(versions, range)).toBe(expected);
+      },
+    );
+  });
+
+  describe('.minSatisfyingVersion(versions, range)', () => {
+    it('should return min satisfying version in range', () => {
+      expect(
+        pvp.minSatisfyingVersion(
+          ['0.9', '1.0.0', '1.0.4', '1.3.0', '2.0.0'],
+          '>=1.0 && <1.1',
+        ),
+      ).toBe('1.0.0');
+    });
+  });
+
+  describe('.isLessThanRange(version, range)', () => {
+    it.each`
+      version    | range              | expected
+      ${'2.0.2'} | ${'>=3.0 && <3.1'} | ${true}
+      ${'3'}     | ${'>=3.0 && <3.1'} | ${true}
+      ${'3'}     | ${'>=3 && <3.1'}   | ${false}
+      ${'3.0'}   | ${'>=3.0 && <3.1'} | ${false}
+      ${'3.0.0'} | ${'>=3.0 && <3.1'} | ${false}
+      ${'4.0.0'} | ${'>=3.0 && <3.1'} | ${false}
+      ${'3.1.0'} | ${'>=3.0 && <3.1'} | ${false}
+      ${'3'}     | ${'gibberish'}     | ${false}
+      ${''}      | ${'>=3.0 && <3.1'} | ${false}
+    `(
+      'pvp.isLessThanRange?.("$version", "$range") === $expected',
+      ({ version, range, expected }) => {
+        expect(pvp.isLessThanRange?.(version, range)).toBe(expected);
+      },
+    );
+  });
+
+  describe('.isValid(version)', () => {
+    it.each`
+      version            | expected
+      ${''}              | ${false}
+      ${'1.0.0.0'}       | ${true}
+      ${'1.0'}           | ${true}
+      ${'>=1.0 && <1.1'} | ${true}
+    `('pvp.isValid("$version") === $expected', ({ version, expected }) => {
+      expect(pvp.isValid(version)).toBe(expected);
+    });
+  });
+
+  describe('.getNewValue(newValueConfig)', () => {
+    it.each`
+      currentValue       | newVersion | rangeStrategy        | expected
+      ${'>=1.0 && <1.1'} | ${'1.1'}   | ${'auto'}            | ${'>=1.0 && <1.2'}
+      ${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'auto'}            | ${null}
+      ${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null}
+      ${'gibberish'}     | ${'1.2.3'} | ${'auto'}            | ${null}
+      ${'>=1.0 && <1.1'} | ${'0.9'}   | ${'auto'}            | ${null}
+      ${'>=1.0 && <1.1'} | ${''}      | ${'auto'}            | ${null}
+    `(
+      'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected',
+      ({ currentValue, newVersion, rangeStrategy, expected }) => {
+        expect(
+          pvp.getNewValue({ currentValue, newVersion, rangeStrategy }),
+        ).toBe(expected);
+      },
+    );
+  });
+
+  describe('.isSame(...)', () => {
+    it.each`
+      type       | a              | b               | expected
+      ${'major'} | ${'4.10'}      | ${'4.1'}        | ${false}
+      ${'major'} | ${'4.1.0'}     | ${'5.1.0'}      | ${false}
+      ${'major'} | ${'4.1'}       | ${'5.1'}        | ${false}
+      ${'major'} | ${'0'}         | ${'1'}          | ${false}
+      ${'major'} | ${'4.1'}       | ${'4.1.0'}      | ${true}
+      ${'major'} | ${'4.1.1'}     | ${'4.1.2'}      | ${true}
+      ${'major'} | ${'0'}         | ${'0'}          | ${true}
+      ${'minor'} | ${'4.1.0'}     | ${'5.1.0'}      | ${true}
+      ${'minor'} | ${'4.1'}       | ${'4.1'}        | ${true}
+      ${'minor'} | ${'4.1'}       | ${'5.1'}        | ${true}
+      ${'minor'} | ${'4.1.0'}     | ${'4.1.1'}      | ${false}
+      ${'minor'} | ${''}          | ${'0'}          | ${false}
+      ${'patch'} | ${'1.0.0.0'}   | ${'1.0.0.0'}    | ${true}
+      ${'patch'} | ${'1.0.0.0'}   | ${'2.0.0.0'}    | ${true}
+      ${'patch'} | ${'1.0.0.0'}   | ${'1.0.0.1'}    | ${false}
+      ${'patch'} | ${'0.0.0.0.1'} | ${'0.0.0.0.10'} | ${false}
+    `(
+      'pvp.isSame("$type", "$a", "$b") === $expected',
+      ({ type, a, b, expected }) => {
+        expect(pvp.isSame?.(type, a, b)).toBe(expected);
+      },
+    );
+  });
+
+  describe('.isVersion(maybeRange)', () => {
+    it.each`
+      version            | expected
+      ${'1.0'}           | ${true}
+      ${'>=1.0 && <1.1'} | ${false}
+    `('pvp.isVersion("$version") === $expected', ({ version, expected }) => {
+      expect(pvp.isVersion(version)).toBe(expected);
+    });
+  });
+
+  describe('.equals(a, b)', () => {
+    it.each`
+      a         | b        | expected
+      ${'1.01'} | ${'1.1'} | ${true}
+      ${'1.01'} | ${'1.0'} | ${false}
+      ${''}     | ${'1.0'} | ${false}
+      ${'1.0'}  | ${''}    | ${false}
+    `('pvp.equals("$a", "$b") === $expected', ({ a, b, expected }) => {
+      expect(pvp.equals(a, b)).toBe(expected);
+    });
+  });
+
+  describe('.isSingleVersion(range)', () => {
+    it.each`
+      version            | expected
+      ${'==1.0'}         | ${true}
+      ${'>=1.0 && <1.1'} | ${false}
+    `(
+      'pvp.isSingleVersion("$version") === $expected',
+      ({ version, expected }) => {
+        expect(pvp.isSingleVersion(version)).toBe(expected);
+      },
+    );
+  });
+
+  describe('.subset(subRange, superRange)', () => {
+    it.each`
+      subRange           | superRange         | expected
+      ${'>=1.0 && <1.1'} | ${'>=1.0 && <2.0'} | ${true}
+      ${'>=1.0 && <2.0'} | ${'>=1.0 && <2.0'} | ${true}
+      ${'>=1.0 && <2.1'} | ${'>=1.0 && <2.0'} | ${false}
+      ${'>=0.9 && <2.1'} | ${'>=1.0 && <2.0'} | ${false}
+      ${'gibberish'}     | ${''}              | ${undefined}
+      ${'>=. && <.'}     | ${'>=. && <.'}     | ${undefined}
+    `(
+      'pvp.subbet("$subRange", "$superRange") === $expected',
+      ({ subRange, superRange, expected }) => {
+        expect(pvp.subset?.(subRange, superRange)).toBe(expected);
+      },
+    );
+  });
+
+  describe('.sortVersions()', () => {
+    it.each`
+      a        | b        | expected
+      ${'1.0'} | ${'1.1'} | ${-1}
+      ${'1.1'} | ${'1.0'} | ${1}
+      ${'1.0'} | ${'1.0'} | ${0}
+    `('pvp.sortVersions("$a", "$b") === $expected', ({ a, b, expected }) => {
+      expect(pvp.sortVersions(a, b)).toBe(expected);
+    });
+  });
+
+  describe('.isStable()', () => {
+    it('should consider 0.0.0 stable', () => {
+      // in PVP, stability is not conveyed in the version number
+      // so we consider all versions stable
+      expect(pvp.isStable('0.0.0')).toBeTrue();
+    });
+  });
+
+  describe('.isCompatible()', () => {
+    it('should consider 0.0.0 compatible', () => {
+      // in PVP, there is no extra information besides the numbers
+      // so we consider all versions compatible
+      expect(pvp.isCompatible('0.0.0')).toBeTrue();
+    });
+  });
+});
diff --git a/lib/modules/versioning/pvp/index.ts b/lib/modules/versioning/pvp/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..59e8b3026ab91c2eb84f6fcf5c4f014300ac0c4d
--- /dev/null
+++ b/lib/modules/versioning/pvp/index.ts
@@ -0,0 +1,257 @@
+import { logger } from '../../../logger';
+import type { RangeStrategy } from '../../../types/versioning';
+import { regEx } from '../../../util/regex';
+import type { NewValueConfig, VersioningApi } from '../types';
+import { parseRange } from './range';
+import { compareIntArray, extractAllParts, getParts, plusOne } from './util';
+
+export const id = 'pvp';
+export const displayName = 'Package Versioning Policy (Haskell)';
+export const urls = ['https://pvp.haskell.org'];
+export const supportsRanges = true;
+export const supportedRangeStrategies: RangeStrategy[] = ['auto'];
+
+const digitsAndDots = regEx(/^[\d.]+$/);
+
+function isGreaterThan(version: string, other: string): boolean {
+  const versionIntMajor = extractAllParts(version);
+  const otherIntMajor = extractAllParts(other);
+  if (versionIntMajor === null || otherIntMajor === null) {
+    return false;
+  }
+  return compareIntArray(versionIntMajor, otherIntMajor) === 'gt';
+}
+
+function getMajor(version: string): number | null {
+  // This basically can't be implemented correctly, since
+  // 1.1 and 1.10 become equal when converted to float.
+  // Consumers should use isSame instead.
+  const parts = getParts(version);
+  if (parts === null) {
+    return null;
+  }
+  return Number(parts.major.join('.'));
+}
+
+function getMinor(version: string): number | null {
+  const parts = getParts(version);
+  if (parts === null || parts.minor.length === 0) {
+    return null;
+  }
+  return Number(parts.minor.join('.'));
+}
+
+function getPatch(version: string): number | null {
+  const parts = getParts(version);
+  if (parts === null || parts.patch.length === 0) {
+    return null;
+  }
+  return Number(parts.patch[0] + '.' + parts.patch.slice(1).join(''));
+}
+
+function matches(version: string, range: string): boolean {
+  const parsed = parseRange(range);
+  if (parsed === null) {
+    return false;
+  }
+  const ver = extractAllParts(version);
+  const lower = extractAllParts(parsed.lower);
+  const upper = extractAllParts(parsed.upper);
+  if (ver === null || lower === null || upper === null) {
+    return false;
+  }
+  return (
+    'gt' === compareIntArray(upper, ver) &&
+    ['eq', 'lt'].includes(compareIntArray(lower, ver))
+  );
+}
+
+function satisfyingVersion(
+  versions: string[],
+  range: string,
+  reverse: boolean,
+): string | null {
+  const copy = versions.slice(0);
+  copy.sort((a, b) => {
+    const multiplier = reverse ? 1 : -1;
+    return sortVersions(a, b) * multiplier;
+  });
+  const result = copy.find((v) => matches(v, range));
+  return result ?? null;
+}
+
+function getSatisfyingVersion(
+  versions: string[],
+  range: string,
+): string | null {
+  return satisfyingVersion(versions, range, false);
+}
+
+function minSatisfyingVersion(
+  versions: string[],
+  range: string,
+): string | null {
+  return satisfyingVersion(versions, range, true);
+}
+
+function isLessThanRange(version: string, range: string): boolean {
+  const parsed = parseRange(range);
+  if (parsed === null) {
+    return false;
+  }
+  const compos = extractAllParts(version);
+  const lower = extractAllParts(parsed.lower);
+  if (compos === null || lower === null) {
+    return false;
+  }
+  return 'lt' === compareIntArray(compos, lower);
+}
+
+function getNewValue({
+  currentValue,
+  newVersion,
+  rangeStrategy,
+}: NewValueConfig): string | null {
+  if (rangeStrategy !== 'auto') {
+    logger.info(
+      { rangeStrategy, currentValue, newVersion },
+      `PVP can't handle this range strategy.`,
+    );
+    return null;
+  }
+  const parsed = parseRange(currentValue);
+  if (parsed === null) {
+    logger.info(
+      { currentValue, newVersion },
+      'could not parse PVP version range',
+    );
+    return null;
+  }
+  if (isLessThanRange(newVersion, currentValue)) {
+    // ignore new releases in old release series
+    return null;
+  }
+  if (matches(newVersion, currentValue)) {
+    // the upper bound is already high enough
+    return null;
+  }
+  const compos = getParts(newVersion);
+  if (compos === null) {
+    return null;
+  }
+  const majorPlusOne = plusOne(compos.major);
+  // istanbul ignore next: since all versions that can be parsed, can also be bumped, this can never happen
+  if (!matches(newVersion, `>=${parsed.lower} && <${majorPlusOne}`)) {
+    logger.warn(
+      { newVersion },
+      "Even though the major bound was bumped, the newVersion still isn't accepted.",
+    );
+    return null;
+  }
+  return `>=${parsed.lower} && <${majorPlusOne}`;
+}
+
+function isSame(
+  type: 'major' | 'minor' | 'patch',
+  a: string,
+  b: string,
+): boolean {
+  const aParts = getParts(a);
+  const bParts = getParts(b);
+  if (aParts === null || bParts === null) {
+    return false;
+  }
+  if (type === 'major') {
+    return 'eq' === compareIntArray(aParts.major, bParts.major);
+  } else if (type === 'minor') {
+    return 'eq' === compareIntArray(aParts.minor, bParts.minor);
+  } else {
+    return 'eq' === compareIntArray(aParts.patch, bParts.patch);
+  }
+}
+
+function subset(subRange: string, superRange: string): boolean | undefined {
+  const sub = parseRange(subRange);
+  const sup = parseRange(superRange);
+  if (sub === null || sup === null) {
+    return undefined;
+  }
+  const subLower = extractAllParts(sub.lower);
+  const subUpper = extractAllParts(sub.upper);
+  const supLower = extractAllParts(sup.lower);
+  const supUpper = extractAllParts(sup.upper);
+  if (
+    subLower === null ||
+    subUpper === null ||
+    supLower === null ||
+    supUpper === null
+  ) {
+    return undefined;
+  }
+  if ('lt' === compareIntArray(subLower, supLower)) {
+    return false;
+  }
+  if ('gt' === compareIntArray(subUpper, supUpper)) {
+    return false;
+  }
+  return true;
+}
+
+function isVersion(maybeRange: string | undefined | null): boolean {
+  return typeof maybeRange === 'string' && parseRange(maybeRange) === null;
+}
+
+function isValid(ver: string): boolean {
+  return extractAllParts(ver) !== null || parseRange(ver) !== null;
+}
+
+function isSingleVersion(range: string): boolean {
+  const noSpaces = range.trim();
+  return noSpaces.startsWith('==') && digitsAndDots.test(noSpaces.slice(2));
+}
+
+function equals(a: string, b: string): boolean {
+  const aParts = extractAllParts(a);
+  const bParts = extractAllParts(b);
+  if (aParts === null || bParts === null) {
+    return false;
+  }
+  return 'eq' === compareIntArray(aParts, bParts);
+}
+
+function sortVersions(a: string, b: string): number {
+  if (equals(a, b)) {
+    return 0;
+  }
+  return isGreaterThan(a, b) ? 1 : -1;
+}
+
+function isStable(version: string): boolean {
+  return true;
+}
+
+function isCompatible(version: string): boolean {
+  return true;
+}
+
+export const api: VersioningApi = {
+  isValid,
+  isVersion,
+  isStable,
+  isCompatible,
+  getMajor,
+  getMinor,
+  getPatch,
+  isSingleVersion,
+  sortVersions,
+  equals,
+  matches,
+  getSatisfyingVersion,
+  minSatisfyingVersion,
+  isLessThanRange,
+  isGreaterThan,
+  getNewValue,
+  isSame,
+  subset,
+};
+export default api;
diff --git a/lib/modules/versioning/pvp/range.spec.ts b/lib/modules/versioning/pvp/range.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..52b9128d8843cf45755c51388aca66261e835a08
--- /dev/null
+++ b/lib/modules/versioning/pvp/range.spec.ts
@@ -0,0 +1,12 @@
+import { parseRange } from './range';
+
+describe('modules/versioning/pvp/range', () => {
+  describe('.parseRange(range)', () => {
+    it('should parse >=1.0 && <1.1', () => {
+      const parsed = parseRange('>=1.0 && <1.1');
+      expect(parsed).not.toBeNull();
+      expect(parsed!.lower).toBe('1.0');
+      expect(parsed!.upper).toBe('1.1');
+    });
+  });
+});
diff --git a/lib/modules/versioning/pvp/range.ts b/lib/modules/versioning/pvp/range.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7bf77b2c43758f0f92f1d1f0c4ab4aadd098cca6
--- /dev/null
+++ b/lib/modules/versioning/pvp/range.ts
@@ -0,0 +1,21 @@
+import { regEx } from '../../../util/regex';
+import type { Range } from './types';
+
+// This range format was chosen because it is common in the ecosystem
+const gteAndLtRange = regEx(/>=(?<lower>[\d.]+)&&<(?<upper>[\d.]+)/);
+const ltAndGteRange = regEx(/<(?<upper>[\d.]+)&&>=(?<lower>[\d.]+)/);
+
+export function parseRange(input: string): Range | null {
+  const noSpaces = input.replaceAll(' ', '');
+  let m = gteAndLtRange.exec(noSpaces);
+  if (!m?.groups) {
+    m = ltAndGteRange.exec(noSpaces);
+    if (!m?.groups) {
+      return null;
+    }
+  }
+  return {
+    lower: m.groups['lower'],
+    upper: m.groups['upper'],
+  };
+}
diff --git a/lib/modules/versioning/pvp/readme.md b/lib/modules/versioning/pvp/readme.md
new file mode 100644
index 0000000000000000000000000000000000000000..c542f9c7d6441481a00499cb10bd5f098eb3052f
--- /dev/null
+++ b/lib/modules/versioning/pvp/readme.md
@@ -0,0 +1,18 @@
+[Package Versioning Policy](https://pvp.haskell.org/) is used with Haskell.
+It's like semver, except that the first _two_ parts are of the major
+version. That is, in `A.B.C`:
+
+- `A.B`: major version
+- `C`: minor
+
+The remaining parts are all considered of the patch version, and
+they will be concatenated to form a `number`, i.e. IEEE 754 double. This means
+that both `0.0.0.0.1` and `0.0.0.0.10` have patch version `0.1`.
+
+The range syntax comes from Cabal, specifically the [build-depends
+section](https://cabal.readthedocs.io/en/3.10/cabal-package.html).
+
+This module is considered experimental since it only supports ranges of forms:
+
+- `>=W.X && <Y.Z`
+- `<Y.Z && >=W.X`
diff --git a/lib/modules/versioning/pvp/types.ts b/lib/modules/versioning/pvp/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d2ab5e550debb4b734c5264c05c6869719df3319
--- /dev/null
+++ b/lib/modules/versioning/pvp/types.ts
@@ -0,0 +1,9 @@
+export interface Range {
+  lower: string;
+  upper: string;
+}
+export interface Parts {
+  major: number[];
+  minor: number[];
+  patch: number[];
+}
diff --git a/lib/modules/versioning/pvp/util.spec.ts b/lib/modules/versioning/pvp/util.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c8f46e91e599bc6754d644e270697d6fd7913adf
--- /dev/null
+++ b/lib/modules/versioning/pvp/util.spec.ts
@@ -0,0 +1,23 @@
+import { extractAllParts, getParts } from './util';
+
+describe('modules/versioning/pvp/util', () => {
+  describe('.extractAllParts(version)', () => {
+    it('should return null when there are no numbers', () => {
+      expect(extractAllParts('')).toBeNull();
+    });
+
+    it('should parse 3.0', () => {
+      expect(extractAllParts('3.0')).toEqual([3, 0]);
+    });
+  });
+
+  describe('.getParts(...)', () => {
+    it('"0" is valid major version', () => {
+      expect(getParts('0')?.major).toEqual([0]);
+    });
+
+    it('returns null when no parts could be extracted', () => {
+      expect(getParts('')).toBeNull();
+    });
+  });
+});
diff --git a/lib/modules/versioning/pvp/util.ts b/lib/modules/versioning/pvp/util.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d4e4cfe8ac6ad3e051b92809d1da655aca3c9ce3
--- /dev/null
+++ b/lib/modules/versioning/pvp/util.ts
@@ -0,0 +1,54 @@
+import type { Parts } from './types';
+
+export function extractAllParts(version: string): number[] | null {
+  const parts = version.split('.').map((x) => parseInt(x, 10));
+  const ret: number[] = [];
+  for (const l of parts) {
+    if (l < 0 || !isFinite(l)) {
+      return null;
+    }
+    ret.push(l);
+  }
+  return ret;
+}
+
+export function getParts(splitOne: string): Parts | null {
+  const c = extractAllParts(splitOne);
+  if (c === null) {
+    return null;
+  }
+  return {
+    major: c.slice(0, 2),
+    minor: c.slice(2, 3),
+    patch: c.slice(3),
+  };
+}
+
+export function plusOne(majorOne: number[]): string {
+  return `${majorOne[0]}.${majorOne[1] + 1}`;
+}
+
+export function compareIntArray(
+  versionPartsInt: number[],
+  otherPartsInt: number[],
+): 'lt' | 'eq' | 'gt' {
+  for (
+    let i = 0;
+    i < Math.min(versionPartsInt.length, otherPartsInt.length);
+    i++
+  ) {
+    if (versionPartsInt[i] > otherPartsInt[i]) {
+      return 'gt';
+    }
+    if (versionPartsInt[i] < otherPartsInt[i]) {
+      return 'lt';
+    }
+  }
+  if (versionPartsInt.length === otherPartsInt.length) {
+    return 'eq';
+  }
+  if (versionPartsInt.length > otherPartsInt.length) {
+    return 'gt';
+  }
+  return 'lt';
+}