diff --git a/.github/workflows/dependency-policy-gate.yaml b/.github/workflows/dependency-policy-gate.yaml new file mode 100644 index 000000000..30d43aa49 --- /dev/null +++ b/.github/workflows/dependency-policy-gate.yaml @@ -0,0 +1,21 @@ +name: Dependency Policy Gate + +on: + pull_request: + push: + +jobs: + dependency-policy-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install policy check dependencies + run: python3 -m pip install packaging 'tomli; python_version<"3.11"' + - name: Check runtime dependency policy + run: python3 scripts/check_dependency_e2e.py policy diff --git a/.github/workflows/publish-tag-to-pypi.yaml b/.github/workflows/publish-tag-to-pypi.yaml index 89a782d6f..3a39b62f6 100644 --- a/.github/workflows/publish-tag-to-pypi.yaml +++ b/.github/workflows/publish-tag-to-pypi.yaml @@ -22,11 +22,13 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.x" - - name: Install build tool - run: python3 -m pip install build --user --upgrade + python-version: "3.10" + - name: Install build tools + run: python3 -m pip install build packaging 'tomli; python_version<"3.11"' --user --upgrade - name: Build wheel and source distribution run: python3 -m build + - name: Check runtime dependency policy + run: python3 scripts/check_dependency_e2e.py policy - name: Verify native AgentKit CLI archives stay outside Python distributions run: | python3 - <<'PY' @@ -67,6 +69,12 @@ jobs: echo "Distribution exceeds PyPI's 100 MiB limit: ${oversized_file} (${bytes} bytes)" exit 1 fi + - name: Verify clean wheel install + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + run: python3 scripts/check_dependency_e2e.py clean-install + - name: Verify high-risk dependency upgrades against runtime pins + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + run: python3 scripts/check_dependency_e2e.py declared-range - name: Upload distributables as artifact uses: actions/upload-artifact@v4 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cd97794a..b27493867 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,6 +16,12 @@ repos: - id: gitleaks - repo: local hooks: + - id: dependency-policy-gate + name: Check runtime dependency policy + entry: python3 scripts/check_dependency_e2e.py policy + language: system + pass_filenames: false + files: ^pyproject\.toml$ - id: yaml-secret-scan name: Detect concrete secrets in YAML entry: scripts/scan_yaml_secrets.py diff --git a/AGENTS.md b/AGENTS.md index 71e4f2758..c6cf3afe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,16 @@ Pure documentation, translation, or formatting changes that preserve runtime beh - Keep real credentials, signed URLs, personal data, and unredacted production logs out of source, design documents, reports, test fixtures, and generated artifacts. - Isolate tests from real user state and external services by default. Real cloud/provider operations need explicit authorization and an isolated smoke/E2E procedure; report simulated and live verification separately. +## Dependency metadata guidelines + +- Do not treat `uv.lock` success as proof that published package metadata is installable. PyPI users resolve from `pyproject.toml` metadata, not this repository's lock file. +- When changing runtime dependencies in `pyproject.toml`, consider both direct use and shared transitive constraints from key upstream packages such as `google-adk`, `agentkit-sdk-python`, OpenTelemetry, `mcp`, `fastmcp`, `litellm`, and storage/cloud SDKs. +- High-risk upstream dependencies must declare an explicit tested upper bound. Do not leave them open-ended when an unadapted future version could break installation or runtime behavior. +- Avoid exact pins for shared base dependencies unless there is a concrete compatibility reason. If an exact pin is necessary, document the reason in the dependency comment or the change PRD. +- Before widening a high-risk dependency range, check whether the highest allowed version still resolves with the current runtime pins. Use `scripts/check_dependency_e2e.py declared-range` or the release workflow's manual dispatch when the check is too heavy for ordinary PR iteration. +- If a dependency upgrade only works by relying on another package's current transitive dependency, declare the required direct dependency explicitly in `pyproject.toml` rather than depending on accidental resolver behavior. +- If a heavy dependency E2E check is intentionally not run before merge, record the risk and the expected tag/manual workflow coverage in the PRD or PR description. + ## Verification gates Use the repository-local Python environment and current `pyproject.toml`, `pytest.ini`, package scripts, and CI workflows as command references. Do not invent `make` targets, copy another repository's coverage thresholds, or treat an unavailable check as passing. diff --git a/prd-spec/bugfixes/dependency-e2e-release-gate/2026-10-13-dependency-e2e-release-gate.md b/prd-spec/bugfixes/dependency-e2e-release-gate/2026-10-13-dependency-e2e-release-gate.md new file mode 100644 index 000000000..8f1d3e3ca --- /dev/null +++ b/prd-spec/bugfixes/dependency-e2e-release-gate/2026-10-13-dependency-e2e-release-gate.md @@ -0,0 +1,95 @@ +# Dependency E2E Release Gate + +[中文版](2026-10-13-dependency-e2e-release-gate.zh.md) + +| Field | Value | +| --- | --- | +| Change ID | `dependency-e2e-release-gate` | +| Created | 2026-10-13 | +| Status | approved | +| Component specs | No component contract impact; this change only adds release-time validation for package metadata and dependency resolution. | + +## Background + +The dependency incident showed that local `uv.lock` success is not enough for PyPI users. VeADK can publish metadata that lets pip resolve an unsupported upstream version, or pin a shared base dependency so tightly that a compatible upstream stack cannot be installed. + +## Goals and non-goals + +| Type | Scope | +| --- | --- | +| Goal | Block PyPI publication when the built wheel cannot be installed in a clean environment. | +| Goal | Expose high-risk direct dependency upgrades that conflict with current runtime pins or other package metadata. | +| Goal | Reject high-risk unbounded runtime dependencies and exact pins for shared OpenTelemetry packages. | +| Non-goal | Exhaustively test every optional extra, Studio/docker/sandbox runtime, or real cloud service. | + +## Requirements + +| ID | Requirement | +| --- | --- | +| FR-1 | The tag publish workflow must install the built wheel in a clean venv, run `pip check`, and run a minimal import smoke before uploading to PyPI. | +| FR-2 | The tag publish workflow must test selected high-risk base runtime direct dependencies at their highest currently available versions inside VeADK's declared ranges, together with the built wheel's current runtime pins. | +| FR-3 | The policy check must fail for unbounded `google-adk` and exact `opentelemetry-*` runtime pins. | +| FR-4 | The checks must run on the release artifact, not the developer `uv.lock` environment. | + +## Design + +| Part | Decision | +| --- | --- | +| Script | Add `scripts/check_dependency_e2e.py` with `policy`, `clean-install`, and `declared-range` commands. | +| Workflow | Expose a standalone `dependency-policy-gate` on PRs and pushes; run clean install and declared-range checks only for tag publication or manual workflow dispatch. | +| Dependency scope | Check selected high-risk base dependencies: `google-adk`, `agentkit-sdk-python`, OpenTelemetry packages, `litellm`, `mcp`, `fastmcp`, `tos`, and `jsonschema`; each candidate upgrade is resolved against the full wheel metadata, including exact runtime pins. Optional extras are deferred. | +| Developer guidance | Add repository-level dependency metadata guidelines so developers and coding agents avoid unbounded high-risk dependencies, unjustified shared base pins, and `uv.lock`-only validation before the heavier release E2E runs. | +| Runtime smoke | Import `veadk`, `google.adk`, and `opentelemetry` only; no credentials or external service calls. | + +## Tasks + +| ID | Work | Requirement | +| --- | --- | --- | +| T-1 | Add the dependency E2E script. | FR-1, FR-2, FR-3, FR-4 | +| T-2 | Add unit tests for dependency policy and highest-version selection. | FR-2, FR-3 | +| T-3 | Wire the script into the tag publish workflow and PR-visible dependency policy gate. | FR-1, FR-2, FR-4 | +| T-5 | Run the policy check from pre-commit for dependency metadata edits. | FR-3 | +| T-6 | Document repository-level dependency metadata guidelines for developers and coding agents. | FR-2, FR-3 | +| T-4 | Adjust runtime dependency metadata if the new checks expose an existing conflict. | FR-2, FR-3 | + +## Acceptance + +| Requirement | Task | Criterion | Verification | Result | +| --- | --- | --- | --- | --- | +| FR-1 | T-1, T-3 | Clean wheel install gate exists before PyPI upload. | `uv run --extra dev pytest tests/test_dependency_e2e.py` | pass | +| FR-2 | T-1, T-2, T-3 | Selected high-risk direct dependency highest-version checks are implemented and unit-tested against current runtime pins. | `uv run --extra dev pytest tests/test_dependency_e2e.py`; `uv run python scripts/check_dependency_e2e.py --dist-dir declared-range` | pass | +| FR-3 | T-1, T-2, T-4 | `google-adk` requires an upper bound and `opentelemetry-*` exact pins are rejected. | `uv run python scripts/check_dependency_e2e.py policy` | pass | +| FR-4 | T-3 | Workflow checks `dist/*.whl` instead of `uv.lock`. | workflow diff review | pass | +| FR-3 | T-5 | Local pre-commit runs the same runtime dependency policy check when `pyproject.toml` changes. | `uv run --extra dev pre-commit run --all-files` | pass | +| FR-2, FR-3 | T-6 | `AGENTS.md` documents dependency metadata rules for high-risk upper bounds, shared base pins, direct dependency declarations, and heavyweight E2E deferral. | guideline diff review | pass | + +## Risks + +| Risk | Mitigation | +| --- | --- | +| PyPI state can change after release. | Keep the gate on tag publish and optionally run it manually before tagging. | +| Highest-version installs may be slower than normal unit tests. | Check only selected high-risk dependencies and run the heavy checks only on tag publication or manual workflow dispatch. | + +## Review record + +User approved implementation after reviewing the E2E plan in chat. Direct review found no component-spec change because public SDK behavior and runtime contracts are unchanged. + +## Verification record + +| Date | Command | Result | Notes | +| --- | --- | --- | --- | +| 2026-10-13 | `uv lock` | pass | Refreshed lock metadata after dependency bound updates. | +| 2026-10-13 | `uv run --extra dev pytest tests/test_dependency_e2e.py` | pass | 11 tests passed. | +| 2026-10-13 | `uv run python scripts/check_dependency_e2e.py policy` | pass | Runtime dependency policy passed. | +| 2026-10-13 | `uv run python scripts/check_dependency_e2e.py --dist-dir declared-range` | pass | Validated selected high-risk dependency pins including `google-adk==2.7.1`, `opentelemetry-exporter-otlp==1.42.1`, `opentelemetry-instrumentation-logging==0.63b1`, `fastmcp==3.4.8`, `tos==2.8.7`, and `jsonschema==4.23.0`. | +| 2026-10-13 | `uv run python scripts/check_dependency_e2e.py --dist-dir clean-install` | blocked | Local PyPI download for `google-adk` was too slow and was interrupted; the workflow gate remains wired to run this in GitHub Actions before upload. | + +## Dependency metadata adjustments found by the E2E check + +| Dependency | Before | After | Reason | +| --- | --- | --- | --- | +| `opentelemetry-exporter-otlp` | `>=1.37.0` | `>=1.37.0,<1.43` | `google-adk 2.7.x` caps OpenTelemetry SDK/API at the 1.42 line. | +| `opentelemetry-instrumentation-logging` | `>=0.56b0` | `>=0.56b0,<0.64` | The 0.64+ instrumentation line resolves with newer OpenTelemetry packages than `google-adk 2.7.x` allows. | +| `fastmcp` | `>=2.12.3` | `>=2.12.3,<4` | `fastmcp 4.x` requires `mcp>=2`, while VeADK currently declares `mcp==1.26.0`. | +| `tos` | `>=2.8.4` | `>=2.8.4,<2.9` | `tos 2.9.x` pins `wrapt==1.16.0`, conflicting with VeADK's `wrapt==1.17.2`. | +| `jsonschema` | `>=4.23,<5` | `>=4.23,<4.24` | `litellm 1.83.x` pins `jsonschema==4.23.0`; the previous range overstated installable versions. | diff --git a/prd-spec/bugfixes/dependency-e2e-release-gate/2026-10-13-dependency-e2e-release-gate.zh.md b/prd-spec/bugfixes/dependency-e2e-release-gate/2026-10-13-dependency-e2e-release-gate.zh.md new file mode 100644 index 000000000..df6f9b95f --- /dev/null +++ b/prd-spec/bugfixes/dependency-e2e-release-gate/2026-10-13-dependency-e2e-release-gate.zh.md @@ -0,0 +1,95 @@ +# 依赖 E2E 发布门禁 + +[English](2026-10-13-dependency-e2e-release-gate.md) + +| 字段 | 值 | +| --- | --- | +| 变更 ID | `dependency-e2e-release-gate` | +| 创建日期 | 2026-10-13 | +| 状态 | approved | +| 组件规格 | 无组件契约影响;本变更只增加发布期的包元数据和依赖解析校验。 | + +## 背景 + +本次依赖问题说明:本地 `uv.lock` 可用不代表 PyPI 用户可用。VeADK 可能发布出允许 pip 解析到未适配上游版本的元数据,也可能把共享基础依赖 pin 得过死,导致原本兼容的上游依赖栈无法安装。 + +## 目标和非目标 + +| 类型 | 范围 | +| --- | --- | +| 目标 | 发布到 PyPI 前,如果构建出的 wheel 无法在干净环境安装,则阻塞发布。 | +| 目标 | 暴露高风险 direct dependency 升级后与当前 runtime pin 或其它包元数据冲突的问题。 | +| 目标 | 拒绝高风险无上限 runtime 依赖,以及 OpenTelemetry 共享依赖 exact pin。 | +| 非目标 | 不穷举所有 optional extra、Studio/docker/sandbox runtime,且不调用真实云服务。 | + +## 需求 + +| ID | 需求 | +| --- | --- | +| FR-1 | tag 发布 workflow 必须在上传 PyPI 前,将构建出的 wheel 安装到干净 venv,执行 `pip check` 和最小 import smoke。 | +| FR-2 | tag 发布 workflow 必须验证选定高风险 base runtime direct dependency 在 VeADK 声明范围内当前可用最高版本,能否和构建出的 wheel 当前 runtime pin 一起解析。 | +| FR-3 | 策略检查必须拦截无上限 `google-adk` 和 exact pin 的 `opentelemetry-*` runtime 依赖。 | +| FR-4 | 检查必须基于发布产物,而不是开发者的 `uv.lock` 环境。 | + +## 设计 + +| 部分 | 决策 | +| --- | --- | +| 脚本 | 新增 `scripts/check_dependency_e2e.py`,提供 `policy`、`clean-install`、`declared-range` 命令。 | +| Workflow | 在 PR 和 push 上暴露独立的 `dependency-policy-gate`;clean install 和 declared-range 重检查只在 tag 发布或手动 workflow dispatch 时运行。 | +| 依赖范围 | 只检查选定高风险 base 依赖:`google-adk`、`agentkit-sdk-python`、OpenTelemetry packages、`litellm`、`mcp`、`fastmcp`、`tos` 和 `jsonschema`;每个候选升级都会和完整 wheel metadata 一起解析,包括精确 runtime pin。optional extras 后续再扩展。 | +| 开发规范 | 增加仓库级 dependency metadata 规范,要求开发者和 coding agent 在重 E2E 发布门禁前先规避高风险依赖无上限、无理由 shared base pin、只依赖 `uv.lock` 验证等问题。 | +| Runtime smoke | 只 import `veadk`、`google.adk`、`opentelemetry`;不需要凭据,也不访问外部服务。 | + +## 任务 + +| ID | 工作 | 需求 | +| --- | --- | --- | +| T-1 | 新增依赖 E2E 脚本。 | FR-1, FR-2, FR-3, FR-4 | +| T-2 | 增加依赖策略和最高版本选择的单测。 | FR-2, FR-3 | +| T-3 | 将脚本接入 tag 发布 workflow 和 PR 可见的 dependency policy gate。 | FR-1, FR-2, FR-4 | +| T-5 | 在 pre-commit 中对依赖元数据改动执行 policy 检查。 | FR-3 | +| T-6 | 为开发者和 coding agent 记录仓库级 dependency metadata 规范。 | FR-2, FR-3 | +| T-4 | 如果新检查暴露已有依赖冲突,则修正 runtime dependency metadata。 | FR-2, FR-3 | + +## 验收 + +| 需求 | 任务 | 标准 | 验证 | 结果 | +| --- | --- | --- | --- | --- | +| FR-1 | T-1, T-3 | PyPI 上传前存在 clean wheel install 门禁。 | `uv run --extra dev pytest tests/test_dependency_e2e.py` | pass | +| FR-2 | T-1, T-2, T-3 | 已实现并单测覆盖选定高风险 direct dependency 最高声明版本与当前 runtime pin 的兼容检查。 | `uv run --extra dev pytest tests/test_dependency_e2e.py`;`uv run python scripts/check_dependency_e2e.py --dist-dir declared-range` | pass | +| FR-3 | T-1, T-2, T-4 | `google-adk` 必须有上限,`opentelemetry-*` 不允许 exact pin。 | `uv run python scripts/check_dependency_e2e.py policy` | pass | +| FR-4 | T-3 | Workflow 检查 `dist/*.whl`,不依赖 `uv.lock`。 | workflow diff review | pass | +| FR-3 | T-5 | 修改 `pyproject.toml` 时,本地 pre-commit 会执行同一 runtime dependency policy 检查。 | `uv run --extra dev pre-commit run --all-files` | pass | +| FR-2, FR-3 | T-6 | `AGENTS.md` 已记录高风险依赖上限、shared base pin、直接依赖声明和重 E2E 延后执行等 dependency metadata 规则。 | guideline diff review | pass | + +## 风险 + +| 风险 | 缓解 | +| --- | --- | +| PyPI 状态在发布后仍可能变化。 | tag 发布时运行门禁,必要时也可手动预检。 | +| 最高版本安装检查比普通单测慢。 | 只检查选定高风险依赖,且重检查只在 tag 发布或手动 workflow dispatch 时运行。 | + +## 评审记录 + +用户在会话中审阅 E2E 方案后批准开始实现。直接评审结论:本变更不改变公开 SDK 行为和运行时契约,因此无需更新组件 spec。 + +## 验证记录 + +| 日期 | 命令 | 结果 | 说明 | +| --- | --- | --- | --- | +| 2026-10-13 | `uv lock` | pass | 依赖边界调整后刷新 lock metadata。 | +| 2026-10-13 | `uv run --extra dev pytest tests/test_dependency_e2e.py` | pass | 11 个测试通过。 | +| 2026-10-13 | `uv run python scripts/check_dependency_e2e.py policy` | pass | Runtime dependency policy 通过。 | +| 2026-10-13 | `uv run python scripts/check_dependency_e2e.py --dist-dir declared-range` | pass | 已验证选定高风险依赖的声明范围最高版本,包括 `google-adk==2.7.1`、`opentelemetry-exporter-otlp==1.42.1`、`opentelemetry-instrumentation-logging==0.63b1`、`fastmcp==3.4.8`、`tos==2.8.7` 和 `jsonschema==4.23.0`。 | +| 2026-10-13 | `uv run python scripts/check_dependency_e2e.py --dist-dir clean-install` | blocked | 本地下载 `google-adk` 过慢后中断;workflow 已接入该门禁,会在 GitHub Actions 上传前执行。 | + +## E2E 暴露出的依赖元数据调整 + +| 依赖 | 调整前 | 调整后 | 原因 | +| --- | --- | --- | --- | +| `opentelemetry-exporter-otlp` | `>=1.37.0` | `>=1.37.0,<1.43` | `google-adk 2.7.x` 将 OpenTelemetry SDK/API 限制在 1.42 线。 | +| `opentelemetry-instrumentation-logging` | `>=0.56b0` | `>=0.56b0,<0.64` | 0.64+ instrumentation 会解析到 `google-adk 2.7.x` 不允许的新版 OpenTelemetry 包。 | +| `fastmcp` | `>=2.12.3` | `>=2.12.3,<4` | `fastmcp 4.x` 要求 `mcp>=2`,但 VeADK 当前声明 `mcp==1.26.0`。 | +| `tos` | `>=2.8.4` | `>=2.8.4,<2.9` | `tos 2.9.x` pin 了 `wrapt==1.16.0`,与 VeADK 的 `wrapt==1.17.2` 冲突。 | +| `jsonschema` | `>=4.23,<5` | `>=4.23,<4.24` | `litellm 1.83.x` pin 了 `jsonschema==4.23.0`;原范围夸大了可安装版本。 | diff --git a/pyproject.toml b/pyproject.toml index a4e64a995..bac86d31e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,13 +33,13 @@ dependencies = [ # required" on the first session write to sqlite/mysql/postgres. CI never # saw it: ubuntu-latest is x86_64, where the marker matches. "sqlalchemy[asyncio]>=2,<3", # google-adk sessions ([db] on 2.x) - "opentelemetry-exporter-otlp>=1.37.0", - "opentelemetry-instrumentation-logging>=0.56b0", + "opentelemetry-exporter-otlp>=1.37.0,<1.43", + "opentelemetry-instrumentation-logging>=0.56b0,<0.64", "wrapt==1.17.2", # For patching built-in functions "volcengine-python-sdk>=5.0.36", # For Volcengine API and Ark Responses context management "volcengine>=1.0.193", # For Volcengine sign and AgentKit Runtime API access "agent-pilot-sdk==0.1.2", # Prompt optimization by Volcengine AgentPilot/PromptPilot toolkits - "fastmcp>=2.12.3", # For running MCP + "fastmcp>=2.12.3,<4", # For running MCP; 4.x requires mcp >=2 "trustedmcp==0.0.5", # For running TrustedMCP "mcp==1.26.0", # Bumped to satisfy google-adk requirement "cookiecutter==2.6.0", # For cloud deploy @@ -58,10 +58,10 @@ dependencies = [ "openviking-sdk>=0.1.9", "python-frontmatter==1.1.0", "PyYAML>=6.0.2", - "tos>=2.8.4", # For TOS storage and Viking DB + "tos>=2.8.4,<2.9", # For TOS storage and Viking DB; 2.9.x pins wrapt 1.16.0 "httpx>=0.27,<1", # Secure server-side webpage fetching for Studio knowledge imports "PyJWT[crypto]>=2.8,<3", # Sign GitHub App JWTs for PR review automation - "jsonschema>=4.23,<5", # Validate Studio BFF dynamic-tool arguments + "jsonschema>=4.23,<4.24", # Validate Studio BFF dynamic-tool arguments; litellm 1.83.x pins jsonschema 4.23.0 "trafilatura>=2.0,<2.1", # Extract webpage main content as Markdown for knowledge imports "tomli>=2.0.1; python_version < '3.11'", # TOML parser for supported Python 3.10 ] diff --git a/scripts/check_dependency_e2e.py b/scripts/check_dependency_e2e.py new file mode 100755 index 000000000..0f2303179 --- /dev/null +++ b/scripts/check_dependency_e2e.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Release-time dependency E2E checks for the built VeADK wheel.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import urllib.request +from pathlib import Path +from typing import Iterable, Mapping, Sequence + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback + try: + import tomli as tomllib # type: ignore[no-redef] + except ModuleNotFoundError: # pragma: no cover - standalone script fallback + from pip._vendor import tomli as tomllib # type: ignore[no-redef] + +try: + from packaging.markers import default_environment + from packaging.requirements import Requirement + from packaging.specifiers import SpecifierSet + from packaging.utils import ( + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, + ) + from packaging.version import InvalidVersion, Version +except ModuleNotFoundError: # pragma: no cover - standalone script fallback + from pip._vendor.packaging.markers import default_environment + from pip._vendor.packaging.requirements import Requirement + from pip._vendor.packaging.specifiers import SpecifierSet + from pip._vendor.packaging.utils import ( + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, + ) + from pip._vendor.packaging.version import InvalidVersion, Version + + +RUNTIME_IMPORT_SMOKE = ( + "import veadk\n" + "import google.adk\n" + "import opentelemetry\n" + "print('dependency smoke ok')\n" +) +UPPER_BOUND_OPERATORS = {"<", "<=", "==", "===", "~="} +HIGH_RISK_UPPER_BOUND_DEPENDENCIES = {"google-adk"} +SHARED_BASE_DEPENDENCY_PREFIXES = ("opentelemetry-",) +DECLARED_RANGE_CHECK_DEPENDENCIES = { + "agentkit-sdk-python", + "fastmcp", + "google-adk", + "jsonschema", + "litellm", + "mcp", + "opentelemetry-exporter-otlp", + "opentelemetry-instrumentation-logging", + "tos", +} + + +def load_runtime_dependencies(pyproject: Path) -> list[str]: + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + return list(data["project"]["dependencies"]) + + +def validate_dependency_policy(dependencies: Sequence[str]) -> list[str]: + violations: list[str] = [] + for dependency in dependencies: + requirement = Requirement(dependency) + name = canonicalize_name(requirement.name) + if name in HIGH_RISK_UPPER_BOUND_DEPENDENCIES and not _has_upper_bound( + requirement.specifier + ): + violations.append(f"{name} must declare an upper bound such as <3.") + if name.startswith(SHARED_BASE_DEPENDENCY_PREFIXES) and _has_exact_pin( + requirement.specifier + ): + violations.append( + f"{name} must not be exact-pinned in base runtime dependencies." + ) + return violations + + +def highest_satisfying_release( + requirement_text: str, + releases: Iterable[str], + *, + environment: Mapping[str, str] | None = None, +) -> str | None: + requirement = Requirement(requirement_text) + marker_environment = default_environment() + if environment is not None: + marker_environment.update(environment) + if requirement.marker is not None and not requirement.marker.evaluate( + marker_environment + ): + return None + + selected: Version | None = None + for release in releases: + try: + version = Version(release) + except InvalidVersion: + continue + if version.is_prerelease and not requirement.specifier.prereleases: + continue + if version in requirement.specifier: + selected = version if selected is None else max(selected, version) + return str(selected) if selected is not None else None + + +def run_policy(pyproject: Path) -> None: + violations = validate_dependency_policy(load_runtime_dependencies(pyproject)) + if violations: + raise SystemExit( + "\n".join(f"dependency policy violation: {v}" for v in violations) + ) + print("dependency policy ok") + + +def run_clean_install(dist_dir: Path) -> None: + wheel = _single_wheel(dist_dir) + with tempfile.TemporaryDirectory(prefix="veadk-dependency-e2e-") as temp_dir: + venv = Path(temp_dir) / "venv" + _create_venv(venv) + python = _venv_python(venv) + _run([python, "-m", "pip", "install", "--upgrade", "pip"]) + _run([python, "-m", "pip", "install", str(wheel)]) + _run([python, "-m", "pip", "check"]) + _run([python, "-c", RUNTIME_IMPORT_SMOKE]) + + +def run_declared_range(pyproject: Path, dist_dir: Path) -> None: + wheel = _single_wheel(dist_dir) + with tempfile.TemporaryDirectory(prefix="veadk-dependency-range-") as temp_dir: + venv = Path(temp_dir) / "venv" + _create_venv(venv) + python = _venv_python(venv) + _run([python, "-m", "pip", "install", "--upgrade", "pip"]) + print( + "checking high-risk dependency upgrades against the built wheel metadata, " + "including current pinned runtime dependencies", + flush=True, + ) + pins = highest_runtime_dependency_pins( + load_runtime_dependencies(pyproject), + python=python, + ) + failures: list[str] = [] + for pin in pins: + print(f"checking declared range with {pin}", flush=True) + command = [ + python, + "-m", + "pip", + "install", + "--dry-run", + "--ignore-installed", + str(wheel), + pin, + ] + try: + result = subprocess.run( + command, + capture_output=True, + check=False, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired as exc: + failures.append(pin) + print( + f"dependency range check timed out for {pin}: {' '.join(command)}", + file=sys.stderr, + flush=True, + ) + if exc.stdout: + print(exc.stdout, flush=True) + if exc.stderr: + print(exc.stderr, file=sys.stderr, flush=True) + continue + if result.returncode: + failures.append(pin) + if result.stdout: + print(result.stdout, flush=True) + if result.stderr: + print(result.stderr, file=sys.stderr, flush=True) + else: + print(f"declared range ok: {pin}", flush=True) + if failures: + raise SystemExit(declared_range_failure_message(failures)) + print("declared range dependency pins:") + for pin in pins: + print(f" {pin}") + + +def declared_range_failure_message(failures: Sequence[str]) -> str: + formatted_failures = "\n".join(f" - {pin}" for pin in failures) + return ( + "Dependency E2E failed: the built VeADK wheel cannot be resolved with " + "one or more high-risk dependencies at the highest versions allowed by " + "pyproject.toml. This may mean the upgraded dependency conflicts with " + "another current runtime pin.\n" + "Failing dependency pins:\n" + f"{formatted_failures}\n" + "Developer action: inspect the pip resolver error above, then either " + "fix VeADK compatibility or tighten the affected dependency's version " + "range so published metadata does not allow an uninstallable " + "combination." + ) + + +def highest_runtime_dependency_pins( + dependencies: Sequence[str], *, python: str | None = None +) -> list[str]: + candidates: list[str] = [] + for dependency in dependencies: + requirement = Requirement(dependency) + name = canonicalize_name(requirement.name) + if name not in DECLARED_RANGE_CHECK_DEPENDENCIES: + continue + if requirement.url is not None: + continue + if not requirement_applies(requirement): + continue + candidates.append(dependency) + + pins = [ + _highest_runtime_dependency_pin(dependency, python=python) + for dependency in candidates + ] + return [pin for pin in pins if pin is not None] + + +def _highest_runtime_dependency_pin( + dependency: str, *, python: str | None +) -> str | None: + requirement = Requirement(dependency) + print(f"resolving highest declared release for {requirement.name}", flush=True) + version = highest_declared_bound(requirement) + if version is None: + releases = _pypi_releases(requirement.name, python=python) + version = highest_satisfying_release(dependency, releases) + if version is None: + return None + return f"{requirement.name}=={version}" + + +def highest_declared_bound(requirement: Requirement) -> str | None: + selected: Version | None = None + for specifier in requirement.specifier: + if specifier.operator not in {"==", "===", "<="}: + continue + try: + version = Version(specifier.version) + except InvalidVersion: + continue + if version in requirement.specifier: + selected = version if selected is None else min(selected, version) + return str(selected) if selected is not None else None + + +def requirement_applies( + requirement: Requirement, environment: Mapping[str, str] | None = None +) -> bool: + if requirement.marker is None: + return True + marker_environment = default_environment() + if environment is not None: + marker_environment.update(environment) + return requirement.marker.evaluate(marker_environment) + + +def _pypi_releases(package_name: str, *, python: str | None) -> list[str]: + if python is not None: + releases = _pip_index_releases(package_name, python=python) + if releases: + return releases + url = f"https://pypi.org/simple/{package_name}/" + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.pypi.simple.v1+json", + "User-Agent": "veadk-dependency-e2e", + }, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + data = json.load(response) + except Exception as exc: + raise SystemExit( + f"Failed to resolve PyPI releases for {package_name}: {exc}" + ) from exc + + if "files" not in data: + url = f"https://pypi.org/pypi/{package_name}/json" + request = urllib.request.Request( + url, headers={"User-Agent": "veadk-dependency-e2e"} + ) + with urllib.request.urlopen(request, timeout=10) as response: + data = json.load(response) + return _legacy_pypi_releases(data) + + return _simple_pypi_releases(data) + + +def _pip_index_releases(package_name: str, *, python: str) -> list[str]: + try: + result = subprocess.run( + [python, "-m", "pip", "index", "versions", package_name], + capture_output=True, + check=False, + text=True, + timeout=30, + ) + except subprocess.TimeoutExpired as exc: + raise SystemExit( + f"Timed out while resolving PyPI releases for {package_name}." + ) from exc + if result.returncode: + return [] + for line in result.stdout.splitlines(): + if line.startswith("Available versions:"): + return [ + version.strip() + for version in line.split(":", 1)[1].split(",") + if version.strip() + ] + first_line = result.stdout.splitlines()[0] if result.stdout.splitlines() else "" + if first_line.startswith(f"{package_name} (") and first_line.endswith(")"): + return [first_line.rsplit("(", 1)[1].removesuffix(")")] + return [] + + +def _legacy_pypi_releases(data: Mapping[str, object]) -> list[str]: + releases: list[str] = [] + python_version = Version(default_environment()["python_full_version"]) + raw_releases = data.get("releases") + if not isinstance(raw_releases, dict): + return releases + for version, files in raw_releases.items(): + if not isinstance(files, list) or not files: + continue + if all(isinstance(file, dict) and file.get("yanked", False) for file in files): + continue + if not any( + isinstance(file, dict) and _file_supports_python(file, python_version) + for file in files + ): + continue + releases.append(str(version)) + return releases + + +def _simple_pypi_releases(data: Mapping[str, object]) -> list[str]: + raw_files = data.get("files") + if not isinstance(raw_files, list): + return [] + python_version = Version(default_environment()["python_full_version"]) + releases: set[str] = set() + for file in raw_files: + if not isinstance(file, dict): + continue + if file.get("yanked", False): + continue + if not _file_supports_python(file, python_version): + continue + version = _version_from_filename(file.get("filename")) + if version is not None: + releases.add(version) + return sorted(releases, key=Version) + + +def _version_from_filename(filename: object) -> str | None: + if not isinstance(filename, str): + return None + try: + if filename.endswith(".whl"): + return str(parse_wheel_filename(filename)[1]) + return str(parse_sdist_filename(filename)[1]) + except Exception: + return None + + +def _file_supports_python(file: Mapping[str, object], python_version: Version) -> bool: + requires_python = file.get("requires_python") + if not isinstance(requires_python, str) or not requires_python: + return True + try: + return python_version in SpecifierSet(requires_python) + except Exception: + return True + + +def _has_upper_bound(specifier: SpecifierSet) -> bool: + return any(spec.operator in UPPER_BOUND_OPERATORS for spec in specifier) + + +def _has_exact_pin(specifier: SpecifierSet) -> bool: + return any(spec.operator in {"==", "==="} for spec in specifier) + + +def _single_wheel(dist_dir: Path) -> Path: + wheels = sorted(dist_dir.glob("*.whl")) + if len(wheels) != 1: + raise SystemExit( + f"Expected exactly one wheel in {dist_dir}, found {len(wheels)}." + ) + return wheels[0] + + +def _create_venv(venv: Path) -> None: + _run([sys.executable, "-m", "venv", str(venv)]) + + +def _venv_python(venv: Path) -> str: + executable = "python.exe" if os.name == "nt" else "python" + python = venv / ("Scripts" if os.name == "nt" else "bin") / executable + return str(python) + + +def _run(command: Sequence[str]) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, check=True) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pyproject", type=Path, default=Path("pyproject.toml")) + parser.add_argument("--dist-dir", type=Path, default=Path("dist")) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("policy") + subparsers.add_parser("clean-install") + subparsers.add_parser("declared-range") + args = parser.parse_args(argv) + + if args.command == "policy": + run_policy(args.pyproject) + elif args.command == "clean-install": + run_clean_install(args.dist_dir) + elif args.command == "declared-range": + run_declared_range(args.pyproject, args.dist_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_dependency_e2e.py b/tests/test_dependency_e2e.py new file mode 100644 index 000000000..95f95c428 --- /dev/null +++ b/tests/test_dependency_e2e.py @@ -0,0 +1,200 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for release dependency E2E helpers.""" + +import importlib.util +from pathlib import Path + +import yaml + + +def _load_dependency_e2e_module(): + module_path = Path(__file__).parents[1] / "scripts" / "check_dependency_e2e.py" + spec = importlib.util.spec_from_file_location("check_dependency_e2e", module_path) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_policy_rejects_unbounded_google_adk_and_exact_opentelemetry_pin() -> None: + checker = _load_dependency_e2e_module() + + violations = checker.validate_dependency_policy( + [ + "google-adk>=2.7.0", + "opentelemetry-exporter-otlp==1.37.0", + "agentkit-sdk-python>=0.8.5", + ] + ) + + assert violations == [ + "google-adk must declare an upper bound such as <3.", + "opentelemetry-exporter-otlp must not be exact-pinned in base runtime " + "dependencies.", + ] + + +def test_policy_accepts_bounded_google_adk_and_open_opentelemetry_range() -> None: + checker = _load_dependency_e2e_module() + + violations = checker.validate_dependency_policy( + [ + "google-adk>=2.7.0,<3", + "opentelemetry-exporter-otlp>=1.37.0", + "agentkit-sdk-python>=0.8.5", + ] + ) + + assert violations == [] + + +def test_highest_satisfying_release_uses_declared_range() -> None: + checker = _load_dependency_e2e_module() + + version = checker.highest_satisfying_release( + "google-adk>=2.7.0,<3", + ["2.6.0", "2.7.0", "2.11.0", "3.0.0", "3.0.0rc1"], + ) + + assert version == "2.11.0" + + +def test_highest_satisfying_release_respects_environment_marker() -> None: + checker = _load_dependency_e2e_module() + + version = checker.highest_satisfying_release( + "tomli>=2.0.1; python_version < '3.11'", + ["2.0.1", "2.2.1"], + environment={"python_version": "3.11"}, + ) + + assert version is None + + +def test_requirement_applies_filters_inactive_environment_marker() -> None: + checker = _load_dependency_e2e_module() + + applies = checker.requirement_applies( + checker.Requirement("tomli>=2.0.1; python_version < '3.11'"), + environment={"python_version": "3.11"}, + ) + + assert applies is False + + +def test_highest_declared_bound_uses_inclusive_upper_bound() -> None: + checker = _load_dependency_e2e_module() + + version = checker.highest_declared_bound( + checker.Requirement("litellm>=1.83.7,<=1.83.14") + ) + + assert version == "1.83.14" + + +def test_highest_runtime_dependency_pins_only_checks_key_dependencies() -> None: + checker = _load_dependency_e2e_module() + + pins = checker.highest_runtime_dependency_pins( + [ + "google-adk==2.7.0", + "opentelemetry-exporter-otlp==1.37.0", + "pillow>=10.0.0", + ], + python=None, + ) + + assert pins == [ + "google-adk==2.7.0", + "opentelemetry-exporter-otlp==1.37.0", + ] + + +def test_declared_range_failure_message_tells_developer_what_to_fix() -> None: + checker = _load_dependency_e2e_module() + + message = checker.declared_range_failure_message( + ["opentelemetry-exporter-otlp==1.45.1", "fastmcp==4.1.0"] + ) + + assert "Dependency E2E failed" in message + assert "Failing dependency pins:" in message + assert "opentelemetry-exporter-otlp==1.45.1" in message + assert "fastmcp==4.1.0" in message + assert "current runtime pin" in message + assert "Developer action:" in message + + +def test_publish_workflow_runs_dependency_e2e_before_upload() -> None: + workflow_path = ( + Path(__file__).parents[1] / ".github" / "workflows" / "publish-tag-to-pypi.yaml" + ) + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + build_steps = workflow["jobs"]["build"]["steps"] + step_names = [step.get("name") for step in build_steps] + + assert "Check runtime dependency policy" in step_names + assert "Verify clean wheel install" in step_names + assert "Verify high-risk dependency upgrades against runtime pins" in step_names + clean_install_step = build_steps[step_names.index("Verify clean wheel install")] + declared_range_step = build_steps[ + step_names.index("Verify high-risk dependency upgrades against runtime pins") + ] + assert clean_install_step["if"] == ( + "startsWith(github.ref, 'refs/tags/') || github.event_name == " + "'workflow_dispatch'" + ) + assert declared_range_step["if"] == clean_install_step["if"] + assert step_names.index("Build wheel and source distribution") < step_names.index( + "Verify clean wheel install" + ) + assert step_names.index( + "Verify high-risk dependency upgrades against runtime pins" + ) < step_names.index("Upload distributables as artifact") + + +def test_dependency_policy_gate_runs_on_pull_requests_and_pushes() -> None: + workflow_path = ( + Path(__file__).parents[1] + / ".github" + / "workflows" + / "dependency-policy-gate.yaml" + ) + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + + assert workflow[True] == { + "pull_request": None, + "push": None, + } + assert "dependency-policy-gate" in workflow["jobs"] + steps = workflow["jobs"]["dependency-policy-gate"]["steps"] + step_names = [step.get("name") for step in steps] + + assert "Check runtime dependency policy" in step_names + + +def test_pre_commit_runs_dependency_policy_for_pyproject_changes() -> None: + config_path = Path(__file__).parents[1] / ".pre-commit-config.yaml" + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + local_repo = next(repo for repo in config["repos"] if repo["repo"] == "local") + hook = next( + hook for hook in local_repo["hooks"] if hook["id"] == "dependency-policy-gate" + ) + + assert hook["entry"] == "python3 scripts/check_dependency_e2e.py policy" + assert hook["pass_filenames"] is False + assert hook["files"] == r"^pyproject\.toml$" diff --git a/tests/test_runtime_dependency_compatibility.py b/tests/test_runtime_dependency_compatibility.py index 6d1f2945d..2a5135bb8 100644 --- a/tests/test_runtime_dependency_compatibility.py +++ b/tests/test_runtime_dependency_compatibility.py @@ -25,7 +25,10 @@ GOOGLE_ADK_BASELINE = "google-adk>=2.7.0,<2.8.0" GOOGLE_ADK_EVAL_BASELINE = "google-adk[eval]>=2.7.0,<2.8.0" AGENTKIT_BASELINE = "agentkit-sdk-python>=0.8.5" -OPENTELEMETRY_EXPORTER_BASELINE = "opentelemetry-exporter-otlp>=1.37.0" +OPENTELEMETRY_EXPORTER_BASELINE = "opentelemetry-exporter-otlp>=1.37.0,<1.43" +OPENTELEMETRY_EXPORTER_LOCK_BASELINE = ( + '{ name = "opentelemetry-exporter-otlp", specifier = ">=1.37.0,<1.43" }' +) def _pyproject() -> dict[str, object]: @@ -57,4 +60,4 @@ def test_lock_metadata_matches_project_dependency_baseline() -> None: '{ name = "google-adk", extras = ["eval"], marker = "extra == ' '\'eval\'", specifier = ">=2.7.0,<2.8.0" }' ) in lock - assert '{ name = "opentelemetry-exporter-otlp", specifier = ">=1.37.0" }' in lock + assert OPENTELEMETRY_EXPORTER_LOCK_BASELINE in lock diff --git a/uv.lock b/uv.lock index 3540d671d..e01b26047 100644 --- a/uv.lock +++ b/uv.lock @@ -5923,14 +5923,14 @@ requires-dist = [ { name = "deepeval", marker = "extra == 'eval'", specifier = ">=3.2.6" }, { name = "deprecated", specifier = "==1.2.18" }, { name = "fastapi", marker = "extra == 'codex'" }, - { name = "fastmcp", specifier = ">=2.12.3" }, + { name = "fastmcp", specifier = ">=2.12.3,<4" }, { name = "filetype", specifier = "==1.2.0" }, { name = "google-adk", specifier = ">=2.7.0,<2.8.0" }, { name = "google-adk", extras = ["eval"], marker = "extra == 'eval'", specifier = ">=2.7.0,<2.8.0" }, { name = "headroom", marker = "extra == 'harness'" }, { name = "httpx", specifier = ">=0.27,<1" }, { name = "httpx", extras = ["socks"], marker = "extra == 'sandbox'", specifier = ">=0.27.0" }, - { name = "jsonschema", specifier = ">=4.23,<5" }, + { name = "jsonschema", specifier = ">=4.23,<4.24" }, { name = "lark-channel-sdk", marker = "extra == 'extensions'" }, { name = "lark-oapi", marker = "extra == 'extensions'" }, { name = "litellm", specifier = ">=1.83.7,<=1.83.14" }, @@ -5945,8 +5945,8 @@ requires-dist = [ { name = "omegaconf", specifier = "==2.3.0" }, { name = "openai-codex", marker = "extra == 'codex'", specifier = "==0.159.2" }, { name = "opensearch-py", marker = "extra == 'extensions'", specifier = ">=2.8.0" }, - { name = "opentelemetry-exporter-otlp", specifier = ">=1.37.0" }, - { name = "opentelemetry-instrumentation-logging", specifier = ">=0.56b0" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.37.0,<1.43" }, + { name = "opentelemetry-instrumentation-logging", specifier = ">=0.56b0,<0.64" }, { name = "openviking-sdk", specifier = ">=0.1.9" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, @@ -5971,7 +5971,7 @@ requires-dist = [ { name = "redis", marker = "extra == 'extensions'", specifier = ">=5.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2,<3" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, - { name = "tos", specifier = ">=2.8.4" }, + { name = "tos", specifier = ">=2.8.4,<2.9" }, { name = "trafilatura", specifier = ">=2.0,<2.1" }, { name = "trustedmcp", specifier = "==0.0.5" }, { name = "uvicorn", marker = "extra == 'codex'" },