diff --git a/.github/actions/01_integration/adjust_bazel_dep_version/action.yml b/.github/actions/01_integration/adjust_bazel_dep_version/action.yml new file mode 100644 index 000000000..5535da46f --- /dev/null +++ b/.github/actions/01_integration/adjust_bazel_dep_version/action.yml @@ -0,0 +1,223 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: "Adjust bazel dependency version" +description: > + Append or update a single_version_override in the specified MODULE.bazel so the provided dependency resolves to the + provided version. + +inputs: + module-file: + description: > + Path to the MODULE.bazel file to modify. + required: true + module-name: + description: > + Name of the dependency to override. + required: true + version: + description: > + Version of the dependency to select. + required: true + retain-patches: + description: > + When converting a matching git_override to single_version_override, retain patch_strip/patches if present. + required: false + default: "true" + +runs: + using: "composite" + steps: + - name: Adjust Bazel dependency version + shell: bash + env: + MODULE_FILE: ${{ inputs.module-file }} + DEPENDENCY: ${{ inputs.module-name }} + VERSION: ${{ inputs.version }} + RETAIN_PATCHES: ${{ inputs.retain-patches }} + run: | + set -euo pipefail + + python3 - <<'PY' + import os + import re + from pathlib import Path + + def resolve_path(raw: str) -> Path: + path = Path(raw) + if path.is_absolute(): + return path + return Path(os.environ["GITHUB_WORKSPACE"]) / path + + module_file = resolve_path(os.environ["MODULE_FILE"]) + dependency = os.environ["DEPENDENCY"] + version = os.environ["VERSION"] + retain_patches = os.environ["RETAIN_PATCHES"].strip().lower() not in {"", "0", "false", "no", "off"} + + text = module_file.read_text(encoding="utf-8") + lines = text.splitlines() + + def find_blocks(block_name: str): + blocks = [] + index = 0 + while index < len(lines): + if not lines[index].lstrip().startswith(f"{block_name}("): + index += 1 + continue + + start = index + end = index + depth = lines[index].count("(") - lines[index].count(")") + + while depth > 0: + end += 1 + if end >= len(lines): + raise SystemExit(f"Unterminated {block_name} block in {module_file}") + depth += lines[end].count("(") - lines[end].count(")") + + blocks.append((start, end)) + index = end + 1 + + return blocks + + def matches_dependency(block_start: int, block_end: int) -> bool: + block_text = "\n".join(lines[block_start : block_end + 1]) + return re.search(rf'\bmodule_name\s*=\s*"{re.escape(dependency)}"\s*(?:,|\))', block_text) is not None + + def extract_assignment_chunk(block_lines, attribute): + for idx, line in enumerate(block_lines): + if not re.match(rf'^\s*{attribute}\s*=', line): + continue + + chunk = [line] + bracket_depth = line.count("[") - line.count("]") + while ( + bracket_depth > 0 or not chunk[-1].strip().endswith(",") + ) and idx + 1 < len(block_lines): + idx += 1 + chunk.append(block_lines[idx]) + bracket_depth += block_lines[idx].count("[") - block_lines[idx].count("]") + + return [f" {entry.lstrip()}" for entry in chunk] + + return [] + + def matches_bazel_dep(block_start: int, block_end: int) -> bool: + block_text = "\n".join(lines[block_start : block_end + 1]) + return re.search(rf'\bname\s*=\s*"{re.escape(dependency)}"\s*(?:,|\))', block_text) is not None + + bazel_dep_blocks = find_blocks("bazel_dep") + has_direct_bazel_dep = any(matches_bazel_dep(start, end) for start, end in bazel_dep_blocks) + + if not has_direct_bazel_dep: + insertion_index = 0 + if bazel_dep_blocks: + insertion_index = bazel_dep_blocks[-1][1] + 1 + else: + module_blocks = find_blocks("module") + if module_blocks: + insertion_index = module_blocks[0][1] + 1 + + if insertion_index > 0 and lines[insertion_index - 1] != "": + lines.insert(insertion_index, "") + insertion_index += 1 + + lines.insert(insertion_index, f'bazel_dep(name = "{dependency}", version = "{version}")') + + if insertion_index + 1 < len(lines) and lines[insertion_index + 1] != "": + lines.insert(insertion_index + 1, "") + + matching_single_blocks = [ + (start, end) + for start, end in find_blocks("single_version_override") + if matches_dependency(start, end) + ] + + matching_git_blocks = [ + (start, end) + for start, end in find_blocks("git_override") + if matches_dependency(start, end) + ] + + if len(matching_single_blocks) > 1: + raise SystemExit( + f"Found multiple single_version_override blocks for dependency '{dependency}' in {module_file}; refusing to guess" + ) + if len(matching_git_blocks) > 1: + raise SystemExit( + f"Found multiple git_override blocks for dependency '{dependency}' in {module_file}; refusing to guess" + ) + if matching_single_blocks and matching_git_blocks: + raise SystemExit( + f"Found both single_version_override and git_override for dependency '{dependency}' in {module_file}; refusing to guess" + ) + + if matching_git_blocks: + start, end = matching_git_blocks[0] + git_block_lines = lines[start : end + 1] + replacement_lines = [ + "single_version_override(", + f' module_name = "{dependency}",', + f' version = "{version}",', + ] + + if retain_patches: + replacement_lines.extend(extract_assignment_chunk(git_block_lines, "patch_strip")) + replacement_lines.extend(extract_assignment_chunk(git_block_lines, "patches")) + + replacement_lines.append(")") + lines[start : end + 1] = replacement_lines + action = "converted git_override" + elif matching_single_blocks: + start, end = matching_single_blocks[0] + block_lines = lines[start : end + 1] + version_line_index = next( + ( + index + for index, line in enumerate(block_lines) + if re.match(r'^\s*version\s*=\s*".*"\s*,?\s*$', line) + ), + None, + ) + + if version_line_index is None: + module_name_index = next( + index + for index, line in enumerate(block_lines) + if re.match(r'^\s*module_name\s*=\s*".*"\s*,?\s*$', line) + ) + indent = re.match(r'^(\s*)', block_lines[module_name_index]).group(1) + block_lines.insert(module_name_index + 1, f'{indent}version = "{version}",') + else: + indent = re.match(r'^(\s*)', block_lines[version_line_index]).group(1) + block_lines[version_line_index] = f'{indent}version = "{version}",' + + lines[start : end + 1] = block_lines + action = "updated single_version_override" + else: + if lines and lines[-1] != "": + lines.append("") + + lines.extend( + [ + "single_version_override(", + f' module_name = "{dependency}",', + f' version = "{version}",', + ")", + ] + ) + action = "appended single_version_override" + + module_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Updated {module_file}: {dependency} -> {version} ({action})") + PY diff --git a/.github/workflows/test_dependency_version_combinations.yml b/.github/workflows/test_dependency_version_combinations.yml new file mode 100644 index 000000000..e0dcf6059 --- /dev/null +++ b/.github/workflows/test_dependency_version_combinations.yml @@ -0,0 +1,290 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: "Test Dependency Version Combinations (Host)" + +on: + schedule: + - cron: "0 0 * * *" # every day at midnight UTC + # TODO used for testing - remove before merge + pull_request: + types: [ opened, reopened, synchronize ] + workflow_dispatch: + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-24.04 + continue-on-error: true + strategy: + fail-fast: false + # Test all combinations of Bazel version and main dependency versions. + # We start with the version that is selected by the module (minimum supported version) + # From there we test every latest patch of every minor release + # + # To safe resources, we disable unsupported versions after creating a ticket. + # Part of the ticket DOD shall be to reenable the version here. + matrix: + bazel_version: [ + "8.5.1", + "8.6.0", + "8.7.0", + # "9.0.2", Bazel version 9+ is currently known to not work #694 + # "9.1.1", Bazel version 9+ is currently known to not work #694 + ] + rules_cc: ["0.2.17", "0.2.21"] + rules_python: [ + "1.8.5", + "1.9.1", + # "2.0.3", Python version 2+ is currently known to not work #695 + # "2.1.0", Python version 2+ is currently known to not work #695 + # "2.2.0", Python version 2+ is currently known to not work #695 + ] + rules_rust: ["0.68.1-score"] + score_baselibs: ["0.2.9"] + permissions: + contents: read + steps: + - uses: actions/checkout@v7.0.0 + + - uses: ./.github/actions/00_infrastructure/prepare_bazel_environment + with: + # We only ever read from disk-cache since this matrix would either confuse or spam the cache + disk-cache: 'build_and_test_host' + cache-mode: 'read-only' + ubuntu-snapshot-mirror-url: ${{ secrets.UBUNTU_SNAPSHOT_MIRROR_URL }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "examples/MODULE.bazel" + module-name: "rules_cc" + version: ${{ matrix.rules_cc }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "MODULE.bazel" + module-name: "rules_cc" + version: ${{ matrix.rules_cc }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "examples/MODULE.bazel" + module-name: "rules_python" + version: ${{ matrix.rules_python }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "MODULE.bazel" + module-name: "rules_python" + version: ${{ matrix.rules_python }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "examples/MODULE.bazel" + module-name: "rules_rust" + version: ${{ matrix.rules_rust }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "MODULE.bazel" + module-name: "rules_rust" + version: ${{ matrix.rules_rust }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "examples/MODULE.bazel" + module-name: "score_baselibs" + version: ${{ matrix.score_baselibs }} + + - uses: ./.github/actions/01_integration/adjust_bazel_dep_version + with: + module-file: "MODULE.bazel" + module-name: "score_baselibs" + version: ${{ matrix.score_baselibs }} + + - name: Print examples/MODULE.bazel + run: cat examples/MODULE.bazel + + - name: Print MODULE.bazel + run: cat MODULE.bazel + + - name: Update lockfile + env: + USE_BAZEL_VERSION: ${{ matrix.bazel_version }} + run: | + bazel mod deps --lockfile_mode=update + cd examples + bazel mod deps --lockfile_mode=update + + - name: Reformat MODULE.bazel to pass formatting check + env: + USE_BAZEL_VERSION: ${{ matrix.bazel_version }} + run: bazel run //:format_Starlark_with_buildifier + + - name: Build module + env: + USE_BAZEL_VERSION: ${{ matrix.bazel_version }} + run: bazel build //... && bazel test //... --build_tests_only + + - name: Build examples + env: + USE_BAZEL_VERSION: ${{ matrix.bazel_version }} + run: cd examples && bazel build //... + + - name: Persist matrix result + if: ${{ always() }} + env: + JOB_STATUS: ${{ job.status }} + MATRIX_INDEX: ${{ strategy.job-index }} + BAZEL_VERSION: ${{ matrix.bazel_version }} + RULES_CC: ${{ matrix.rules_cc }} + RULES_PYTHON: ${{ matrix.rules_python }} + RULES_RUST: ${{ matrix.rules_rust }} + SCORE_BASELIBS: ${{ matrix.score_baselibs }} + run: | + set -euo pipefail + + mkdir -p matrix-results + + python3 - <<'PY' + import json + import os + from pathlib import Path + + data = { + "job_status": os.environ["JOB_STATUS"], + "matrix_index": int(os.environ["MATRIX_INDEX"]), + "bazel_version": os.environ["BAZEL_VERSION"], + "rules_cc": os.environ["RULES_CC"], + "rules_python": os.environ["RULES_PYTHON"], + "rules_rust": os.environ["RULES_RUST"], + "score_baselibs": os.environ["SCORE_BASELIBS"], + } + + output_file = Path("matrix-results") / f"result-{data['matrix_index']}.json" + output_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + print(f"Wrote {output_file}") + PY + + - name: Upload matrix result + if: ${{ always() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: matrix-result-${{ strategy.job-index }} + path: matrix-results/result-${{ strategy.job-index }}.json + if-no-files-found: error + + build-html-report: + name: Build matrix HTML report + runs-on: ubuntu-24.04 + needs: build-and-test + if: ${{ always() }} + permissions: + contents: read + steps: + - name: Download matrix results + uses: actions/download-artifact@v8.0.1 + with: + pattern: matrix-result-* + path: matrix-results + merge-multiple: true + + - name: Generate HTML report + run: | + set -euo pipefail + + python3 - <<'PY' + import html + import json + from pathlib import Path + + results_dir = Path("matrix-results") + result_files = sorted(results_dir.glob("result-*.json")) + + results = [] + for result_file in result_files: + results.append(json.loads(result_file.read_text(encoding="utf-8"))) + + results.sort(key=lambda item: item["matrix_index"]) + + rows = [] + failures = 0 + + for data in results: + status = data["job_status"] + failures += 1 if status != "success" else 0 + rows.append( + "" + f"{data['matrix_index']}" + f"{html.escape(data['bazel_version'])}" + f"{html.escape(data['rules_cc'])}" + f"{html.escape(data['rules_python'])}" + f"{html.escape(data['rules_rust'])}" + f"{html.escape(data['score_baselibs'])}" + f"{html.escape(status)}" + "" + ) + + total = len(rows) + successes = total - failures + + if not rows: + rows_html = 'No matrix result artifacts found.' + else: + rows_html = "\n".join(rows) + + report = f""" + + + + Dependency Matrix Report + + + +

Dependency Version Matrix Report

+

Total: {total} | Success: {successes} | Failed: {failures}

+ + + + + + + + + + + + + + {rows_html} + +
IndexBazelrules_ccrules_pythonrules_rustscore_baselibsStatus
+ + + """ + + Path("dependency-matrix-report.html").write_text(report, encoding="utf-8") + print("Generated dependency-matrix-report.html") + PY + + - name: Upload HTML report + uses: actions/upload-artifact@v7.0.1 + with: + name: dependency-matrix-report + path: dependency-matrix-report.html + if-no-files-found: error diff --git a/examples/.bazelversion b/examples/.bazelversion index 2bf50aaf1..f9c71a52e 100644 --- a/examples/.bazelversion +++ b/examples/.bazelversion @@ -1 +1 @@ -8.3.0 +8.5.1