diff --git a/.github/actions/detect-commit-files/action.yml b/.github/actions/detect-commit-files/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fce001abef07683eda971f0c88d04510a3fe9699
--- /dev/null
+++ b/.github/actions/detect-commit-files/action.yml
@@ -0,0 +1,61 @@
+# This is the composite action:
+#   https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
+#
+# Composite actions have some limitations:
+#   - many contexts are unavailable, e.g. `runner`
+#   - `env` can be specified per-step only,
+#     but can be set via `$GITHUB_ENV` for the next steps
+#   - if `run` is used, `shell` must be explicitly specified
+name: 'Calculate files committed in the branch'
+inputs:
+  repo:
+    description: 'Repository name'
+    required: true
+  token:
+    description: 'GitHub token'
+    required: true
+  event:
+    description: 'GitHub actions event serialized as JSON'
+    required: true
+outputs:
+  commit-files:
+    description: 'List of commit files serialized as JSON'
+    value: ${{ steps.commit-files.outputs.commit-files }}
+runs:
+  using: 'composite'
+  steps:
+    - name: Detect BASEHEAD from `pull_request` event field
+      if: |
+        fromJSON(inputs.event).pull_request.base.sha &&
+        fromJSON(inputs.event).pull_request.head.sha
+      shell: bash
+      run: |
+        echo 'BASEHEAD=${{
+          fromJSON(inputs.event).pull_request.base.sha
+        }}...${{
+          fromJSON(inputs.event).pull_request.head.sha
+        }}' >> "$GITHUB_ENV"
+
+    - name: Detect BASEHEAD from `compare` event field
+      if: |
+        !env.BASEHEAD &&
+        fromJSON(inputs.event).compare
+      env:
+        COMPARE_URL: ${{ fromJSON(inputs.event).compare }}
+      shell: bash
+      run: |
+        echo '${{ env.COMPARE_URL }}' \
+          | sed 's/.*compare\/\([^ ]*\)/BASEHEAD=\1/' \
+          >> "$GITHUB_ENV"
+
+    - name: Fetch files changed
+      id: commit-files
+      if: ${{ env.BASEHEAD }}
+      env:
+        GH_TOKEN: ${{ inputs.token }}
+        GH_REPO: ${{ inputs.repo }}
+        PR_URL: https://api.github.com/repos/{owner}/{repo}/compare/${{ env.BASEHEAD }}
+        JQ_FILTER: >-
+          "commit-files=" + ([.files[].filename] | tostring)
+      shell: bash
+      run: gh api ${{ env.PR_URL }} | jq -rc '${{ env.JQ_FILTER }}' >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b19fe80f693dfbcaac2b413e5dc6cae91273a86f..6570d6a70a39e5debb724df7c80b840e163cdd00 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -42,6 +42,8 @@ jobs:
       os-matrix: ${{ steps.os-matrix.outputs.os-matrix }}
       os-matrix-is-full: ${{ steps.os-matrix-is-full.outputs.os-matrix-is-full }}
       os-matrix-prefetch: ${{ steps.os-matrix-prefetch.outputs.matrix }}
+      test-shards: ${{ steps.test-shards.outputs.test-shards }}
+      test-shards-all: ${{ steps.test-shards.outputs.test-shards-all }}
 
     env:
       # Field required for GitHub CLI
@@ -111,6 +113,20 @@ jobs:
           node-version: ${{ env.NODE_VERSION }}
           os: ${{ runner.os }}
 
+      - name: Detect changed files list
+        id: commit-files
+        uses: ./.github/actions/detect-commit-files
+        with:
+          repo: ${{ env.GH_REPO }}
+          token: ${{ env.GH_TOKEN }}
+          event: ${{ toJSON(github.event) }}
+
+      - name: Detect test shards
+        id: test-shards
+        env:
+          COMMIT_FILES: ${{ steps.commit-files.outputs.commit-files || '[]' }}
+        run: yarn -s test-shards >> "$GITHUB_OUTPUT"
+
   prefetch:
     needs: [setup]
     # We can't use `if: needs.setup.outputs.os-matrix-is-full` here,
diff --git a/jest.config.ts b/jest.config.ts
index 6f7aef640e34718c595b73024bbce1f9bced144f..5772e3c5f23db197476c485da593dfbe2caf48b2 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -1,6 +1,7 @@
 import os from 'node:os';
 import v8 from 'node:v8';
-import type { InitialOptionsTsJest } from 'ts-jest/dist/types';
+import { minimatch } from 'minimatch';
+import type { InitialOptionsTsJest } from 'ts-jest';
 
 const ci = !!process.env.CI;
 
@@ -13,12 +14,6 @@ const cpus = os.cpus();
 const mem = os.totalmem();
 const stats = v8.getHeapStatistics();
 
-process.stderr.write(`Host stats:
-  Cpus:      ${cpus.length}
-  Memory:    ${(mem / 1024 / 1024 / 1024).toFixed(2)} GB
-  HeapLimit: ${(stats.heap_size_limit / 1024 / 1024 / 1024).toFixed(2)} GB
-`);
-
 /**
  * https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
  * Currently it seems the runner only have 4GB
@@ -37,28 +32,315 @@ function jestGithubRunnerSpecs(): JestConfig {
   };
 }
 
+/**
+ * Configuration for single test shard.
+ */
+interface ShardConfig {
+  /**
+   * Path patterns to match against the test file paths, of two types:
+   *
+   * 1. Particular file, e.g. `lib/util/git/index.spec.ts`
+   *
+   *    - File pattern MUST end with `.spec.ts`
+   *    - This will only search for the particular test file
+   *    - It enables coverage for the `*.ts` file with the same name,
+   *      e.g. `lib/util/git/index.ts`
+   *    - You probably want to use directory pattern instead
+   *
+   * 2. Whole directory, e.g. `lib/modules/datasource`
+   *
+   *    - This will search for all `*.spec.ts` files under the directory
+   *    - It enables coverage all `*.ts` files under the directory,
+   *      e.g. `lib/modules/datasource/foo/bar/baz.ts`
+   */
+  matchPaths: string[];
+
+  /**
+   * Coverage threshold settings for the entire shard (via `global` field).
+   * Ommitted fields default to `100` (i.e. 100%).
+   */
+  threshold?: {
+    branches?: number;
+    functions?: number;
+    lines?: number;
+    statements?: number;
+  };
+}
+
+/**
+ * Configuration for test shards that can be run with `TEST_SHARD` environment variable.
+ *
+ * For each shard, we specify a subset of tests to run.
+ * The tests from previous shards are excluded from the next shard.
+ *
+ * If the coverage threshold is not met, we adjust it
+ * using the optional `threshold` field.
+ *
+ * Eventually, we aim to reach 100% coverage for most cases,
+ * so the `threshold` field is meant to be mostly omitted in the future.
+ *
+ * Storing shards config in the separate file helps to form CI matrix
+ * using pre-installed `jq` utility.
+ */
+const testShards: Record<string, ShardConfig> = {
+  'datasources-1': {
+    matchPaths: ['lib/modules/datasource/[a-g]*'],
+    threshold: {
+      branches: 96.95,
+    },
+  },
+  'datasources-2': {
+    matchPaths: ['lib/modules/datasource'],
+    threshold: {
+      statements: 99.35,
+      branches: 96.0,
+      functions: 98.25,
+      lines: 99.35,
+    },
+  },
+  'managers-1': {
+    matchPaths: ['lib/modules/manager/[a-c]*'],
+    threshold: {
+      functions: 99.3,
+    },
+  },
+  'managers-2': {
+    matchPaths: ['lib/modules/manager/[d-h]*'],
+    threshold: {
+      functions: 99.7,
+    },
+  },
+  'managers-3': {
+    matchPaths: ['lib/modules/manager/[i-n]*'],
+    threshold: {
+      statements: 99.65,
+      branches: 98.5,
+      functions: 98.65,
+      lines: 99.65,
+    },
+  },
+  'managers-4': {
+    matchPaths: ['lib/modules/manager'],
+  },
+  platform: {
+    matchPaths: ['lib/modules/platform'],
+    threshold: {
+      branches: 97.5,
+    },
+  },
+  versioning: {
+    matchPaths: ['lib/modules/versioning'],
+    threshold: {
+      branches: 97.25,
+    },
+  },
+  'workers-1': {
+    matchPaths: ['lib/workers/repository/{onboarding,process}'],
+  },
+  'workers-2': {
+    matchPaths: ['lib/workers/repository/update/pr'],
+    threshold: {
+      branches: 97.1,
+    },
+  },
+  'workers-3': {
+    matchPaths: ['lib/workers/repository/update'],
+    threshold: {
+      branches: 97.75,
+    },
+  },
+  'workers-4': {
+    matchPaths: ['lib/workers'],
+    threshold: {
+      statements: 99.95,
+      branches: 97.2,
+      lines: 99.95,
+    },
+  },
+  'git-1': {
+    matchPaths: ['lib/util/git/index.spec.ts'],
+    threshold: {
+      statements: 99.8,
+      functions: 97.55,
+      lines: 99.8,
+    },
+  },
+  'git-2': {
+    matchPaths: ['lib/util/git'],
+    threshold: {
+      statements: 98.4,
+      branches: 98.65,
+      functions: 93.9,
+      lines: 98.4,
+    },
+  },
+  util: {
+    matchPaths: ['lib/util'],
+    threshold: {
+      statements: 97.85,
+      branches: 96.15,
+      functions: 95.85,
+      lines: 97.95,
+    },
+  },
+  other: {
+    matchPaths: ['lib'],
+  },
+};
+
+/**
+ * Subset of Jest config that is relevant for sharded test run.
+ */
+type JestShardedSubconfig = Pick<
+  JestConfig,
+  'testMatch' | 'collectCoverageFrom' | 'coverageThreshold'
+>;
+
+/**
+ * Convert match pattern to a form that matches on file with `.ts` or `.spec.ts` extension.
+ */
+function normalizePattern(pattern: string, suffix: '.ts' | '.spec.ts'): string {
+  return pattern.endsWith('.spec.ts')
+    ? pattern.replace(/\.spec\.ts$/, suffix)
+    : `${pattern}/**/*${suffix}`;
+}
+
+/**
+ * Generates Jest config for sharded test run.
+ *
+ * If `TEST_SHARD` environment variable is not set,
+ * it falls back to the provided config.
+ *
+ * Otherwise, `fallback` value is used to determine some defaults.
+ */
+function configureShardingOrFallbackTo(
+  fallback: JestShardedSubconfig
+): JestShardedSubconfig {
+  const shardKey = process.env.TEST_SHARD;
+  if (!shardKey) {
+    return fallback;
+  }
+
+  if (!testShards[shardKey]) {
+    const keys = Object.keys(testShards).join(', ');
+    throw new Error(
+      `Unknown value for TEST_SHARD: ${shardKey} (possible values: ${keys})`
+    );
+  }
+
+  const testMatch: string[] = [];
+
+  // Use exclusion patterns from the fallback config
+  const collectCoverageFrom: string[] =
+    fallback.collectCoverageFrom?.filter((pattern) =>
+      pattern.startsWith('!')
+    ) ?? [];
+
+  // Use coverage threshold from the fallback config
+  const defaultGlobal = fallback.coverageThreshold?.global;
+  const coverageThreshold: JestConfig['coverageThreshold'] = {
+    global: {
+      branches: defaultGlobal?.branches ?? 100,
+      functions: defaultGlobal?.functions ?? 100,
+      lines: defaultGlobal?.lines ?? 100,
+      statements: defaultGlobal?.statements ?? 100,
+    },
+  };
+
+  for (const [key, { matchPaths: patterns, threshold }] of Object.entries(
+    testShards
+  )) {
+    if (key === shardKey) {
+      const testMatchPatterns = patterns.map((pattern) => {
+        const filePattern = normalizePattern(pattern, '.spec.ts');
+        return `<rootDir>/${filePattern}`;
+      });
+      testMatch.push(...testMatchPatterns);
+
+      const coveragePatterns = patterns.map((pattern) =>
+        normalizePattern(pattern, '.ts')
+      );
+      collectCoverageFrom.push(...coveragePatterns);
+
+      if (threshold) {
+        coverageThreshold.global = {
+          ...coverageThreshold.global,
+          ...threshold,
+        };
+      }
+
+      break;
+    }
+
+    const testMatchPatterns = patterns.map((pattern) => {
+      const filePattern = normalizePattern(pattern, '.spec.ts');
+      return `!**/${filePattern}`;
+    });
+    testMatch.push(...testMatchPatterns);
+
+    const coveragePatterns = patterns.map((pattern) => {
+      const filePattern = normalizePattern(pattern, '.ts');
+      return `!${filePattern}`;
+    });
+    collectCoverageFrom.push(...coveragePatterns);
+  }
+
+  testMatch.reverse();
+  collectCoverageFrom.reverse();
+  return { testMatch, collectCoverageFrom, coverageThreshold };
+}
+
+/**
+ * Given the file list affected by commit, return the list
+ * of shards that  test these changes.
+ */
+function getMatchingShards(files: string[]): string[] {
+  const matchingShards = new Set<string>();
+  for (const file of files) {
+    for (const [key, { matchPaths }] of Object.entries(testShards)) {
+      const patterns = matchPaths.map((path) =>
+        path.endsWith('.spec.ts')
+          ? path.replace(/\.spec\.ts$/, '{.ts,.spec.ts}')
+          : `${path}/**/*`
+      );
+
+      if (patterns.some((pattern) => minimatch(file, pattern))) {
+        matchingShards.add(key);
+        break;
+      }
+    }
+  }
+
+  const allShards = Object.keys(testShards);
+  return matchingShards.size > 0
+    ? allShards.filter((shard) => matchingShards.has(shard))
+    : allShards;
+}
+
 const config: JestConfig = {
+  ...configureShardingOrFallbackTo({
+    collectCoverageFrom: [
+      'lib/**/*.{js,ts}',
+      '!lib/**/*.{d,spec}.ts',
+      '!lib/**/{__fixtures__,__mocks__,__testutil__,test}/**/*.{js,ts}',
+      '!lib/**/types.ts',
+    ],
+    coverageThreshold: {
+      global: {
+        branches: 98,
+        functions: 100,
+        lines: 100,
+        statements: 100,
+      },
+    },
+  }),
   cacheDirectory: '.cache/jest',
   clearMocks: true,
   collectCoverage: true,
-  collectCoverageFrom: [
-    'lib/**/*.{js,ts}',
-    '!lib/**/*.{d,spec}.ts',
-    '!lib/**/{__fixtures__,__mocks__,__testutil__,test}/**/*.{js,ts}',
-    '!lib/**/types.ts',
-  ],
   coverageDirectory: './coverage',
   coverageReporters: ci
     ? ['html', 'json', 'text-summary']
     : ['html', 'text-summary'],
-  coverageThreshold: {
-    global: {
-      branches: 98,
-      functions: 100,
-      lines: 100,
-      statements: 100,
-    },
-  },
   transform: {
     '\\.ts$': [
       'ts-jest',
@@ -92,3 +374,32 @@ const config: JestConfig = {
 };
 
 export default config;
+
+/**
+ * If `COMMIT_FILES` env variable is set, it means we're in `setup` CI job.
+ * We don't want to see anything except key-value pairs in the output.
+ * Otherwise, we're printing useful stats.
+ */
+if (process.env.COMMIT_FILES) {
+  try {
+    const commitFiles = JSON.parse(process.env.COMMIT_FILES);
+
+    const matchingShards = getMatchingShards(commitFiles);
+    // eslint-disable-next-line no-console
+    console.log(`test-shards=${JSON.stringify(matchingShards)}`);
+
+    const allShards = Object.keys(testShards);
+    // eslint-disable-next-line no-console
+    console.log(`test-shards-all=${JSON.stringify(allShards)}`);
+  } catch (err) {
+    throw new Error(
+      `Invalid COMMIT_FILES value: "${process.env.COMMIT_FILES}"`
+    );
+  }
+} else {
+  process.stderr.write(`Host stats:
+    Cpus:      ${cpus.length}
+    Memory:    ${(mem / 1024 / 1024 / 1024).toFixed(2)} GB
+    HeapLimit: ${(stats.heap_size_limit / 1024 / 1024 / 1024).toFixed(2)} GB
+  `);
+}
diff --git a/package.json b/package.json
index 3b871fe1aab67a87c1f48d3f70da33c653c317f7..bce7f0d509606049c2864acc1b75ac56aa70b1cb 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
     "test-e2e:install": "yarn --cwd test/e2e install --no-lockfile --ignore-optional --prod",
     "test-e2e:run": "yarn --cwd test/e2e test",
     "test-schema": "run-s create-json-schema",
+    "test-shards": "ts-node jest.config.ts",
     "tsc": "tsc",
     "type-check": "run-s generate:* \"tsc --noEmit {@}\" --",
     "update-static-data": "run-s update-static-data:*",