diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8103788a..6c9080f3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,6 +8,10 @@ jobs: permissions: contents: write id-token: write + outputs: + released: ${{ steps.semantic_release.outputs.released }} + tag: ${{ steps.semantic_release.outputs.tag }} + contrib_matrix: ${{ steps.contrib_matrix.outputs.matrix }} steps: - name: Checkout @@ -25,8 +29,20 @@ jobs: - name: Setup UV uses: astral-sh/setup-uv@v4 + - name: Verify contrib wiring + run: python scripts/contrib_packages.py verify + + - name: Compute contrib matrix + id: contrib_matrix + run: | + { + echo "matrix<> "$GITHUB_OUTPUT" + - name: Python Semantic Release - id: release + id: semantic_release uses: python-semantic-release/python-semantic-release@v10.5.3 with: git_committer_name: galileo-automation @@ -37,54 +53,135 @@ jobs: root_options: "-vv" - name: Build Packages - if: steps.release.outputs.released == 'true' + if: steps.semantic_release.outputs.released == 'true' run: | uv sync uv run python scripts/build.py all - # Publish in dependency order: models -> evaluators -> sdk -> evaluator-galileo + - name: Upload built distributions + if: steps.semantic_release.outputs.released == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-dists + if-no-files-found: error + path: | + models/dist/* + evaluators/builtin/dist/* + sdks/python/dist/* + server/dist/* + evaluators/contrib/*/dist/* + + publish-models: + runs-on: ubuntu-latest + needs: release + if: needs.release.outputs.released == 'true' + permissions: + id-token: write + + steps: + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: . + - name: Publish agent-control-models to PyPI - if: steps.release.outputs.released == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: models/dist/ user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} + publish-evaluators: + runs-on: ubuntu-latest + needs: [release, publish-models] + if: needs.release.outputs.released == 'true' + permissions: + id-token: write + + steps: + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: . + - name: Publish agent-control-evaluators to PyPI - if: steps.release.outputs.released == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: evaluators/builtin/dist/ user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} - - name: Publish agent-control-sdk to PyPI - if: steps.release.outputs.released == 'true' + publish-contrib: + runs-on: ubuntu-latest + needs: [release, publish-evaluators] + if: needs.release.outputs.released == 'true' + permissions: + id-token: write + strategy: + fail-fast: false + matrix: + contrib: ${{ fromJSON(needs.release.outputs.contrib_matrix) }} + + steps: + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: . + + - name: Publish ${{ matrix.contrib.package }} to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - packages-dir: sdks/python/dist/ + packages-dir: ${{ matrix.contrib.dir }}/dist/ user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} - - name: Publish agent-control-evaluator-galileo to PyPI - if: steps.release.outputs.released == 'true' + publish-sdk: + runs-on: ubuntu-latest + needs: [release, publish-contrib] + if: needs.release.outputs.released == 'true' + permissions: + id-token: write + + steps: + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: . + + - name: Publish agent-control-sdk to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - packages-dir: evaluators/contrib/galileo/dist/ + packages-dir: sdks/python/dist/ user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} + upload-release-assets: + runs-on: ubuntu-latest + needs: [release, publish-sdk] + if: needs.release.outputs.released == 'true' + permissions: + contents: write + + steps: + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: . + - name: Upload to GitHub Release - if: steps.release.outputs.released == 'true' uses: python-semantic-release/upload-to-gh-release@main with: github_token: ${{ secrets.GALILEO_AUTOMATION_GITHUB_TOKEN || github.token }} - tag: ${{ steps.release.outputs.tag }} + tag: ${{ needs.release.outputs.tag }} root_options: "-vv" dist_glob: | models/dist/* evaluators/builtin/dist/* sdks/python/dist/* server/dist/* - evaluators/contrib/galileo/dist/* + evaluators/contrib/*/dist/* diff --git a/evaluators/builtin/pyproject.toml b/evaluators/builtin/pyproject.toml index cdb34f5f..d636a9f2 100644 --- a/evaluators/builtin/pyproject.toml +++ b/evaluators/builtin/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.12" license = { text = "Apache-2.0" } authors = [{ name = "Agent Control Team" }] dependencies = [ - "agent-control-models", + "agent-control-models>=7.5.0", "pydantic>=2.12.4", "google-re2>=1.1", "jsonschema>=4.0.0", diff --git a/scripts/build.py b/scripts/build.py index 43095af2..d572c969 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -6,15 +6,20 @@ then cleans up afterward. This allows the published wheels to be self-contained. Usage: - python scripts/build.py [models|evaluators|sdk|server|galileo|all] + python scripts/build.py [models|evaluators|sdk|server|contrib|all|] """ +from __future__ import annotations + +import re import shutil import subprocess -import re +import sys from pathlib import Path -ROOT = Path(__file__).parent.parent +from contrib_packages import ContribPackage, discover_contrib_packages + +ROOT = Path(__file__).resolve().parent.parent def get_global_version() -> str: @@ -38,11 +43,25 @@ def set_package_version(pyproject_path: Path, version: str) -> None: pyproject_path.write_text(updated) +def sync_dependency_floors(pyproject_path: Path, dependency_names: list[str], version: str) -> None: + """Update internal dependency lower bounds to the release version.""" + content = pyproject_path.read_text() + updated = content + for dependency_name in dependency_names: + updated = re.sub( + rf'("{re.escape(dependency_name)}>=)([^",;\]\s]+)', + rf"\g<1>{version}", + updated, + ) + + if updated != content: + pyproject_path.write_text(updated) + + def inject_bundle_metadata(init_file: Path, package_name: str, version: str) -> None: """Add bundling metadata to __init__.py for conflict detection.""" content = init_file.read_text() - # Only add if not already present if "__bundled_by__" in content: return @@ -53,23 +72,44 @@ def inject_bundle_metadata(init_file: Path, package_name: str, version: str) -> init_file.write_text(metadata + content) -def build_models() -> None: - """Build agent-control-models (standalone, no vendoring needed).""" - version = get_global_version() - models_dir = ROOT / "models" - - print(f"Building agent-control-models v{version}") - - # Clean previous builds - dist_dir = models_dir / "dist" +def clean_dist_dir(package_dir: Path) -> Path: + """Remove any previous build output and return the dist directory path.""" + dist_dir = package_dir / "dist" if dist_dir.exists(): shutil.rmtree(dist_dir) + return dist_dir + + +def build_python_package( + distribution_name: str, + package_dir: Path, + version: str, + dependency_names: list[str] | None = None, +) -> None: + """Build a standalone Python package into its local dist directory.""" + print(f"Building {distribution_name} v{version}") + dist_dir = clean_dist_dir(package_dir) + pyproject_path = package_dir / "pyproject.toml" + set_package_version(pyproject_path, version) + if dependency_names: + sync_dependency_floors(pyproject_path, dependency_names, version) + subprocess.run(["uv", "build", "-o", str(dist_dir)], cwd=package_dir, check=True) + print(f" Built {distribution_name} v{version}") - # Set version - set_package_version(models_dir / "pyproject.toml", version) - subprocess.run(["uv", "build", "-o", str(dist_dir)], cwd=models_dir, check=True) - print(f" Built agent-control-models v{version}") +def discover_contrib_by_name() -> dict[str, ContribPackage]: + """Return discovered contrib packages keyed by contrib name.""" + return {package.name: package for package in discover_contrib_packages()} + + +def discover_contrib_distribution_names() -> list[str]: + """Return the distribution names for all discovered contrib packages.""" + return [package.package for package in discover_contrib_packages()] + + +def build_models() -> None: + """Build agent-control-models (standalone, no vendoring needed).""" + build_python_package("agent-control-models", ROOT / "models", get_global_version()) def build_sdk() -> None: @@ -80,17 +120,13 @@ def build_sdk() -> None: print(f"Building agent-control-sdk v{version}") - # Clean previous builds and vendored code for pkg in ["agent_control_models", "agent_control_engine", "agent_control_telemetry"]: target = sdk_src / pkg if target.exists(): shutil.rmtree(target) - dist_dir = sdk_dir / "dist" - if dist_dir.exists(): - shutil.rmtree(dist_dir) + dist_dir = clean_dist_dir(sdk_dir) - # Copy vendored packages shutil.copytree( ROOT / "models" / "src" / "agent_control_models", sdk_src / "agent_control_models", @@ -104,7 +140,6 @@ def build_sdk() -> None: sdk_src / "agent_control_telemetry", ) - # Inject bundle metadata for conflict detection inject_bundle_metadata( sdk_src / "agent_control_models" / "__init__.py", "agent-control-sdk", @@ -121,14 +156,18 @@ def build_sdk() -> None: version, ) - # Set version - set_package_version(sdk_dir / "pyproject.toml", version) + sdk_pyproject = sdk_dir / "pyproject.toml" + set_package_version(sdk_pyproject, version) + sync_dependency_floors( + sdk_pyproject, + ["agent-control-evaluators", *discover_contrib_distribution_names()], + version, + ) try: subprocess.run(["uv", "build", "-o", str(dist_dir)], cwd=sdk_dir, check=True) print(f" Built agent-control-sdk v{version}") finally: - # Clean up vendored code (don't commit it) for pkg in ["agent_control_models", "agent_control_engine", "agent_control_telemetry"]: target = sdk_src / pkg if target.exists(): @@ -139,7 +178,7 @@ def build_server() -> None: """Build agent-control-server with vendored packages. Note: evaluators are NOT vendored - server uses agent-control-evaluators as a - runtime dependency to avoid duplicate module conflicts with galileo extras. + runtime dependency to avoid duplicate module conflicts with contrib extras. """ version = get_global_version() server_dir = ROOT / "server" @@ -147,17 +186,13 @@ def build_server() -> None: print(f"Building agent-control-server v{version}") - # Clean previous builds and vendored code for pkg in ["agent_control_models", "agent_control_engine", "agent_control_telemetry"]: target = server_src / pkg if target.exists(): shutil.rmtree(target) - dist_dir = server_dir / "dist" - if dist_dir.exists(): - shutil.rmtree(dist_dir) + dist_dir = clean_dist_dir(server_dir) - # Copy vendored packages (models, engine, and telemetry only, NOT evaluators) shutil.copytree( ROOT / "models" / "src" / "agent_control_models", server_src / "agent_control_models", @@ -171,7 +206,6 @@ def build_server() -> None: server_src / "agent_control_telemetry", ) - # Inject bundle metadata for conflict detection inject_bundle_metadata( server_src / "agent_control_models" / "__init__.py", "agent-control-server", @@ -188,14 +222,18 @@ def build_server() -> None: version, ) - # Set version - set_package_version(server_dir / "pyproject.toml", version) + server_pyproject = server_dir / "pyproject.toml" + set_package_version(server_pyproject, version) + sync_dependency_floors( + server_pyproject, + ["agent-control-evaluators", *discover_contrib_distribution_names()], + version, + ) try: subprocess.run(["uv", "build", "-o", str(dist_dir)], cwd=server_dir, check=True) print(f" Built agent-control-server v{version}") finally: - # Clean up vendored code (don't commit it) for pkg in ["agent_control_models", "agent_control_engine", "agent_control_telemetry"]: target = server_src / pkg if target.exists(): @@ -204,40 +242,49 @@ def build_server() -> None: def build_evaluators() -> None: """Build agent-control-evaluators (standalone, no vendoring needed).""" - version = get_global_version() - evaluators_dir = ROOT / "evaluators" / "builtin" - - print(f"Building agent-control-evaluators v{version}") - - # Clean previous builds - dist_dir = evaluators_dir / "dist" - if dist_dir.exists(): - shutil.rmtree(dist_dir) + build_python_package( + "agent-control-evaluators", + ROOT / "evaluators" / "builtin", + get_global_version(), + ["agent-control-models", *discover_contrib_distribution_names()], + ) - # Set version - set_package_version(evaluators_dir / "pyproject.toml", version) - subprocess.run(["uv", "build", "-o", str(dist_dir)], cwd=evaluators_dir, check=True) - print(f" Built agent-control-evaluators v{version}") +def build_contrib_package(package: ContribPackage, version: str) -> None: + """Build a discovered contrib evaluator package.""" + build_python_package( + package.package, + ROOT / Path(package.directory), + version, + ["agent-control-evaluators", "agent-control-models"], + ) -def build_evaluator_galileo() -> None: - """Build agent-control-evaluator-galileo (standalone, no vendoring needed).""" +def build_contrib() -> None: + """Build all discovered contrib evaluator packages.""" version = get_global_version() - galileo_dir = ROOT / "evaluators" / "contrib" / "galileo" + packages = discover_contrib_packages() + if not packages: + print("No contrib evaluator packages discovered.") + return - print(f"Building agent-control-evaluator-galileo v{version}") + package_names = ", ".join(package.name for package in packages) + print(f"Building discovered contrib packages ({package_names})") + for package in packages: + build_contrib_package(package, version) - # Clean previous builds - dist_dir = galileo_dir / "dist" - if dist_dir.exists(): - shutil.rmtree(dist_dir) - # Set version - set_package_version(galileo_dir / "pyproject.toml", version) - - subprocess.run(["uv", "build", "-o", str(dist_dir)], cwd=galileo_dir, check=True) - print(f" Built agent-control-evaluator-galileo v{version}") +def build_named_contrib_package(target: str) -> None: + """Build one discovered contrib evaluator package by name.""" + packages = discover_contrib_by_name() + package = packages.get(target) + if package is None: + available_targets = ", ".join(sorted(packages)) + raise ValueError( + "Unknown build target " + f"{target!r}. Available contrib targets: {available_targets or '(none)'}" + ) + build_contrib_package(package, get_global_version()) def build_all() -> None: @@ -245,15 +292,21 @@ def build_all() -> None: print(f"Building all packages (version {get_global_version()})\n") build_models() build_evaluators() + build_contrib() build_sdk() build_server() - build_evaluator_galileo() print("\nAll packages built successfully!") -if __name__ == "__main__": - import sys +def usage() -> str: + """Return the CLI usage string.""" + return ( + "Usage: python scripts/build.py " + "[models|evaluators|sdk|server|contrib|all|]" + ) + +if __name__ == "__main__": target = sys.argv[1] if len(sys.argv) > 1 else "all" if target == "models": @@ -264,10 +317,14 @@ def build_all() -> None: build_sdk() elif target == "server": build_server() - elif target == "galileo": - build_evaluator_galileo() + elif target == "contrib": + build_contrib() elif target == "all": build_all() else: - print("Usage: python scripts/build.py [models|evaluators|sdk|server|galileo|all]") - sys.exit(1) + try: + build_named_contrib_package(target) + except ValueError as error: + print(error) + print(usage()) + sys.exit(1) diff --git a/scripts/tests/test_build.py b/scripts/tests/test_build.py new file mode 100644 index 00000000..7e841721 --- /dev/null +++ b/scripts/tests/test_build.py @@ -0,0 +1,63 @@ +import sys +import tomllib +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import build + + +def test_sync_dependency_floors_updates_internal_minimums(tmp_path: Path) -> None: + # Given: a package manifest with mixed internal and external dependency constraints + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """ +[project] +dependencies = [ + "agent-control-evaluators>=7.5.0", + "agent-control-models>=7.5.0,<8.0.0", + "httpx>=0.28.0", +] + +[project.optional-dependencies] +galileo = ["agent-control-evaluator-galileo>=7.5.0"] +""".strip() + ) + + # When: syncing internal dependency floors for a new release + build.sync_dependency_floors( + pyproject_path, + [ + "agent-control-evaluators", + "agent-control-models", + "agent-control-evaluator-galileo", + ], + "7.6.0", + ) + + # Then: only the internal minimum versions move to the release version + assert pyproject_path.read_text() == ( + """ +[project] +dependencies = [ + "agent-control-evaluators>=7.6.0", + "agent-control-models>=7.6.0,<8.0.0", + "httpx>=0.28.0", +] + +[project.optional-dependencies] +galileo = ["agent-control-evaluator-galileo>=7.6.0"] +""".strip() + ) + + +def test_builtin_evaluators_manifest_keeps_models_floor_rewritable() -> None: + builtin_pyproject = SCRIPTS_DIR.parent / "evaluators" / "builtin" / "pyproject.toml" + with builtin_pyproject.open("rb") as handle: + manifest = tomllib.load(handle) + + dependencies = manifest["project"]["dependencies"] + + assert "agent-control-models>=7.5.0" in dependencies