diff --git a/.github/workflows/ci-rust-python-package.yaml b/.github/workflows/ci-rust-python-package.yaml index a4f9090..91f27ac 100644 --- a/.github/workflows/ci-rust-python-package.yaml +++ b/.github/workflows/ci-rust-python-package.yaml @@ -58,6 +58,8 @@ jobs: mutation_cargo_packages: ${{ steps.detect.outputs.mutation_cargo_packages }} mutation_jobs: ${{ steps.detect.outputs.mutation_jobs }} has_mutation_cargo_packages: ${{ steps.detect.outputs.has_mutation_cargo_packages }} + release_validation_tags: ${{ steps.detect.outputs.release_validation_tags }} + has_release_validation_tags: ${{ steps.detect.outputs.has_release_validation_tags }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: @@ -89,6 +91,8 @@ jobs: mutation_cargo_packages="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["mutation_cargo_packages"]))')" mutation_jobs="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["mutation_jobs"]))')" has_mutation_cargo_packages="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_mutation_cargo_packages"]).lower())')" + release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["release_validation_tags"]))')" + has_release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_release_validation_tags"]).lower())')" if [[ "${has_plugins}" == "false" ]]; then has_plugins_output="false" else @@ -103,6 +107,8 @@ jobs: echo "mutation_cargo_packages=${mutation_cargo_packages}" echo "mutation_jobs=${mutation_jobs}" echo "has_mutation_cargo_packages=${has_mutation_cargo_packages_output}" + echo "release_validation_tags=${release_validation_tags}" + echo "has_release_validation_tags=${has_release_validation_tags}" } >> "$GITHUB_OUTPUT" build-test: @@ -360,14 +366,18 @@ jobs: cargo doc "${cargo_args[@]}" --lib --no-deps --document-private-items release-validation: - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_release_validation_tags == 'true' needs: validate-and-detect + strategy: + fail-fast: false + matrix: + tag: ${{ fromJson(needs.validate-and-detect.outputs.release_validation_tags) }} permissions: contents: read pull-requests: read id-token: write uses: ./.github/workflows/release-rust-python-package.yaml with: - tag: retry-with-backoff-v0.2.0 + tag: ${{ matrix.tag }} repository: testpypi publish_enabled: false diff --git a/.github/workflows/release-rust-python-package.yaml b/.github/workflows/release-rust-python-package.yaml index 6491a3a..1d2ba16 100644 --- a/.github/workflows/release-rust-python-package.yaml +++ b/.github/workflows/release-rust-python-package.yaml @@ -213,7 +213,8 @@ jobs: if [[ ! -f "${venv_python}" ]]; then venv_python="${tmpdir}/venv/Scripts/python.exe" fi - "${venv_python}" -m pip install dist/*.whl pytest pytest-asyncio PyYAML + uv pip install --python "${venv_python}" --group dev PyYAML + "${venv_python}" -m pip install dist/*.whl if [[ -d "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" ]]; then mkdir -p "${tmpdir}/tests" cp -R "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" "${tmpdir}/tests/${{ needs.resolve.outputs.slug }}" @@ -265,7 +266,8 @@ jobs: if [[ ! -f "${venv_python}" ]]; then venv_python="${tmpdir}/venv/Scripts/python.exe" fi - "${venv_python}" -m pip install dist/*.tar.gz pytest pytest-asyncio PyYAML + uv pip install --python "${venv_python}" --group dev PyYAML + "${venv_python}" -m pip install dist/*.tar.gz if [[ -d "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" ]]; then mkdir -p "${tmpdir}/tests" cp -R "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" "${tmpdir}/tests/${{ needs.resolve.outputs.slug }}" diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index 8070af5..56d33f3 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -1537,9 +1537,48 @@ def test_ci_selection_returns_has_plugins_contract(self) -> None: "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) + def test_ci_selection_detects_plugin_version_bump_for_release_validation(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + git = lambda *args: subprocess.run( # noqa: E731 + ["git", *args], + cwd=root, + text=True, + capture_output=True, + check=True, + ) + git("init") + git("config", "user.name", "Test User") + git("config", "user.email", "test@example.com") + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["plugins/rust/python-package/rate_limiter", "plugins/rust/python-package/pii_filter"]\n' + ) + rate_limiter = self._create_plugin(root, "rate_limiter") + self._create_plugin(root, "pii_filter") + git("add", ".") + git("commit", "--no-verify", "-m", "seed layout") + base_sha = git("rev-parse", "HEAD").stdout.strip() + + (rate_limiter / "Cargo.toml").write_text( + '[package]\nname = "rate_limiter"\nversion = "0.0.2"\nrepository = "https://github.com/IBM/cpex-plugins"\n' + ) + (rate_limiter / "cpex_rate_limiter" / "plugin-manifest.yaml").write_text( + 'description: "rate_limiter"\nauthor: "ContextForge Team"\nversion: "0.0.2"\nkind: "cpex_rate_limiter.rate_limiter.RateLimiterPlugin"\navailable_hooks:\n - "tool_pre_invoke"\n' + ) + git("add", ".") + git("commit", "--no-verify", "-m", "bump rate limiter") + + result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["release_validation_tags"], ["rate-limiter-v0.0.2"]) + self.assertTrue(payload["has_release_validation_tags"]) + def test_ci_selection_treats_catalog_test_change_as_not_shared(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -1581,6 +1620,8 @@ def test_ci_selection_treats_catalog_test_change_as_not_shared(self) -> None: "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -1625,6 +1666,8 @@ def test_ci_selection_treats_shared_tool_changes_as_all_plugins(self) -> None: "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -1671,6 +1714,8 @@ def test_ci_selection_treats_tooling_config_changes_as_all_plugins(self) -> None "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -1749,6 +1794,8 @@ def test_ci_selection_treats_cargo_lock_change_as_all_plugins(self) -> None: "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -1792,6 +1839,8 @@ def test_ci_selection_treats_deny_config_change_as_all_plugins(self) -> None: "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -1837,6 +1886,8 @@ def test_changed_returns_plugin_for_plugin_integration_test_change(self) -> None "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -1882,6 +1933,8 @@ def test_ci_selection_treats_shared_plugin_tests_change_as_all_plugins(self) -> "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -1937,6 +1990,8 @@ def test_ci_selection_treats_shared_crate_changes_as_all_plugins(self) -> None: "test_packages": ["rate_limiter"], } ], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -2177,6 +2232,8 @@ def test_ci_selection_reports_cargo_packages_for_single_plugin_diff(self) -> Non "mutation_cargo_packages": [], "has_mutation_cargo_packages": False, "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -2225,6 +2282,8 @@ def test_ci_selection_reports_mutation_package_for_single_rust_diff(self) -> Non "mutation_jobs": [ {"cargo_package": "rate_limiter", "in_diff": True, "test_packages": []} ], + "release_validation_tags": [], + "has_release_validation_tags": False, }, ) @@ -2271,6 +2330,14 @@ def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), "true") + result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "release_validation_tags") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), []) + + result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "has_release_validation_tags") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "false") + def test_ci_selection_field_supports_diff_mode(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -2498,7 +2565,7 @@ def test_ci_workflow_uses_make_targets_for_plugin_checks(self) -> None: self.assertIn("working-directory: plugins/rust/python-package/${{ matrix.plugin }}", workflow) self.assertIn("release-validation:", workflow) self.assertIn("uses: ./.github/workflows/release-rust-python-package.yaml", workflow) - self.assertIn("tag: retry-with-backoff-v0.2.0", workflow) + self.assertIn("tag: ${{ matrix.tag }}", workflow) self.assertIn("repository: testpypi", workflow) self.assertIn("publish_enabled: false", workflow) self.assertNotIn("tools/plugin_catalog.py ci-selection-field", workflow) @@ -2574,6 +2641,9 @@ def test_ci_workflow_includes_parity_jobs_for_rust_plugin_checks(self) -> None: documentation_section = self._extract_workflow_job_section( workflow, "documentation" ) + release_validation_section = self._extract_workflow_job_section( + workflow, "release-validation" + ) detect_run = self._extract_workflow_step_run( workflow, "validate-and-detect", step_id="detect" ) @@ -2621,6 +2691,8 @@ def test_ci_workflow_includes_parity_jobs_for_rust_plugin_checks(self) -> None: self.assertIn("mutation_cargo_packages: ${{ steps.detect.outputs.mutation_cargo_packages }}", workflow) self.assertIn("mutation_jobs: ${{ steps.detect.outputs.mutation_jobs }}", workflow) self.assertIn("has_mutation_cargo_packages: ${{ steps.detect.outputs.has_mutation_cargo_packages }}", workflow) + self.assertIn("release_validation_tags: ${{ steps.detect.outputs.release_validation_tags }}", workflow) + self.assertIn("has_release_validation_tags: ${{ steps.detect.outputs.has_release_validation_tags }}", workflow) self.assertIn("security-policy:", workflow) self.assertIn("mutation-testing:", workflow) self.assertIn("coverage:", workflow) @@ -2630,6 +2702,8 @@ def test_ci_workflow_includes_parity_jobs_for_rust_plugin_checks(self) -> None: self.assertIn("if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_mutation_cargo_packages == 'true'", mutants_section) self.assertIn("if: needs.validate-and-detect.outputs.has_plugins == 'true'", coverage_section) self.assertIn("if: needs.validate-and-detect.outputs.has_plugins == 'true'", documentation_section) + self.assertIn("if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_release_validation_tags == 'true'", release_validation_section) + self.assertIn("tag: ${{ fromJson(needs.validate-and-detect.outputs.release_validation_tags) }}", release_validation_section) self.assertNotIn("cargo-audit", security_section) self.assertNotIn("cargo audit", security_section) self.assertIn("cargo install cargo-deny", security_section) @@ -3259,7 +3333,9 @@ def test_release_workflow_tests_artifacts_outside_source_tree(self) -> None: 'venv_python="${tmpdir}/venv/Scripts/python.exe"', workflow, ) + self.assertIn('uv pip install --python "${venv_python}" --group dev PyYAML', workflow) self.assertIn('"${venv_python}" -m pip install', workflow) + self.assertNotIn('pytest pytest-asyncio PyYAML', workflow) self.assertIn('"${venv_python}" -m pytest', workflow) self.assertIn( "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd", diff --git a/tools/plugin_catalog.py b/tools/plugin_catalog.py index d663431..722b659 100644 --- a/tools/plugin_catalog.py +++ b/tools/plugin_catalog.py @@ -486,6 +486,31 @@ def _git_changed_paths(root: Path, base: str, head: str) -> list[str]: return [line for line in completed.stdout.splitlines() if line.strip()] +def _git_file_text(root: Path, revision: str, path: str) -> str | None: + completed = subprocess.run( + ["git", "show", f"{revision}:{path}"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + return None + return completed.stdout + + +def _cargo_version_from_text(text: str) -> str | None: + try: + payload = tomllib.loads(text) + except tomllib.TOMLDecodeError: + return None + package = payload.get("package", {}) + if not isinstance(package, dict): + return None + version = package.get("version") + return version if isinstance(version, str) else None + + def changed_plugins(root: Path, base: str, head: str) -> list[str]: plugins = discover_plugins(root) return _changed_plugins_for_records(root, plugins, _git_changed_paths(root, base, head)) @@ -563,6 +588,21 @@ def add_job(cargo_package: str, *, in_diff: bool, test_packages: list[str] | Non return [jobs[key] for key in sorted(jobs)] +def _release_validation_tags_for_records( + root: Path, plugins: list[PluginRecord], changed_paths: list[str], base: str +) -> list[str]: + tags: list[str] = [] + for plugin in plugins: + cargo_path = f"{plugin.path}/Cargo.toml" + if cargo_path not in changed_paths: + continue + old_text = _git_file_text(root, base, cargo_path) + old_version = _cargo_version_from_text(old_text) if old_text is not None else None + if old_version is not None and old_version != plugin.version: + tags.append(f"{plugin.slug.replace('_', '-')}-v{plugin.version}") + return sorted(tags) + + def ci_selection(root: Path, mode: str, base: str | None = None, head: str | None = None) -> dict: plugins = discover_plugins(root) plugin_lookup = {plugin.slug: plugin for plugin in plugins} @@ -576,12 +616,16 @@ def ci_selection(root: Path, mode: str, base: str | None = None, head: str | Non } for slug in selected ] + release_validation_tags: list[str] = [] else: if base is None or head is None: raise CatalogError("ci-selection diff mode requires base and head revisions") changed_paths = _git_changed_paths(root, base, head) selected = _changed_plugins_for_records(root, plugins, changed_paths) mutation_jobs = _mutation_jobs_for_records(root, plugins, changed_paths) + release_validation_tags = _release_validation_tags_for_records( + root, plugins, changed_paths, base + ) cargo_packages = [plugin_lookup[slug].cargo_package_name for slug in selected] mutation_cargo_packages = [str(job["cargo_package"]) for job in mutation_jobs] return { @@ -592,6 +636,8 @@ def ci_selection(root: Path, mode: str, base: str | None = None, head: str | Non "mutation_cargo_packages": mutation_cargo_packages, "has_mutation_cargo_packages": bool(mutation_cargo_packages), "mutation_jobs": mutation_jobs, + "release_validation_tags": release_validation_tags, + "has_release_validation_tags": bool(release_validation_tags), } @@ -840,6 +886,8 @@ def build_parser() -> argparse.ArgumentParser: "mutation_cargo_packages", "has_mutation_cargo_packages", "mutation_jobs", + "release_validation_tags", + "has_release_validation_tags", ), ) diff --git a/tools/validate_ci_selection.py b/tools/validate_ci_selection.py index 63d7387..7bf82c3 100644 --- a/tools/validate_ci_selection.py +++ b/tools/validate_ci_selection.py @@ -48,9 +48,14 @@ def main() -> int: payload.get("mutation_cargo_packages"), "mutation_cargo_packages" ) mutation_jobs = _assert_mutation_jobs(payload.get("mutation_jobs")) + release_validation_tags = payload.get("release_validation_tags") has_plugins = payload.get("has_plugins") plugin_count = payload.get("plugin_count") + if not isinstance(release_validation_tags, list) or any( + not isinstance(item, str) for item in release_validation_tags + ): + raise AssertionError("release_validation_tags must be a string list") if not isinstance(has_plugins, bool): raise AssertionError("has_plugins must be bool") if not isinstance(plugin_count, int) or plugin_count != len(plugins): @@ -66,6 +71,8 @@ def main() -> int: "mutation_cargo_packages": mutation_cargo_packages, "mutation_jobs": mutation_jobs, "has_mutation_cargo_packages": bool(mutation_cargo_packages), + "release_validation_tags": release_validation_tags, + "has_release_validation_tags": bool(release_validation_tags), } ) )