From 96c2b4377f5f0b1363ba4d4057fbbb7c6dccd9d8 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 9 Sep 2026 04:39:58 +0000 Subject: [PATCH] Gate Python SDK publishing on runtime availability and verify PyPI files (#44055) ## Why The SDK publish job needs to wait for runtime wheels to become available on PyPI, and release reruns need to tolerate SDK files that have already been uploaded. ## What changed - Require runtime publication and verification before publishing the SDK, and enable `skip-existing` for SDK uploads. - Share a PyPI verifier between runtime and SDK releases. Require the exact expected artifact set, including the SDK wheel and source distribution. - Canonicalize versions with `packaging.version.Version` and retry registry errors, malformed responses, and incomplete artifact sets with a bounded retry loop. ## Testing Add unit tests for transient failures and malformed responses, waiting for complete SDK artifacts, canonical version lookup, and retry exhaustion when runtime wheels are missing. Run them in repository checks. GitOrigin-RevId: 9cb2ddced4ce6d509ebfd130511ab3f39239dd62 --- .github/scripts/test_verify_pypi_release.py | 96 ++++++++++++++++++++ .github/scripts/verify_pypi_release.py | 76 ++++++++++++++++ .github/workflows/python-runtime-release.yml | 61 +++---------- .github/workflows/python-sdk-release.yml | 82 +++++++---------- .github/workflows/repo-checks.yml | 12 ++- 5 files changed, 224 insertions(+), 103 deletions(-) create mode 100644 .github/scripts/test_verify_pypi_release.py create mode 100644 .github/scripts/verify_pypi_release.py diff --git a/.github/scripts/test_verify_pypi_release.py b/.github/scripts/test_verify_pypi_release.py new file mode 100644 index 0000000000..980becbed4 --- /dev/null +++ b/.github/scripts/test_verify_pypi_release.py @@ -0,0 +1,96 @@ +import http.client +import io +import json +import unittest +import urllib.error +from unittest.mock import MagicMock, patch + +from verify_pypi_release import verify_release + + +class VerifyPyPIReleaseTest(unittest.TestCase): + def test_waits_for_complete_release_after_registry_error(self) -> None: + wheel = "openai_codex-1.2.3-py3-none-any.whl" + sdist = "openai_codex-1.2.3.tar.gz" + reset_response = MagicMock() + reset_response.__enter__.return_value.read.side_effect = ConnectionResetError( + "connection reset while reading response" + ) + responses = [ + urllib.error.URLError("registry unavailable"), + TimeoutError("registry timed out"), + http.client.IncompleteRead(b"{", 20), + reset_response, + io.StringIO('{"urls":'), + *[ + io.StringIO(json.dumps(data)) + for data in ( + None, + [], + {}, + {"urls": None}, + {"urls": {}}, + {"urls": [None]}, + {"urls": [{}]}, + {"urls": [{"filename": 42}]}, + ) + ], + io.StringIO(json.dumps({"urls": [{"filename": wheel}]})), + io.StringIO( + json.dumps({"urls": [{"filename": wheel}, {"filename": sdist}]}) + ), + ] + with ( + patch( + "verify_pypi_release.urllib.request.urlopen", side_effect=responses + ) as urlopen, + patch("verify_pypi_release.time.sleep") as sleep, + ): + verify_release("openai-codex", "1.2.3") + + self.assertEqual(urlopen.call_count, 15) + self.assertEqual(sleep.call_count, 14) + urlopen.assert_called_with( + "https://pypi.org/pypi/openai-codex/1.2.3/json", timeout=30 + ) + + def test_uses_canonical_version_for_lookup_and_artifact_names(self) -> None: + data = { + "urls": [ + {"filename": name} + for name in ( + "openai_codex-1.2.3b1-py3-none-any.whl", + "openai_codex-1.2.3b1.tar.gz", + ) + ] + } + with ( + patch( + "verify_pypi_release.urllib.request.urlopen", + return_value=io.StringIO(json.dumps(data)), + ) as urlopen, + patch("verify_pypi_release.time.sleep") as sleep, + ): + verify_release("openai-codex", "1.2.3b01") + urlopen.assert_called_once_with( + "https://pypi.org/pypi/openai-codex/1.2.3b1/json", timeout=30 + ) + sleep.assert_not_called() + + def test_fails_after_bounded_retries_when_runtime_wheels_are_missing(self) -> None: + with ( + patch( + "verify_pypi_release.urllib.request.urlopen", + side_effect=lambda *args, **kwargs: io.StringIO('{"urls": []}'), + ) as urlopen, + patch("verify_pypi_release.time.sleep") as sleep, + self.assertRaisesRegex(SystemExit, "did not become available on PyPI"), + ): + verify_release("openai-codex-cli-bin", "1.2.3a4.post5") + + self.assertEqual(urlopen.call_count, 30) + self.assertEqual(sleep.call_count, 29) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/verify_pypi_release.py b/.github/scripts/verify_pypi_release.py new file mode 100644 index 0000000000..c10f933534 --- /dev/null +++ b/.github/scripts/verify_pypi_release.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Wait for every Python release artifact to become visible on PyPI.""" + +import argparse +import http.client +import json +import time +import urllib.request + +from packaging.version import Version + +RUNTIME_PLATFORM_TAGS = { + "macosx_10_9_x86_64", + "macosx_11_0_arm64", + "manylinux_2_17_aarch64", + "manylinux_2_17_x86_64", + "musllinux_1_1_aarch64", + "musllinux_1_1_x86_64", + "win_amd64", + "win_arm64", +} + + +def verify_release(package: str, version: str) -> None: + version = str(Version(version)) + name = package.replace("-", "_") + if package == "openai-codex-cli-bin": + expected = { + f"{name}-{version}-py3-none-{tag}.whl" for tag in RUNTIME_PLATFORM_TAGS + } + else: + expected = {f"{name}-{version}-py3-none-any.whl", f"{name}-{version}.tar.gz"} + + for attempt in range(30): + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{package}/{version}/json", timeout=30 + ) as response: + data = json.load(response) + if not isinstance(data, dict) or not isinstance(data.get("urls"), list): + raise ValueError("Expected a PyPI response with a urls list") + if any( + not isinstance(file, dict) + or not isinstance(file.get("filename"), str) + for file in data["urls"] + ): + raise ValueError("Expected each PyPI file to have a filename") + actual = {file["filename"] for file in data["urls"]} + except ( + OSError, + http.client.HTTPException, + ValueError, + ) as error: + print(f"Could not read {package} {version} from PyPI: {error}.") + else: + if actual == expected: + print(f"All {package} {version} files are available on PyPI.") + return + print(f"Missing files: {sorted(expected - actual)}") + print(f"Unexpected files: {sorted(actual - expected)}") + if attempt < 29: + time.sleep(10) + + raise SystemExit(f"{package} {version} files did not become available on PyPI.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", choices=["openai-codex-cli-bin", "openai-codex"]) + parser.add_argument("version") + args = parser.parse_args() + verify_release(args.package, args.version) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/python-runtime-release.yml b/.github/workflows/python-runtime-release.yml index f088c37201..b5c7ba64a1 100644 --- a/.github/workflows/python-runtime-release.yml +++ b/.github/workflows/python-runtime-release.yml @@ -33,6 +33,11 @@ jobs: id-token: write # Required for PyPI trusted publishing. steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Download Python runtime wheels uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -45,56 +50,14 @@ jobs: packages-dir: dist/python-runtime skip-existing: true + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.3" + - name: Verify Python runtime wheels are available on PyPI env: PYTHON_RUNTIME_VERSION: ${{ inputs.runtime_version }} run: | - set -euo pipefail - for attempt in {1..30}; do - if python3 - <<'PY' - import json - import os - import urllib.error - import urllib.request - - version = os.environ["PYTHON_RUNTIME_VERSION"] - tags = { - "macosx_10_9_x86_64", - "macosx_11_0_arm64", - "manylinux_2_17_aarch64", - "manylinux_2_17_x86_64", - "musllinux_1_1_aarch64", - "musllinux_1_1_x86_64", - "win_amd64", - "win_arm64", - } - expected = { - f"openai_codex_cli_bin-{version}-py3-none-{tag}.whl" - for tag in tags - } - - try: - with urllib.request.urlopen( - f"https://pypi.org/pypi/openai-codex-cli-bin/{version}/json", - timeout=30, - ) as response: - payload = json.load(response) - except urllib.error.URLError as error: - print(f"Could not read runtime {version} from PyPI: {error}.") - raise SystemExit(1) from error - - actual = {file["filename"] for file in payload["urls"]} - if actual != expected: - print(f"Missing runtime wheels: {sorted(expected - actual)}") - print(f"Unexpected runtime files: {sorted(actual - expected)}") - raise SystemExit(1) - PY - then - exit 0 - fi - echo "Runtime wheels are not available on PyPI yet; retrying (${attempt}/30)." - sleep 10 - done - - echo "Runtime wheels did not become available on PyPI." - exit 1 + uv run --no-project --with packaging==26.2 python .github/scripts/verify_pypi_release.py \ + openai-codex-cli-bin "$PYTHON_RUNTIME_VERSION" diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml index 16834a62fc..b7b69ac55d 100644 --- a/.github/workflows/python-sdk-release.yml +++ b/.github/workflows/python-sdk-release.yml @@ -91,6 +91,11 @@ jobs: id-token: write # Required for PyPI trusted publishing. steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Download Python runtime wheels uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -103,59 +108,17 @@ jobs: packages-dir: dist/python-runtime skip-existing: true + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.3" + - name: Verify Python runtime wheels are available on PyPI env: PYTHON_RUNTIME_VERSION: ${{ needs.resolve-python-release.outputs.runtime_version }} run: | - set -euo pipefail - for attempt in {1..30}; do - if python3 - <<'PY' - import json - import os - import urllib.error - import urllib.request - - version = os.environ["PYTHON_RUNTIME_VERSION"] - tags = { - "macosx_10_9_x86_64", - "macosx_11_0_arm64", - "manylinux_2_17_aarch64", - "manylinux_2_17_x86_64", - "musllinux_1_1_aarch64", - "musllinux_1_1_x86_64", - "win_amd64", - "win_arm64", - } - expected = { - f"openai_codex_cli_bin-{version}-py3-none-{tag}.whl" - for tag in tags - } - - try: - with urllib.request.urlopen( - f"https://pypi.org/pypi/openai-codex-cli-bin/{version}/json", - timeout=30, - ) as response: - payload = json.load(response) - except urllib.error.URLError as error: - print(f"Could not read runtime {version} from PyPI: {error}.") - raise SystemExit(1) from error - - actual = {file["filename"] for file in payload["urls"]} - if actual != expected: - print(f"Missing runtime wheels: {sorted(expected - actual)}") - print(f"Unexpected runtime files: {sorted(actual - expected)}") - raise SystemExit(1) - PY - then - exit 0 - fi - echo "Runtime wheels are not available on PyPI yet; retrying (${attempt}/30)." - sleep 10 - done - - echo "Runtime wheels did not become available on PyPI." - exit 1 + uv run --no-project --with packaging==26.2 python .github/scripts/verify_pypi_release.py \ + openai-codex-cli-bin "$PYTHON_RUNTIME_VERSION" build-python-sdk: if: github.repository == 'openai/codex' @@ -217,7 +180,10 @@ jobs: publish-python-sdk: name: publish-python-sdk - needs: build-python-sdk + needs: + - build-python-sdk + - publish-python-runtime + - resolve-python-release runs-on: ubuntu-latest environment: pypi permissions: @@ -225,6 +191,11 @@ jobs: id-token: write # Required for PyPI trusted publishing. steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Download Python SDK package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -235,3 +206,14 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist/python-sdk + skip-existing: true + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.3" + + - name: Verify Python SDK is available on PyPI + env: + SDK_VERSION: ${{ needs.resolve-python-release.outputs.sdk_version }} + run: uv run --no-project --with packaging==26.2 python .github/scripts/verify_pypi_release.py openai-codex "$SDK_VERSION" diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index be2b9aa489..b025de718d 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -26,6 +26,14 @@ jobs: - name: Verify Bazel clippy flags match Cargo workspace lints run: python3 .github/scripts/verify_bazel_clippy_lints.py + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.3" + + - name: Test PyPI release verification + run: uv run --no-project --with packaging==26.2 python -m unittest discover -s .github/scripts -p 'test_verify_pypi_release.py' + - name: Test Codex package builder run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py' @@ -53,10 +61,6 @@ jobs: - name: Check root README ToC run: python3 scripts/readme_toc.py README.md - - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - version: "0.11.3" - name: Check formatting (run `just fmt` to fix) run: just fmt-check