diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c964510f91..ca2bcd3992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,12 @@ on: - '!codegen/**' - 'codegen/stl/**' pull_request: + types: [opened, synchronize, reopened, edited] branches-ignore: - 'stl-preview-head/**' - 'stl-preview-base/**' + merge_group: + types: [checks_requested] workflow_dispatch: inputs: release_pr: @@ -33,7 +36,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 +55,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: @@ -105,7 +108,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 @@ -116,7 +119,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 @@ -146,26 +149,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 == '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"] + 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 ${{ 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 @@ -173,7 +171,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" @@ -187,7 +217,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 +346,37 @@ jobs: tests/test_client.py \ tests/test_streaming.py \ tests/lib/test_pydantic.py + + 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.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] + 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 }} + 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" "$PYTHON310_RESULT" "$PYTHON314_RESULT" "$HTTPX2_RESULT" "$POLICY_RESULT"; do + if [ "$result" != success ]; then + echo "Required CI job did not succeed: $result" + exit 1 + fi + done 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)