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
58 changes: 58 additions & 0 deletions .github/workflows/python-dependency-bounds.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion python/.github/skills/agent-framework-py-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg> --dependency <name>` for that dependency.

If only prereleases changed (no `core` bump, no floor changes), release validation is still required because the
Expand Down
6 changes: 6 additions & 0 deletions python/DEV_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
25 changes: 24 additions & 1 deletion python/scripts/dependencies/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "*"`
Expand Down
92 changes: 92 additions & 0 deletions python/scripts/dependencies/detect_version_bumps.py
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
eavanvalkenburg marked this conversation as resolved.
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()
Loading
Loading