diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 129fc766458c..69402439d08a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,55 @@ name: lint permissions: contents: read +env: + NOX_REUSE_EXISTING_VIRTUALENVS: "true" + NOX_ENVDIR: "/tmp/shared_nox_envs" + NOX_DEFAULT_VENV_BACKEND: "uv" + UV_VENV_SEED: "1" + # Run checks in parallel using 4 cores + PARALLEL_WORKERS: "4" + jobs: + initialize: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + is_full_run: ${{ steps.check-label.outputs.is_full_run }} + env: + MAX_SHARDS: 4 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + # Use a fetch-depth of 2 to avoid error `fatal: origin/main...HEAD: no merge base` + # See https://github.com/googleapis/google-cloud-python/issues/12013 + # and https://github.com/actions/checkout#checkout-head. + with: + fetch-depth: 2 + persist-credentials: false + - name: Check for unit_test:all_packages label + id: check-label + run: | + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'unit_test:all_packages') }}" == "true" ]]; then + echo "is_full_run=true" >> $GITHUB_OUTPUT + else + echo "is_full_run=false" >> $GITHUB_OUTPUT + fi + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.10" + - name: Get package shards + id: set-matrix + env: + BUILD_TYPE: presubmit + TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} + TEST_ALL_PACKAGES: ${{ steps.check-label.outputs.is_full_run }} + run: | + if [ -n "$TARGET_BRANCH" ]; then + git fetch origin "$TARGET_BRANCH" --depth=1 || true + fi + python3 ci/get_package_shards.py + lint: runs-on: ubuntu-latest steps: @@ -24,6 +72,14 @@ jobs: with: fetch-depth: 2 persist-credentials: false + - name: Check for unit_test:all_packages label + id: check-label + run: | + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'unit_test:all_packages') }}" == "true" ]]; then + echo "is_full_run=true" >> $GITHUB_OUTPUT + else + echo "is_full_run=false" >> $GITHUB_OUTPUT + fi - name: Setup Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: @@ -31,11 +87,12 @@ jobs: - name: Install nox run: | python -m pip install --upgrade setuptools pip wheel - python -m pip install nox + python -m pip install nox uv - name: Run lint env: BUILD_TYPE: presubmit TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} + TEST_ALL_PACKAGES: ${{ steps.check-label.outputs.is_full_run }} TEST_TYPE: lint # TODO(https://github.com/googleapis/google-cloud-python/issues/13775): Specify `PY_VERSION` rather than relying on the default python version of the nox session. PY_VERSION: "unused" @@ -45,13 +102,22 @@ jobs: env: BUILD_TYPE: presubmit TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} + TEST_ALL_PACKAGES: ${{ steps.check-label.outputs.is_full_run }} TEST_TYPE: lint_setup_py # TODO(https://github.com/googleapis/google-cloud-python/issues/13775): Specify `PY_VERSION` rather than relying on the default python version of the nox session. PY_VERSION: "unused" run: | ci/run_conditional_tests.sh - mypy: + + mypy-shard: + needs: initialize + if: needs.initialize.outputs.matrix != '[]' && needs.initialize.outputs.matrix != '' runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + package_shard: ${{ fromJson(needs.initialize.outputs.matrix) }} + name: ${{ matrix.package_shard.is_sharded && format('mypy ({0})', matrix.package_shard.name) || format('mypy ({0})', matrix.package_shard.description) }} steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -68,13 +134,28 @@ jobs: - name: Install nox run: | python -m pip install --upgrade setuptools pip wheel - python -m pip install nox - - name: Run mypy + python -m pip install nox uv + - name: Run mypy for ${{ matrix.package_shard.description }} env: BUILD_TYPE: presubmit TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} TEST_TYPE: mypy + PACKAGE_LIST: ${{ matrix.package_shard.packages }} # TODO(https://github.com/googleapis/google-cloud-python/issues/13775): Specify `PY_VERSION` rather than relying on the default python version of the nox session. PY_VERSION: "unused" run: | ci/run_conditional_tests.sh + + mypy-status: + if: always() + needs: [initialize, mypy-shard] + runs-on: ubuntu-latest + name: mypy-status + steps: + - name: Check mypy job status + run: | + if [[ "${{ needs['mypy-shard'].result }}" != "success" && "${{ needs['mypy-shard'].result }}" != "skipped" ]]; then + echo "mypy failed with result: ${{ needs['mypy-shard'].result }}" + exit 1 + fi + echo "All mypy shards passed successfully!" diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 392f5ece6270..c9e8f5239bf1 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -12,13 +12,80 @@ name: unittest permissions: contents: read +env: + PACKAGE_DIRS: packages preview-packages + NOX_REUSE_EXISTING_VIRTUALENVS: "true" + NOX_ENVDIR: "/tmp/shared_nox_envs" + NOX_DEFAULT_VENV_BACKEND: "uv" + UV_VENV_SEED: "1" + # Note: PARALLEL_WORKERS must remain "1" for unit tests because running pytest concurrently + # on the same runner VM can cause socket/IPC/process deadlocks across packages. + PARALLEL_WORKERS: "1" + jobs: + initialize: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + is_full_run: ${{ steps.check-label.outputs.is_full_run }} + env: + MAX_SHARDS: 16 + # Define weights for long-running unit tests to balance shard execution time + # Each weight is roughly 1 minute of expected execution time + # Default for unset packages is 1 + PACKAGE_WEIGHTS: | + bigframes: 6 + google-ai-generativelanguage: 4 + google-auth: 5 + google-cloud-compute: 12 + google-cloud-compute-v1beta: 12 + google-cloud-dialogflow: 6 + google-cloud-dialogflow-cx: 6 + google-cloud-discoveryengine: 8 + google-cloud-retail: 5 + google-shopping-merchant-accounts: 4 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 2 + persist-credentials: false + - name: Check for unit_test:all_packages label + id: check-label + run: | + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'unit_test:all_packages') }}" == "true" ]]; then + echo "is_full_run=true" >> $GITHUB_OUTPUT + else + echo "is_full_run=false" >> $GITHUB_OUTPUT + fi + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.10" + - name: Get package shards + id: set-matrix + env: + BUILD_TYPE: presubmit + TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} + TEST_ALL_PACKAGES: ${{ steps.check-label.outputs.is_full_run }} + MAX_SHARDS: ${{ env.MAX_SHARDS }} + PACKAGE_WEIGHTS: ${{ env.PACKAGE_WEIGHTS }} + run: | + if [ -n "$TARGET_BRANCH" ]; then + git fetch origin "$TARGET_BRANCH" --depth=1 || true + fi + python3 ci/get_package_shards.py + unit: + needs: initialize + if: needs.initialize.outputs.matrix != '[]' && needs.initialize.outputs.matrix != '' runs-on: ubuntu-22.04 strategy: fail-fast: true matrix: python: ['3.10', "3.11", "3.12", "3.13", "3.14"] + package_shard: ${{ fromJson(needs.initialize.outputs.matrix) }} + name: ${{ matrix.package_shard.is_sharded && format('unit ({0}, {1})', matrix.python, matrix.package_shard.name) || format('unit ({0})', matrix.python) }} steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -32,30 +99,52 @@ jobs: uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python }} + cache: 'pip' - name: Install nox run: | python -m pip install --upgrade setuptools pip wheel - python -m pip install nox - - name: Run unit tests + python -m pip install nox uv + - name: Run unit tests for ${{ matrix.package_shard.description }} env: - COVERAGE_FILE: ${{ github.workspace }}/.coverage-${{ matrix.python }} BUILD_TYPE: presubmit TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} TEST_TYPE: unit PY_VERSION: ${{ matrix.python }} + PACKAGE_LIST: ${{ matrix.package_shard.packages }} run: | ci/run_conditional_tests.sh - name: Upload coverage results uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: coverage-artifact-${{ matrix.python }} - path: .coverage-${{ matrix.python }} + name: coverage-artifact-${{ matrix.python }}-${{ matrix.package_shard.index }} + path: .coverage.${{ matrix.python }}.* include-hidden-files: true + all-tests: + needs: [initialize, unit] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check unit test results + run: | + # 1. Check initialize job + if [[ "${{ needs.initialize.result }}" != "success" ]]; then + echo "Error: The initialize job status was: ${{ needs.initialize.result }}" + exit 1 + fi + # 2. Check unit test shards + if [[ "${{ needs.unit.result }}" != "success" && "${{ needs.unit.result }}" != "skipped" ]]; then + echo "Unit tests failed" + exit 1 + fi + echo "All unit tests passed or were skipped" + cover: + if: always() && !cancelled() && needs.all-tests.result == 'success' runs-on: ubuntu-latest needs: - - unit + - all-tests + - initialize steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -69,116 +158,43 @@ jobs: uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.10" - - name: Set number of files changes in packages and preview-packages directories + - name: Determine if coverage evaluation is required id: packages + env: + TEST_ALL_PACKAGES: ${{ needs.initialize.outputs.is_full_run }} + TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} run: | - git diff HEAD~1 -- packages preview-packages > /dev/null - num_files_changed=$(git diff HEAD~1 -- packages preview-packages | wc -l | tr -d ' ') - echo "num_files_changed=${num_files_changed}" >> "$GITHUB_OUTPUT" + if [[ "${TEST_ALL_PACKAGES}" == "true" ]]; then + echo "should_evaluate_coverage=true" >> "$GITHUB_OUTPUT" + else + TARGET_BRANCH="${TARGET_BRANCH:-main}" + git fetch origin "$TARGET_BRANCH" --depth=1 || true + num_files_changed=$(git diff --name-only "origin/${TARGET_BRANCH}" -- ${PACKAGE_DIRS} | wc -l | tr -d ' ') + if [[ "${num_files_changed}" -gt 0 ]]; then + echo "should_evaluate_coverage=true" >> "$GITHUB_OUTPUT" + else + echo "should_evaluate_coverage=false" >> "$GITHUB_OUTPUT" + fi + fi - name: Install coverage - if: ${{ steps.packages.outputs.num_files_changed > 0 }} + if: ${{ steps.packages.outputs.should_evaluate_coverage == 'true' }} run: | python -m pip install --upgrade setuptools pip wheel python -m pip install coverage - name: Download coverage results - if: ${{ steps.packages.outputs.num_files_changed > 0 }} + if: ${{ steps.packages.outputs.should_evaluate_coverage == 'true' }} uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: - path: .coverage-results/ + path: /dev/shm/.coverage-results/ + merge-multiple: true - name: Report coverage results - if: ${{ steps.packages.outputs.num_files_changed > 0 }} + if: ${{ steps.packages.outputs.should_evaluate_coverage == 'true' }} env: # TODO: default to 100% coverage after next gapic-generator release # https://github.com/googleapis/google-cloud-python/issues/17459 DEFAULT_FAIL_UNDER: 99 + TEST_ALL_PACKAGES: ${{ needs.initialize.outputs.is_full_run }} + TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }} + BUILD_TYPE: presubmit run: | - # Find all modified packages - modified_packages=$(git diff --name-only HEAD~1 -- packages preview-packages | cut -d/ -f1,2 | sort -u) - - existing_modified_packages=() - for pkg in ${modified_packages}; do - if [ -d "${pkg}" ]; then - existing_modified_packages+=("${pkg}") - fi - done - - if [ ${#existing_modified_packages[@]} -eq 0 ]; then - echo "All modified packages were deleted. Skipping coverage evaluation." - exit 0 - fi - - if [ -d .coverage-results ]; then - # Unzip any zipped coverage results - find .coverage-results -type f -name '*.zip' -exec unzip -o {} \; - - # Find all coverage files and combine them. - # We find files starting with .coverage (excluding .coveragerc files and templates) - coverage_files=$(find .coverage-results . -type f -name '.coverage*' ! -name '.coveragerc*') - if [ -n "${coverage_files}" ]; then - coverage combine ${coverage_files} - else - echo "Error: No coverage files found to combine." - exit 1 - fi - - # Find all modified packages - modified_packages=$(git diff --name-only HEAD~1 -- packages preview-packages | cut -d/ -f1,2 | sort -u) - - failed_packages=() - passed_packages=() - - for pkg in ${modified_packages}; do - if [ -d "${pkg}" ]; then - echo "============================================================" - echo "Evaluating coverage for package: ${pkg}" - echo "============================================================" - - set +e - pushd "${pkg}" > /dev/null - if [ -f ".coveragerc" ]; then - echo "Using package-specific configuration: ${pkg}/.coveragerc" - # If fail_under is specified in the package-specific .coveragerc, coverage report - # will automatically enforce it. Otherwise, we enforce the default. - if grep -q "fail_under" ".coveragerc"; then - COVERAGE_FILE=../../.coverage coverage report --include="$PWD/**" - else - echo "No fail_under specified in ${pkg}/.coveragerc, enforcing default" - COVERAGE_FILE=../../.coverage coverage report --include="$PWD/**" --fail-under="${DEFAULT_FAIL_UNDER}" - fi - else - echo "No .coveragerc found for ${pkg}, enforcing default" - COVERAGE_FILE=../../.coverage coverage report --include="$PWD/**" --fail-under="${DEFAULT_FAIL_UNDER}" - fi - status=$? - popd > /dev/null - set -e - - if [ ${status} -ne 0 ]; then - failed_packages+=("${pkg}") - else - passed_packages+=("${pkg}") - fi - fi - done - - echo "============================================================" - echo "Coverage Evaluation Summary" - echo "============================================================" - if [ ${#passed_packages[@]} -gt 0 ]; then - echo "Passed packages:" - for pkg in "${passed_packages[@]}"; do - echo " - ${pkg}" - done - fi - if [ ${#failed_packages[@]} -gt 0 ]; then - echo "Failed packages:" - for pkg in "${failed_packages[@]}"; do - echo " - ${pkg}" - done - exit 1 - fi - else - echo "Error: No coverage results were downloaded from the unit test jobs." - echo "This usually means the unit tests did not run or failed to upload their coverage files." - exit 1 - fi + ci/report_coverage.sh diff --git a/ci/get_package_shards.py b/ci/get_package_shards.py new file mode 100644 index 000000000000..77cc191cc3ac --- /dev/null +++ b/ci/get_package_shards.py @@ -0,0 +1,233 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Script to group changed packages into balanced shards for CI testing. + +This script identifies which packages have changed compared to a target branch +(or since the last commit) and groups them into a fixed number of shards. +It uses package weights (configured via environment variables) to balance the +execution time across shards while maintaining alphabetical order and +contiguous grouping. All directory variants of a package (e.g. packages/foo and +preview-packages/foo) are kept aligned in the exact same shard. +""" + +import os +import subprocess +import json +import math +import sys +import collections + + +def get_package_directories(): + """Parses package directory roots from the PACKAGE_DIRS environment variable. + + Defaults to ['packages', 'preview-packages'] if not set. + """ + env_dirs = os.environ.get("PACKAGE_DIRS", "") + if env_dirs: + dirs = [d.strip() for d in env_dirs.replace('\n', ' ').split(' ') if d.strip()] + if dirs: + return dirs + return ["packages", "preview-packages"] + + +def get_package_weights(): + """Parses package weights from the PACKAGE_WEIGHTS environment variable. + Package weights represent the relative size of the test, where the weight + approximately represents the minutes the test takes to run. This is used + for distributing work properly across shards. + + The environment variable is expected to be a multiline string where each line + is in the format 'package_name: weight'. Lines starting with '#' are ignored. + + Returns: + dict: A mapping of package names to their integer weights. + """ + weights = {} + env_weights = os.environ.get("PACKAGE_WEIGHTS", "") + for line in env_weights.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if ":" in line: + try: + pkg, weight = line.split(":", 1) + weights[pkg.strip()] = int(weight.strip()) + except ValueError: + continue + return weights + + +def get_packages(): + """Lists all package directory paths in the repository grouped by package name. + + Returns: + dict: A dictionary mapping package_name -> list of relative directory paths. + """ + subdirs = get_package_directories() + packages_map = collections.defaultdict(list) + for subdir in subdirs: + if not os.path.exists(subdir): + continue + for d in os.listdir(subdir): + full_path = os.path.join(subdir, d) + '/' + if os.path.isdir(full_path): + packages_map[d].append(full_path) + return packages_map + + +def get_packages_to_test(): + """Determines the dictionary of package names to directory paths that need to be tested. + + This is based on git diffs against the target branch (presubmit) or the + previous commit (continuous). If TEST_ALL_PACKAGES is set to true, + all packages are returned. + + Returns: + dict: A dictionary mapping package_name -> list of relative directory paths to be tested. + """ + build_type = os.environ.get('BUILD_TYPE', 'presubmit') + target_branch = os.environ.get('TARGET_BRANCH', 'main') + test_all_packages = os.environ.get('TEST_ALL_PACKAGES', 'false').lower() == 'true' + + all_packages = get_packages() + + if test_all_packages: + return all_packages + + if build_type == 'presubmit': + git_diff_arg = f"origin/{target_branch}" + elif build_type == 'continuous': + git_diff_arg = "HEAD~1.." + else: + return all_packages + + try: + res = subprocess.check_output(['git', 'diff', '--name-only', git_diff_arg]).decode('utf-8') + changed_files = res.splitlines() + except subprocess.CalledProcessError: + # If change detection fails, fall back to all packages + return all_packages + + package_dirs = set(get_package_directories()) + to_test_paths = collections.defaultdict(list) + for f in changed_files: + parts = f.split('/') + if len(parts) >= 2 and parts[0] in package_dirs: + pkg_name = parts[1] + full_path = f"{parts[0]}/{parts[1]}/" + if pkg_name in all_packages and full_path in all_packages[pkg_name]: + if full_path not in to_test_paths[pkg_name]: + to_test_paths[pkg_name].append(full_path) + + return dict(to_test_paths) + + +def group_packages(packages_map): + """Groups packages by package name into balanced shards. + + All directory variants of a package (e.g. packages/foo and preview-packages/foo) + are kept together in the exact same shard. + + Args: + packages_map (dict): Dictionary mapping package_name -> list of directory paths. + + Returns: + list: A list of dictionaries, each representing a shard with its name, + index, description, and the space-separated list of packages. + """ + if not packages_map: + return [] + + package_weights_map = get_package_weights() + sorted_pkg_names = sorted(packages_map.keys()) + + pkg_items = [] + total_weight = 0 + for name in sorted_pkg_names: + paths = packages_map[name] + # Multiply base weight by number of directory variants (e.g. standard + preview) + weight = package_weights_map.get(name, 1) * len(paths) + pkg_items.append((name, paths, weight)) + total_weight += weight + + # Dynamically determine target weight to balance across max shards. + max_shards = int(os.environ.get("MAX_SHARDS", 16)) + target_weight = max(10, math.ceil(total_weight / max_shards)) + + shards_list = [] + current_shard_items = [] + current_shard_weight = 0 + + # Pack packages alphabetically by package name. + for name, paths, weight in pkg_items: + # If adding this package would exceed target weight AND we haven't reached the + # shard limit, start a new shard. Otherwise, keep "stuffing" the current one. + if current_shard_items and (current_shard_weight + weight > target_weight) and len(shards_list) < max_shards - 1: + shards_list.append(current_shard_items) + current_shard_items = [(name, paths, weight)] + current_shard_weight = weight + else: + current_shard_items.append((name, paths, weight)) + current_shard_weight += weight + + if current_shard_items: + shards_list.append(current_shard_items) + + # Construct the final shards output list + shards = [] + for i, shard_items in enumerate(shards_list): + index = i + 1 + name = f"Shard {index}" + num_in_shard = len(shard_items) + + # Calculate contiguous range description using package names + first_pkg_name = shard_items[0][0] + last_pkg_name = shard_items[-1][0] + if num_in_shard == 1: + desc = first_pkg_name + else: + desc = f"{first_pkg_name}...{last_pkg_name} ({num_in_shard} packages)" + + all_paths = [] + for _, paths, _ in shard_items: + all_paths.extend(paths) + + shards.append({ + "name": name, + "index": index, + "description": desc, + "packages": " ".join(all_paths), + "is_sharded": True + }) + + # Set is_sharded dynamically based on the total number of shards + total_shards = len(shards) + for shard in shards: + shard["is_sharded"] = total_shards > 1 + + return shards + + +if __name__ == "__main__": + packages = get_packages_to_test() + shards = group_packages(packages) + shards_json = json.dumps(shards) + print(shards_json) + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as f: + f.write(f"matrix={shards_json}\n") diff --git a/ci/report_coverage.sh b/ci/report_coverage.sh new file mode 100755 index 000000000000..f204bc45cdcc --- /dev/null +++ b/ci/report_coverage.sh @@ -0,0 +1,164 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eo pipefail + +# This script generates coverage reports for modified packages. +# It assumes coverage databases have been downloaded to RESULTS_DIR. +# It uses /dev/shm for high-performance processing. + +RESULTS_DIR="${RESULTS_DIR:-/dev/shm/.coverage-results}" +LOG_DIR="/dev/shm/coverage-logs" +DEFAULT_FAIL_UNDER="${DEFAULT_FAIL_UNDER:-99}" +MAX_JOBS=$(nproc) + +mkdir -p "${LOG_DIR}" + +if [ ! -d "${RESULTS_DIR}" ]; then + echo "Error: No coverage results found in ${RESULTS_DIR}." + exit 1 +fi + +# Unzip any zipped coverage results +find "$RESULTS_DIR" -type f -name '*.zip' -print0 | xargs -0 -P "${MAX_JOBS}" -I {} unzip -q -o {} -d "$RESULTS_DIR" + +# Identify modified packages +BUILD_TYPE="${BUILD_TYPE:-presubmit}" +TARGET_BRANCH="${TARGET_BRANCH:-main}" + +PACKAGE_DIRS="${PACKAGE_DIRS:-packages preview-packages}" + +if [[ "${TEST_ALL_PACKAGES}" == "true" ]]; then + # Test all packages mode: evaluate coverage for every package in the repository + modified_packages=$(for dir in ${PACKAGE_DIRS}; do ls -d ${dir}/*/ 2>/dev/null; done | cut -d/ -f1,2 | sort -u) +elif [[ "${BUILD_TYPE}" == "presubmit" ]]; then + # Presubmit build: evaluate coverage only for packages modified relative to the target branch + modified_packages=$(git diff --name-only "origin/${TARGET_BRANCH}" -- ${PACKAGE_DIRS} 2>/dev/null | cut -d/ -f1,2 | sort -u) +else + # Continuous build (post-merge on main): evaluate coverage for packages modified in the last commit + modified_packages=$(git diff --name-only HEAD~1 -- ${PACKAGE_DIRS} 2>/dev/null | cut -d/ -f1,2 | sort -u) +fi + +# Function to report coverage for a single package +report_package_coverage() { + local pkg=$1 + local pkg_name_clean=$(echo "${pkg}" | sed 's|/$||' | sed 's|/|_|g') + local pkg_log="${LOG_DIR}/${pkg_name_clean}.log" + local pkg_status="${LOG_DIR}/${pkg_name_clean}.status" + + # Use /dev/shm for the combined coverage file to reduce disk I/O + local pkg_coverage_db="/dev/shm/.coverage.${pkg_name_clean}" + + set +e + ( + # Find coverage databases belonging specifically to this package + shopt -s nullglob + local pkg_files=( + "${RESULTS_DIR}/.coverage."*"."${pkg_name_clean} + "${RESULTS_DIR}/.coverage."*"."${pkg_name_clean}"."* + "${RESULTS_DIR}"/*/".coverage."*"."${pkg_name_clean} + "${RESULTS_DIR}"/*/".coverage."*"."${pkg_name_clean}"."* + ) + shopt -u nullglob + + # Handle explicit skip via .coveragerc + if [ -f "${pkg}/.coveragerc" ] && grep -Eq "fail_under\s*=\s*0" "${pkg}/.coveragerc"; then + echo "Package has fail_under = 0, passing instantly with 100% success (even without coverage files)" > "${pkg_log}" + echo 0 > "${pkg_status}" + return + fi + + if [ ${#pkg_files[@]} -eq 0 ]; then + echo "Warning: No coverage results found for ${pkg}" > "${pkg_log}" + echo 1 > "${pkg_status}" + return + fi + + pushd "${pkg}" > /dev/null + + # Combine databases + COVERAGE_FILE="${pkg_coverage_db}" coverage combine "${pkg_files[@]}" >> "${pkg_log}" 2>&1 + + # Generate report + if [ -f ".coveragerc" ]; then + echo "Using package-specific configuration: ${pkg}/.coveragerc" >> "${pkg_log}" + if grep -q "fail_under" ".coveragerc"; then + COVERAGE_FILE="${pkg_coverage_db}" coverage report --rcfile=".coveragerc" --include="$PWD/**" >> "${pkg_log}" 2>&1 + else + echo "No fail_under specified in ${pkg}/.coveragerc, enforcing default" >> "${pkg_log}" + COVERAGE_FILE="${pkg_coverage_db}" coverage report --rcfile=".coveragerc" --include="$PWD/**" --fail-under="${DEFAULT_FAIL_UNDER}" >> "${pkg_log}" 2>&1 + fi + else + echo "No .coveragerc found for ${pkg}, enforcing default" >> "${pkg_log}" + COVERAGE_FILE="${pkg_coverage_db}" coverage report --include="$PWD/**" --fail-under="${DEFAULT_FAIL_UNDER}" >> "${pkg_log}" 2>&1 + fi + + echo $? > "${pkg_status}" + popd > /dev/null + ) + set -e +} + +# Run coverage reporting in parallel +for pkg in ${modified_packages}; do + if [ -d "${pkg}" ]; then + # Limit concurrent jobs + while [ $(jobs -r | wc -l) -ge "${MAX_JOBS}" ]; do + sleep 0.1 + done + report_package_coverage "${pkg}" & + fi +done + +wait + +# Summary and exit +failed_packages=() +passed_packages=() + +for pkg in ${modified_packages}; do + if [ -d "${pkg}" ]; then + pkg_name_clean=$(echo "${pkg}" | sed 's|/$||' | sed 's|/|_|g') + pkg_log="${LOG_DIR}/${pkg_name_clean}.log" + pkg_status="${LOG_DIR}/${pkg_name_clean}.status" + + echo "============================================================" + echo "Evaluating coverage for package: ${pkg}" + echo "============================================================" + [ -f "${pkg_log}" ] && cat "${pkg_log}" + + status=$(cat "${pkg_status}" 2>/dev/null || echo 1) + if [ "${status}" -ne 0 ]; then + failed_packages+=("${pkg}") + else + passed_packages+=("${pkg}") + fi + fi +done + +echo "============================================================" +echo "Coverage Evaluation Summary" +echo "============================================================" +if [ ${#passed_packages[@]} -gt 0 ]; then + echo "✅ Passed: ${#passed_packages[@]} packages" +fi + +if [ ${#failed_packages[@]} -gt 0 ]; then + echo "❌ Failed: ${#failed_packages[@]} packages" + for pkg in "${failed_packages[@]}"; do + echo " - ${pkg}" + done + exit 1 +fi diff --git a/ci/run_conditional_tests.sh b/ci/run_conditional_tests.sh index b4df9cb923f4..241de51a4f88 100755 --- a/ci/run_conditional_tests.sh +++ b/ci/run_conditional_tests.sh @@ -47,77 +47,94 @@ git config --global url."${PROJECT_ROOT}".insteadOf "https://github.com/googleap # A script file for running the test in a sub project. test_script="${PROJECT_ROOT}/ci/run_single_test.sh" -if [[ ${BUILD_TYPE} == "presubmit" ]]; then +if [ "${TEST_ALL_PACKAGES}" = "true" ]; then + GIT_DIFF_ARG="" + +elif [[ ${BUILD_TYPE} == "presubmit" ]]; then # For presubmit build, we want to know the difference from the # common commit in the target branch. - GIT_DIFF_ARG="origin/$TARGET_BRANCH..." - - # Then fetch enough history for finding the common commit. - git fetch origin "$TARGET_BRANCH" --deepen=200 + if [ -n "${TARGET_BRANCH}" ]; then + git fetch origin "${TARGET_BRANCH}" --depth=1 || true + fi + GIT_DIFF_ARG="origin/${TARGET_BRANCH}" elif [[ ${BUILD_TYPE} == "continuous" ]]; then # For continuous build, we want to know the difference in the last # commit. This assumes we use squash commit when merging PRs. - GIT_DIFF_ARG="HEAD~.." - - # Then fetch one last commit for getting the diff. - git fetch origin "$TARGET_BRANCH" --deepen=1 + GIT_DIFF_ARG="HEAD~1.." else # Run everything. GIT_DIFF_ARG="" fi -# Then detect changes in the test scripts. - -set +e -git diff --quiet ${GIT_DIFF_ARG} ci -changed=$? -set -e - -# Now we have a fixed list, but we can change it to autodetect if -# necessary. - -subdirs=( - packages - preview-packages -) - -RETVAL=0 - -for subdir in ${subdirs[@]}; do - for d in `ls -d ${subdir}/*/`; do - should_test=false - if [ -n "${GIT_DIFF_ARG}" ]; then - echo "checking changes with 'git diff --quiet ${GIT_DIFF_ARG} ${d}'" - set +e - git diff --quiet ${GIT_DIFF_ARG} ${d} - changed=$? - set -e - if [[ "${changed}" -eq 0 ]]; then - echo "no change detected in ${d}, skipping" +run_test_in_dir() { + local d=$1 + local pkg_name_clean=$(echo ${d} | sed 's|/$||' | sed 's|/|_|g') + local log_file="/tmp/test_log_${PY_VERSION}_${pkg_name_clean}.log" + export COVERAGE_FILE="${PROJECT_ROOT}/.coverage.${PY_VERSION}.${pkg_name_clean}" + + pushd ${d} > /dev/null + set +e + ${test_script} > "${log_file}" 2>&1 + local ret=$? + set -e + popd > /dev/null + + echo "============================================================" + echo "Running tests in ${d}" + echo "============================================================" + cat "${log_file}" + rm -f "${log_file}" + + if [ ${ret} -ne 0 ]; then + exit ${ret} + fi +} +export -f run_test_in_dir +export test_script PROJECT_ROOT PY_VERSION TEST_TYPE + +dirs_to_test=() + +if [ -n "${PACKAGE_LIST}" ]; then + echo "Using provided PACKAGE_LIST" + dirs_to_test=(${PACKAGE_LIST}) +else + subdirs=(${PACKAGE_DIRS:-packages preview-packages}) + + for subdir in ${subdirs[@]}; do + if [ ! -d "${subdir}" ]; then continue; fi + for d in `ls -d ${subdir}/*/ 2>/dev/null`; do + should_test=false + if [ -n "${GIT_DIFF_ARG}" ]; then + set +e + git diff --quiet ${GIT_DIFF_ARG} -- "${d}" + changed=$? + set -e + if [[ "${changed}" -ne 0 ]]; then + should_test=true + fi else - echo "change detected in ${d}" + # If GIT_DIFF_ARG is empty, run all the tests. should_test=true fi - else - # If GIT_DIFF_ARG is empty, run all the tests. - should_test=true - fi - if [ "${should_test}" = true ]; then - echo "running test in ${d}" - pushd ${d} - # Temporarily allow failure. - set +e - ${test_script} - ret=$? - set -e - if [ ${ret} -ne 0 ]; then - RETVAL=${ret} + if [ "${should_test}" = true ]; then + dirs_to_test+=("${d}") fi - popd - fi + done done -done +fi + +if [ ${#dirs_to_test[@]} -eq 0 ]; then + echo "No packages to test." + exit 0 +fi + +PARALLEL_WORKERS="${PARALLEL_WORKERS:-1}" +AVAIL_CORES=$(nproc 2>/dev/null || echo 4) +if [ "${PARALLEL_WORKERS}" -gt "${AVAIL_CORES}" ]; then + PARALLEL_WORKERS="${AVAIL_CORES}" +fi -exit ${RETVAL} +echo "Running tests across ${#dirs_to_test[@]} package(s) using ${PARALLEL_WORKERS} parallel worker(s)..." +printf "%s\0" "${dirs_to_test[@]}" | xargs -0 -P "${PARALLEL_WORKERS}" -I {} bash -c 'run_test_in_dir "$@"' _ {}