diff --git a/.github/actions/gcovr/action.yaml b/.github/actions/gcovr/action.yaml new file mode 100644 index 0000000000..a128d35d2b --- /dev/null +++ b/.github/actions/gcovr/action.yaml @@ -0,0 +1,79 @@ +name: Gcovr +description: Generate code coverage reports with gcovr and llvm-cov + +inputs: + build-directory: + description: Build directory containing coverage data (.gcda / .gcno) + required: false + default: build + source-root: + description: Source root for gcovr --root + required: false + default: ${{ github.workspace }} + gcov-executable: + description: gcov executable passed to gcovr (e.g. llvm-cov-16 gcov for Clang) + required: false + default: llvm-cov-16 gcov + gcovr-version: + description: gcovr PyPI version to install + required: false + default: "8.4" + output-directory: + description: Directory for generated coverage reports + required: false + default: build/coverage + fail-under-line: + description: Fail if line coverage is below this percent (0 disables the gate) + required: false + default: "0" + post-pr-comment: + description: Post or update a sticky coverage summary on the pull request + required: false + default: "true" + github-token: + description: Token for posting PR comments + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Install gcovr + shell: bash + run: | + python3 -m pip install --user "gcovr==${{ inputs.gcovr-version }}" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Verify llvm-cov + shell: bash + run: | + llvm_cov="${{ inputs.gcov-executable }}" + llvm_cov_bin="${llvm_cov%% *}" + if ! command -v "${llvm_cov_bin}" >/dev/null 2>&1; then + echo "::error::${llvm_cov_bin} not found; install LLVM or adjust gcov-executable" + exit 1 + fi + + - name: Generate coverage report + shell: bash + run: | + chmod +x "${{ github.action_path }}/run_gcovr.sh" + SOURCE_ROOT="${{ inputs.source-root }}" \ + BUILD_DIR="${{ github.workspace }}/${{ inputs.build-directory }}" \ + OUTPUT_DIR="${{ github.workspace }}/${{ inputs.output-directory }}" \ + GCOV_EXECUTABLE="${{ inputs.gcov-executable }}" \ + FAIL_UNDER_LINE="${{ inputs.fail-under-line }}" \ + "${{ github.action_path }}/run_gcovr.sh" + + - name: Post coverage PR comment + if: inputs.post-pr-comment == 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + OUTPUT_DIR: ${{ github.workspace }}/${{ inputs.output-directory }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + run: | + chmod +x "${{ github.action_path }}/post_pr_comment.sh" + "${{ github.action_path }}/post_pr_comment.sh" diff --git a/.github/actions/gcovr/post_pr_comment.sh b/.github/actions/gcovr/post_pr_comment.sh new file mode 100755 index 0000000000..031a64632e --- /dev/null +++ b/.github/actions/gcovr/post_pr_comment.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${OUTPUT_DIR:?OUTPUT_DIR is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" + +MARKER='' +SUMMARY_JSON="${OUTPUT_DIR}/summary.json" + +if [[ -z "${PR_NUMBER:-}" ]]; then + echo "PR_NUMBER is not set; skipping coverage PR comment" + exit 0 +fi + +if [[ ! -f "${SUMMARY_JSON}" ]]; then + echo "::warning::${SUMMARY_JSON} not found; skipping coverage PR comment" + exit 0 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "::error::gh CLI is required to post PR comments" + exit 1 +fi + +BODY="$(MARKER="${MARKER}" SUMMARY_JSON="${SUMMARY_JSON}" GITHUB_REPOSITORY="${GITHUB_REPOSITORY}" GITHUB_RUN_ID="${GITHUB_RUN_ID}" python3 <<'PY' +import json +import os +from pathlib import Path + +marker = os.environ["MARKER"] +summary = json.loads(Path(os.environ["SUMMARY_JSON"]).read_text()) +repo = os.environ["GITHUB_REPOSITORY"] +run_id = os.environ["GITHUB_RUN_ID"] + +line = summary["line"] +branch = summary["branch"] +func = summary["function"] + +def bar(percent: float) -> str: + filled = max(0, min(10, int(round(percent / 10)))) + return "█" * filled + "░" * (10 - filled) + +files = [ + f for f in summary.get("files", []) + if f.get("line_total", 0) >= 20 +] +files.sort(key=lambda f: f.get("line_percent") or 0) +lowest = files[:8] + +run_url = f"https://github.com/{repo}/actions/runs/{run_id}" +lines = [ + marker, + "## Code coverage", + "", + f"Workflow run: [Coverage job #{run_id}]({run_url}) · download the **coverage-report** artifact for the HTML report.", + "", + "| Metric | Coverage | Covered / total |", + "| --- | --- | --- |", + f"| Line | **{line['percent']:.1f}%** {bar(line['percent'])} | {line['covered']} / {line['num']} |", + f"| Branch | **{branch['percent']:.1f}%** {bar(branch['percent'])} | {branch['covered']} / {branch['num']} |", + f"| Function | **{func['percent']:.1f}%** {bar(func['percent'])} | {func['covered']} / {func['num']} |", + "", + "Scope: `src/`, `include/ydb-cpp-sdk/`, `plugins/` (contrib, tests, and `_deps` excluded).", +] + +if lowest: + lines.extend([ + "", + "
", + "Lowest line coverage (≥ 20 lines)", + "", + "| File | Line % | Covered / total |", + "| --- | ---: | --- |", + ]) + for f in lowest: + pct = f.get("line_percent") or 0 + lines.append( + f"| `{f['filename']}` | {pct:.1f}% | {f['line_covered']} / {f['line_total']} |" + ) + lines.extend(["", "
"]) + +print("\n".join(lines)) +PY +)" + +COMMENT_ID="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --paginate \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + | head -n1)" + +if [[ -n "${COMMENT_ID}" ]]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \ + -f body="${BODY}" >/dev/null + echo "Updated coverage comment ${COMMENT_ID} on PR #${PR_NUMBER}" +else + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f body="${BODY}" >/dev/null + echo "Posted coverage comment on PR #${PR_NUMBER}" +fi diff --git a/.github/actions/gcovr/run_gcovr.sh b/.github/actions/gcovr/run_gcovr.sh new file mode 100755 index 0000000000..94d3c418f4 --- /dev/null +++ b/.github/actions/gcovr/run_gcovr.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${SOURCE_ROOT:?SOURCE_ROOT is required}" +: "${BUILD_DIR:?BUILD_DIR is required}" +: "${OUTPUT_DIR:?OUTPUT_DIR is required}" +: "${GCOV_EXECUTABLE:=llvm-cov-16 gcov}" + +mkdir -p "${OUTPUT_DIR}" + +gcovr_args=( + --root "${SOURCE_ROOT}" + --object-directory "${BUILD_DIR}" + --gcov-executable "${GCOV_EXECUTABLE}" + --print-summary + --json-summary "${OUTPUT_DIR}/summary.json" + --html --html-details + -o "${OUTPUT_DIR}/index.html" + --cobertura "${OUTPUT_DIR}/cobertura.xml" + --exclude '.*contrib/.*' + --exclude '.*tests/.*' + --exclude '.*/_deps/.*' + --filter 'src/' + --filter 'include/ydb-cpp-sdk/' + --filter 'plugins/' +) + +if [[ -n "${FAIL_UNDER_LINE:-}" && "${FAIL_UNDER_LINE}" != "0" ]]; then + gcovr_args+=(--fail-under-line "${FAIL_UNDER_LINE}") +fi + +gcovr "${gcovr_args[@]}" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" && -f "${OUTPUT_DIR}/summary.json" ]]; then + { + echo "## Code coverage" + echo "" + echo "| Metric | Covered | Total | Percent |" + echo "| --- | ---: | ---: | ---: |" + jq -r ' + .line as $l + | .branch as $b + | .function as $f + | "| Line | \($l.covered) | \($l.num) | \($l.percent // 0)% |", + "| Branch | \($b.covered) | \($b.num) | \($b.percent // 0)% |", + "| Function | \($f.covered) | \($f.num) | \($f.percent // 0)% |" + ' "${OUTPUT_DIR}/summary.json" + } >> "${GITHUB_STEP_SUMMARY}" +fi diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000000..24bbb2deed --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,79 @@ +name: Coverage + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - main + +concurrency: + group: coverage-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + coverage: + runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: write + services: + ydb: + image: ydbplatform/local-ydb:24.4 + ports: + - 2135:2135 + - 2136:2136 + - 8765:8765 + volumes: + - /tmp/ydb_certs:/ydb_certs + env: + YDB_LOCAL_SURVIVE_RESTART: true + YDB_USE_IN_MEMORY_PDISKS: true + YDB_TABLE_ENABLE_PREPARED_DDL: true + options: '-h localhost' + steps: + - name: Checkout PR + uses: actions/checkout@v4 + if: github.event.pull_request.head.sha != '' + with: + submodules: true + ref: ${{ github.event.pull_request.head.sha }} + + - name: Checkout + uses: actions/checkout@v4 + if: github.event.pull_request.head.sha == '' + with: + submodules: true + + - name: Install dependencies + uses: ./.github/actions/prepare_vm + + - name: Configure and build with coverage + shell: bash + run: | + mkdir -p build + rm -rf build/* + cmake --preset coverage-test-gcc + cmake --build build -j"$(nproc)" + + - name: Test + shell: bash + run: | + ctest -j1 --preset coverage-all --output-on-failure + + - name: Generate coverage report + uses: ./.github/actions/gcovr + with: + build-directory: build + source-root: ${{ github.workspace }} + gcov-executable: gcov-13 + post-pr-comment: ${{ github.event_name == 'pull_request' }} + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: build/coverage/ + retention-days: 14 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6df450c510..c56c49bcb2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ include(CMakePackageConfigHelpers) list(APPEND CMAKE_MODULE_PATH ${YDB_SDK_SOURCE_DIR}/cmake) include(cmake/global_flags.cmake) +include(cmake/coverage.cmake) include(cmake/global_vars.cmake) include(cmake/install.cmake) include(cmake/common.cmake) diff --git a/CMakePresets.json b/CMakePresets.json index d33c11b681..22383da5e6 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -99,6 +99,21 @@ "name": "debug-test-gcc", "inherits": ["debug-base", "gcc-toolchain", "test"], "displayName": "Default Debug Test Config (GCC)" + }, + { + "name": "coverage-base", + "inherits": ["release-base", "test"], + "hidden": true, + "cacheVariables": { + "YDB_SDK_COVERAGE": "ON", + "ARCADIA_ROOT": "${sourceDir}", + "ARCADIA_BUILD_ROOT": "${sourceDir}/build" + } + }, + { + "name": "coverage-test-gcc", + "inherits": ["coverage-base", "gcc-toolchain"], + "displayName": "Coverage Test Config (GCC)" } ], "buildPresets": [ @@ -154,6 +169,25 @@ "YDB_ENDPOINT": "localhost:2136", "YDB_DATABASE": "/local" } + }, + { + "name": "coverage-all", + "inherits": "all", + "displayName": "Run All Tests (coverage CI)", + "configurePreset": "coverage-test-gcc", + "execution": { + "timeout": 1800 + }, + "filter": { + "exclude": { + "name": "(ManyMessages|DiscoveryHang|DescribeHang)" + } + }, + "environment": { + "YDB_ENDPOINT": "localhost:2136", + "YDB_DATABASE": "/local", + "YDB_VERSION": "24.4" + } } ] } diff --git a/cmake/ccache.cmake b/cmake/ccache.cmake index ef68402c46..311a9b8f12 100644 --- a/cmake/ccache.cmake +++ b/cmake/ccache.cmake @@ -1,5 +1,7 @@ find_program(CCACHE_PATH ccache) -if (NOT CCACHE_PATH) +if (YDB_SDK_COVERAGE) + message(STATUS "YDB_SDK_COVERAGE is ON: ccache compiler launchers disabled") +elseif (NOT CCACHE_PATH) message(AUTHOR_WARNING "Ccache is not found, that will increase the re-compilation time; " "pass -DCCACHE_PATH=path/to/bin to specify the path to the `ccache` binary file." diff --git a/cmake/common.cmake b/cmake/common.cmake index bd01742519..04cfd5838d 100644 --- a/cmake/common.cmake +++ b/cmake/common.cmake @@ -201,6 +201,7 @@ function(_ydb_sdk_add_library Tgt) target_compile_definitions(${Tgt} ${includeMode} YDB_SDK_OSS ) + _ydb_sdk_apply_coverage(${Tgt}) endfunction() function(_ydb_sdk_validate_public_headers) diff --git a/cmake/coverage.cmake b/cmake/coverage.cmake new file mode 100644 index 0000000000..d21180b7d3 --- /dev/null +++ b/cmake/coverage.cmake @@ -0,0 +1,21 @@ +option(YDB_SDK_COVERAGE "Instrument SDK and tests for gcov/llvm-cov coverage" OFF) + +if (YDB_SDK_COVERAGE) + add_link_options(--coverage) +endif() + +function(_ydb_sdk_apply_coverage target) + if (NOT YDB_SDK_COVERAGE) + return() + endif() + get_target_property(is_iface ${target} TYPE) + if (is_iface STREQUAL "INTERFACE_LIBRARY") + return() + endif() + # Do not use -fprofile-abs-path: it expands __FILE__/__LOCATION__ to absolute paths + # and breaks util/system/src_location_ut and src_root_ut (and similar assertions). + # gcovr --root maps profile data to the source tree without it. + set(_coverage_compile_flags --coverage -O0 -g -fno-inline) + target_compile_options(${target} PRIVATE ${_coverage_compile_flags}) + target_link_options(${target} PRIVATE --coverage) +endfunction() diff --git a/cmake/testing.cmake b/cmake/testing.cmake index 0d8ba73d64..247be6de8b 100644 --- a/cmake/testing.cmake +++ b/cmake/testing.cmake @@ -51,6 +51,7 @@ function(add_ydb_test) endif() add_executable(${YDB_TEST_NAME}) + _ydb_sdk_apply_coverage(${YDB_TEST_NAME}) target_include_directories(${YDB_TEST_NAME} PRIVATE ${YDB_TEST_INCLUDE_DIRS}) target_link_libraries(${YDB_TEST_NAME} PRIVATE ${YDB_TEST_LINK_LIBRARIES}) target_sources(${YDB_TEST_NAME} PRIVATE ${YDB_TEST_SOURCES})