Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/_python-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,6 @@ jobs:
if: matrix.dist
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist-${{ inputs.plugin }}
name: dist-${{ inputs.plugin }}-${{ inputs.deps }} # one lane per name; the release publishes -locked
path: python/${{ inputs.plugin }}/dist
if-no-files-found: error
128 changes: 88 additions & 40 deletions .github/workflows/release-python.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Release pipeline for ONE Python plugin, triggered by a scoped tag such as
# python/openai_agents/v1.0.0rc1. The plugin is a parameter parsed from the tag.
#
# prepare -> test (reusable CI, full matrix) + build (once) -> publish-testpypi
# -> smoke-testpypi -> [finals only] publish-pypi -> smoke-pypi -> github-release (draft)
# prepare -> test (reusable CI, full matrix; its ubuntu dist cell builds and checks the artifacts)
# -> publish-testpypi -> smoke-testpypi -> [finals only] publish-pypi -> smoke-pypi -> github-release (draft)
#
# The artifacts that were tested are the artifacts that get published. Dry run: dispatch on
# main with the `tag` input set to the tag you intend to push and `skip-publish` on; everything
# up to and including the tested build runs, nothing is uploaded and no tag is consumed.
# Uploads and the draft release happen only for a real tag ref.
#
# Publishing uses PyPI trusted publishing (OIDC): no stored credentials. The
# publish jobs live INLINE here on purpose: PyPI rejects reusable workflows as
Expand All @@ -16,6 +21,14 @@ on:
- "python/*/v*"
workflow_dispatch:
inputs:
tag:
description: Release tag to validate and build when dispatching from a branch (dry run), e.g. python/mcp/v0.1.0. Leave empty when dispatching on a tag ref.
type: string
default: ""
skip-publish:
description: Dry run - validate, test and build, but upload nothing and draft no release
type: boolean
default: false
publish-prerelease-to-pypi:
description: For a pre-release tag ref, also publish to PyPI (still gated by the pypi environment; ignored while plugin.toml [release] allow-final is false)
type: boolean
Expand All @@ -28,7 +41,7 @@ defaults:
shell: bash

concurrency:
group: release-${{ github.ref }}
group: release-${{ inputs.tag || github.ref }} # dry runs of different tags on main do not queue behind each other
cancel-in-progress: false

jobs:
Expand All @@ -46,6 +59,9 @@ jobs:
coordinate: ${{ steps.tag.outputs.coordinate }}
root_api: ${{ steps.tag.outputs.root_api }}
smoke_imports: ${{ steps.tag.outputs.smoke_imports }}
tag: ${{ steps.tag.outputs.tag }}
allow_final: ${{ steps.tag.outputs.allow_final }}
publish: ${{ steps.gate.outputs.publish }}
publish_pypi: ${{ steps.gate.outputs.publish_pypi }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -55,10 +71,23 @@ jobs:
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version-file: scripts/pyproject.toml
- name: Dispatch inputs are consistent with the ref
if: github.event_name == 'workflow_dispatch'
env:
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [ "$REF_TYPE" = "tag" ] && [ -n "$INPUT_TAG" ] && [ "$INPUT_TAG" != "$REF_NAME" ]; then
echo "::error::tag input '$INPUT_TAG' does not match the dispatched tag ref '$REF_NAME'; dispatch on the tag without a tag input, or on a branch for a dry run"; exit 1
fi
if [ "$REF_TYPE" != "tag" ] && [ -z "$INPUT_TAG" ]; then
echo "::error::dispatching from a branch is a dry run and needs the tag input, e.g. python/mcp/v0.1.0"; exit 1
fi
- name: Parse tag
id: tag
env:
TAG: ${{ github.ref_name }}
TAG: ${{ inputs.tag || github.ref_name }}
run: uv run --project scripts --locked python scripts/release/release_tool.py parse-tag "$TAG" --github-output "$GITHUB_OUTPUT"
- name: Tag version matches the manifest
env:
Expand All @@ -76,23 +105,37 @@ jobs:
PY
- name: Tag is reachable from main
# fetch-depth 0 above already brought origin/main; no extra fetch (credentials are not persisted).
env:
REF_TYPE: ${{ github.ref_type }}
run: |
git rev-parse --verify --quiet origin/main > /dev/null || { echo "::error::origin/main not present in checkout"; exit 1; }
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
if git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then exit 0; fi
if [ "$REF_TYPE" = "tag" ]; then
echo "::error::release tags must point at a commit on main"; exit 1
fi
echo "::warning::dry run from a commit that is not on main; a release tag here would be rejected"
- name: Version policy
env:
PLUGIN_DIR: ${{ steps.tag.outputs.plugin_dir }}
VERSION: ${{ steps.tag.outputs.version }}
run: uv run --project scripts --locked python scripts/release/release_tool.py --repo-root "$PWD" check-version-policy --plugin-dir "$PLUGIN_DIR" --version "$VERSION"
run: uv run --project scripts --locked python scripts/release/release_tool.py --repo-root "$PWD" check-version-policy --plugin-dir "$PLUGIN_DIR" --version "$VERSION" --check-testpypi
- name: Decide whether PyPI publication is allowed
id: gate
env:
PRERELEASE: ${{ steps.tag.outputs.prerelease }}
ALLOW_FINAL: ${{ steps.tag.outputs.allow_final }}
OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish-prerelease-to-pypi }}
REF_TYPE: ${{ github.ref_type }}
SKIP_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.skip-publish }}
run: |
# Uploads and the draft release happen only for a real tag ref, and never on a dry run.
# Compare against the literal "false": anything else (a typo such as `True` or `1`) is a dry run.
if [ "$REF_TYPE" = "tag" ] && [ "$SKIP_PUBLISH" = "false" ]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "::notice::dry run: validation, tests and the build run; nothing is uploaded and no release is drafted"
fi
# Finals reach PyPI; pre-releases only via the dispatch override, and never while the plugin
# is still in the SDK-cutover transition (allow-final = false).
if [ "$PRERELEASE" = "false" ] || { [ "$OVERRIDE" = "true" ] && [ "$ALLOW_FINAL" = "true" ]; }; then
Expand All @@ -111,39 +154,20 @@ jobs:
plugin: ${{ needs.prepare.outputs.plugin }}
deps: locked

build:
name: Build distributions
needs: prepare
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version-file: ${{ needs.prepare.outputs.plugin_dir }}/pyproject.toml
- uses: ./.github/actions/python-build-check
with:
plugin-dir: ${{ needs.prepare.outputs.plugin_dir }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: ${{ needs.prepare.outputs.plugin_dir }}/dist
if-no-files-found: error

publish-testpypi:
name: Publish to TestPyPI
needs: [prepare, test, build]
# dist-<plugin>-locked is built, verified and smoke-tested by the test job's ubuntu dist cell
# (_python-plugin.yml, deps=locked): the published bytes are the tested bytes.
needs: [prepare, test]
if: needs.prepare.outputs.publish == 'true'
runs-on: ubuntu-latest
environment: testpypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
name: dist-${{ needs.prepare.outputs.plugin }}-locked
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
Expand All @@ -155,6 +179,7 @@ jobs:
smoke-testpypi:
name: Smoke-test from TestPyPI
needs: [prepare, publish-testpypi]
if: needs.prepare.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -165,28 +190,40 @@ jobs:
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version-file: scripts/pyproject.toml
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist-${{ needs.prepare.outputs.plugin }}-locked
path: dist/
- name: Index files are the tested artifacts
# skip-existing keeps a re-run from failing, so prove the files the index serves are the
# bytes this run built (uv_build is reproducible) rather than an earlier upload's.
env:
COORDINATE: ${{ needs.prepare.outputs.coordinate }}
VERSION: ${{ needs.prepare.outputs.version }}
run: uv run --project scripts --locked python scripts/release/release_tool.py verify-index-files --coordinate "$COORDINATE" --version "$VERSION" --dist dist --index testpypi
- name: Install from TestPyPI into a clean project and smoke-test
env:
COORDINATE: ${{ needs.prepare.outputs.coordinate }}
VERSION: ${{ needs.prepare.outputs.version }}
ROOT_API: ${{ needs.prepare.outputs.root_api }}
SMOKE_IMPORTS: ${{ needs.prepare.outputs.smoke_imports }}
INDEX_URL: https://test.pypi.org/simple/
ALLOW_OVERLAP_WITH_CORE: "1"
# A plugin the SDK still bundles (allow-final = false) may overlap it; any other must not.
ALLOW_OVERLAP_WITH_CORE: ${{ needs.prepare.outputs.allow_final == 'true' && '0' || '1' }}
run: bash scripts/release/smoke_from_index.sh

publish-pypi:
name: Publish to PyPI
needs: [prepare, smoke-testpypi]
if: needs.prepare.outputs.publish_pypi == 'true'
if: needs.prepare.outputs.publish == 'true' && needs.prepare.outputs.publish_pypi == 'true'
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
name: dist-${{ needs.prepare.outputs.plugin }}-locked
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
Expand All @@ -197,7 +234,7 @@ jobs:
smoke-pypi:
name: Smoke-test from PyPI
needs: [prepare, publish-pypi]
if: needs.prepare.outputs.publish_pypi == 'true'
if: needs.prepare.outputs.publish == 'true' && needs.prepare.outputs.publish_pypi == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -208,21 +245,31 @@ jobs:
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version-file: scripts/pyproject.toml
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist-${{ needs.prepare.outputs.plugin }}-locked
path: dist/
- name: Index files are the tested artifacts
env:
COORDINATE: ${{ needs.prepare.outputs.coordinate }}
VERSION: ${{ needs.prepare.outputs.version }}
run: uv run --project scripts --locked python scripts/release/release_tool.py verify-index-files --coordinate "$COORDINATE" --version "$VERSION" --dist dist --index pypi
- name: Install from PyPI into a clean project and smoke-test
env:
COORDINATE: ${{ needs.prepare.outputs.coordinate }}
VERSION: ${{ needs.prepare.outputs.version }}
ROOT_API: ${{ needs.prepare.outputs.root_api }}
SMOKE_IMPORTS: ${{ needs.prepare.outputs.smoke_imports }}
# Finals must never overlap the SDK; a dispatched pre-release still may.
ALLOW_OVERLAP_WITH_CORE: ${{ needs.prepare.outputs.prerelease == 'true' && '1' || '0' }}
# Same rule as TestPyPI: only a plugin the SDK still bundles may overlap it.
ALLOW_OVERLAP_WITH_CORE: ${{ needs.prepare.outputs.allow_final == 'true' && '0' || '1' }}
run: bash scripts/release/smoke_from_index.sh

github-release:
name: Draft GitHub release
needs: [prepare, smoke-testpypi, smoke-pypi]
if: >-
always() && needs.prepare.result == 'success' && needs.smoke-testpypi.result == 'success'
!cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.publish == 'true'
&& needs.smoke-testpypi.result == 'success'
&& (needs.smoke-pypi.result == 'success' || needs.smoke-pypi.result == 'skipped')
runs-on: ubuntu-latest
permissions:
Expand All @@ -231,23 +278,24 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false # gh uses GH_TOKEN below; do not leave the write token in .git/config
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version-file: scripts/pyproject.toml
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
name: dist-${{ needs.prepare.outputs.plugin }}-locked
path: dist/
- name: Generate release notes from history
env:
PLUGIN_DIR: ${{ needs.prepare.outputs.plugin_dir }}
TAG: ${{ github.ref_name }}
TAG: ${{ needs.prepare.outputs.tag }}
run: uv run --project scripts --locked python scripts/release/release_tool.py --repo-root "$PWD" release-notes --plugin-dir "$PLUGIN_DIR" --tag "$TAG" --output notes.md
- name: Create or update the draft release
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
TAG: ${{ github.ref_name }}
TAG: ${{ needs.prepare.outputs.tag }}
TITLE: ${{ needs.prepare.outputs.plugin }} ${{ needs.prepare.outputs.version }}
PRERELEASE: ${{ needs.prepare.outputs.prerelease }}
run: |
Expand Down
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,15 @@ One entry workflow, one reusable workflow per language, plugin as a parameter, n

Trusted publishing by ecosystem: PyPI uses OIDC trusted publishing (`pypa/gh-action-pypi-publish`, no stored token; PyPI cannot bind a reusable workflow, so publish jobs live inline in `release-python.yml`). npm supports OIDC trusted publishing (GitHub-hosted runners, npm >= 11.5.1, one publisher per package, register the calling workflow's filename; provenance is automatic for a public repo and package). Maven Central has no OIDC: Central Portal user token plus GPG signing, kept as environment-scoped secrets. Go has nothing to upload: an immutable tag plus `sum.golang.org` is the release.

Version policy (`release_tool.py check-version-policy`, evaluated against pypi.org only): a coordinate with no published release must start at exactly `1.0.0` (`ga`) or `0.1.0` (otherwise), pre-releases of that version allowed; an existing coordinate must be strictly greater than its highest published version, yanked releases included. Final versions additionally require `plugin.toml` `[release] allow-final = true` and no `TRANSITION(sdk-cutover)` marker in the plugin.
Version policy (`release_tool.py check-version-policy`; version ordering is evaluated against pypi.org): a coordinate with no published release must start at exactly `1.0.0` (`ga`) or `0.1.0` (otherwise), pre-releases of that version allowed; an existing coordinate must be strictly greater than its highest published version, yanked releases included. A version already staged on TestPyPI only produces a warning (a re-run after a staged upload is the normal recovery path); each smoke job then proves the index serves exactly the artifacts this run built (`verify-index-files`). Final versions additionally require `plugin.toml` `[release] allow-final = true` and no `TRANSITION(sdk-cutover)` marker in the plugin.

Runbook for `python/<name>`:
1. Open a release PR that sets `version` in `pyproject.toml` (re-sync from upstream first while the transition rules apply). Merge it.
2. `git tag -a python/<name>/v<version> -m "python/<name> v<version>"` on the merged `main` commit and push the tag. Tags must match `<language>/<name>/v<version>` and are protected by a tag ruleset.
3. `release-python.yml` validates the tag, runs the full test matrix, builds once, publishes to TestPyPI (environment `testpypi`), smoke-installs from TestPyPI in a clean project, and for final versions publishes to PyPI (environment `pypi`, required reviewers confirm the tag SHA is on `main`) and smoke-installs again.
4. A draft GitHub Release is created idempotently with generated notes and the artifacts. Edit the notes and publish it by hand.
5. If anything fails after upload, fix forward with the next `rcN`; uploaded files are immutable and tags are never moved.
2. Dry run on the merged `main`: `gh workflow run release-python.yml --ref main -f tag=python/<name>/v<version> -f skip-publish=true` runs the tag validation, the version policy, the full test matrix and the artifact build without uploading anything or consuming a tag (the manifest check needs the version bump to be on `main`).
3. `git tag -a python/<name>/v<version> -m "python/<name> v<version>"` on the merged `main` commit and push the tag. Tags must match `<language>/<name>/v<version>` and are protected by a tag ruleset.
4. `release-python.yml` validates the tag, runs the full test matrix (its ubuntu dist cell builds, checks and smoke-tests the wheel and sdist), publishes those tested artifacts to TestPyPI (environment `testpypi`), proves TestPyPI serves exactly those files, smoke-installs from TestPyPI in a clean project, and for final versions publishes to PyPI (environment `pypi`, required reviewers confirm the tag SHA is on `main`) and repeats the proof and the smoke there. The clean-project smoke tolerates file overlap with the SDK only while `allow-final = false`.
5. A draft GitHub Release is created idempotently with generated notes and the artifacts. Edit the notes and publish it by hand; a later re-run refuses to touch a release that is already published.
6. If a job fails after the TestPyPI upload, "Re-run failed jobs" (or a fresh dispatch on the tag) is safe: the upload is skipped and the smoke jobs verify the served files. If the artifacts themselves must change, fix forward with the next `rcN`; uploaded files are immutable and tags are never moved.

## Migration and re-sync

Expand Down
Loading