Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci-rust-python-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
6 changes: 4 additions & 2 deletions .github/workflows/release-rust-python-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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 }}"
Expand Down
78 changes: 77 additions & 1 deletion tests/test_plugin_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions tools/plugin_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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}
Expand All @@ -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 {
Expand All @@ -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),
}


Expand Down Expand Up @@ -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",
),
)

Expand Down
7 changes: 7 additions & 0 deletions tools/validate_ci_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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),
}
)
)
Expand Down
Loading