diff --git a/.github/workflows/python-dependency-bounds.yml b/.github/workflows/python-dependency-bounds.yml new file mode 100644 index 00000000000..7670b671f4f --- /dev/null +++ b/.github/workflows/python-dependency-bounds.yml @@ -0,0 +1,58 @@ +name: Python - Dependency Bounds + +on: + pull_request: + branches: ["main"] + merge_group: + workflow_dispatch: + +permissions: + contents: read + +env: + UV_CACHE_DIR: /tmp/.uv-cache + +jobs: + dependency-bounds: + name: Dependency Bounds Validation + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + UV_PYTHON: "3.13" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Test version-bump detection + working-directory: ./python + run: python3 -m unittest discover -s scripts/dependencies/tests -p test_detect_version_bumps.py + - name: Detect new packages and package version changes + id: versions + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "required=true" >> "$GITHUB_OUTPUT" + else + python3 python/scripts/dependencies/detect_version_bumps.py \ + --base-ref "$BASE_SHA" --github-output "$GITHUB_OUTPUT" + fi + - name: Set up python and install the project + if: steps.versions.outputs.required == 'true' + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Run lower and upper dependency tests and typing + if: steps.versions.outputs.required == 'true' + working-directory: ./python + run: uv run --locked --group tool-runtime poe validate-dependency-bounds-test --package "*" + - name: Upload dependency bounds report + if: always() && steps.versions.outputs.required == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dependency-bounds-test-results + path: python/scripts/dependencies/dependency-bounds-test-results.json + if-no-files-found: warn diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index 58993d54787..ef366e7c28c 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -340,7 +340,10 @@ package/bound and re-run before committing. Do not substitute the workspace-wide `validate-dependency-bounds-test` command here. That command runs every package's full tests and Pyright in separate isolated environments and is intentionally reserved for CI or an -explicit dependency-range audit. If the release itself changes an external dependency range, also run +explicit dependency-range audit. The `Dependency Bounds Validation` check in `python-dependency-bounds.yml` runs that +full matrix for version-bump and new-workspace-package PRs and merge groups, while ordinary PRs skip it. +The bounded local release probes remain separate and do not replace that regression coverage. +If the release itself changes an external dependency range, also run `validate-dependency-bounds-project --mode both --package --dependency ` for that dependency. If only prereleases changed (no `core` bump, no floor changes), release validation is still required because the diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index 3420185f149..ec55a0e7a93 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -429,6 +429,12 @@ uv run poe validate-dependency-bounds-test # Defaults to --package "*"; pass a package to scope test mode uv run poe validate-dependency-bounds-test -P core ``` +The `Dependency Bounds Validation` CI check runs this full matrix only when a PR or merge group adds a workspace package +or changes an existing package's `project.version` (including the root package), or on manual workflow dispatch. +Adding an existing package to workspace `members` or removing it from `exclude` also triggers the matrix. +Other metadata and ordinary code edits do not run the matrix. The standalone Lab project is excluded. The check fails on detection or +validation errors, has a 60-minute job timeout, and uploads the validator's JSON report. Requiring this check in repository +rules enforces it at merge time. See [Dependency Scripts](scripts/dependencies/README.md) for the comparison and skip policy. #### `validate-dependency-bounds-project` Validate and extend dependency bounds for a single dependency in a single package. Use `--mode lower`, `--mode upper`, or the default `--mode both`: diff --git a/python/scripts/dependencies/README.md b/python/scripts/dependencies/README.md index d512c9b97e7..01730914890 100644 --- a/python/scripts/dependencies/README.md +++ b/python/scripts/dependencies/README.md @@ -17,6 +17,15 @@ Run the commands below from the `python/` directory. - `test` runs the exhaustive workspace test+typing compatibility matrix. - `lower`, `upper`, and `both` dispatch to the lower/upper optimizer implementations for one package. +- `detect_version_bumps.py` + - Dependency-free CI selector requiring Python 3.11 or newer. + - Compares committed `project.version` values against an explicit base commit for the root package and existing + workspace members, and compares workspace membership at both commits. Newly added package manifests and existing + packages newly included through `members` or `exclude` changes require validation, even without a version change. + - Other metadata edits, deleted packages, and excluded standalone projects do not trigger it. + - Treats any version value change, including a downgrade, as requiring validation. + - Invalid base refs or malformed version metadata fail detection instead of silently skipping the gate. + - `_dependency_bounds_release_impl.py` - Discovers package metadata changed from the selected release base. - Verifies that every Agent Framework integration in `agent-framework-core[all]` has lower and upper bounds, and @@ -78,13 +87,27 @@ uv run poe validate-dependency-bounds-project --mode both --package core --depen - `validate-python-release` is the bounded release gate: it refreshes `uv.lock`, finds changed package metadata, and probes both dependency-bound extremes without reusing the lockfile. - `validate-dependency-bounds-test` runs the exhaustive package test+typing matrix and is intentionally not part of - the routine release path. + the routine local release-preparation path. Version-bump and new-package PRs run it as a blocking CI check. - `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--package` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs. ### GitHub Actions workflows These workflows call the Poe tasks: +- `.github/workflows/python-dependency-bounds.yml` + - `Dependency Bounds Validation` is blocking on PRs and merge groups containing a new workspace package or an existing + package version change; ordinary PRs run only the lightweight selector and its offline regression tests, + without installing the workspace. + - Compares the checked-out synthetic merge commit against the immutable PR `base.sha` or merge-group `base_sha`, + not a moving branch or the previous commit. Manual dispatch explicitly runs the full matrix. + - Runs `uv run --locked --group tool-runtime poe validate-dependency-bounds-test --package "*"` on Python 3.13, + with a 60-minute job timeout and the validator's existing per-command timeouts and first-failure behavior. + - Uploads `dependency-bounds-test-results.json` even on validation failure. + - To enforce this gate at merge time, repository rules must require the `Dependency Bounds Validation` check. + No new package or version change produces a successful no-op check; detector/setup/validation errors fail the check. + The workflow has no PR path filter, so non-Python PRs also receive that successful no-op instead of a pending required check. + - This does not substitute the bounded `validate-python-release` import probes for test-and-typing coverage. + - `.github/workflows/python-dependency-range-validation.yml` - Trigger: `workflow_dispatch` - Runs `uv run poe validate-dependency-bounds-project --mode upper --package "*"` diff --git a/python/scripts/dependencies/detect_version_bumps.py b/python/scripts/dependencies/detect_version_bumps.py new file mode 100644 index 00000000000..e457dfd01db --- /dev/null +++ b/python/scripts/dependencies/detect_version_bumps.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Detect new packages and release version changes without installing the Python workspace (Python 3.11+).""" + +from __future__ import annotations + +import argparse +import fnmatch +import subprocess +import tomllib +from pathlib import Path + + +def _git(repo_root: Path, *args: str) -> str: + result = subprocess.run(["git", *args], cwd=repo_root, check=False, capture_output=True, text=True) + if result.returncode: + raise RuntimeError(f"Git comparison failed: {result.stderr.strip()}") + return result.stdout + + +def _version(content: str, manifest: str) -> str: + version = tomllib.loads(content).get("project", {}).get("version") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"{manifest} must declare a non-empty project.version") + return version + + +def _workspace_manifests(repo_root: Path, ref: str) -> set[str]: + workspace = tomllib.loads(_git(repo_root, "show", f"{ref}:python/pyproject.toml"))["tool"]["uv"]["workspace"] + manifests = {"python/pyproject.toml"} + tracked = _git(repo_root, "ls-tree", "-r", "--name-only", "-z", ref, "--", "python/packages") + for manifest in filter(None, tracked.split("\0")): + relative = Path(manifest).relative_to("python") + if len(relative.parts) != 3 or relative.name != "pyproject.toml": + continue + project_path = relative.parent.as_posix() + if not any(fnmatch.fnmatchcase(project_path, pattern) for pattern in workspace["members"]): + continue + if any(fnmatch.fnmatchcase(project_path, pattern) for pattern in workspace.get("exclude", [])): + continue + manifests.add(manifest) + return manifests + + +def changed_package_versions(repo_root: Path, base_ref: str) -> list[str]: + """Return newly included workspace manifests and those with changed versions.""" + if not base_ref.strip(): + raise ValueError("An explicit PR or merge-group base commit is required") + base_sha = _git(repo_root, "rev-parse", "--verify", "--end-of-options", f"{base_ref}^{{commit}}").strip() + base_manifests = _workspace_manifests(repo_root, base_sha) + head_manifests = _workspace_manifests(repo_root, "HEAD") + newly_included = head_manifests - base_manifests + changed = _git( + repo_root, + "diff", + "--name-only", + "-z", + "--no-renames", + "--diff-filter=AM", + base_sha, + "HEAD", + "--", + "python/pyproject.toml", + "python/packages/*/pyproject.toml", + ) + bumps: list[str] = [] + candidates = (set(changed.split("\0")) & head_manifests) | newly_included + for manifest in sorted(candidates): + current = _version(_git(repo_root, "show", f"HEAD:{manifest}"), manifest) + # A downgrade also runs validation; it must not provide a way to bypass the gate. + if manifest in newly_included or current != _version(_git(repo_root, "show", f"{base_sha}:{manifest}"), manifest): + bumps.append(manifest) + return sorted(bumps) + + +def main() -> None: + """Write the gate decision as a GitHub Actions step output.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-ref", required=True) + parser.add_argument("--github-output", type=Path, required=True) + args = parser.parse_args() + bumps = changed_package_versions(Path(__file__).resolve().parents[3], args.base_ref) + with args.github_output.open("a") as output: + output.write(f"required={'true' if bumps else 'false'}\n") + if bumps: + print("New packages or package version changes require dependency-bounds tests:\n" + "\n".join(bumps)) + else: + print("No new workspace packages or package version changes; dependency-bounds tests are not required.") + + +if __name__ == "__main__": + main() diff --git a/python/scripts/dependencies/tests/test_detect_version_bumps.py b/python/scripts/dependencies/tests/test_detect_version_bumps.py new file mode 100644 index 00000000000..a6b8ddc8baf --- /dev/null +++ b/python/scripts/dependencies/tests/test_detect_version_bumps.py @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Offline regression tests, also runnable with the standard-library unittest runner.""" + +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from scripts.dependencies.detect_version_bumps import changed_package_versions, main + + +class VersionBumpTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.git("init", "--quiet") + self.git("config", "user.email", "test@example.com") + self.git("config", "user.name", "Test") + self.git("config", "commit.gpgsign", "false") + self.write( + "python/pyproject.toml", + '[project]\nname = "agent-framework"\nversion = "1.0.0"\n' + '[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/lab"]\n', + ) + self.write("python/packages/core/pyproject.toml", '[project]\nversion = "1.0.0"\n') + self.write("python/packages/provider/pyproject.toml", '[project]\nversion = "1.0.0b1"\n') + self.write("python/packages/lab/pyproject.toml", '[project]\nversion = "1.0.0a1"\n') + self.commit() + self.base = self.git("rev-parse", "HEAD").strip() + + def git(self, *args: str) -> str: + return subprocess.run(["git", *args], cwd=self.root, check=True, capture_output=True, text=True).stdout + + def write(self, name: str, content: str) -> None: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + def commit(self) -> None: + self.git("add", ".") + self.git("commit", "--quiet", "-m", "Test snapshot") + + def test_unchanged_tree_skips(self) -> None: + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_metadata_dependencies_and_tool_versions_do_not_trigger(self) -> None: + self.write( + "python/packages/core/pyproject.toml", + '[project]\nversion = "1.0.0"\ndescription = "changed"\ndependencies = ["example>=2"]\n' + '[tool.example]\nversion = "9"\n', + ) + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_toml_formatting_does_not_trigger(self) -> None: + self.write("python/packages/core/pyproject.toml", "[project]\nversion='1.0.0' # comment\n") + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_package_version_changes_trigger(self) -> None: + for version in ("1.0.0b2", "1.0.0rc1", "1.0.0", "0.9.0"): + with self.subTest(version=version): + self.write("python/packages/provider/pyproject.toml", f'[project]\nversion = "{version}"\n') + self.commit() + self.assertEqual( + changed_package_versions(self.root, self.base), ["python/packages/provider/pyproject.toml"] + ) + + def test_root_version_change_triggers(self) -> None: + manifest = self.root / "python/pyproject.toml" + manifest.write_text(manifest.read_text().replace('version = "1.0.0"', 'version = "1.1.0"')) + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), ["python/pyproject.toml"]) + + def test_multiple_version_changes_are_reported(self) -> None: + for package in ("core", "provider"): + self.write(f"python/packages/{package}/pyproject.toml", '[project]\nversion = "1.1.0"\n') + self.commit() + self.assertEqual( + changed_package_versions(self.root, self.base), + ["python/packages/core/pyproject.toml", "python/packages/provider/pyproject.toml"], + ) + + def test_excluded_standalone_package_does_not_trigger(self) -> None: + self.write("python/packages/lab/pyproject.toml", '[project]\nversion = "1.0.0a2"\n') + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_new_workspace_package_triggers(self) -> None: + self.write("python/packages/new/pyproject.toml", '[project]\nversion = "1.0.0"\n') + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), ["python/packages/new/pyproject.toml"]) + + def test_deleted_package_does_not_trigger(self) -> None: + (self.root / "python/packages/provider/pyproject.toml").unlink() + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_new_excluded_package_does_not_trigger(self) -> None: + manifest = self.root / "python/pyproject.toml" + manifest.write_text(manifest.read_text().replace('["packages/lab"]', '["packages/lab", "packages/standalone"]')) + self.write("python/packages/standalone/pyproject.toml", '[project]\nversion = "1.0.0"\n') + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_new_nonmember_package_does_not_trigger(self) -> None: + manifest = self.root / "python/pyproject.toml" + manifest.write_text(manifest.read_text().replace('members = ["packages/*"]', 'members = ["packages/core"]')) + self.write("python/packages/new/pyproject.toml", '[project]\nversion = "1.0.0"\n') + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_new_package_without_version_fails_instead_of_skipping(self) -> None: + self.write("python/packages/new/pyproject.toml", '[project]\nname = "new"\n') + self.commit() + with self.assertRaisesRegex(ValueError, "project.version"): + changed_package_versions(self.root, self.base) + + def test_existing_package_added_to_members_triggers(self) -> None: + manifest = self.root / "python/pyproject.toml" + config = manifest.read_text() + manifest.write_text(config.replace('members = ["packages/*"]', 'members = ["packages/core"]')) + self.commit() + base = self.git("rev-parse", "HEAD").strip() + for members in ('["packages/core", "packages/provider"]', '["packages/*"]'): + with self.subTest(members=members): + manifest.write_text(config.replace('members = ["packages/*"]', f"members = {members}")) + self.commit() + self.assertEqual(changed_package_versions(self.root, base), ["python/packages/provider/pyproject.toml"]) + + def test_existing_package_removed_from_exclusions_triggers(self) -> None: + manifest = self.root / "python/pyproject.toml" + manifest.write_text(manifest.read_text().replace('["packages/lab"]', '["packages/lab", "packages/pro*"]')) + self.commit() + base = self.git("rev-parse", "HEAD").strip() + manifest.write_text(manifest.read_text().replace('["packages/lab", "packages/pro*"]', '["packages/lab"]')) + self.commit() + self.assertEqual(changed_package_versions(self.root, base), ["python/packages/provider/pyproject.toml"]) + + def test_removed_workspace_member_does_not_trigger(self) -> None: + manifest = self.root / "python/pyproject.toml" + manifest.write_text(manifest.read_text().replace('members = ["packages/*"]', 'members = ["packages/core"]')) + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_newly_excluded_package_does_not_trigger_even_with_version_change(self) -> None: + manifest = self.root / "python/pyproject.toml" + manifest.write_text(manifest.read_text().replace('["packages/lab"]', '["packages/lab", "packages/provider"]')) + self.write("python/packages/provider/pyproject.toml", '[project]\nversion = "1.1.0"\n') + self.commit() + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_new_and_bumped_packages_are_both_reported(self) -> None: + self.write("python/packages/new/pyproject.toml", '[project]\nversion = "1.0.0"\n') + self.write("python/packages/core/pyproject.toml", '[project]\nversion = "1.1.0"\n') + self.commit() + self.assertEqual( + changed_package_versions(self.root, self.base), + ["python/packages/core/pyproject.toml", "python/packages/new/pyproject.toml"], + ) + + def test_uses_explicit_base_not_previous_commit_or_moving_branch(self) -> None: + self.write("python/packages/core/pyproject.toml", '[project]\nversion = "1.1.0"\n') + self.commit() + self.git("branch", "moving-base") + self.write("python/README.md", "An unrelated second PR commit") + self.commit() + self.git("branch", "--force", "moving-base", "HEAD") + self.assertEqual(changed_package_versions(self.root, self.base), ["python/packages/core/pyproject.toml"]) + self.assertEqual(changed_package_versions(self.root, "moving-base"), []) + + def test_uncommitted_versions_are_not_part_of_the_comparison(self) -> None: + self.write("python/packages/core/pyproject.toml", '[project]\nversion = "1.1.0"\n') + self.assertEqual(changed_package_versions(self.root, self.base), []) + + def test_merge_commit_compares_against_event_base(self) -> None: + self.git("checkout", "--quiet", "-b", "release") + self.write("python/packages/core/pyproject.toml", '[project]\nversion = "1.1.0"\n') + self.commit() + self.git("checkout", "--quiet", "-b", "base", self.base) + self.write("python/packages/provider/pyproject.toml", '[project]\nversion = "1.1.0"\n') + self.commit() + event_base = self.git("rev-parse", "HEAD").strip() + self.git("merge", "--quiet", "--no-ff", "release", "-m", "Synthetic merge") + self.assertEqual(changed_package_versions(self.root, event_base), ["python/packages/core/pyproject.toml"]) + + def test_invalid_or_missing_base_fails_instead_of_skipping(self) -> None: + with self.assertRaises(ValueError): + changed_package_versions(self.root, "") + with self.assertRaisesRegex(RuntimeError, "Git comparison failed"): + changed_package_versions(self.root, "not-a-ref") + + def test_missing_version_fails_instead_of_skipping(self) -> None: + self.write("python/packages/core/pyproject.toml", '[project]\nname = "core"\n') + self.commit() + with self.assertRaisesRegex(ValueError, "project.version"): + changed_package_versions(self.root, self.base) + + def test_invalid_toml_fails_instead_of_skipping(self) -> None: + self.write("python/packages/core/pyproject.toml", '[project\nversion = "1.1.0"\n') + self.commit() + with self.assertRaises(ValueError): + changed_package_versions(self.root, self.base) + + def test_cli_writes_explicit_true_or_false(self) -> None: + output = self.root / "output" + for manifests, expected in (([], "false"), (["python/pyproject.toml"], "true")): + with self.subTest(expected=expected): + output.write_text("existing=value\n") + with ( + patch("sys.argv", ["detect-version-bumps", "--base-ref", self.base, "--github-output", str(output)]), + patch( + "scripts.dependencies.detect_version_bumps.changed_package_versions", return_value=manifests + ), + ): + main() + self.assertEqual(output.read_text(), f"existing=value\nrequired={expected}\n") + + +if __name__ == "__main__": + unittest.main()