Stage publishable Python runtime wheels (#18865)

This is PR 2 of the Python SDK PyPI publishing split. [PR
1](https://github.com/openai/codex/pull/18862) refreshed the generated
SDK bindings; this PR makes the runtime package itself publishable, and
PR 3 will wire the SDK package/version pinning to this runtime package.

## Summary
- Rename the runtime distribution to `openai-codex-cli-bin` while
keeping the import package as `codex_cli_bin`.
- Make the runtime package wheel-only and build `py3-none-<platform>`
wheels instead of interpreter-specific wheels.
- Add `stage-runtime --codex-version` and `--platform-tag` so release
staging can produce the platform wheel matrix from Codex release tags.
- Add focused artifact workflow tests for version normalization,
platform tag injection, and runtime wheel metadata.

## Why Rename
There is already an unofficial PyPI package,
[`codex-bin`](https://pypi.org/project/codex-bin/), distributing OpenAI
Codex binaries. Publishing the official SDK runtime dependency as
`openai-codex-cli-bin` makes the ownership clear, avoids confusing the
SDK-pinned runtime wheel with that unowned wrapper, and keeps the import
package unchanged as `codex_cli_bin`.

## Tests
- `uv run --extra dev pytest
tests/test_artifact_workflow_and_binaries.py` -> 21 passed
- `uv run --extra dev python scripts/update_sdk_artifacts.py
stage-runtime /tmp/codex-python-pr2-rebased/runtime-stage
/tmp/codex-python-pr2-rebased/codex --codex-version
rust-v0.116.0-alpha.1 --platform-tag macosx_11_0_arm64`
- `uv run --with build --extra dev python -m build --wheel
/tmp/codex-python-pr2-rebased/runtime-stage`
- `uv run --with twine --extra dev twine check
/tmp/codex-python-pr2-rebased/runtime-stage/dist/openai_codex_cli_bin-0.116.0a1-py3-none-macosx_11_0_arm64.whl`

## Note
- Full `uv run --extra dev pytest` currently fails because regenerating
from schemas already on `main` adds new DeviceKey Python types. I left
that generated catch-up out of this runtime-only PR.
This commit is contained in:
Steve Coffey
2026-04-22 08:14:48 -07:00
committed by GitHub
Unverified
parent 0ebe69a8c3
commit 0127cef5db
12 changed files with 228 additions and 54 deletions
+2 -2
View File
@@ -5,5 +5,5 @@ Platform-specific runtime package consumed by the published `codex-app-server-sd
This package is staged during release so the SDK can pin an exact Codex CLI
version without checking platform binaries into the repo.
`codex-cli-bin` is intentionally wheel-only. Do not build or publish an sdist
for this package.
`openai-codex-cli-bin` is intentionally wheel-only. Do not build or publish an
sdist for this package.
+17 -2
View File
@@ -1,15 +1,30 @@
from __future__ import annotations
import os
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
def _platform_tag() -> str:
from packaging.tags import sys_tags
return next(iter(sys_tags())).platform
class RuntimeBuildHook(BuildHookInterface):
def initialize(self, version: str, build_data: dict[str, object]) -> None:
del version
if self.target_name == "sdist":
raise RuntimeError(
"codex-cli-bin is wheel-only; build and publish platform wheels only."
"openai-codex-cli-bin is wheel-only; build and publish platform wheels only."
)
platform_tag = self.config.get("platform-tag") or os.environ.get(
"CODEX_CLI_BIN_PLATFORM_TAG"
)
if not isinstance(platform_tag, str) or not platform_tag:
platform_tag = _platform_tag()
build_data["pure_python"] = False
build_data["infer_tag"] = True
build_data["infer_tag"] = False
build_data["tag"] = f"py3-none-{platform_tag}"
+1 -1
View File
@@ -3,7 +3,7 @@ requires = ["hatchling>=1.24.0"]
build-backend = "hatchling.build"
[project]
name = "codex-cli-bin"
name = "openai-codex-cli-bin"
version = "0.0.0-dev"
description = "Pinned Codex CLI runtime for the Python SDK"
readme = "README.md"
@@ -3,7 +3,7 @@ from __future__ import annotations
import os
from pathlib import Path
PACKAGE_NAME = "codex-cli-bin"
PACKAGE_NAME = "openai-codex-cli-bin"
def bundled_codex_path() -> Path:
+7 -7
View File
@@ -11,7 +11,7 @@ cd sdk/python
python -m pip install -e .
```
Published SDK builds pin an exact `codex-cli-bin` runtime dependency. For local
Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency. For local
repo development, either pass `AppServerConfig(codex_bin=...)` to point at a
local build explicitly, or use the repo examples/notebook bootstrap which
installs the pinned runtime package automatically.
@@ -53,7 +53,7 @@ python examples/01_quickstart_constructor/async.py
The repo no longer checks `codex` binaries into `sdk/python`.
Published SDK builds are pinned to an exact `codex-cli-bin` package version,
Published SDK builds are pinned to an exact `openai-codex-cli-bin` package version,
and that runtime package carries the platform-specific binary for the target
wheel.
@@ -73,7 +73,7 @@ python scripts/update_sdk_artifacts.py \
--runtime-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/codex-cli-bin \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
```
@@ -81,14 +81,14 @@ python scripts/update_sdk_artifacts.py \
This supports the CI release flow:
- run `generate-types` before packaging
- stage `codex-app-server-sdk` once with an exact `codex-cli-bin==...` dependency
- stage `codex-cli-bin` on each supported platform runner with the same pinned runtime version
- build and publish `codex-cli-bin` as platform wheels only; do not publish an sdist
- stage `codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency
- stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version
- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist
## Compatibility and versioning
- Package: `codex-app-server-sdk`
- Runtime package: `codex-cli-bin`
- Runtime package: `openai-codex-cli-bin`
- Current SDK version in this repo: `0.2.0`
- Python: `>=3.10`
- Target protocol: Codex `app-server` JSON-RPC v2
+2 -2
View File
@@ -15,7 +15,7 @@ import urllib.request
import zipfile
from pathlib import Path
PACKAGE_NAME = "codex-cli-bin"
PACKAGE_NAME = "openai-codex-cli-bin"
PINNED_RUNTIME_VERSION = "0.116.0-alpha.1"
REPO_SLUG = "openai/codex"
@@ -105,7 +105,7 @@ def _installed_runtime_version(python_executable: str | Path) -> str | None:
"try:\n"
" from codex_cli_bin import bundled_codex_path\n"
" bundled_codex_path()\n"
" print(json.dumps({'version': importlib.metadata.version('codex-cli-bin')}))\n"
f" print(json.dumps({{'version': importlib.metadata.version({PACKAGE_NAME!r})}}))\n"
"except Exception:\n"
" sys.exit(1)\n"
)
+3 -3
View File
@@ -54,13 +54,13 @@ This avoids duplicate ways to do the same operation and keeps behavior explicit.
Common causes:
- published runtime package (`codex-cli-bin`) is not installed
- published runtime package (`openai-codex-cli-bin`) is not installed
- local `codex_bin` override points to a missing file
- local auth/session is missing
- incompatible/old app-server
Maintainers stage releases by building the SDK once and the runtime once per
platform with the same pinned runtime version. Publish `codex-cli-bin` as
platform with the same pinned runtime version. Publish `openai-codex-cli-bin` as
platform wheels only; do not publish an sdist:
```bash
@@ -72,7 +72,7 @@ python scripts/update_sdk_artifacts.py \
--runtime-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/codex-cli-bin \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
```
+1 -1
View File
@@ -16,7 +16,7 @@ python -m pip install -e .
Requirements:
- Python `>=3.10`
- installed `codex-cli-bin` runtime package, or an explicit `codex_bin` override
- installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override
- local Codex auth/session configured
## 2) Run your first turn (sync)
+4 -4
View File
@@ -23,11 +23,11 @@ python -m pip install -e .
When running examples from this repo checkout, the SDK source uses the local
tree and does not bundle a runtime binary. The helper in `examples/_bootstrap.py`
uses the installed `codex-cli-bin` runtime package.
uses the installed `openai-codex-cli-bin` runtime package.
If the pinned `codex-cli-bin` runtime is not already installed, the bootstrap
If the pinned `openai-codex-cli-bin` runtime is not already installed, the bootstrap
will download the matching GitHub release artifact, stage a temporary local
`codex-cli-bin` package, install it into your active interpreter, and clean up
`openai-codex-cli-bin` package, install it into your active interpreter, and clean up
the temporary files afterward.
Current pinned runtime version: `0.116.0-alpha.1`
@@ -43,7 +43,7 @@ python examples/<example-folder>/async.py
The examples bootstrap local imports from `sdk/python/src` automatically, so no
SDK wheel install is required. You only need the Python dependencies for your
active interpreter and an installed `codex-cli-bin` runtime package (either
active interpreter and an installed `openai-codex-cli-bin` runtime package (either
already present or automatically provisioned by the bootstrap).
## Recommended first run
+112 -10
View File
@@ -17,6 +17,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Sequence, get_args, get_origin
RUNTIME_DISTRIBUTION_NAME = "openai-codex-cli-bin"
def repo_root() -> Path:
return Path(__file__).resolve().parents[3]
@@ -76,6 +78,24 @@ def current_sdk_version() -> str:
return match.group(1)
def normalize_codex_version(version: str) -> str:
normalized = version.strip()
if normalized.startswith("rust-v"):
normalized = normalized.removeprefix("rust-v")
elif normalized.startswith("v"):
normalized = normalized.removeprefix("v")
normalized = re.sub(r"-alpha\.?([0-9]+)$", r"a\1", normalized)
normalized = re.sub(r"-beta\.?([0-9]+)$", r"b\1", normalized)
normalized = re.sub(r"-rc\.?([0-9]+)$", r"rc\1", normalized)
if not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*(?:(?:a|b|rc)[0-9]+)?", normalized):
raise RuntimeError(
f"Could not normalize Codex version {version!r} to a PEP 440 version"
)
return normalized
def _copy_package_tree(src: Path, dst: Path) -> None:
if dst.exists():
if dst.is_dir():
@@ -110,6 +130,46 @@ def _rewrite_project_version(pyproject_text: str, version: str) -> str:
return updated
def _rewrite_runtime_platform_tag(pyproject_text: str, platform_tag: str) -> str:
section = "[tool.hatch.build.targets.wheel.hooks.custom]"
section_index = pyproject_text.find(section)
if section_index == -1:
raise RuntimeError("Could not find runtime wheel custom hook config")
next_section_index = pyproject_text.find("\n[", section_index + len(section))
if next_section_index == -1:
section_text = pyproject_text[section_index:]
tail = ""
else:
section_text = pyproject_text[section_index:next_section_index]
tail = pyproject_text[next_section_index:]
updated_section, count = re.subn(
r'^platform-tag = "[^"]*"$',
f'platform-tag = "{platform_tag}"',
section_text,
count=1,
flags=re.MULTILINE,
)
if count == 0:
updated_section = section_text.rstrip() + f'\nplatform-tag = "{platform_tag}"\n'
return pyproject_text[:section_index] + updated_section + tail
def _rewrite_project_name(pyproject_text: str, name: str) -> str:
updated, count = re.subn(
r'^name = "[^"]+"$',
f'name = "{name}"',
pyproject_text,
count=1,
flags=re.MULTILINE,
)
if count != 1:
raise RuntimeError("Could not rewrite project name in pyproject.toml")
return updated
def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -> str:
match = re.search(r"^dependencies = \[(.*?)\]$", pyproject_text, flags=re.MULTILINE)
if match is None:
@@ -119,7 +179,7 @@ def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -
raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()]
raw_items = [item for item in raw_items if "codex-cli-bin" not in item]
raw_items.append(f'"codex-cli-bin=={runtime_version}"')
raw_items.append(f'"{RUNTIME_DISTRIBUTION_NAME}=={runtime_version}"')
replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]"
return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :]
@@ -141,14 +201,21 @@ def stage_python_sdk_package(
def stage_python_runtime_package(
staging_dir: Path, runtime_version: str, binary_path: Path
staging_dir: Path,
codex_version: str,
binary_path: Path,
platform_tag: str | None = None,
) -> Path:
package_version = normalize_codex_version(codex_version)
_copy_package_tree(python_runtime_root(), staging_dir)
pyproject_path = staging_dir / "pyproject.toml"
pyproject_path.write_text(
_rewrite_project_version(pyproject_path.read_text(), runtime_version)
)
pyproject_text = pyproject_path.read_text()
pyproject_text = _rewrite_project_name(pyproject_text, RUNTIME_DISTRIBUTION_NAME)
pyproject_text = _rewrite_project_version(pyproject_text, package_version)
if platform_tag is not None:
pyproject_text = _rewrite_runtime_platform_tag(pyproject_text, platform_tag)
pyproject_path.write_text(pyproject_text)
out_bin = staged_runtime_bin_path(staging_dir)
out_bin.parent.mkdir(parents=True, exist_ok=True)
@@ -559,7 +626,7 @@ class PublicFieldSpec:
class CliOps:
generate_types: Callable[[], None]
stage_python_sdk_package: Callable[[Path, str, str], Path]
stage_python_runtime_package: Callable[[Path, str, Path], Path]
stage_python_runtime_package: Callable[[Path, str, Path, str | None], Path]
current_sdk_version: Callable[[], str]
@@ -928,7 +995,7 @@ def build_parser() -> argparse.ArgumentParser:
stage_sdk_parser.add_argument(
"--runtime-version",
required=True,
help="Pinned codex-cli-bin version for the staged SDK package",
help="Pinned openai-codex-cli-bin version for the staged SDK package",
)
stage_sdk_parser.add_argument(
"--sdk-version",
@@ -949,10 +1016,23 @@ def build_parser() -> argparse.ArgumentParser:
type=Path,
help="Path to the codex binary to package for this platform",
)
stage_runtime_parser.add_argument(
"--codex-version",
help=(
"Codex release version to write into the staged runtime package. "
"Accepts PEP 440 versions or release tags such as rust-v0.116.0-alpha.1."
),
)
stage_runtime_parser.add_argument(
"--runtime-version",
required=True,
help="Version to write into the staged runtime package",
help=argparse.SUPPRESS,
)
stage_runtime_parser.add_argument(
"--platform-tag",
help=(
"Optional wheel platform tag override, for example "
"macosx_11_0_arm64 or musllinux_1_1_x86_64."
),
)
return parser
@@ -970,6 +1050,26 @@ def default_cli_ops() -> CliOps:
)
def _resolve_runtime_version(args: argparse.Namespace) -> str:
versions = [
value
for value in (
getattr(args, "codex_version", None),
getattr(args, "runtime_version", None),
)
if value is not None
]
if not versions:
raise RuntimeError("Pass --codex-version to stage the Python runtime package")
normalized_versions = [normalize_codex_version(version) for version in versions]
if len(set(normalized_versions)) != 1:
raise RuntimeError(
"Runtime package versions must match; pass one --codex-version"
)
return normalized_versions[0]
def run_command(args: argparse.Namespace, ops: CliOps) -> None:
if args.command == "generate-types":
ops.generate_types()
@@ -981,10 +1081,12 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None:
args.runtime_version,
)
elif args.command == "stage-runtime":
runtime_version = _resolve_runtime_version(args)
ops.stage_python_runtime_package(
args.staging_dir,
args.runtime_version,
runtime_version,
args.runtime_binary.resolve(),
args.platform_tag,
)
+1 -1
View File
@@ -47,7 +47,7 @@ from .retry import retry_on_overload
ModelT = TypeVar("ModelT", bound=BaseModel)
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
RUNTIME_PKG_NAME = "codex-cli-bin"
RUNTIME_PKG_NAME = "openai-codex-cli-bin"
def _params_dict(
@@ -168,6 +168,19 @@ def test_examples_readme_matches_pinned_runtime_version() -> None:
)
def test_runtime_distribution_name_is_consistent() -> None:
script = _load_update_script_module()
runtime_setup = _load_runtime_setup_module()
from codex_app_server import client as client_module
assert script.RUNTIME_DISTRIBUTION_NAME == "openai-codex-cli-bin"
assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin"
assert client_module.RUNTIME_PKG_NAME == "openai-codex-cli-bin"
assert "importlib.metadata.version('codex-cli-bin')" not in (
ROOT / "_runtime_setup.py"
).read_text()
def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None:
runtime_setup = _load_runtime_setup_module()
authorizations: list[str | None] = []
@@ -222,19 +235,24 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() ->
),
None,
)
build_data_assignments = {
node.targets[0].slice.value: node.value.value
for node in initialize_fn.body
if isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Subscript)
and isinstance(node.targets[0].value, ast.Name)
and node.targets[0].value.id == "build_data"
and isinstance(node.targets[0].slice, ast.Constant)
and isinstance(node.targets[0].slice.value, str)
and isinstance(node.value, ast.Constant)
}
build_data_assignments = {}
for node in initialize_fn.body:
if (
not isinstance(node, ast.Assign)
or len(node.targets) != 1
or not isinstance(node.targets[0], ast.Subscript)
or not isinstance(node.targets[0].value, ast.Name)
or node.targets[0].value.id != "build_data"
or not isinstance(node.targets[0].slice, ast.Constant)
or not isinstance(node.targets[0].slice.value, str)
):
continue
if isinstance(node.value, ast.Constant):
build_data_assignments[node.targets[0].slice.value] = node.value.value
elif isinstance(node.value, ast.JoinedStr):
build_data_assignments[node.targets[0].slice.value] = "joined-string"
assert pyproject["project"]["name"] == "openai-codex-cli-bin"
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == {
"packages": ["src/codex_cli_bin"],
"include": ["src/codex_cli_bin/bin/**"],
@@ -244,7 +262,11 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() ->
"hooks": {"custom": {}},
}
assert sdist_guard is not None
assert build_data_assignments == {"pure_python": False, "infer_tag": True}
assert build_data_assignments == {
"pure_python": False,
"infer_tag": False,
"tag": "joined-string",
}
def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> None:
@@ -260,9 +282,19 @@ def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) ->
assert staged == tmp_path / "runtime-stage"
assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n"
assert 'name = "openai-codex-cli-bin"' in (staged / "pyproject.toml").read_text()
assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text()
def test_normalize_codex_version_accepts_release_tags_and_pep440_versions() -> None:
script = _load_update_script_module()
assert script.normalize_codex_version("rust-v0.116.0-alpha.1") == "0.116.0a1"
assert script.normalize_codex_version("v0.116.0-beta.2") == "0.116.0b2"
assert script.normalize_codex_version("0.116.0rc3") == "0.116.0rc3"
assert script.normalize_codex_version("0.116.0") == "0.116.0"
def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None:
script = _load_update_script_module()
staging_dir = tmp_path / "runtime-stage"
@@ -284,13 +316,30 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) ->
assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n"
def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> None:
script = _load_update_script_module()
fake_binary = tmp_path / script.runtime_binary_name()
fake_binary.write_text("fake codex\n")
staged = script.stage_python_runtime_package(
tmp_path / "runtime-stage",
"0.116.0a1",
fake_binary,
platform_tag="musllinux_1_1_x86_64",
)
pyproject = (staged / "pyproject.toml").read_text()
assert 'platform-tag = "musllinux_1_1_x86_64"' in pyproject
def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None:
script = _load_update_script_module()
staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3")
pyproject = (staged / "pyproject.toml").read_text()
assert 'version = "0.2.1"' in pyproject
assert '"codex-cli-bin==1.2.3"' in pyproject
assert '"openai-codex-cli-bin==1.2.3"' in pyproject
assert '"codex-cli-bin==1.2.3"' not in pyproject
assert not any((staged / "src" / "codex_app_server").glob("bin/**"))
@@ -329,7 +378,10 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
return tmp_path / "sdk-stage"
def fake_stage_runtime_package(
_staging_dir: Path, _runtime_version: str, _runtime_binary: Path
_staging_dir: Path,
_runtime_version: str,
_runtime_binary: Path,
_platform_tag: str | None,
) -> Path:
raise AssertionError("runtime staging should not run for stage-sdk")
@@ -358,8 +410,10 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
"stage-runtime",
str(tmp_path / "runtime-stage"),
str(fake_binary),
"--runtime-version",
"1.2.3",
"--codex-version",
"rust-v0.116.0-alpha.1",
"--platform-tag",
"musllinux_1_1_x86_64",
]
)
@@ -372,9 +426,12 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
raise AssertionError("sdk staging should not run for stage-runtime")
def fake_stage_runtime_package(
_staging_dir: Path, _runtime_version: str, _runtime_binary: Path
_staging_dir: Path,
codex_version: str,
_runtime_binary: Path,
platform_tag: str | None,
) -> Path:
calls.append("stage_runtime")
calls.append(f"stage_runtime:{codex_version}:{platform_tag}")
return tmp_path / "runtime-stage"
def fake_current_sdk_version() -> str:
@@ -389,7 +446,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
script.run_command(args, ops)
assert calls == ["stage_runtime"]
assert calls == ["stage_runtime:0.116.0a1:musllinux_1_1_x86_64"]
def test_default_runtime_is_resolved_from_installed_runtime_package(