diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index c800e1787d..23617faa98 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -1,131 +1,60 @@ -name: Python - Build Release Assets +name: Python - Validate Release on: release: types: [published] - + pull_request: + branches: ["main", "feature*"] + paths: + - "python/.github/release-*.json" + - "python/pyproject.toml" + - "python/packages/*/pyproject.toml" + - "python/scripts/prepare_release.py" + - "python/scripts/tests/test_prepare_release.py" + - "python/scripts/dependencies/**" + - ".github/workflows/python-release.yml" + +# Azure DevOps owns build, ESRP approval/publication, exact asset upload, and the +# final draft -> published transition. This workflow must never write releases +# or obtain an identity that can start an Azure DevOps run. permissions: - contents: write - id-token: write -env: - # Configure a constant location for the uv cache - UV_CACHE_DIR: /tmp/.uv-cache + contents: read jobs: - python-build-assets: - if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'python-') - name: Python Build Assets and add to Release + validate: + if: github.event_name == 'pull_request' || startsWith(github.event.release.tag_name, 'python-') + name: Validate Python release manifest runs-on: ubuntu-latest - environment: "integration" - env: - UV_PYTHON: "3.13" defaults: run: working-directory: python steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up python and install the project - id: python-setup - uses: ./.github/actions/python-setup with: - python-version: ${{ matrix.python-version }} - os: ${{ runner.os }} + # A release must validate the exact tagged commit, never current main. + ref: ${{ github.event_name == 'release' && format('refs/tags/{0}', github.event.release.tag_name) || github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version-file: "python/pyproject.toml" + enable-cache: true + - name: Test release tooling + run: >- + uv run --no-project --python 3.12 --with packaging==26.3 --with rich --with pytest==9.1.1 + python -m pytest -c /dev/null -p no:cacheprovider + scripts/tests/test_prepare_release.py scripts/dependencies/tests/test_dependency_bounds_release.py -q + - name: Check reviewed batch in PR + if: github.event_name == 'pull_request' env: - # Configure a constant location for the uv cache - UV_CACHE_DIR: /tmp/.uv-cache - - name: Resolve the package to build + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: >- + uv run --no-project --python 3.12 --with packaging==26.3 --with rich + python -m scripts.prepare_release --check --ci --base-ref "$BASE_SHA" + - name: Check published tag contract + if: github.event_name == 'release' env: - TAG_NAME: ${{ github.event.release.tag_name }} - run: | - set -euo pipefail - - TAG="$TAG_NAME" - - # Release tags are either python- for the whole workspace, or - # python-- for a single package. Package names may - # themselves contain hyphens (hosting-a2a, azure-ai-search), so the - # package part cannot be found by splitting on the first hyphen. - # - # Versions follow the lifecycle patterns in the python-package-management - # skill: X.Y.Z, X.Y.ZaYYMMDD, X.Y.ZbYYMMDD, X.Y.ZrcN, each optionally - # carrying a .N or .postN re-cut suffix. - VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+([ab][0-9]+|rc[0-9]+)?(\.[0-9]+|\.post[0-9]+)?$' - - REST="${TAG#python-}" - - if [[ -z "$REST" ]]; then - echo "Error: tag '$TAG' has no version or package component" - exit 1 - fi - - if [[ "$REST" =~ $VERSION_PATTERN ]]; then - # python-: build every workspace package plus the root meta package. - PACKAGE="all" - echo "Resolved tag '$TAG' to the full workspace build" - else - # python--: split off the trailing version component and - # require it to be a real version, so a malformed tag fails here rather - # than being mistaken for another kind of release. - CANDIDATE="${REST%-*}" - VERSION="${REST##*-}" - - if [[ "$CANDIDATE" == "$REST" || -z "$CANDIDATE" ]]; then - echo "Error: tag '$TAG' is neither python- nor python--" - exit 1 - fi - - if [[ ! "$VERSION" =~ $VERSION_PATTERN ]]; then - echo "Error: tag '$TAG' does not end in a supported version" - echo "Derived version: '$VERSION'" - exit 1 - fi - - # Resolve the package part against the real package directories. Tags use - # hyphens even where the directory uses underscores - # (python-github-copilot -> github_copilot). - PACKAGE="" - - for dir in packages/*/; do - name="${dir#packages/}" - name="${name%/}" - if [[ "$name" == "$CANDIDATE" || "${name//_/-}" == "$CANDIDATE" ]]; then - PACKAGE="$name" - break - fi - done - - if [[ -z "$PACKAGE" ]]; then - echo "Error: tag '$TAG' does not map to a directory in packages/" - echo "Derived package name: '$CANDIDATE'" - echo "Available packages: $(ls packages/)" - exit 1 - fi - - echo "Resolved tag '$TAG' to package '$PACKAGE'" - fi - - echo "PACKAGE=$PACKAGE" >> "$GITHUB_ENV" - - - name: Check version - env: - TAG_NAME: ${{ github.event.release.tag_name }} - run: | - echo "Building and uploading Python release: $TAG_NAME" - if [[ "$PACKAGE" == "all" ]]; then - echo "Build scope: all workspace packages and the root meta package" - else - echo "Build scope: packages/$PACKAGE" - fi - - name: Build the package - run: | - set -euo pipefail - if [[ "$PACKAGE" == "all" ]]; then - uv run poe build - else - uv run poe --directory "packages/$PACKAGE" build - fi - - name: Release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 - with: - files: | - python/dist/* + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: >- + uv run --no-project --python 3.12 --with packaging==26.3 --with rich + python -m scripts.prepare_release --check --tag "$RELEASE_TAG" diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index ef366e7c28..7d84af2472 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -1,12 +1,15 @@ --- name: agent-framework-py-release -description: Use when cutting a Python release for the microsoft/agent-framework monorepo. Triggers on "bump py versions", "cut a python release", "prepare release PR for python", "release py packages", "bump python to X.Y.Z", or similar requests to bump Python package versions and prepare a release PR. Handles all four lifecycle tiers (alpha, beta, rc, released) with CHANGELOG-driven selective bumps, floor bound checks, and post-bump validation. +description: Use when cutting a Python release for the microsoft/agent-framework monorepo. Triggers on "bump py versions", "cut a python release", "prepare release PR for python", "release py packages", "bump python to X.Y.Z", or similar requests to bump Python package versions and prepare a release PR. Handles all four lifecycle tiers with CHANGELOG-driven selective bumps, a reviewed batch manifest, dependency checks, and the protected Azure DevOps release handoff. --- # Agent Framework Python Release Cuts a Python release PR for the `microsoft/agent-framework` monorepo. +Release preparation does **not** authorize production tags, draft creation, publication, or deployment. Stop at the +requested PR unless a maintainer separately authorizes the release handoff below. + ## Core principle: CHANGELOG drives bumps **No more universal lockstep.** A package only bumps if it has a CHANGELOG entry this cycle. Draft the CHANGELOG first, then derive the bump list from it. @@ -49,6 +52,11 @@ If the user states target versions or a date explicitly, use exactly what they s `tooling/requirements-*.txt` bundle only for single-version tools. - **Never rename an existing CHANGELOG section header** during a new release cut. Only INSERT a new section above existing ones. - **Footer reference links are part of the CHANGELOG edit**, not optional. +- **One batch, one overall tag, one Azure DevOps run.** Generate `python/.github/release-.json` in the same + version-bump PR. Keep earlier per-tag manifests unchanged for browsing release-batch history. + Never replace the reviewed selection with "all versions absent from PyPI", per-package tags, or one run per package. +- **Azure DevOps is the only production publisher.** GitHub has no Azure DevOps API credentials or federated queue + permission. The GitHub release workflow is read-only validation; local production `publish` tasks are retired. ## Workflow @@ -319,10 +327,93 @@ rm "${file}.bak" If `core` did not bump this cycle, do not touch floors. -### 7. Validate +### 7. Generate the reviewed release batch and validate + +After applying the selected bumps and dependency edits, stage any new workspace package metadata with +`git add python/packages//pyproject.toml`. The generator fails explicitly if workspace metadata is still +untracked, rather than silently omitting that distribution. Existing tracked metadata need not be staged. +Generate the manifest from that same metadata diff: ```bash -cd python && uv run --group tool-runtime poe validate-python-release --base-ref "$RELEASE_BASE" +RELEASE_TAG="python-" +uv run --directory python --group tool-runtime poe prepare-python-release --base-ref "$RELEASE_BASE" --tag "$RELEASE_TAG" +``` + +The generator reuses the release validator's changed-`pyproject.toml` selection, then includes only packages whose +version increased (or new workspace packages). Compare package identity by canonical distribution name, not directory: +a move does not make an existing version new, and a renamed distribution is a new identity. Dependency-only metadata +edits remain part of dependency validation but do not select an unchanged version for publication. The generator +rejects a Core release without a version increase for root `agent-framework`; the explicit cohort decision above +still determines other bumps. The excluded standalone Lab workspace is not part of this batch. + +Commit **`python/.github/release-${RELEASE_TAG}.json` in this same PR**, together with the version and lockfile changes. +The filename includes the full tag: `python-1.24.0` produces `python/.github/release-python-1.24.0.json`. +Do not maintain a duplicate list of versions manually. Regenerate the new file after changing the bump list while +the PR is open. Once it is part of the release base, it is historical: never edit, replace, or delete it. +For example, `python/.github/release-python-1.24.0.json` contains: + +```json +{ + "tag": "python-1.24.0", + "packages": { + "agent-framework": "1.24.0", + "agent-framework-core": "1.24.0", + "agent-framework-foundry": "1.16.0" + }, + "smoke_imports": { + "agent-framework": ["agent_framework:Agent"], + "agent-framework-core": ["agent_framework:Message"], + "agent-framework-foundry": ["agent_framework.foundry:FoundryAgent"] + } +} +``` + +Contract: + +- `tag` and non-empty `packages` are required. The tag is one overall `python-` release label, not + a requirement that every selected distribution share a version. No source commit SHA is stored in this file: + Azure DevOps resolves the immutable tagged commit and reads `python/.github/release-.json` there. + Both sides derive this exact path from the expected full tag. Never scan for the latest file or fall back to + the old fixed `.github/release.json` or root `release.json` paths. + Generation and PR validation reject tags already present in the freshly fetched local tag refs. Fetch tags as in + step 1 before preparation. Published-release validation permits its existing tag and checks the qualified + `refs/tags/` revision; PR checks use the immutable event SHA, not a moving merge ref. +- Keys are exact canonical distribution names; values are PEP 440 strings equal to the tagged `project.version`. + The expected tag must match the manifest tag, preventing reuse of a stale batch. PyPI absence is validation, + **never selection**. +- `smoke_imports` is an optional additive map of public `module:export` checks (classes or functions). The generator + emits it for **every** selected distribution. The consumer only has built-in defaults for meta/core/Foundry; + other packages require explicit reviewed checks. Unknown packages fail until `scripts/prepare_release.py` + has a reviewed public import entry. A check imports the module and resolves the export, without creating an + agent, opening a connection, or calling a service. +- Prefer public `agent_framework.*` bridges. Never derive checks from build configuration (the root's + `agent_framework_meta` is not its public API), or substitute a private implementation module. + Packages without a bridge use their documented public top-level entry points. The explicit exceptions are + `agent_framework_azure_cosmos_memory`, `agent_framework_azure_documentdb`, `agent_framework_duckdb`, + `agent_framework_hosting`, `agent_framework_hosting_a2a`, `agent_framework_hosting_mcp`, + `agent_framework_hosting_responses`, `agent_framework_hosting_telegram`, `agent_framework_mongodb`, + `agent_framework_nvidia`, `agent_framework_oracle`, `agent_framework_postgres`, `agent_framework_qdrant`, + `agent_framework_sql_server`, and `agent_framework_typesafe`. See their package READMEs and exported `__all__`; + the inventory test checks each selected public export. A new exception must also be reviewed in the consumer + allowlist before release. + +From the repository root, check the generated file against the same base: + +```bash +uv run --directory python --group tool-runtime python -m scripts.prepare_release --check --base-ref "$RELEASE_BASE" --tag "$RELEASE_TAG" +``` + +For version increases of existing distributions or an explicit manifest change, PR CI requires exactly one new +per-tag manifest and enforces agreement between its filename, JSON tag, and version changes. Ordinary development +PRs that only add new distribution identities do not need to choose a release tag or add a manifest. Explicit +release preparation still includes all selected new packages, and a supplied manifest is always validated. +Changes or deletions of historical manifests fail the check. Earlier files remain browsable on `main`; +their old package versions are not revalidated against current metadata. PRs without version or manifest changes +(including dependency maintenance) do not need to add a manifest. These files record reviewed intended batches, +not proof that publication completed; that evidence is the verified artifact manifest on the published release. + +```bash +uv run --directory python --group tool-runtime poe validate-python-release --base-ref "$RELEASE_BASE" ``` Use the same freshly fetched main ref that the release branch was based on (`upstream/main` above; use `origin/main` @@ -355,6 +446,7 @@ The project's pre-commit hook runs `poe check` / `poe typing` / `uv lock` and ma ```bash git add -u +git add "python/.github/release-${RELEASE_TAG}.json" # New files are not included by git add -u. git commit -m "" # may fail with "files were modified by this hook" git add -u # re-stage hook-generated changes git commit -m "" # succeeds @@ -383,6 +475,69 @@ git push origin bump-py-ver-release-YYMMDD The push output includes a `Create a pull request for '' on GitHub by visiting: ` line — surface that to the user. +### 10. Maintainer handoff: tag and existing draft (only when authorized) + +After the reviewed bump PR merges, the maintainer selects the exact merged commit and creates the **one** overall +tag matching its `python/.github/release-.json`. The maintainer creates a GitHub **draft** release for that tag, with the +human-written title, complete release notes, and intended prerelease flag. Do not use a published release as the +trigger and do not create per-package tags. Release notes must be complete **before ESRP approval**; do not edit +the draft, tag, or selected source while a release run is in progress. + +Azure DevOps discovery polls eligible existing drafts and tags. It has a disabled-by-default activation switch, +an explicit first eligible release ID, and durable claims on one retained ADO ledger run. A shared exclusive-lock +environment serializes discovery, authorization and finalization to admit one run for the whole batch. No Azure +storage account is needed. No GitHub workflow calls or queues Azure DevOps. + +The protected pipeline reads `python/.github/release-.json` from the exact tagged commit, validates the requested names, +versions and tag, builds once, and runs isolated install/public-import checks against the selected artifacts. +It then waits for protected ESRP approval/publication and verifies the published distributions. Only after that +verification does it upload the **same built wheels/sdists and artifact manifest** to the **existing draft**, +verify those assets, and change `draft` to `false`. It preserves the draft's title, body, and prerelease flag. +The source request (`python/.github/release-.json`) and the output artifact manifest (file names, versions, sizes/checksums and +source provenance) are different records. + +The published-event GitHub workflow validates the tag contract only. It must not build new artifacts, upload +release assets, publish packages, or request Azure DevOps credentials. + +### Failure and recovery + +- Before approval/publication: leave the draft unpublished, inspect the failing checks, and fix the source in a + reviewed PR if needed. Do not move an existing release tag or silently replace its manifest. A new release + attempt requiring source changes needs a new overall tag and appropriate new package versions. +- A queued/failed batch remains claimed. Use the consumer's audited recovery procedure to retry/reconcile the + **same batch and retained artifact set**. Do not delete a claim to create parallel runs, queue individual + distributions, or bypass the approval gate with local `uv publish`/Twine. +- Publication may partially succeed before a failure. PyPI versions are immutable: inspect package versions, + retained artifact hashes, and the publication record before retrying. Already-published versions do not change + the reviewed selection. Never overwrite or rebuild artifacts and assume they are identical. +- If publication succeeds but GitHub asset upload or finalization fails, keep the draft unpublished and retry + that finalization using the verified retained artifacts. Do not republish packages, create a replacement + release, or lose the human-authored release metadata. + +### Rollout order (no publication as part of implementation) + +1. Merge the Azure DevOps consumer first, with discovery disabled. Review the matching contract, artifact + verification, recovery, and draft-only finalization. +2. Merge this repository's producer and retirement of the old GitHub build/upload path second. +3. Reuse the existing Azure DevOps GitHub service connection `github-agent-framework`, already used by the .NET + release pipeline, and authorize it for the Python pipelines. Verify its repository and release access; do not + introduce a separate GitHub App, private key, or Key Vault token-minting setup. Configure the shared + `agent-framework-python-coordination` environment with an exclusive-lock check, initialize a retained discovery + run with `initializeLedger: true`, and set its `releaseLedgerRunId` in both ADO pipelines. Keep ledger and release + runs retained indefinitely. Verify the Build Service permissions and protected ESRP approval gates outside GitHub. + No separate Azure storage identity is needed. Verify the merged upstream revision is compatible. Reusing the + connection does not authorize a new pipeline automatically. +4. Choose the first eligible draft release ID and activate tag/draft discovery **last**, only after the previous + steps are confirmed. Do not create production tags/drafts merely to test rollout. + +The coordinated change requires both PRs and maintainer approval. Merging producer code alone must not enable +a second publisher or silently activate discovery. + +Connection reuse does not mean reusing a release task that rewrites metadata or replaces existing assets. +The consumer's asset-only REST finalizer must still preserve the existing draft's title, body, and prerelease flag, +verify the exact retained artifacts, and publish only after verification. GitHub never receives Azure DevOps +credentials or queue permission. + ## Pitfalls from past cycles — avoid repeating - **Scoping from the wrong tag.** A release may have been tagged between bump cycles. Always compute `LAST_RELEASED_TAG` fresh — do NOT reuse the previous compare base. Symptom: dragging already-shipped PRs into the new CHANGELOG section. @@ -407,4 +562,6 @@ The push output includes a `Create a pull request for '' on GitHub by vi - Lifecycle source of truth: `python/PACKAGE_STATUS.md` - Release validator: `python/scripts/dependencies/validate_dependency_bounds.py --mode release` (changed-package, lock-independent `lowest-direct` and `highest` import probes under a five-minute deadline) +- Batch generator and read-only validator: `python/scripts/prepare_release.py` +- Read-only PR/published-tag check: `.github/workflows/python-release.yml` - Poe task definitions: `python/pyproject.toml` `[tool.poe.tasks]` diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index ec8c01e216..c7f437d7cd 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -465,14 +465,6 @@ Use this for repo-wide tooling and dependency-group refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package ` plus the package-scoped bound validation tasks above. -### Building and Publishing - -#### `publish` -Publish packages to PyPI: -```bash -uv run poe publish -``` - ## Prek Hooks Prek hooks run automatically on commit and stay intentionally lightweight: diff --git a/python/pyproject.toml b/python/pyproject.toml index fe902d5f8e..ec07eb8d5a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -354,10 +354,6 @@ sequence = [ ] args = [{ name = "project", default = "*", options = ["-P", "--package"] }] -[tool.poe.tasks.publish] -help = "Publish built distributions with uv." -cmd = "uv publish" - # Dependency maintenance [tool.poe.tasks.upgrade-dev-dependency-pins] help = "Repin exact workspace development dependencies and on-demand tool requirements." @@ -389,6 +385,14 @@ help = "Run the exhaustive workspace dependency-bound test+typing matrix, option shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test --package \"$project\"" args = [{ name = "project", default = "*", options = ["-P", "--package"] }] +[tool.poe.tasks.prepare-python-release] +help = "Generate .github/release-.json from version changes against the same base as release validation." +cmd = "python -m scripts.prepare_release --base-ref ${base_ref} --tag ${tag}" +args = [ + { name = "base_ref", options = ["-B", "--base-ref"] }, + { name = "tag", options = ["--tag"] }, +] + [tool.poe.tasks.validate-python-release] help = "Refresh uv.lock, then run lower/upper import probes for changed package metadata on each package closure's minimum Python." executor = "simple" diff --git a/python/scripts/prepare_release.py b/python/scripts/prepare_release.py new file mode 100644 index 0000000000..1769ce8341 --- /dev/null +++ b/python/scripts/prepare_release.py @@ -0,0 +1,295 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Generate or check the reviewed batch consumed by the Azure DevOps publisher.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +from packaging.utils import canonicalize_name +from packaging.version import Version + +from scripts.dependencies._dependency_bounds_release_impl import ( + ReleaseProject, + _build_release_project_map, + _selected_release_projects, +) + +RELEASE_MANIFEST_GLOB = ".github/release-*.json" + +# Prefer the public namespace bridges; use documented package entry points where +# no bridge exists. Importing the build module (e.g. agent_framework_meta) is not a smoke test. +SMOKE_IMPORTS: dict[str, tuple[str, ...]] = { + "agent-framework": ("agent_framework:Agent",), + "agent-framework-core": ("agent_framework:Message",), + "agent-framework-a2a": ("agent_framework.a2a:A2AAgent",), + "agent-framework-ag-ui": ("agent_framework.ag_ui:AgentFrameworkAgent",), + "agent-framework-anthropic": ("agent_framework.anthropic:AnthropicClient",), + "agent-framework-azure-ai-search": ("agent_framework.azure:AzureAISearchStore",), + "agent-framework-azure-contentunderstanding": ("agent_framework.foundry:ContentUnderstandingContextProvider",), + "agent-framework-azure-cosmos": ("agent_framework.azure:CosmosStore",), + "agent-framework-azure-cosmos-memory": ("agent_framework_azure_cosmos_memory:CosmosMemoryContextProvider",), + "agent-framework-azure-documentdb": ("agent_framework_azure_documentdb:AzureDocumentDBStore",), + "agent-framework-bedrock": ("agent_framework.amazon:BedrockChatClient",), + "agent-framework-chatkit": ("agent_framework.chatkit:stream_agent_response",), + "agent-framework-claude": ("agent_framework.anthropic:ClaudeAgent",), + "agent-framework-copilotstudio": ("agent_framework.microsoft:CopilotStudioAgent",), + "agent-framework-declarative": ("agent_framework.declarative:AgentFactory",), + "agent-framework-devui": ("agent_framework.devui:serve",), + "agent-framework-duckdb": ("agent_framework_duckdb:DuckDBStore",), + "agent-framework-foundry": ("agent_framework.foundry:FoundryAgent",), + "agent-framework-foundry-hosting": ("agent_framework.foundry:FoundryAgentSessionStore",), + "agent-framework-foundry-local": ("agent_framework.foundry:FoundryLocalClient",), + "agent-framework-gemini": ("agent_framework.gemini:GeminiEmbeddingClient",), + "agent-framework-github-copilot": ("agent_framework.github:GitHubCopilotAgent",), + "agent-framework-hosting": ("agent_framework_hosting:AgentState",), + "agent-framework-hosting-a2a": ("agent_framework_hosting_a2a:AgentA2AAdapter",), + "agent-framework-hosting-mcp": ("agent_framework_hosting_mcp:AgentMCPTool",), + "agent-framework-hosting-responses": ("agent_framework_hosting_responses:responses_from_run",), + "agent-framework-hosting-telegram": ("agent_framework_hosting_telegram:telegram_to_run",), + "agent-framework-hyperlight": ("agent_framework.hyperlight:HyperlightCodeActProvider",), + "agent-framework-mem0": ("agent_framework.mem0:Mem0ContextProvider",), + "agent-framework-mistral": ("agent_framework.mistral:MistralChatClient",), + "agent-framework-mongodb": ("agent_framework_mongodb:MongoDBStore",), + "agent-framework-monty": ("agent_framework.monty:MontyCodeActProvider",), + "agent-framework-nvidia": ("agent_framework_nvidia:NvidiaEmbeddingClient",), + "agent-framework-ollama": ("agent_framework.ollama:OllamaChatClient",), + "agent-framework-openai": ("agent_framework.openai:OpenAIChatClient",), + "agent-framework-oracle": ("agent_framework_oracle:OracleStore",), + "agent-framework-orchestrations": ("agent_framework.orchestrations:SequentialBuilder",), + "agent-framework-postgres": ("agent_framework_postgres:PostgresStore",), + "agent-framework-purview": ("agent_framework.microsoft:PurviewPolicyMiddleware",), + "agent-framework-qdrant": ("agent_framework_qdrant:QdrantStore",), + "agent-framework-redis": ("agent_framework.redis:RedisStore",), + "agent-framework-sql-server": ("agent_framework_sql_server:SqlServerStore",), + "agent-framework-tools": ("agent_framework.tools:LocalShellTool",), + "agent-framework-typesafe": ("agent_framework_typesafe:TypeSafeChatClient",), +} + + +def _git(workspace_root: Path, *args: str) -> str: + return subprocess.run(["git", *args], cwd=workspace_root, capture_output=True, text=True, check=True).stdout + + +def _base_release_versions(workspace_root: Path, base_commit: str) -> dict[str, str]: + """Discover the base workspace by the same membership rules as the current tree.""" + paths = _git( + workspace_root, "ls-tree", "-r", "--name-only", "-z", base_commit, "--", "pyproject.toml", "packages/" + ).split("\0") + with tempfile.TemporaryDirectory(prefix="release-metadata-") as directory: + snapshot = Path(directory) + for value in paths: + path = Path(value) + if path.name != "pyproject.toml": + continue + destination = snapshot / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(_git(workspace_root, "show", f"{base_commit}:./{path.as_posix()}")) + return {name: project.version for name, project in _build_release_project_map(snapshot).items()} + + +def selected_versions(workspace_root: Path, base_ref: str) -> dict[str, str]: + """Reuse release-probe selection, excluding dependency-only metadata edits.""" + base_commit = _git(workspace_root, "rev-parse", "--verify", "--end-of-options", f"{base_ref}^{{commit}}").strip() + projects = _build_release_project_map(workspace_root) + untracked = set(_git(workspace_root, "ls-files", "--others", "--exclude-standard", "-z").split("\0")) + untracked_metadata = sorted( + (project.project_path / "pyproject.toml").as_posix() + for project in projects.values() + if (project.project_path / "pyproject.toml").as_posix() in untracked + ) + if untracked_metadata: + raise ValueError( + "Stage new workspace metadata with git add before preparing a release: " + ", ".join(untracked_metadata) + ) + base_versions = _base_release_versions(workspace_root, base_commit) + selected = _selected_release_projects( + workspace_root=workspace_root, projects=projects, base_ref=base_commit, package_filter=None + ) + versions: dict[str, str] = {} + for project in selected: + name = canonicalize_name(project.package_name) + if name in base_versions: + previous_version = Version(base_versions[name]) + if previous_version == Version(project.version): + continue + if Version(project.version) < previous_version: + raise ValueError(f"{project.package_name}: version must increase from {previous_version}") + versions[project.package_name] = project.version + if "agent-framework-core" in versions and "agent-framework" not in versions: + raise ValueError("Releasing agent-framework-core requires a version increase for agent-framework too") + return dict(sorted(versions.items())) + + +def _validate_tag(tag: object) -> None: + if not isinstance(tag, str) or not re.fullmatch(r"python-[0-9]+\.[0-9]+\.[0-9]+[a-zA-Z0-9.]*", tag): + raise ValueError("Expected one overall python- tag, not a per-package tag") + Version(tag.removeprefix("python-")) + + +def release_manifest_path(tag: str) -> Path: + """Derive the request path from the exact expected overall tag.""" + _validate_tag(tag) + return Path(".github") / f"release-{tag}.json" + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate manifest key: {key}") + result[key] = value + return result + + +def validate_manifest( + payload: object, + projects: dict[str, ReleaseProject], + *, + expected_tag: str, +) -> None: + """Check the contract without contacting PyPI or executing package imports.""" + _validate_tag(expected_tag) + if not isinstance(payload, dict) or set(payload) - {"tag", "packages", "smoke_imports"}: + raise ValueError("Manifest must be an object containing tag, packages, and optional smoke_imports") + if payload.get("tag") != expected_tag: + raise ValueError(f"Manifest tag does not match expected tag {expected_tag}") + packages = payload.get("packages") + if not isinstance(packages, dict) or not packages: + raise ValueError("Manifest packages must be a non-empty object") + smoke_imports = payload.get("smoke_imports", {}) + if not isinstance(smoke_imports, dict) or set(smoke_imports) - set(packages): + raise ValueError("smoke_imports must be an object keyed only by selected distributions") + for name, version in packages.items(): + if not isinstance(name, str) or canonicalize_name(name) != name or name not in projects: + raise ValueError(f"Unknown or non-canonical distribution: {name}") + if not isinstance(version, str) or version != projects[name].version: + raise ValueError(f"{name}: manifest version must equal project.version {projects[name].version}") + Version(version) + if name not in SMOKE_IMPORTS: + raise ValueError(f"{name}: add reviewed public smoke imports before releasing this package") + # Older consumers have defaults for these three distributions only. + imports = smoke_imports.get( + name, + list(SMOKE_IMPORTS[name]) + if name in {"agent-framework", "agent-framework-core", "agent-framework-foundry"} + else None, + ) + if ( + not isinstance(imports, list) + or not imports + or any( + not isinstance(value, str) or not re.fullmatch(r"[a-zA-Z]\w*(?:\.[a-zA-Z]\w*)*:[a-zA-Z]\w*", value) + for value in imports + ) + ): + raise ValueError(f"{name}: expected non-empty public module:export smoke imports") + if not set(SMOKE_IMPORTS[name]).issubset(imports): + raise ValueError( + f"{name}: smoke imports must include the reviewed public entry points {SMOKE_IMPORTS[name]}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-ref", help="Same main ref used by validate-python-release") + parser.add_argument( + "--tag", help="Expected overall python-* tag (required to generate or validate a release event)" + ) + parser.add_argument( + "--check", action="store_true", help="Validate instead of writing .github/release-.json" + ) + parser.add_argument( + "--ci", action="store_true", help="Check changed batches; skip PRs with no version/manifest change" + ) + args = parser.parse_args() + workspace_root = Path(__file__).resolve().parents[1] + if (not args.check or args.ci) and not args.base_ref: + parser.error("--base-ref is required for generation and --ci") + if args.ci and not args.check: + parser.error("--ci requires --check") + if not args.ci and not args.tag: + parser.error("--tag is required outside PR CI") + try: + base_commit = ( + _git(workspace_root, "rev-parse", "--verify", "--end-of-options", f"{args.base_ref}^{{commit}}").strip() + if args.base_ref + else None + ) + versions = selected_versions(workspace_root, base_commit) if base_commit else None + expected_tag = args.tag + if args.ci: + changed_paths = [ + Path(value) + for value in _git( + workspace_root, + "diff", + "--relative", + "--no-renames", + "--name-only", + "-z", + base_commit, + "--", + RELEASE_MANIFEST_GLOB, + ).split("\0") + if value + ] + if not versions and not changed_paths: + print("No release batch changes.") + return + if ( + not changed_paths + and not args.tag + and versions + and not (set(versions) & _base_release_versions(workspace_root, base_commit).keys()) + ): + print("New-package development only; no release manifest required.") + return + if len(changed_paths) != 1: + raise ValueError("A release PR must add exactly one .github/release-.json batch") + expected_tag = expected_tag or changed_paths[0].stem.removeprefix("release-") + if changed_paths[0] != release_manifest_path(expected_tag): + raise ValueError("Changed manifest filename does not match the expected release tag") + relative_manifest = release_manifest_path(expected_tag) + manifest_path = workspace_root / relative_manifest + if ( + base_commit + and _git(workspace_root, "ls-tree", "--name-only", base_commit, "--", relative_manifest.as_posix()).strip() + ): + raise ValueError("Historical release manifests must not be changed or removed; use a new tag") + if ( + base_commit + and f"refs/tags/{expected_tag}" + in _git(workspace_root, "for-each-ref", "--format=%(refname)", f"refs/tags/{expected_tag}").splitlines() + ): + raise ValueError(f"Release tag {expected_tag} already exists; choose a new overall release tag") + if args.check: + payload = json.loads(manifest_path.read_text(), object_pairs_hook=_reject_duplicate_keys) + else: + payload = { + "tag": args.tag, + "packages": versions, + "smoke_imports": {name: list(SMOKE_IMPORTS.get(name, ())) for name in versions or {}}, + } + validate_manifest(payload, _build_release_project_map(workspace_root), expected_tag=expected_tag) + if versions is not None and payload["packages"] != versions: + raise ValueError("Manifest packages must exactly match version changes from --base-ref; regenerate it") + if not args.check: + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(payload, indent=2) + "\n") + print( + f"{'Validated' if args.check else 'Generated'} {relative_manifest.as_posix()} for {expected_tag}: " + f"{len(payload['packages'])} packages" + ) + except (OSError, ValueError, subprocess.CalledProcessError) as exc: + parser.exit(1, f"Release manifest error: {exc}\n") + + +if __name__ == "__main__": + main() diff --git a/python/scripts/tests/test_prepare_release.py b/python/scripts/tests/test_prepare_release.py new file mode 100644 index 0000000000..955be9862f --- /dev/null +++ b/python/scripts/tests/test_prepare_release.py @@ -0,0 +1,521 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Contract and selection tests that do not build or publish distributions.""" + +import ast +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts import prepare_release +from scripts.dependencies._dependency_bounds_release_impl import _build_release_project_map +from scripts.prepare_release import SMOKE_IMPORTS, main, release_manifest_path, selected_versions, validate_manifest + + +def _git(path: Path, *args: str) -> str: + return subprocess.run(["git", *args], cwd=path, check=True, capture_output=True, text=True).stdout.strip() + + +def _project(path: Path, name: str, version: str) -> None: + path.mkdir(parents=True, exist_ok=True) + (path / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "{version}"\nrequires-python = ">=3.10"\n' + ) + + +@pytest.fixture +def workspace(tmp_path: Path, monkeypatch) -> Path: + root = tmp_path / "python" + _project(root, "agent-framework", "1.23.0") + with (root / "pyproject.toml").open("a") as file: + file.write('\n[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/lab"]\n') + _project(root / "packages/core", "agent-framework-core", "1.23.0") + _project(root / "packages/foundry", "agent-framework-foundry", "1.15.0") + _project(root / "packages/lab", "agent-framework-lab", "0.1.0") + (root / ".github").mkdir() + _git(tmp_path, "init", "-q") + _git(tmp_path, "add", ".") + _git(tmp_path, "-c", "user.name=Release Test", "-c", "user.email=release@example.invalid", "commit", "-qm", "base") + monkeypatch.setattr(prepare_release, "__file__", str(root / "scripts/prepare_release.py")) + return root + + +def _bump(path: Path, old: str, new: str) -> None: + file = path / "pyproject.toml" + file.write_text(file.read_text().replace(f'version = "{old}"', f'version = "{new}"')) + + +def _payload() -> dict: + return { + "tag": "python-1.24.0", + "packages": {"agent-framework": "1.23.0", "agent-framework-foundry": "1.15.0"}, + "smoke_imports": { + "agent-framework": ["agent_framework:Agent"], + "agent-framework-foundry": ["agent_framework.foundry:FoundryAgent"], + }, + } + + +def test_selection_reuses_metadata_diff_but_only_releases_changed_versions(workspace: Path) -> None: + _bump(workspace, "1.23.0", "1.24.0") + _bump(workspace / "packages/core", "1.23.0", "1.24.0") + with (workspace / "packages/foundry/pyproject.toml").open("a") as file: + file.write('dependencies = ["agent-framework-core>=1.24.0,<2"]\n') + _bump(workspace / "packages/lab", "0.1.0", "0.2.0") + _project(workspace / "packages/new", "agent-framework-new", "0.1.0") + _git(workspace, "add", ".") + assert selected_versions(workspace, "HEAD") == { + "agent-framework": "1.24.0", + "agent-framework-core": "1.24.0", + "agent-framework-new": "0.1.0", + } + + +def test_selection_rejects_version_regression(workspace: Path) -> None: + _bump(workspace / "packages/core", "1.23.0", "1.22.0") + with pytest.raises(ValueError, match="version must increase"): + selected_versions(workspace, "HEAD") + + +@pytest.mark.parametrize("with_existing_bump", [False, True]) +def test_untracked_metadata_fails_with_staging_instruction(workspace: Path, with_existing_bump: bool) -> None: + _project(workspace / "packages/new", "agent-framework-new", "0.1.0") + if with_existing_bump: + _bump(workspace / "packages/foundry", "1.15.0", "1.16.0") + with pytest.raises(ValueError, match="Stage new workspace metadata with git add.*packages/new/pyproject.toml"): + selected_versions(workspace, "HEAD") + + +def test_untracked_excluded_metadata_does_not_block_selection(workspace: Path) -> None: + _project(workspace / "packages/lab/nested", "agent-framework-lab-nested", "0.1.0") + assert selected_versions(workspace, "HEAD") == {} + + +@pytest.mark.parametrize("version", ["1.15.0", "1.16.0", "1.14.0"]) +def test_package_move_compares_distribution_identity(workspace: Path, version: str) -> None: + (workspace / "packages/foundry").rename(workspace / "packages/moved") + _bump(workspace / "packages/moved", "1.15.0", version) + _git(workspace, "add", ".") + if version == "1.14.0": + with pytest.raises(ValueError, match="version must increase"): + selected_versions(workspace, "HEAD") + else: + expected = {} if version == "1.15.0" else {"agent-framework-foundry": "1.16.0"} + assert selected_versions(workspace, "HEAD") == expected + + +def test_distribution_rename_is_new_even_at_same_version(workspace: Path) -> None: + _project(workspace / "packages/foundry", "agent-framework-renamed", "1.15.0") + assert selected_versions(workspace, "HEAD") == {"agent-framework-renamed": "1.15.0"} + + +def test_excluded_base_distribution_is_new_when_added_to_workspace(workspace: Path) -> None: + _project(workspace / "packages/foundry", "agent-framework-lab", "0.1.0") + # Lab is excluded from the base workspace, so this is a genuinely new release identity. + assert selected_versions(workspace, "HEAD") == {"agent-framework-lab": "0.1.0"} + + +def test_canonical_identity_change_does_not_create_a_new_distribution(workspace: Path) -> None: + _project(workspace / "packages/foundry", "agent_framework_foundry", "1.15.0") + assert selected_versions(workspace, "HEAD") == {} + + +def test_core_bump_requires_root_version_increase_even_if_pin_is_updated(workspace: Path) -> None: + _bump(workspace / "packages/core", "1.23.0", "1.24.0") + root = workspace / "pyproject.toml" + root.write_text( + root.read_text().replace( + 'requires-python = ">=3.10"', + ('requires-python = ">=3.10"\ndependencies = ["agent-framework-core[all]==1.24.0"]'), + ) + ) + with pytest.raises(ValueError, match="requires a version increase for agent-framework"): + selected_versions(workspace, "HEAD") + + +def test_core_and_root_may_bump_together(workspace: Path) -> None: + _bump(workspace, "1.23.0", "1.24.0") + _bump(workspace / "packages/core", "1.23.0", "1.24.0") + assert selected_versions(workspace, "HEAD") == {"agent-framework": "1.24.0", "agent-framework-core": "1.24.0"} + + +def test_root_may_bump_independently(workspace: Path) -> None: + _bump(workspace, "1.23.0", "1.24.0") + assert selected_versions(workspace, "HEAD") == {"agent-framework": "1.24.0"} + + +def test_generate_and_check_batch_without_duplicate_version_input(workspace: Path, monkeypatch) -> None: + _bump(workspace, "1.23.0", "1.24.0") + _bump(workspace / "packages/core", "1.23.0", "1.24.0") + _bump(workspace / "packages/foundry", "1.15.0", "1.16.0") + args = ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.24.0"] + monkeypatch.setattr(sys, "argv", args) + main() + payload = json.loads((workspace / ".github/release-python-1.24.0.json").read_text()) + assert not (workspace / "release.json").exists() + assert not (workspace / ".github/release.json").exists() + assert payload["packages"] == { + "agent-framework": "1.24.0", + "agent-framework-core": "1.24.0", + "agent-framework-foundry": "1.16.0", + } + assert set(payload["smoke_imports"]) == set(payload["packages"]) + monkeypatch.setattr(sys, "argv", [*args, "--check"]) + main() + _bump(workspace / "packages/foundry", "1.16.0", "1.17.0") + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +def test_check_rejects_omitted_bumped_distribution(workspace: Path, monkeypatch) -> None: + _bump(workspace, "1.23.0", "1.24.0") + _bump(workspace / "packages/core", "1.23.0", "1.24.0") + (workspace / ".github/release-python-1.24.0.json").write_text(json.dumps(_payload())) + _git(workspace, "add", ".github/release-python-1.24.0.json") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +def test_ci_skips_unrelated_metadata_edits_without_reading_stale_manifest(workspace: Path, monkeypatch, capsys) -> None: + with (workspace / "pyproject.toml").open("a") as file: + file.write("\n[tool.example]\nsetting = true\n") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + main() + assert "No release batch changes." in capsys.readouterr().out + assert not list((workspace / ".github").glob("release-*.json")) + + +def test_ci_requires_manifest_when_versions_change(workspace: Path, monkeypatch) -> None: + _bump(workspace / "packages/foundry", "1.15.0", "1.16.0") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +def test_ci_allows_new_package_development_without_release_intent(workspace: Path, monkeypatch, capsys) -> None: + _project(workspace / "packages/new", "agent-framework-new", "0.1.0") + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + main() + assert "New-package development only" in capsys.readouterr().out + assert not list((workspace / ".github").glob("release-*.json")) + + +def test_ci_requires_manifest_for_mixed_new_package_and_version_bump(workspace: Path, monkeypatch, capsys) -> None: + _project(workspace / "packages/new", "agent-framework-new", "0.1.0") + _bump(workspace / "packages/foundry", "1.15.0", "1.16.0") + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "exactly one" in capsys.readouterr().err + + +def test_ci_with_explicit_tag_requires_manifest_even_for_new_package_only(workspace: Path, monkeypatch, capsys) -> None: + _project(workspace / "packages/redis", "agent-framework-redis", "1.0.0rc1") + _git(workspace, "add", ".") + monkeypatch.setattr( + sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD", "--tag", "python-1.24.0"] + ) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "exactly one" in capsys.readouterr().err + + +def test_explicit_new_package_release_is_generated_and_validated(workspace: Path, monkeypatch) -> None: + _project(workspace / "packages/redis", "agent-framework-redis", "1.0.0rc1") + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.24.0"]) + main() + manifest = workspace / ".github/release-python-1.24.0.json" + assert json.loads(manifest.read_text())["packages"] == {"agent-framework-redis": "1.0.0rc1"} + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + main() + + +def test_new_package_manifest_is_not_ignored_in_ci(workspace: Path, monkeypatch, capsys) -> None: + _project(workspace / "packages/redis", "agent-framework-redis", "1.0.0rc1") + manifest = workspace / ".github/release-python-1.24.0.json" + manifest.write_text(json.dumps(_payload())) + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "must exactly match" in capsys.readouterr().err + + +@pytest.mark.parametrize("annotated", [False, True]) +@pytest.mark.parametrize("mode", ["generate", "check", "ci"]) +def test_preparation_rejects_an_existing_tag(workspace: Path, monkeypatch, capsys, annotated: bool, mode: str) -> None: + _bump(workspace / "packages/foundry", "1.15.0", "1.16.0") + manifest = workspace / ".github/release-python-1.24.0.json" + manifest.write_text("{}") + _git(workspace, "add", ".") + tag_args = ["tag", "python-1.24.0"] + if annotated: + tag_args.extend(["-a", "-m", "existing release"]) + _git(workspace, "-c", "user.name=Release Test", "-c", "user.email=release@example.invalid", *tag_args) + args = ["prepare_release", "--base-ref", "HEAD"] + if mode == "ci": + args.extend(["--check", "--ci"]) + else: + args.extend(["--tag", "python-1.24.0"]) + if mode == "check": + args.append("--check") + monkeypatch.setattr(sys, "argv", args) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "tag python-1.24.0 already exists" in capsys.readouterr().err + assert manifest.read_text() == "{}" + + +def test_same_named_branch_does_not_block_a_new_release_tag(workspace: Path, monkeypatch) -> None: + _git(workspace, "branch", "python-1.24.0") + _bump(workspace / "packages/foundry", "1.15.0", "1.16.0") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.24.0"]) + main() + assert (workspace / ".github/release-python-1.24.0.json").exists() + + +def test_generation_failure_does_not_replace_existing_manifest(workspace: Path, monkeypatch) -> None: + _project(workspace / "packages/new", "agent-framework-new", "0.1.0") + _git(workspace, "add", ".") + (workspace / ".github/release-python-1.24.0.json").write_text("previous reviewed batch\n") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.24.0"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert (workspace / ".github/release-python-1.24.0.json").read_text() == "previous reviewed batch\n" + + +def test_release_event_check_needs_expected_tag_but_not_release_base(workspace: Path, monkeypatch) -> None: + (workspace / ".github/release-python-1.24.0.json").write_text(json.dumps(_payload())) + _git(workspace, "tag", "python-1.24.0") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--tag", "python-1.24.0"]) + main() + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--tag", "python-1.25.0"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +@pytest.mark.parametrize("old_path", ["release.json", ".github/release.json"]) +def test_fixed_manifest_is_not_a_fallback(workspace: Path, monkeypatch, capsys, old_path: str) -> None: + (workspace / old_path).write_text(json.dumps(_payload())) + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--tag", "python-1.24.0"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert str(workspace / ".github/release-python-1.24.0.json") in capsys.readouterr().err + + +def test_per_tag_path_uses_exact_full_tag() -> None: + assert release_manifest_path("python-1.24.0") == Path(".github/release-python-1.24.0.json") + assert release_manifest_path("python-1.0.0b261029") == Path(".github/release-python-1.0.0b261029.json") + + +@pytest.mark.parametrize("tag", ["../python-1.24.0", "python-1.24.0/../other", "python-1.24.0\n", "python-1.24.0.json"]) +def test_path_derivation_rejects_invalid_tags(tag: str) -> None: + with pytest.raises(ValueError): + release_manifest_path(tag) + + +def _commit_archive(workspace: Path) -> Path: + historical = workspace / ".github/release-python-1.23.0.json" + historical.write_text(json.dumps({"tag": "python-1.23.0", "packages": {"agent-framework": "1.23.0"}})) + _git(workspace, "add", ".") + _git( + workspace, + "-c", + "user.name=Release Test", + "-c", + "user.email=release@example.invalid", + "commit", + "-qm", + "archive", + ) + return historical + + +def test_generation_and_ci_preserve_history_without_revalidating_old_versions(workspace: Path, monkeypatch) -> None: + historical = _commit_archive(workspace) + historical_bytes = historical.read_bytes() + _bump(workspace, "1.23.0", "1.24.0") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.24.0"]) + main() + current = workspace / ".github/release-python-1.24.0.json" + assert json.loads(current.read_text())["packages"] == {"agent-framework": "1.24.0"} + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + main() + assert historical.read_bytes() == historical_bytes + + +@pytest.mark.parametrize("action", ["modify", "delete", "regenerate"]) +def test_historical_manifest_cannot_be_changed_or_removed(workspace: Path, monkeypatch, capsys, action: str) -> None: + historical = _commit_archive(workspace) + if action == "regenerate": + args = ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.23.0"] + else: + if action == "modify": + historical.write_text("{}") + else: + historical.unlink() + args = ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"] + monkeypatch.setattr(sys, "argv", args) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "Historical release manifests" in capsys.readouterr().err + + +def test_ci_rejects_multiple_new_batch_files(workspace: Path, monkeypatch, capsys) -> None: + for tag in ("python-1.24.0", "python-1.25.0"): + (workspace / release_manifest_path(tag)).write_text(json.dumps(_payload())) + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "exactly one" in capsys.readouterr().err + + +def test_ci_rejects_renaming_a_historical_manifest(workspace: Path, monkeypatch, capsys) -> None: + historical = _commit_archive(workspace) + historical.rename(workspace / ".github/release-python-1.24.0.json") + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "exactly one" in capsys.readouterr().err + + +def test_ci_rejects_filename_content_tag_mismatch(workspace: Path, monkeypatch, capsys) -> None: + (workspace / ".github/release-python-1.25.0.json").write_text(json.dumps(_payload())) + _git(workspace, "add", ".") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert "expected tag python-1.25.0" in capsys.readouterr().err + + +def test_explicit_tag_never_falls_back_to_another_historical_manifest(workspace: Path, monkeypatch, capsys) -> None: + _commit_archive(workspace) + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--tag", "python-1.24.0"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + assert str(workspace / ".github/release-python-1.24.0.json") in capsys.readouterr().err + + +@pytest.mark.parametrize("tag", ["python-1.24.0", "python-1.0.0b261029", "python-1.0.0rc2", "python-1.0.0.post1"]) +def test_overall_tags_allow_independent_package_versions(workspace: Path, tag: str) -> None: + payload = _payload() + payload["tag"] = tag + validate_manifest(payload, _build_release_project_map(workspace), expected_tag=tag) + + +@pytest.mark.parametrize("tag", ["python-foundry-1.16.0", "python-", "v1.24.0", "python-1.0.0nonsense", "python-1.2"]) +def test_rejects_invalid_and_per_package_tags(workspace: Path, tag: str) -> None: + with pytest.raises(ValueError): + validate_manifest(_payload(), _build_release_project_map(workspace), expected_tag=tag) + + +def test_expected_tag_prevents_reuse_of_stale_manifest(workspace: Path) -> None: + with pytest.raises(ValueError, match="expected tag"): + validate_manifest(_payload(), _build_release_project_map(workspace), expected_tag="python-1.25.0") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("packages", {}), + ("packages", []), + ("packages", {"agent_framework": "1.23.0"}), + ("packages", {"agent-framework-lab": "0.1.0"}), + ("packages", {"agent-framework": "1.24.0"}), + ("packages", {"agent-framework": 123}), + ("smoke_imports", []), + ("smoke_imports", {"agent-framework-core": ["agent_framework:Message"]}), + ("smoke_imports", {"agent-framework": []}), + ("smoke_imports", {"agent-framework": ["agent_framework_meta:Agent"]}), + ("smoke_imports", {"agent-framework": ["agent_framework._agents:Agent"]}), + ], +) +def test_rejects_invalid_contract(workspace: Path, field: str, value: object) -> None: + payload = _payload() + payload[field] = value + with pytest.raises(ValueError): + validate_manifest(payload, _build_release_project_map(workspace), expected_tag="python-1.24.0") + + +def test_unknown_distribution_requires_reviewed_imports(workspace: Path) -> None: + _project(workspace / "packages/new", "agent-framework-new", "0.1.0") + payload = {"tag": "python-1.24.0", "packages": {"agent-framework-new": "0.1.0"}} + with pytest.raises(ValueError, match="add reviewed public smoke imports"): + validate_manifest(payload, _build_release_project_map(workspace), expected_tag="python-1.24.0") + + +def test_non_default_distribution_requires_smoke_imports(workspace: Path) -> None: + _project(workspace / "packages/redis", "agent-framework-redis", "1.0.0rc1") + payload = {"tag": "python-1.24.0", "packages": {"agent-framework-redis": "1.0.0rc1"}} + with pytest.raises(ValueError, match="non-empty public module:export"): + validate_manifest(payload, _build_release_project_map(workspace), expected_tag="python-1.24.0") + payload["smoke_imports"] = {"agent-framework-redis": ["agent_framework.redis:RedisStore"]} + validate_manifest(payload, _build_release_project_map(workspace), expected_tag="python-1.24.0") + + +def test_smoke_imports_are_additive_and_default_mapping_is_optional(workspace: Path) -> None: + payload = _payload() + payload.pop("smoke_imports") + validate_manifest(payload, _build_release_project_map(workspace), expected_tag="python-1.24.0") + payload["smoke_imports"] = {"agent-framework": ["agent_framework:Agent", "agent_framework:Message"]} + validate_manifest(payload, _build_release_project_map(workspace), expected_tag="python-1.24.0") + + +@pytest.mark.parametrize("text", ['{"tag":"python-1.24.0","tag":"python-1.25.0"}', '["invalid"]', "{"]) +def test_cli_rejects_malformed_manifest(workspace: Path, monkeypatch, text: str) -> None: + (workspace / ".github/release-python-1.24.0.json").write_text(text) + _git(workspace, "add", ".github/release-python-1.24.0.json") + monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +def test_inventory_covers_every_workspace_distribution_with_a_public_export() -> None: + root = Path(__file__).resolve().parents[2] + projects = _build_release_project_map(root) + assert set(SMOKE_IMPORTS) == set(projects) + for distribution, imports in SMOKE_IMPORTS.items(): + for value in imports: + module, symbol = value.split(":") + relative = Path(*module.split(".")) / "__init__.py" + candidates = list(root.glob(f"packages/*/{relative}")) + assert len(candidates) == 1, (distribution, module) + tree = ast.parse(candidates[0].read_text()) + public_names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(target, ast.Name) and target.id in {"__all__", "_IMPORTS"} for target in targets): + public_names.update( + child.value + for child in ast.walk(node.value) + if isinstance(child, ast.Constant) and isinstance(child.value, str) + ) + assert symbol in public_names, (distribution, module, symbol) diff --git a/python/shared_tasks.toml b/python/shared_tasks.toml index eb177c0f7e..f314afec6d 100644 --- a/python/shared_tasks.toml +++ b/python/shared_tasks.toml @@ -14,10 +14,6 @@ cmd = "ruff check" help = "Run Pyright for this package." cmd = "pyright" -[tool.poe.tasks.publish] -help = "Publish this package with uv." -cmd = "uv publish" - [tool.poe.tasks.clean-dist] help = "Remove generated dist artifacts for this package." cmd = "rm -rf dist"