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