From 3a36ab344b17cde5e3bcd9dbf5d4962fdcbd456a Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 21 Oct 2026 16:47:59 -0700 Subject: [PATCH 1/5] ci: validate next pull requests and merge queue commits --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c964510f91..464cf1982e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ on: branches-ignore: - 'stl-preview-head/**' - 'stl-preview-base/**' + merge_group: + types: [checks_requested] workflow_dispatch: inputs: release_pr: @@ -33,7 +35,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + if: (github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.base_ref == 'next') || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -52,7 +54,7 @@ jobs: run: ./scripts/lint build: - if: (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + if: (github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.base_ref == 'next') || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') timeout-minutes: 10 name: build permissions: @@ -150,7 +152,7 @@ jobs: timeout-minutes: 15 name: test (Python ${{ matrix.python-version }}) runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork + if: github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.base_ref == 'next') || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork strategy: fail-fast: false matrix: @@ -187,7 +189,7 @@ jobs: timeout-minutes: 20 name: test (HTTPX2) runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork + if: github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.base_ref == 'next') || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -316,3 +318,29 @@ jobs: tests/test_client.py \ tests/test_streaming.py \ tests/lib/test_pydantic.py + + ci-required: + name: ci-required + if: >- + always() && + (github.event_name == 'push' || github.event_name == 'merge_group' || + (github.event_name == 'pull_request' && github.base_ref == 'next')) + needs: [lint, build, test, test-httpx2, python-policy] + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 5 + steps: + - name: Require every CI job to pass + env: + LINT_RESULT: ${{ needs.lint.result }} + BUILD_RESULT: ${{ needs.build.result }} + TEST_RESULT: ${{ needs.test.result }} + HTTPX2_RESULT: ${{ needs.test-httpx2.result }} + POLICY_RESULT: ${{ needs.python-policy.result }} + run: | + for result in "$LINT_RESULT" "$BUILD_RESULT" "$TEST_RESULT" "$HTTPX2_RESULT" "$POLICY_RESULT"; do + if [ "$result" != success ]; then + echo "Required CI job did not succeed: $result" + exit 1 + fi + done From b328753d3b7f0ab44e5562e40051e9f2472801c3 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 21 Oct 2026 16:50:02 -0700 Subject: [PATCH 2/5] ci: preserve metadata-only push exclusion --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 464cf1982e..7bb42fd414 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -324,7 +324,8 @@ jobs: if: >- always() && (github.event_name == 'push' || github.event_name == 'merge_group' || - (github.event_name == 'pull_request' && github.base_ref == 'next')) + (github.event_name == 'pull_request' && github.base_ref == 'next')) && + (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') needs: [lint, build, test, test-httpx2, python-policy] runs-on: ubuntu-latest permissions: {} From 8f2edd6966242899a532faf5051b4d88de14db9a Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 21 Oct 2026 16:53:26 -0700 Subject: [PATCH 3/5] ci: isolate trusted uploads and runtime gate results --- .github/workflows/ci.yml | 58 +++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bb42fd414..af68cd11bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: - name: Stage tarball for Stainless upload if: |- github.repository == 'stainless-sdks/openai-python' && - !startsWith(github.ref, 'refs/heads/stl/') + github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: stainless-package-tarball @@ -118,7 +118,7 @@ jobs: stainless-upload: name: upload tarball to Stainless needs: build - if: github.repository == 'stainless-sdks/openai-python' && !startsWith(github.ref, 'refs/heads/stl/') + if: github.repository == 'stainless-sdks/openai-python' && github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read @@ -148,26 +148,21 @@ jobs: SHA: ${{ github.sha }} run: ./scripts/utils/upload-artifact.sh - test: + test-python310: timeout-minutes: 15 - name: test (Python ${{ matrix.python-version }}) + name: test (Python 3.10) runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.base_ref == 'next') || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork - strategy: - fail-fast: false - matrix: - # Per-PR coverage protects both ends of the support window. - python-version: ["3.10", "3.14"] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.10 id: setup-python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: ${{ matrix.python-version }} + python-version: '3.10' - name: Set up uv uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 @@ -175,7 +170,39 @@ jobs: version: '0.12.5' enable-cache: false - - name: Select Python ${{ matrix.python-version }} + - name: Select Python 3.10 + run: | + echo 'UV_PYTHON=${{ steps.setup-python.outputs.python-path }}' >> "$GITHUB_ENV" + + - name: Bootstrap + run: ./scripts/ci-bootstrap + + - name: Run tests + run: ./scripts/test + + test-python314: + timeout-minutes: 15 + name: test (Python 3.14) + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.base_ref == 'next') || (github.event_name == 'workflow_dispatch' && inputs.release_pr && github.ref == 'refs/heads/release-please--branches--main') || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Python 3.14 + id: setup-python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.14' + + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.12.5' + enable-cache: false + + - name: Select Python 3.14 run: | echo 'UV_PYTHON=${{ steps.setup-python.outputs.python-path }}' >> "$GITHUB_ENV" @@ -326,7 +353,7 @@ jobs: (github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.base_ref == 'next')) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') - needs: [lint, build, test, test-httpx2, python-policy] + needs: [lint, build, test-python310, test-python314, test-httpx2, python-policy] runs-on: ubuntu-latest permissions: {} timeout-minutes: 5 @@ -335,11 +362,12 @@ jobs: env: LINT_RESULT: ${{ needs.lint.result }} BUILD_RESULT: ${{ needs.build.result }} - TEST_RESULT: ${{ needs.test.result }} + PYTHON310_RESULT: ${{ needs.test-python310.result }} + PYTHON314_RESULT: ${{ needs.test-python314.result }} HTTPX2_RESULT: ${{ needs.test-httpx2.result }} POLICY_RESULT: ${{ needs.python-policy.result }} run: | - for result in "$LINT_RESULT" "$BUILD_RESULT" "$TEST_RESULT" "$HTTPX2_RESULT" "$POLICY_RESULT"; do + for result in "$LINT_RESULT" "$BUILD_RESULT" "$PYTHON310_RESULT" "$PYTHON314_RESULT" "$HTTPX2_RESULT" "$POLICY_RESULT"; do if [ "$result" != success ]; then echo "Required CI job did not succeed: $result" exit 1 From 20c96d0660ce4667d45af0bfbdd5a836a6c5a8c8 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 21 Oct 2026 16:56:55 -0700 Subject: [PATCH 4/5] ci: validate independently required Python runtimes --- scripts/check-python-version-policy.py | 19 +++++++---------- tests/test_python_version_policy.py | 28 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 tests/test_python_version_policy.py diff --git a/scripts/check-python-version-policy.py b/scripts/check-python-version-policy.py index 16217267c0..a0b300e084 100644 --- a/scripts/check-python-version-policy.py +++ b/scripts/check-python-version-policy.py @@ -45,12 +45,12 @@ def matrix_body(job: str) -> str: return match.group("body") -def matrix_versions(job: str) -> tuple[str, ...]: - versions: list[str] = [] - for line in matrix_body(job).splitlines(): - if re.match(r"^\s+(?:-\s+)?python-version:", line): - versions.extend(re.findall(r'"(3\.\d+)"', line)) - return tuple(versions) +def check_pr_runtimes(workflow: str) -> None: + for version in (MINIMUM, CURRENT_STABLE): + name = f"test-python{version.replace('.', '')}" + job = workflow_job(workflow, name) + versions = re.findall(r"^ +python-version: ['\"](3\.\d+)['\"]$", job, re.MULTILINE) + require(versions == [version], f"CI job {name!r} runtimes are {versions}, expected {[version]}") def compatibility_matrix(job: str) -> tuple[tuple[str, bool], ...]: @@ -96,12 +96,7 @@ def main() -> None: "Realtime example metadata does not state the minimum Python", ) - pr_versions = matrix_versions(workflow_job(workflow, "test")) - expected_pr_versions = (MINIMUM, CURRENT_STABLE) - require( - pr_versions == expected_pr_versions, - f"PR test matrix is {pr_versions}, expected {expected_pr_versions}", - ) + check_pr_runtimes(workflow) compatibility_job = workflow_job(workflow, "compatibility") require( diff --git a/tests/test_python_version_policy.py b/tests/test_python_version_policy.py new file mode 100644 index 0000000000..97c69cb1b8 --- /dev/null +++ b/tests/test_python_version_policy.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import runpy +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CHECK = runpy.run_path(str(ROOT / "scripts/check-python-version-policy.py"))["check_pr_runtimes"] +WORKFLOW = (ROOT / ".github/workflows/ci.yml").read_text() + + +def test_required_runtimes() -> None: + CHECK(WORKFLOW) + + +@pytest.mark.parametrize("version", ["3.10", "3.14"]) +@pytest.mark.parametrize("defect", ["missing", "wrong", "duplicate"]) +def test_rejects_invalid_required_runtime(version: str, defect: str) -> None: + declaration = f"python-version: '{version}'" + if defect == "missing": + candidate = WORKFLOW.replace(f"test-python{version.replace('.', '')}:\n", "missing-runtime:\n") + elif defect == "wrong": + candidate = WORKFLOW.replace(declaration, "python-version: '3.11'") + else: + candidate = WORKFLOW.replace(declaration, declaration + "\n " + declaration) + with pytest.raises(RuntimeError): + CHECK(candidate) From a50acee8e5adf046fdd0c637527be6f7406b7e1c Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 21 Oct 2026 17:04:17 -0700 Subject: [PATCH 5/5] ci: bind required context to next and handle retargeting --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af68cd11bc..ca2bcd3992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: - '!codegen/**' - 'codegen/stl/**' pull_request: + types: [opened, synchronize, reopened, edited] branches-ignore: - 'stl-preview-head/**' - 'stl-preview-base/**' @@ -347,10 +348,16 @@ jobs: tests/lib/test_pydantic.py ci-required: - name: ci-required + # Even skipped jobs publish a check: keep other targets out of next's context. + name: >- + ${{ ((github.event_name == 'push' && github.ref == 'refs/heads/next') || + (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/next') || + (github.event_name == 'pull_request' && github.base_ref == 'next')) && + 'ci-required (next)' || 'ci-required (non-next)' }} if: >- always() && - (github.event_name == 'push' || github.event_name == 'merge_group' || + ((github.event_name == 'push' && github.ref == 'refs/heads/next') || + (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/next') || (github.event_name == 'pull_request' && github.base_ref == 'next')) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') needs: [lint, build, test-python310, test-python314, test-httpx2, python-policy]