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:*",