diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 3c616e86f..030b33c31 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -26,7 +26,36 @@ on: types: [validate-examples] merge_group: jobs: + prepare: + runs-on: ubuntu-latest + env: + CHECKOUT_REPO: ${{ github.repository }} + CHECKOUT_REF: ${{ github.ref }} + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Parse repository_dispatch payload + if: github.event_name == 'repository_dispatch' + run: | + if [ ${{ github.event.client_payload.command }} = "ok-to-test" ]; then + echo "CHECKOUT_REPO=${{ github.event.client_payload.pull_head_repo }}" >> $GITHUB_ENV + echo "CHECKOUT_REF=${{ github.event.client_payload.pull_head_ref }}" >> $GITHUB_ENV + fi + + - name: Check out code + uses: actions/checkout@v6 + with: + repository: ${{ env.CHECKOUT_REPO }} + ref: ${{ env.CHECKOUT_REF }} + + - name: Compute compatibility test matrix + id: set-matrix + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python tools/compute_compat_matrix.py + validate: + needs: prepare runs-on: ubuntu-latest env: CHECKOUT_REPO: ${{ github.repository }} @@ -34,8 +63,7 @@ jobs: strategy: fail-fast: false - matrix: - python_ver: ["3.10", "3.11", "3.12", "3.13", "3.14"] + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: - name: Parse repository_dispatch payload if: github.event_name == 'repository_dispatch' @@ -50,35 +78,6 @@ jobs: with: repository: ${{ env.CHECKOUT_REPO }} ref: ${{ env.CHECKOUT_REF }} - - name: Determine latest Dapr Runtime version - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - MIN_RUNTIME_VERSION="1.18.0" - RUNTIME_VERSION=$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \ - "https://api.github.com/repos/dapr/dapr/releases?per_page=10" | \ - jq -r 'map(select(.prerelease == false)) | sort_by(.created_at) | reverse | .[0].tag_name | ltrimstr("v")') - if [ -z "$RUNTIME_VERSION" ] || [ "$RUNTIME_VERSION" = "null" ]; then - echo "Failed to resolve Dapr Runtime version" && exit 1 - fi - if [ "$(printf '%s\n' "$MIN_RUNTIME_VERSION" "$RUNTIME_VERSION" | sort -V | head -n1)" != "$MIN_RUNTIME_VERSION" ]; then - echo "Resolved runtime version $RUNTIME_VERSION is below minimum $MIN_RUNTIME_VERSION, using minimum instead" - RUNTIME_VERSION="$MIN_RUNTIME_VERSION" - fi - echo "DAPR_RUNTIME_VER=$RUNTIME_VERSION" >> $GITHUB_ENV - echo "Found $RUNTIME_VERSION" - - name: Determine latest Dapr CLI version - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - CLI_VERSION=$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \ - "https://api.github.com/repos/dapr/cli/releases?per_page=10" | \ - jq -r 'map(select(.prerelease == false)) | sort_by(.created_at) | reverse | .[0].tag_name | ltrimstr("v")') - if [ -z "$CLI_VERSION" ] || [ "$CLI_VERSION" = "null" ]; then - echo "Failed to resolve Dapr CLI version" && exit 1 - fi - echo "DAPR_CLI_VER=$CLI_VERSION" >> $GITHUB_ENV - echo "Found $CLI_VERSION" - name: Set up Python ${{ matrix.python_ver }} uses: actions/setup-python@v6 with: @@ -92,9 +91,10 @@ jobs: with: commit: ${{ github.event.inputs.daprcli_commit }} github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Dapr runtime + - name: Set up Dapr runtime ${{ matrix.runtime_version }} uses: dapr/.github/.github/actions/setup-dapr-runtime@main with: + version: ${{ matrix.runtime_version }} commit: ${{ github.event.inputs.daprdapr_commit }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Llama diff --git a/tests/test_compute_compat_matrix.py b/tests/test_compute_compat_matrix.py new file mode 100644 index 000000000..0c7623291 --- /dev/null +++ b/tests/test_compute_compat_matrix.py @@ -0,0 +1,95 @@ +import unittest +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +_MODULE_PATH = Path(__file__).resolve().parent.parent / 'tools' / 'compute_compat_matrix.py' +_spec = spec_from_file_location('compute_compat_matrix', _MODULE_PATH) +if _spec is None or _spec.loader is None: + raise ImportError(f'Cannot load compute_compat_matrix from {_MODULE_PATH}') +compute_compat_matrix = module_from_spec(_spec) +_spec.loader.exec_module(compute_compat_matrix) + +parse_sdk_version = compute_compat_matrix.parse_sdk_version +runtime_minors_from_sdk = compute_compat_matrix.runtime_minors_from_sdk +version_key = compute_compat_matrix.version_key +latest_patch_for_minor = compute_compat_matrix.latest_patch_for_minor +build_matrix = compute_compat_matrix.build_matrix + +SAMPLE_RELEASES = [ + {'tag_name': 'v1.18.0', 'prerelease': False}, + {'tag_name': 'v1.18.0-rc.5', 'prerelease': True}, + {'tag_name': 'v1.18.0-rc.1', 'prerelease': True}, + {'tag_name': 'v1.17.9', 'prerelease': False}, + {'tag_name': 'v1.17.0', 'prerelease': False}, + {'tag_name': 'v1.16.14', 'prerelease': False}, +] + + +class ParseSdkVersionTests(unittest.TestCase): + def test_strips_dev_suffix(self) -> None: + self.assertEqual(parse_sdk_version('1.19.0.dev'), (1, 19)) + + def test_stable_version(self) -> None: + self.assertEqual(parse_sdk_version('1.18.0'), (1, 18)) + + +class RuntimeMinorsFromSdkTests(unittest.TestCase): + def test_three_minors_from_main(self) -> None: + self.assertEqual(runtime_minors_from_sdk(1, 19), ['1.19', '1.18', '1.17']) + + def test_three_minors_from_release_branch(self) -> None: + self.assertEqual(runtime_minors_from_sdk(1, 16), ['1.16', '1.15', '1.14']) + + +class VersionKeyTests(unittest.TestCase): + def test_rc_ordering(self) -> None: + self.assertLess(version_key('1.18.0-rc.1'), version_key('1.18.0-rc.5')) + + def test_stable_ordering(self) -> None: + self.assertLess(version_key('1.17.0'), version_key('1.17.9')) + + +class LatestPatchForMinorTests(unittest.TestCase): + def test_prefers_stable_over_rc(self) -> None: + patch = latest_patch_for_minor(SAMPLE_RELEASES, '1.18') + self.assertEqual(patch, '1.18.0') + + def test_picks_latest_stable_patch(self) -> None: + patch = latest_patch_for_minor(SAMPLE_RELEASES, '1.17') + self.assertEqual(patch, '1.17.9') + + def test_falls_back_to_rc_when_no_stable(self) -> None: + releases = [ + {'tag_name': 'v1.19.0-rc.2', 'prerelease': True}, + {'tag_name': 'v1.19.0-rc.5', 'prerelease': True}, + ] + patch = latest_patch_for_minor(releases, '1.19') + self.assertEqual(patch, '1.19.0-rc.5') + + def test_returns_none_when_missing(self) -> None: + self.assertIsNone(latest_patch_for_minor(SAMPLE_RELEASES, '1.15')) + + +class BuildMatrixTests(unittest.TestCase): + def test_builds_python_by_runtime_jobs(self) -> None: + matrix = build_matrix('1.18.0', SAMPLE_RELEASES, python_versions=('3.10', '3.11')) + + self.assertEqual(len(matrix['include']), 6) + self.assertEqual( + matrix['include'][0], + {'python_ver': '3.10', 'runtime_version': '1.18.0'}, + ) + + def test_skips_unresolved_runtime_minor(self) -> None: + matrix = build_matrix('1.19.0.dev', SAMPLE_RELEASES, python_versions=('3.10',)) + + runtime_versions = {entry['runtime_version'] for entry in matrix['include']} + self.assertEqual(runtime_versions, {'1.18.0', '1.17.9'}) + + def test_raises_when_no_runtime_resolves(self) -> None: + with self.assertRaises(ValueError): + build_matrix('1.20.0.dev', [], python_versions=('3.10',)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/compute_compat_matrix.py b/tools/compute_compat_matrix.py new file mode 100644 index 000000000..16f261a43 --- /dev/null +++ b/tools/compute_compat_matrix.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Build a GitHub Actions matrix for Dapr runtime compatibility testing. + +Derives runtime minors N, N-1, N-2 from the SDK VERSION file, resolves the +latest patch (or RC) per minor from dapr/dapr GitHub releases, and emits a +matrix of Python version × runtime version pairs for CI. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +DAPR_RELEASES_URL = 'https://api.github.com/repos/dapr/dapr/releases?per_page=100' +DEFAULT_PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13', '3.14') +COMPAT_RUNTIME_COUNT = 3 +DEFAULT_VERSION_FILE = Path('VERSION') +GITHUB_OUTPUT_ENV = 'GITHUB_OUTPUT' +GITHUB_TOKEN_ENV = 'GITHUB_TOKEN' +MATRIX_OUTPUT_KEY = 'matrix' + + +def parse_sdk_version(sdk_version: str) -> tuple[int, int]: + """Return major and minor integers from an SDK version string.""" + base_version = sdk_version.split('.dev', maxsplit=1)[0] + major_text, minor_text, *_ = base_version.split('.') + return int(major_text), int(minor_text) + + +def runtime_minors_from_sdk(major: int, minor: int, count: int = COMPAT_RUNTIME_COUNT) -> list[str]: + """Return Dapr runtime minor strings for N .. N-(count-1).""" + return [f'{major}.{minor - offset}' for offset in range(count)] + + +def version_key(version: str) -> tuple[int, ...]: + """Sort key for Dapr runtime version strings including RC suffixes.""" + base_version, _, suffix = version.partition('-') + parts = tuple(int(part) for part in base_version.split('.')) + if suffix.startswith('rc.'): + return parts + (int(suffix.removeprefix('rc.')),) + return parts + + +def latest_patch_for_minor(releases: list[dict[str, Any]], runtime_minor: str) -> str | None: + """Return the latest stable or RC patch version for a runtime minor.""" + prefix = f'{runtime_minor}.' + for prerelease in (False, True): + versions = [ + release['tag_name'].removeprefix('v') + for release in releases + if release.get('prerelease') == prerelease + and release['tag_name'].removeprefix('v').startswith(prefix) + ] + if versions: + return sorted(versions, key=version_key)[-1] + return None + + +def build_matrix( + sdk_version: str, + releases: list[dict[str, Any]], + python_versions: tuple[str, ...] = DEFAULT_PYTHON_VERSIONS, +) -> dict[str, list[dict[str, str]]]: + """Build the GitHub Actions strategy matrix for compatibility testing.""" + major, minor = parse_sdk_version(sdk_version) + runtime_minors = runtime_minors_from_sdk(major, minor) + matrix_include: list[dict[str, str]] = [] + + for runtime_minor in runtime_minors: + runtime_version = latest_patch_for_minor(releases, runtime_minor) + if runtime_version is None: + print(f'Warning: no Dapr runtime release found for {runtime_minor}, skipping') + continue + for python_version in python_versions: + matrix_include.append( + { + 'python_ver': python_version, + 'runtime_version': runtime_version, + } + ) + + if not matrix_include: + raise ValueError('No Dapr runtime releases found for compatibility matrix') + + return {'include': matrix_include} + + +def fetch_dapr_releases( + github_token: str | None, timeout_seconds: float = 30.0 +) -> list[dict[str, Any]]: + """Fetch release metadata from the dapr/dapr GitHub repository.""" + headers: dict[str, str] = {} + if github_token: + headers['Authorization'] = f'Bearer {github_token}' + + request = urllib.request.Request(DAPR_RELEASES_URL, headers=headers) + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + payload = json.load(response) + + if not isinstance(payload, list): + raise ValueError('Unexpected GitHub API response for dapr/dapr releases') + + return payload + + +def read_sdk_version(version_file: Path) -> str: + """Read and strip the SDK version from VERSION.""" + return version_file.read_text(encoding='utf-8').strip() + + +def write_github_output( + matrix: dict[str, list[dict[str, str]]], output_key: str = MATRIX_OUTPUT_KEY +) -> None: + """Append matrix JSON to the GitHub Actions output file.""" + output_path = os.environ.get(GITHUB_OUTPUT_ENV) + if not output_path: + return + + with open(output_path, 'a', encoding='utf-8') as output_file: + output_file.write(f'{output_key}={json.dumps(matrix)}\n') + + +def main(argv: list[str] | None = None) -> int: + """Compute the compatibility matrix and print or write CI outputs.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--version-file', + type=Path, + default=DEFAULT_VERSION_FILE, + help='Path to the SDK VERSION file (default: VERSION)', + ) + parser.add_argument( + '--version', + help='SDK version string (overrides --version-file)', + ) + args = parser.parse_args(argv) + + sdk_version = args.version if args.version else read_sdk_version(args.version_file) + github_token = os.environ.get(GITHUB_TOKEN_ENV) + + try: + releases = fetch_dapr_releases(github_token) + except urllib.error.URLError as err: + print(f'Failed to fetch Dapr releases: {err}', file=sys.stderr) + return 1 + + try: + matrix = build_matrix(sdk_version, releases) + except ValueError as err: + print(str(err), file=sys.stderr) + return 1 + + major, minor = parse_sdk_version(sdk_version) + runtime_minors = runtime_minors_from_sdk(major, minor) + print(f'SDK version: {sdk_version}') + print(f'Runtime minors: {runtime_minors}') + print(f'Matrix ({len(matrix["include"])} jobs): {json.dumps(matrix)}') + + write_github_output(matrix) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())