From a1773d4d9b5f4e7d568dd0a4b3e8b35bc47830e5 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 29 Oct 2026 09:45:06 +0100 Subject: [PATCH 1/8] Python: prepare coordinated release batches for Azure DevOps --- .github/workflows/python-release.yml | 159 ++++------- ...0046-python-coordinated-release-batches.md | 57 ++++ .../agent-framework-py-release/SKILL.md | 130 ++++++++- python/DEV_SETUP.md | 18 +- python/pyproject.toml | 12 +- python/scripts/prepare_release.py | 212 +++++++++++++++ python/scripts/tests/test_prepare_release.py | 247 ++++++++++++++++++ python/shared_tasks.toml | 4 - 8 files changed, 709 insertions(+), 130 deletions(-) create mode 100644 docs/decisions/0046-python-coordinated-release-batches.md create mode 100644 python/scripts/prepare_release.py create mode 100644 python/scripts/tests/test_prepare_release.py diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index c800e1787da..ef8abfeb6b6 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/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' && github.event.release.tag_name || github.ref }} + 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/docs/decisions/0046-python-coordinated-release-batches.md b/docs/decisions/0046-python-coordinated-release-batches.md new file mode 100644 index 00000000000..698198eb0e5 --- /dev/null +++ b/docs/decisions/0046-python-coordinated-release-batches.md @@ -0,0 +1,57 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-10-29 +deciders: [eavanvalkenburg] +informed: [Python release maintainers, engineering and architecture leads] +--- + +# Coordinate Python releases through a reviewed batch and one protected publisher + +## Context and Problem Statement + +Python distributions have independent versions, while release preparation already selects a batch through +CHANGELOG-driven version bumps. The GitHub published-release workflow currently builds and uploads assets after +the release is public. Production package publication needs protected Azure DevOps ESRP approval, and GitHub +must not receive credentials or federated permission to queue that pipeline. + +## Decision Drivers + +- Review selected packages, versions, and public smoke imports in the same version-bump PR. +- Build once and publish those exact artifacts, not a later rebuild. +- Preserve maintainer-written release notes and keep the release a draft until verification succeeds. +- Use one overall tag and one protected run for the whole batch, without adding GitHub-to-Azure DevOps authority. + +## Considered Options + +- Keep the GitHub post-publication asset builder. This preserves current behavior, but creates competing artifact + paths and exposes a public release before its verified artifacts are attached. +- Queue per-package runs from GitHub. This gives independent queues but duplicates batch selection and approvals, + introduces race conditions, and requires forbidden cross-system credentials. +- Check in a generated batch manifest and let Azure DevOps discover eligible existing drafts and tags. This + requires coordinated rollout and durable claims, but preserves a single reviewed selection and publisher. + +## Decision Outcome + +Proposed option: **generated batch manifest with Azure DevOps discovery and publication**. + +`python/release.json` contains the expected overall `python-` tag, canonical distribution names mapped +to their exact metadata versions, and optional additive public `module:export` smoke checks. The release tooling +generates all selected smoke checks and derives versions from the existing changed-metadata selection, excluding +dependency-only edits whose version did not change. The source manifest does not contain its own commit SHA. + +Azure DevOps resolves the existing tag, reads the manifest from that exact commit, claims the batch once, builds, +checks isolated installs/imports, obtains protected approval, publishes through ESRP, and verifies publication. +It then uploads the retained exact artifacts and output artifact manifest to the existing draft and changes +only its draft status, preserving the human-written title/body and prerelease flag. PyPI absence validates the +batch; it does not select packages. Partial publication requires reconciliation, not a new selection or rebuild. + +GitHub performs read-only PR and published-tag contract checks. The previous asset builder and local production +publish tasks are retired. Public runtime APIs and package versions do not change as part of this infrastructure +change. The standalone Lab workspace remains outside the coordinated release batch. + +Merge the disabled Azure DevOps consumer first, then the upstream producer and old-publisher retirement. +Configure identities, durable claims, and protected gates before choosing the first eligible draft ID and +activating discovery last. Neither PR authorizes production tags, drafts, publication, or infrastructure changes. +The [release skill](../../python/.github/skills/agent-framework-py-release/SKILL.md) contains the operator procedure +and recovery requirements. Maintainer and engineering/architecture approval is required before accepting this ADR. diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index 58993d54787..50715e17459 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,10 @@ 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/release.json` in the same version-bump PR. + 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 +326,73 @@ 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, generate the manifest from that same metadata diff: ```bash -cd python && uv run --group tool-runtime poe validate-python-release --base-ref "$RELEASE_BASE" +uv run --directory python --group tool-runtime poe prepare-python-release --base-ref "$RELEASE_BASE" --tag "python-" +``` + +The generator reuses the release validator's changed-`pyproject.toml` selection, then includes only packages whose +version increased (or new workspace packages). Dependency-only metadata edits remain part of dependency validation +but do not select an unchanged version for publication. The root/core coupling and explicit cohort decision above +still determine which versions are bumped. The excluded standalone Lab workspace is not part of this batch. + +Commit **`python/release.json` in this same PR**, together with the version and lockfile changes. Do not maintain a +duplicate list of versions manually. Regenerate after changing the bump list. For example: + +```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 the manifest there. +- 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 "python-" +``` + +PR CI enforces exact agreement between the manifest and version changes. PRs without version or manifest changes +(including dependency maintenance) do not need to rewrite an old manifest. + +```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` @@ -352,6 +422,7 @@ The project's pre-commit hook runs `poe check` / `poe typing` / `uv lock` and ma ```bash git add -u +git add python/release.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 @@ -380,6 +451,57 @@ 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 `release.json`. The maintainer creates a GitHub **draft** release for that existing 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 Azure Blob compare-and-swap claims to admit one run for the +whole batch. No GitHub workflow calls or queues Azure DevOps. + +The protected pipeline reads `python/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 (`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. Configure and verify the Azure DevOps identities, least-privilege GitHub release access, durable claim storage, + and protected ESRP approval gates outside GitHub. Verify the merged upstream revision is compatible. +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. + ## 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. @@ -404,4 +526,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 3420185f149..da35ef23f9c 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -455,14 +455,24 @@ 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 +### Building and Releasing + +Local build tasks remain available for development. Production publication is owned by the protected Azure DevOps +release pipeline, not by a local `publish` task or a GitHub Actions publisher. + +Prepare selective version bumps and generate the reviewed `release.json` in the same PR: -#### `publish` -Publish packages to PyPI: ```bash -uv run poe publish +uv run --group tool-runtime poe prepare-python-release --base-ref origin/main --tag python- +uv run --group tool-runtime poe validate-python-release --base-ref origin/main ``` +Use the same freshly fetched base for both commands. The first command derives the selected distributions and +versions from version changes in package metadata; do not enter a second set of versions by hand. See +[the release skill](.github/skills/agent-framework-py-release/SKILL.md) for the batch contract, overall tag and existing +draft handoff, approval timing, recovery, and deployment order. Do not create a tag or draft until that handoff is +explicitly authorized. + ## Prek Hooks Prek hooks run automatically on commit and stay intentionally lightweight: diff --git a/python/pyproject.toml b/python/pyproject.toml index 5f81f44adb4..87f3a7077f6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -355,10 +355,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." @@ -390,6 +386,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 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 00000000000..1f6fb832d09 --- /dev/null +++ b/python/scripts/prepare_release.py @@ -0,0 +1,212 @@ +# 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 +from pathlib import Path +from typing import Any + +from packaging.utils import canonicalize_name +from packaging.version import Version + +from scripts._toml import tomllib +from scripts.dependencies._dependency_bounds_release_impl import ( + ReleaseProject, + _build_release_project_map, + _selected_release_projects, +) + +# 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 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) + 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: + path = project.project_path / "pyproject.toml" + if _git(workspace_root, "ls-tree", "--name-only", base_commit, "--", path.as_posix()).strip(): + previous = tomllib.loads(_git(workspace_root, "show", f"{base_commit}:./{path.as_posix()}"))["project"] + if Version(previous["version"]) == Version(project.version): + continue + if Version(project.version) <= Version(previous["version"]): + raise ValueError(f"{project.package_name}: version must increase from {previous['version']}") + versions[project.package_name] = project.version + 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 _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 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] + manifest_path = workspace_root / "release.json" + 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: + versions = selected_versions(workspace_root, args.base_ref) if args.base_ref else None + if ( + args.ci + and not versions + and not _git(workspace_root, "diff", "--name-only", args.base_ref, "--", "release.json").strip() + ): + print("No release batch changes.") + return + 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 {}}, + } + expected_tag = args.tag or (payload.get("tag") if isinstance(payload, dict) else None) + 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.write_text(json.dumps(payload, indent=2) + "\n") + print( + f"{'Validated' if args.check else 'Generated'} release.json 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 00000000000..c0e6759f8ea --- /dev/null +++ b/python/scripts/tests/test_prepare_release.py @@ -0,0 +1,247 @@ +# 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, 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") + _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") + + +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 / "release.json").read_text()) + 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 / "packages/core", "1.23.0", "1.24.0") + (workspace / "release.json").write_text(json.dumps(_payload())) + 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 (workspace / "release.json").exists() + + +def test_ci_requires_manifest_when_versions_change(workspace: Path, monkeypatch) -> None: + _bump(workspace / "packages/core", "1.23.0", "1.24.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_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 / "release.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 / "release.json").read_text() == "previous reviewed batch\n" + + +def test_release_event_check_needs_expected_tag_but_not_release_base(workspace: Path, monkeypatch) -> None: + (workspace / "release.json").write_text(json.dumps(_payload())) + 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("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 / "release.json").write_text(text) + _git(workspace, "add", "release.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 eb177c0f7ed..f314afec6d9 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" From 2b1a1efbd8ccbc38f96ed8057f18866e4b066d0c Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 29 Oct 2026 09:47:02 +0100 Subject: [PATCH 2/8] Python: keep release procedures in the release skill --- python/DEV_SETUP.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index da35ef23f9c..1cb59155387 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -455,24 +455,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 Releasing - -Local build tasks remain available for development. Production publication is owned by the protected Azure DevOps -release pipeline, not by a local `publish` task or a GitHub Actions publisher. - -Prepare selective version bumps and generate the reviewed `release.json` in the same PR: - -```bash -uv run --group tool-runtime poe prepare-python-release --base-ref origin/main --tag python- -uv run --group tool-runtime poe validate-python-release --base-ref origin/main -``` - -Use the same freshly fetched base for both commands. The first command derives the selected distributions and -versions from version changes in package metadata; do not enter a second set of versions by hand. See -[the release skill](.github/skills/agent-framework-py-release/SKILL.md) for the batch contract, overall tag and existing -draft handoff, approval timing, recovery, and deployment order. Do not create a tag or draft until that handoff is -explicitly authorized. - ## Prek Hooks Prek hooks run automatically on commit and stay intentionally lightweight: From 3ce39b089edbe441fb2a9b7cdcac17ba1c39ec6f Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 29 Oct 2026 09:48:58 +0100 Subject: [PATCH 3/8] Python: keep release request manifest under .github --- .github/workflows/python-release.yml | 2 +- ...0046-python-coordinated-release-batches.md | 2 +- .../agent-framework-py-release/SKILL.md | 12 ++++----- python/pyproject.toml | 2 +- python/scripts/prepare_release.py | 13 ++++++--- python/scripts/tests/test_prepare_release.py | 27 +++++++++++++------ 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index ef8abfeb6b6..3ba35ad6f90 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -6,7 +6,7 @@ on: pull_request: branches: ["main", "feature*"] paths: - - "python/release.json" + - "python/.github/release.json" - "python/pyproject.toml" - "python/packages/*/pyproject.toml" - "python/scripts/prepare_release.py" diff --git a/docs/decisions/0046-python-coordinated-release-batches.md b/docs/decisions/0046-python-coordinated-release-batches.md index 698198eb0e5..b162ac859e7 100644 --- a/docs/decisions/0046-python-coordinated-release-batches.md +++ b/docs/decisions/0046-python-coordinated-release-batches.md @@ -35,7 +35,7 @@ must not receive credentials or federated permission to queue that pipeline. Proposed option: **generated batch manifest with Azure DevOps discovery and publication**. -`python/release.json` contains the expected overall `python-` tag, canonical distribution names mapped +`python/.github/release.json` contains the expected overall `python-` tag, canonical distribution names mapped to their exact metadata versions, and optional additive public `module:export` smoke checks. The release tooling generates all selected smoke checks and derives versions from the existing changed-metadata selection, excluding dependency-only edits whose version did not change. The source manifest does not contain its own commit SHA. diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index 50715e17459..f723c7dbebd 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -52,7 +52,7 @@ 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/release.json` in the same version-bump PR. +- **One batch, one overall tag, one Azure DevOps run.** Generate `python/.github/release.json` in the same version-bump PR. 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. @@ -339,7 +339,7 @@ version increased (or new workspace packages). Dependency-only metadata edits re but do not select an unchanged version for publication. The root/core coupling and explicit cohort decision above still determine which versions are bumped. The excluded standalone Lab workspace is not part of this batch. -Commit **`python/release.json` in this same PR**, together with the version and lockfile changes. Do not maintain a +Commit **`python/.github/release.json` in this same PR**, together with the version and lockfile changes. Do not maintain a duplicate list of versions manually. Regenerate after changing the bump list. For example: ```json @@ -422,7 +422,7 @@ The project's pre-commit hook runs `poe check` / `poe typing` / `uv lock` and ma ```bash git add -u -git add python/release.json # New files are not included by git add -u. +git add python/.github/release.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 @@ -454,7 +454,7 @@ The push output includes a `Create a pull request for '' on GitHub by vi ### 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 `release.json`. The maintainer creates a GitHub **draft** release for that existing tag, with the +tag matching `python/.github/release.json`. The maintainer creates a GitHub **draft** release for that existing 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. @@ -463,12 +463,12 @@ Azure DevOps discovery polls eligible existing drafts and tags. It has a disable an explicit first eligible release ID, and durable Azure Blob compare-and-swap claims to admit one run for the whole batch. No GitHub workflow calls or queues Azure DevOps. -The protected pipeline reads `python/release.json` from the exact tagged commit, validates the requested names, +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 (`release.json`) and the output artifact manifest (file names, versions, sizes/checksums and +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 diff --git a/python/pyproject.toml b/python/pyproject.toml index 87f3a7077f6..f9abe090039 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -387,7 +387,7 @@ shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test - args = [{ name = "project", default = "*", options = ["-P", "--package"] }] [tool.poe.tasks.prepare-python-release] -help = "Generate release.json from version changes against the same base as release validation." +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"] }, diff --git a/python/scripts/prepare_release.py b/python/scripts/prepare_release.py index 1f6fb832d09..cf6406ec968 100644 --- a/python/scripts/prepare_release.py +++ b/python/scripts/prepare_release.py @@ -21,6 +21,8 @@ _selected_release_projects, ) +RELEASE_MANIFEST = Path(".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, ...]] = { @@ -164,13 +166,13 @@ def main() -> None: 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 release.json") + parser.add_argument("--check", action="store_true", help=f"Validate instead of writing {RELEASE_MANIFEST}") 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] - manifest_path = workspace_root / "release.json" + manifest_path = workspace_root / RELEASE_MANIFEST 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: @@ -182,7 +184,9 @@ def main() -> None: if ( args.ci and not versions - and not _git(workspace_root, "diff", "--name-only", args.base_ref, "--", "release.json").strip() + and not _git( + workspace_root, "diff", "--name-only", args.base_ref, "--", RELEASE_MANIFEST.as_posix() + ).strip() ): print("No release batch changes.") return @@ -199,9 +203,10 @@ def main() -> None: 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'} release.json for {expected_tag}: " + f"{'Validated' if args.check else 'Generated'} {RELEASE_MANIFEST} for {expected_tag}: " f"{len(payload['packages'])} packages" ) except (OSError, ValueError, subprocess.CalledProcessError) as exc: diff --git a/python/scripts/tests/test_prepare_release.py b/python/scripts/tests/test_prepare_release.py index c0e6759f8ea..eb16a19091e 100644 --- a/python/scripts/tests/test_prepare_release.py +++ b/python/scripts/tests/test_prepare_release.py @@ -35,6 +35,7 @@ def workspace(tmp_path: Path, monkeypatch) -> Path: _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") @@ -86,7 +87,8 @@ def test_generate_and_check_batch_without_duplicate_version_input(workspace: Pat args = ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.24.0"] monkeypatch.setattr(sys, "argv", args) main() - payload = json.loads((workspace / "release.json").read_text()) + payload = json.loads((workspace / ".github/release.json").read_text()) + assert not (workspace / "release.json").exists() assert payload["packages"] == { "agent-framework": "1.24.0", "agent-framework-core": "1.24.0", @@ -103,7 +105,7 @@ def test_generate_and_check_batch_without_duplicate_version_input(workspace: Pat def test_check_rejects_omitted_bumped_distribution(workspace: Path, monkeypatch) -> None: _bump(workspace / "packages/core", "1.23.0", "1.24.0") - (workspace / "release.json").write_text(json.dumps(_payload())) + (workspace / ".github/release.json").write_text(json.dumps(_payload())) monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) with pytest.raises(SystemExit) as exc: main() @@ -116,7 +118,7 @@ def test_ci_skips_unrelated_metadata_edits_without_reading_stale_manifest(worksp monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) main() assert "No release batch changes." in capsys.readouterr().out - assert not (workspace / "release.json").exists() + assert not (workspace / ".github/release.json").exists() def test_ci_requires_manifest_when_versions_change(workspace: Path, monkeypatch) -> None: @@ -130,16 +132,16 @@ def test_ci_requires_manifest_when_versions_change(workspace: Path, monkeypatch) 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 / "release.json").write_text("previous reviewed batch\n") + (workspace / ".github/release.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 / "release.json").read_text() == "previous reviewed batch\n" + assert (workspace / ".github/release.json").read_text() == "previous reviewed batch\n" def test_release_event_check_needs_expected_tag_but_not_release_base(workspace: Path, monkeypatch) -> None: - (workspace / "release.json").write_text(json.dumps(_payload())) + (workspace / ".github/release.json").write_text(json.dumps(_payload())) 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"]) @@ -148,6 +150,15 @@ def test_release_event_check_needs_expected_tag_but_not_release_base(workspace: assert exc.value.code == 1 +def test_root_manifest_is_not_a_fallback(workspace: Path, monkeypatch, capsys) -> None: + (workspace / "release.json").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 ".github/release.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() @@ -215,8 +226,8 @@ def test_smoke_imports_are_additive_and_default_mapping_is_optional(workspace: P @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 / "release.json").write_text(text) - _git(workspace, "add", "release.json") + (workspace / ".github/release.json").write_text(text) + _git(workspace, "add", ".github/release.json") monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) with pytest.raises(SystemExit) as exc: main() From b5a5412f537dfa8fc26aeb4d06202cde366e943e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 29 Oct 2026 10:02:03 +0100 Subject: [PATCH 4/8] Python: retain release requests in per-tag manifests --- .github/workflows/python-release.yml | 2 +- ...0046-python-coordinated-release-batches.md | 7 +- .../agent-framework-py-release/SKILL.md | 34 +++-- python/pyproject.toml | 2 +- python/scripts/prepare_release.py | 60 ++++++-- python/scripts/tests/test_prepare_release.py | 128 ++++++++++++++++-- 6 files changed, 193 insertions(+), 40 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 3ba35ad6f90..31c3de3fa65 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -6,7 +6,7 @@ on: pull_request: branches: ["main", "feature*"] paths: - - "python/.github/release.json" + - "python/.github/release-*.json" - "python/pyproject.toml" - "python/packages/*/pyproject.toml" - "python/scripts/prepare_release.py" diff --git a/docs/decisions/0046-python-coordinated-release-batches.md b/docs/decisions/0046-python-coordinated-release-batches.md index b162ac859e7..02e44c6a788 100644 --- a/docs/decisions/0046-python-coordinated-release-batches.md +++ b/docs/decisions/0046-python-coordinated-release-batches.md @@ -35,10 +35,15 @@ must not receive credentials or federated permission to queue that pipeline. Proposed option: **generated batch manifest with Azure DevOps discovery and publication**. -`python/.github/release.json` contains the expected overall `python-` tag, canonical distribution names mapped +`python/.github/release-.json` contains the expected overall `python-` tag, canonical distribution names mapped to their exact metadata versions, and optional additive public `module:export` smoke checks. The release tooling generates all selected smoke checks and derives versions from the existing changed-metadata selection, excluding dependency-only edits whose version did not change. The source manifest does not contain its own commit SHA. +For `python-1.24.0`, its path is `python/.github/release-python-1.24.0.json`. Earlier per-tag files remain unchanged +and browsable on `main`. PR checks validate exactly one new batch against current metadata and reject edits or +deletions of historical requests, without revalidating their old versions. These are intended release batches, +not evidence of successful publication. Producer and consumer derive the exact filename from the validated +expected full tag, never from latest-file scanning or a fixed-path fallback. Azure DevOps resolves the existing tag, reads the manifest from that exact commit, claims the batch once, builds, checks isolated installs/imports, obtains protected approval, publishes through ESRP, and verifies publication. diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index f723c7dbebd..c86f29eb66e 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -52,7 +52,8 @@ 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. +- **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. @@ -331,7 +332,8 @@ If `core` did not bump this cycle, do not touch floors. After applying the selected bumps and dependency edits, generate the manifest from that same metadata diff: ```bash -uv run --directory python --group tool-runtime poe prepare-python-release --base-ref "$RELEASE_BASE" --tag "python-" +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 @@ -339,8 +341,11 @@ version increased (or new workspace packages). Dependency-only metadata edits re but do not select an unchanged version for publication. The root/core coupling and explicit cohort decision above still determine which versions are bumped. The excluded standalone Lab workspace is not part of this batch. -Commit **`python/.github/release.json` in this same PR**, together with the version and lockfile changes. Do not maintain a -duplicate list of versions manually. Regenerate after changing the bump list. For example: +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 { @@ -362,7 +367,9 @@ 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 the manifest there. + 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. - 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**. @@ -385,11 +392,14 @@ Contract: 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 "python-" +uv run --directory python --group tool-runtime python -m scripts.prepare_release --check --base-ref "$RELEASE_BASE" --tag "$RELEASE_TAG" ``` -PR CI enforces exact agreement between the manifest and version changes. PRs without version or manifest changes -(including dependency maintenance) do not need to rewrite an old manifest. +PR CI requires exactly one new per-tag manifest and enforces agreement between its filename, JSON tag, and version +changes. 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" @@ -422,7 +432,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.json # New files are not included by 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 @@ -454,7 +464,7 @@ The push output includes a `Create a pull request for '' on GitHub by vi ### 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 `python/.github/release.json`. The maintainer creates a GitHub **draft** release for that existing tag, with the +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. @@ -463,12 +473,12 @@ Azure DevOps discovery polls eligible existing drafts and tags. It has a disable an explicit first eligible release ID, and durable Azure Blob compare-and-swap claims to admit one run for the whole batch. 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, +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 +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 diff --git a/python/pyproject.toml b/python/pyproject.toml index f9abe090039..6b7afae13eb 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -387,7 +387,7 @@ shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test - 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." +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"] }, diff --git a/python/scripts/prepare_release.py b/python/scripts/prepare_release.py index cf6406ec968..d31864f1a92 100644 --- a/python/scripts/prepare_release.py +++ b/python/scripts/prepare_release.py @@ -21,7 +21,7 @@ _selected_release_projects, ) -RELEASE_MANIFEST = Path(".github/release.json") +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. @@ -103,6 +103,12 @@ def _validate_tag(tag: object) -> None: 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: @@ -166,13 +172,14 @@ def main() -> None: 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=f"Validate instead of writing {RELEASE_MANIFEST}") + 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] - manifest_path = workspace_root / RELEASE_MANIFEST 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: @@ -180,16 +187,44 @@ def main() -> None: if not args.ci and not args.tag: parser.error("--tag is required outside PR CI") try: - versions = selected_versions(workspace_root, args.base_ref) if args.base_ref else None + 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 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 ( - args.ci - and not versions - and not _git( - workspace_root, "diff", "--name-only", args.base_ref, "--", RELEASE_MANIFEST.as_posix() - ).strip() + base_commit + and _git(workspace_root, "ls-tree", "--name-only", base_commit, "--", relative_manifest.as_posix()).strip() ): - print("No release batch changes.") - return + raise ValueError("Historical release manifests must not be changed or removed; use a new tag") if args.check: payload = json.loads(manifest_path.read_text(), object_pairs_hook=_reject_duplicate_keys) else: @@ -198,7 +233,6 @@ def main() -> None: "packages": versions, "smoke_imports": {name: list(SMOKE_IMPORTS.get(name, ())) for name in versions or {}}, } - expected_tag = args.tag or (payload.get("tag") if isinstance(payload, dict) else None) 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") @@ -206,7 +240,7 @@ def main() -> None: 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'} {RELEASE_MANIFEST} for {expected_tag}: " + 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: diff --git a/python/scripts/tests/test_prepare_release.py b/python/scripts/tests/test_prepare_release.py index eb16a19091e..2473ea1c325 100644 --- a/python/scripts/tests/test_prepare_release.py +++ b/python/scripts/tests/test_prepare_release.py @@ -12,7 +12,7 @@ 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, selected_versions, validate_manifest +from scripts.prepare_release import SMOKE_IMPORTS, main, release_manifest_path, selected_versions, validate_manifest def _git(path: Path, *args: str) -> str: @@ -87,8 +87,9 @@ def test_generate_and_check_batch_without_duplicate_version_input(workspace: Pat args = ["prepare_release", "--base-ref", "HEAD", "--tag", "python-1.24.0"] monkeypatch.setattr(sys, "argv", args) main() - payload = json.loads((workspace / ".github/release.json").read_text()) + 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", @@ -105,7 +106,8 @@ def test_generate_and_check_batch_without_duplicate_version_input(workspace: Pat def test_check_rejects_omitted_bumped_distribution(workspace: Path, monkeypatch) -> None: _bump(workspace / "packages/core", "1.23.0", "1.24.0") - (workspace / ".github/release.json").write_text(json.dumps(_payload())) + (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() @@ -118,7 +120,7 @@ def test_ci_skips_unrelated_metadata_edits_without_reading_stale_manifest(worksp monkeypatch.setattr(sys, "argv", ["prepare_release", "--check", "--ci", "--base-ref", "HEAD"]) main() assert "No release batch changes." in capsys.readouterr().out - assert not (workspace / ".github/release.json").exists() + assert not list((workspace / ".github").glob("release-*.json")) def test_ci_requires_manifest_when_versions_change(workspace: Path, monkeypatch) -> None: @@ -132,16 +134,16 @@ def test_ci_requires_manifest_when_versions_change(workspace: Path, monkeypatch) 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.json").write_text("previous reviewed batch\n") + (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.json").read_text() == "previous reviewed batch\n" + 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.json").write_text(json.dumps(_payload())) + (workspace / ".github/release-python-1.24.0.json").write_text(json.dumps(_payload())) 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"]) @@ -150,13 +152,115 @@ def test_release_event_check_needs_expected_tag_but_not_release_base(workspace: assert exc.value.code == 1 -def test_root_manifest_is_not_a_fallback(workspace: Path, monkeypatch, capsys) -> None: - (workspace / "release.json").write_text(json.dumps(_payload())) +@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 ".github/release.json" in capsys.readouterr().err + 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"]) @@ -226,8 +330,8 @@ def test_smoke_imports_are_additive_and_default_mapping_is_optional(workspace: P @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.json").write_text(text) - _git(workspace, "add", ".github/release.json") + (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() From 208bcb06dc6dbccf212fc6cfeb9bd0997f00883c Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 29 Oct 2026 10:51:42 +0100 Subject: [PATCH 5/8] Python: document reuse of existing GitHub service connection --- .../0046-python-coordinated-release-batches.md | 9 +++++++-- .../skills/agent-framework-py-release/SKILL.md | 12 ++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0046-python-coordinated-release-batches.md b/docs/decisions/0046-python-coordinated-release-batches.md index 02e44c6a788..110facd18fc 100644 --- a/docs/decisions/0046-python-coordinated-release-batches.md +++ b/docs/decisions/0046-python-coordinated-release-batches.md @@ -56,7 +56,12 @@ publish tasks are retired. Public runtime APIs and package versions do not chang change. The standalone Lab workspace remains outside the coordinated release batch. Merge the disabled Azure DevOps consumer first, then the upstream producer and old-publisher retirement. -Configure identities, durable claims, and protected gates before choosing the first eligible draft ID and -activating discovery last. Neither PR authorizes production tags, drafts, publication, or infrastructure changes. +Reuse the existing `github-agent-framework` Azure DevOps GitHub service connection used by the .NET release +pipeline, with explicit authorization for the Python pipelines and verification of repository/release access. +Do not introduce a separate GitHub App, private key, or Key Vault token-minting setup. Azure identity for durable +Blob claims and protected ESRP gates remains separate operator setup. Connection reuse does not replace the +asset-only REST finalizer with a task that rewrites release notes, prerelease flags, or existing assets. +Verify that setup before choosing the first eligible draft ID and activating discovery last. Neither PR +authorizes production tags, drafts, publication, or infrastructure changes. The [release skill](../../python/.github/skills/agent-framework-py-release/SKILL.md) contains the operator procedure and recovery requirements. Maintainer and engineering/architecture approval is required before accepting this ADR. diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index c86f29eb66e..621aad56863 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -504,14 +504,22 @@ release assets, publish packages, or request Azure DevOps credentials. 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. Configure and verify the Azure DevOps identities, least-privilege GitHub release access, durable claim storage, - and protected ESRP approval gates outside GitHub. Verify the merged upstream revision is compatible. +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 and verify the separate + Azure identity for durable Blob claims and the protected ESRP approval gates outside GitHub. 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. From c72f86c0ff2607f8938b1b4bc73ed10f9a688a76 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 29 Oct 2026 12:06:37 +0100 Subject: [PATCH 6/8] Remove coordinated Python release ADR --- ...0046-python-coordinated-release-batches.md | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 docs/decisions/0046-python-coordinated-release-batches.md diff --git a/docs/decisions/0046-python-coordinated-release-batches.md b/docs/decisions/0046-python-coordinated-release-batches.md deleted file mode 100644 index 110facd18fc..00000000000 --- a/docs/decisions/0046-python-coordinated-release-batches.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -status: proposed -contact: eavanvalkenburg -date: 2026-10-29 -deciders: [eavanvalkenburg] -informed: [Python release maintainers, engineering and architecture leads] ---- - -# Coordinate Python releases through a reviewed batch and one protected publisher - -## Context and Problem Statement - -Python distributions have independent versions, while release preparation already selects a batch through -CHANGELOG-driven version bumps. The GitHub published-release workflow currently builds and uploads assets after -the release is public. Production package publication needs protected Azure DevOps ESRP approval, and GitHub -must not receive credentials or federated permission to queue that pipeline. - -## Decision Drivers - -- Review selected packages, versions, and public smoke imports in the same version-bump PR. -- Build once and publish those exact artifacts, not a later rebuild. -- Preserve maintainer-written release notes and keep the release a draft until verification succeeds. -- Use one overall tag and one protected run for the whole batch, without adding GitHub-to-Azure DevOps authority. - -## Considered Options - -- Keep the GitHub post-publication asset builder. This preserves current behavior, but creates competing artifact - paths and exposes a public release before its verified artifacts are attached. -- Queue per-package runs from GitHub. This gives independent queues but duplicates batch selection and approvals, - introduces race conditions, and requires forbidden cross-system credentials. -- Check in a generated batch manifest and let Azure DevOps discover eligible existing drafts and tags. This - requires coordinated rollout and durable claims, but preserves a single reviewed selection and publisher. - -## Decision Outcome - -Proposed option: **generated batch manifest with Azure DevOps discovery and publication**. - -`python/.github/release-.json` contains the expected overall `python-` tag, canonical distribution names mapped -to their exact metadata versions, and optional additive public `module:export` smoke checks. The release tooling -generates all selected smoke checks and derives versions from the existing changed-metadata selection, excluding -dependency-only edits whose version did not change. The source manifest does not contain its own commit SHA. -For `python-1.24.0`, its path is `python/.github/release-python-1.24.0.json`. Earlier per-tag files remain unchanged -and browsable on `main`. PR checks validate exactly one new batch against current metadata and reject edits or -deletions of historical requests, without revalidating their old versions. These are intended release batches, -not evidence of successful publication. Producer and consumer derive the exact filename from the validated -expected full tag, never from latest-file scanning or a fixed-path fallback. - -Azure DevOps resolves the existing tag, reads the manifest from that exact commit, claims the batch once, builds, -checks isolated installs/imports, obtains protected approval, publishes through ESRP, and verifies publication. -It then uploads the retained exact artifacts and output artifact manifest to the existing draft and changes -only its draft status, preserving the human-written title/body and prerelease flag. PyPI absence validates the -batch; it does not select packages. Partial publication requires reconciliation, not a new selection or rebuild. - -GitHub performs read-only PR and published-tag contract checks. The previous asset builder and local production -publish tasks are retired. Public runtime APIs and package versions do not change as part of this infrastructure -change. The standalone Lab workspace remains outside the coordinated release batch. - -Merge the disabled Azure DevOps consumer first, then the upstream producer and old-publisher retirement. -Reuse the existing `github-agent-framework` Azure DevOps GitHub service connection used by the .NET release -pipeline, with explicit authorization for the Python pipelines and verification of repository/release access. -Do not introduce a separate GitHub App, private key, or Key Vault token-minting setup. Azure identity for durable -Blob claims and protected ESRP gates remains separate operator setup. Connection reuse does not replace the -asset-only REST finalizer with a task that rewrites release notes, prerelease flags, or existing assets. -Verify that setup before choosing the first eligible draft ID and activating discovery last. Neither PR -authorizes production tags, drafts, publication, or infrastructure changes. -The [release skill](../../python/.github/skills/agent-framework-py-release/SKILL.md) contains the operator procedure -and recovery requirements. Maintainer and engineering/architecture approval is required before accepting this ADR. From 5f4df187f2329420dae13182187649d0abac19d0 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 29 Oct 2026 15:21:11 +0100 Subject: [PATCH 7/8] docs: use retained ADO runs for Python release coordination --- .../skills/agent-framework-py-release/SKILL.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index 621aad56863..a37aa043df7 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -470,8 +470,9 @@ trigger and do not create per-package tags. Release notes must be complete **bef 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 Azure Blob compare-and-swap claims to admit one run for the -whole batch. No GitHub workflow calls or queues Azure DevOps. +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. @@ -506,9 +507,12 @@ release assets, publish packages, or request Azure DevOps credentials. 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 and verify the separate - Azure identity for durable Blob claims and the protected ESRP approval gates outside GitHub. Verify the merged - upstream revision is compatible. Reusing the connection does not authorize a new pipeline automatically. + 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. From ce3a7bbc8912d35c05d6eb23cea38b5e910ff561 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 29 Oct 2026 15:39:12 +0100 Subject: [PATCH 8/8] fix: harden Python release selection and event refs --- .github/workflows/python-release.yml | 2 +- .../agent-framework-py-release/SKILL.md | 23 ++- python/scripts/prepare_release.py | 58 ++++++- python/scripts/tests/test_prepare_release.py | 161 +++++++++++++++++- 4 files changed, 229 insertions(+), 15 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 31c3de3fa65..23617faa98c 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # A release must validate the exact tagged commit, never current main. - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + 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 diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index a37aa043df7..09e3839bbd2 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -329,7 +329,10 @@ If `core` did not bump this cycle, do not touch floors. ### 7. Generate the reviewed release batch and validate -After applying the selected bumps and dependency edits, generate the manifest from that same metadata diff: +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 RELEASE_TAG="python-" @@ -337,9 +340,11 @@ uv run --directory python --group tool-runtime poe prepare-python-release --base ``` The generator reuses the release validator's changed-`pyproject.toml` selection, then includes only packages whose -version increased (or new workspace packages). Dependency-only metadata edits remain part of dependency validation -but do not select an unchanged version for publication. The root/core coupling and explicit cohort decision above -still determine which versions are bumped. The excluded standalone Lab workspace is not part of this batch. +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`. @@ -370,6 +375,9 @@ Contract: 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**. @@ -395,8 +403,11 @@ From the repository root, check the generated file against the same base: uv run --directory python --group tool-runtime python -m scripts.prepare_release --check --base-ref "$RELEASE_BASE" --tag "$RELEASE_TAG" ``` -PR CI requires exactly one new per-tag manifest and enforces agreement between its filename, JSON tag, and version -changes. Changes or deletions of historical manifests fail the check. Earlier files remain browsable on `main`; +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. diff --git a/python/scripts/prepare_release.py b/python/scripts/prepare_release.py index d31864f1a92..1769ce83418 100644 --- a/python/scripts/prepare_release.py +++ b/python/scripts/prepare_release.py @@ -8,13 +8,13 @@ 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._toml import tomllib from scripts.dependencies._dependency_bounds_release_impl import ( ReleaseProject, _build_release_project_map, @@ -77,23 +77,53 @@ 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: - path = project.project_path / "pyproject.toml" - if _git(workspace_root, "ls-tree", "--name-only", base_commit, "--", path.as_posix()).strip(): - previous = tomllib.loads(_git(workspace_root, "show", f"{base_commit}:./{path.as_posix()}"))["project"] - if Version(previous["version"]) == Version(project.version): + 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) <= Version(previous["version"]): - raise ValueError(f"{project.package_name}: version must increase from {previous['version']}") + 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())) @@ -213,6 +243,14 @@ def main() -> None: 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-") @@ -225,6 +263,12 @@ def main() -> None: 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: diff --git a/python/scripts/tests/test_prepare_release.py b/python/scripts/tests/test_prepare_release.py index 2473ea1c325..955be9862f7 100644 --- a/python/scripts/tests/test_prepare_release.py +++ b/python/scripts/tests/test_prepare_release.py @@ -80,6 +80,73 @@ def test_selection_rejects_version_regression(workspace: Path) -> None: 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") @@ -105,6 +172,7 @@ def test_generate_and_check_batch_without_duplicate_version_input(workspace: Pat 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") @@ -124,11 +192,101 @@ def test_ci_skips_unrelated_metadata_edits_without_reading_stale_manifest(worksp def test_ci_requires_manifest_when_versions_change(workspace: Path, monkeypatch) -> None: - _bump(workspace / "packages/core", "1.23.0", "1.24.0") + _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: @@ -144,6 +302,7 @@ def test_generation_failure_does_not_replace_existing_manifest(workspace: Path, 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"])