diff --git a/.github/workflows/python-runtime-build.yml b/.github/workflows/python-runtime-build.yml index 1b91ab5692..433c08614b 100644 --- a/.github/workflows/python-runtime-build.yml +++ b/.github/workflows/python-runtime-build.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: runtime_version: - description: "Runtime version to build, for example 0.136.0 or 0.136.0a2." + description: "Runtime version to build, for example 0.136.0, 0.136.0a2, or 0.136.0a2.post1." required: true type: string @@ -29,26 +29,9 @@ jobs: REQUESTED_RUNTIME_VERSION: ${{ inputs.runtime_version }} run: | set -euo pipefail - python3 - <<'PY' - import os - import re - from pathlib import Path - - python_version = os.environ["REQUESTED_RUNTIME_VERSION"] - if match := re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)", python_version): - release_version = f"{match.group(1)}-alpha.{match.group(2)}" - elif re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version): - release_version = python_version - else: - raise SystemExit( - "Python runtime version must be stable or a numbered alpha, " - f"for example 0.136.0 or 0.136.0a2; found {python_version}" - ) - - with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: - print(f"python_version={python_version}", file=output) - print(f"release_tag=rust-v{release_version}", file=output) - PY + python3 sdk/python/release_version.py \ + "$REQUESTED_RUNTIME_VERSION" \ + --github-output "$GITHUB_OUTPUT" - name: Download Python runtime release artifacts env: diff --git a/.github/workflows/python-runtime-release.yml b/.github/workflows/python-runtime-release.yml index 4068786f31..3efc775d87 100644 --- a/.github/workflows/python-runtime-release.yml +++ b/.github/workflows/python-runtime-release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: runtime_version: - description: "Runtime version to publish before updating the SDK pin, for example 0.136.0 or 0.136.0a2." + description: "Runtime version to publish before updating the SDK pin, for example 0.136.0, 0.136.0a2, or 0.136.0a2.post1." required: true type: string diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 9fdd66637b..f90b594b4e 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -40,7 +40,7 @@ jobs: || { echo "❌ Not a tag ref"; exit 1; } # Release tags must match the version in Cargo.toml. - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha(\.[0-9]+){0,2}|beta(\.[0-9]+)?))?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } tag_ver="${GITHUB_REF_NAME#rust-v}" @@ -1220,7 +1220,7 @@ jobs: if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+(\.[0-9]+)?$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=alpha" >> "$GITHUB_OUTPUT" else @@ -1303,7 +1303,7 @@ jobs: # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: - # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. + # Publish to npm for stable releases, numbered alphas, and alpha hotfixes. if: >- ${{ !cancelled() && diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index e27d34b223..0c12ba0a29 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -71,8 +71,8 @@ function Assert-ValidReleaseVersion { [string]$Version ) - if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+)?)?$") { - throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." + if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-alpha(?:\.[0-9]+){0,2}|-beta(?:\.[0-9]+)?)?$") { + throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N[.M]]|-beta[.N]]." } } diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 0c52542a7a..8d2d08822d 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -55,8 +55,8 @@ validate_version() { return fi - if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$'; then - echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." >&2 + if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-alpha(\.[0-9]+){0,2}|-beta(\.[0-9]+)?)?$'; then + echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N[.M]]|-beta[.N]]." >&2 exit 1 fi } diff --git a/scripts/install/test_install_sh.py b/scripts/install/test_install_sh.py index a805fbf23d..fb38d3b582 100644 --- a/scripts/install/test_install_sh.py +++ b/scripts/install/test_install_sh.py @@ -48,6 +48,22 @@ class InstallShTest(unittest.TestCase): ) self.assertIn(f"Resolved version: {VERSION}", result.stdout) + def test_alpha_hotfix_release_is_valid(self) -> None: + version = "0.145.0-alpha.23.1" + result, requests = run_installer(version) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{version}", + "https://github.com/openai/codex/releases/download/" + f"rust-v{version}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {version}", result.stdout) + def test_latest_release_reuses_version_metadata(self) -> None: result, requests = run_installer("latest") diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py index 2c04254982..e3988fb795 100644 --- a/sdk/python/_runtime_setup.py +++ b/sdk/python/_runtime_setup.py @@ -13,6 +13,15 @@ import urllib.error import urllib.request from pathlib import Path +_SDK_PYTHON_ROOT = str(Path(__file__).resolve().parent) +if _SDK_PYTHON_ROOT not in sys.path: + sys.path.insert(0, _SDK_PYTHON_ROOT) + +from release_version import ( # noqa: E402 + codex_release_tag as _release_tag, + normalize_codex_version as _normalized_package_version, +) + PACKAGE_NAME = "openai-codex-cli-bin" SDK_PACKAGE_NAME = "openai-codex" REPO_SLUG = "openai/codex" @@ -326,34 +335,6 @@ def _github_token() -> str | None: return None -def _normalized_package_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) - return normalized - - -def _codex_release_version(version: str) -> str: - normalized = _normalized_package_version(version) - match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)*)(a|b|rc)([0-9]+)", normalized) - if match is None: - return normalized - - base, prerelease, number = match.groups() - prerelease_name = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease] - return f"{base}-{prerelease_name}.{number}" - - -def _release_tag(version: str) -> str: - return f"rust-v{_codex_release_version(version)}" - - def _source_tree_runtime_dependency_version() -> str | None: """Read the runtime dependency pin when the SDK is running from a checkout.""" pyproject_path = Path(__file__).resolve().parent / "pyproject.toml" diff --git a/sdk/python/release_version.py b/sdk/python/release_version.py new file mode 100644 index 0000000000..0340753e78 --- /dev/null +++ b/sdk/python/release_version.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +import argparse +import re +import sys +from pathlib import Path +from typing import Sequence + +_PYTHON_RUNTIME_VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:a[0-9]+(?:\.post[0-9]+)?)?") +_NORMALIZED_CODEX_VERSION_PATTERN = re.compile( + r"[0-9]+(?:\.[0-9]+)*(?:(?:a|b|rc)[0-9]+)?(?:\.post[0-9]+)?" +) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Resolve a Python runtime package version to its Codex release tag." + ) + parser.add_argument("python_version") + parser.add_argument("--github-output", type=Path, required=True) + args = parser.parse_args(argv) + + try: + python_version, release_tag = resolve_python_runtime_release(args.python_version) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + + with args.github_output.open("a", encoding="utf-8") as output: + print(f"python_version={python_version}", file=output) + print(f"release_tag={release_tag}", file=output) + return 0 + + +def resolve_python_runtime_release(python_version: str) -> tuple[str, str]: + if _PYTHON_RUNTIME_VERSION_PATTERN.fullmatch(python_version) is None: + raise RuntimeError( + "Python runtime version must be stable, a numbered alpha, or an " + "alpha post-release, for example 0.136.0, 0.136.0a2, or " + f"0.136.0a2.post1; found {python_version}" + ) + return python_version, codex_release_tag(python_version) + + +def codex_release_tag(version: str) -> str: + return f"rust-v{codex_release_version(version)}" + + +def codex_release_version(version: str) -> str: + normalized = normalize_codex_version(version) + alpha_hotfix = re.fullmatch( + r"([0-9]+(?:\.[0-9]+)*)a([0-9]+)\.post([0-9]+)", + normalized, + ) + if alpha_hotfix is not None: + base, alpha, hotfix = alpha_hotfix.groups() + return f"{base}-alpha.{alpha}.{hotfix}" + + prerelease = re.fullmatch(r"([0-9]+(?:\.[0-9]+)*)(a|b|rc)([0-9]+)", normalized) + if prerelease is None: + return normalized + + base, prerelease_kind, number = prerelease.groups() + prerelease_name = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease_kind] + return f"{base}-{prerelease_name}.{number}" + + +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]+)\.([0-9]+)$", r"a\1.post\2", normalized) + 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 _NORMALIZED_CODEX_VERSION_PATTERN.fullmatch(normalized) is None: + raise RuntimeError(f"Could not normalize Codex version {version!r} to a PEP 440 version") + return normalized + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 463472e5f9..e0c32bc647 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -17,6 +17,12 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Sequence, get_args, get_origin +_SDK_PYTHON_ROOT = str(Path(__file__).resolve().parents[1]) +if _SDK_PYTHON_ROOT not in sys.path: + sys.path.insert(0, _SDK_PYTHON_ROOT) + +from release_version import normalize_codex_version # noqa: E402 + SDK_DISTRIBUTION_NAME = "openai-codex" RUNTIME_DISTRIBUTION_NAME = "openai-codex-cli-bin" RUNTIME_PACKAGE_ROOT = Path("src") / "codex_cli_bin" @@ -130,22 +136,6 @@ def pinned_runtime_codex_path() -> Path: return codex_path -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(): @@ -1384,7 +1374,8 @@ def build_parser() -> argparse.ArgumentParser: required=True, 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." + "Accepts PEP 440 versions or release tags such as " + "rust-v0.116.0-alpha.1.2." ), ) stage_runtime_parser.add_argument( diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 437ee860e9..f62a1bfecf 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -3,6 +3,7 @@ import importlib.util import io import json import os +import subprocess import sys import tarfile import urllib.error @@ -51,6 +52,18 @@ def _load_runtime_setup_module(): return module +def _load_release_version_module(): + """Load the shared release-version conversions used by release tooling.""" + script_path = ROOT / "release_version.py" + spec = importlib.util.spec_from_file_location("release_version", script_path) + if spec is None or spec.loader is None: + raise AssertionError(f"Failed to load release-version module: {script_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def _write_fake_codex_package(package_dir: Path, script) -> Path: (package_dir / "bin").mkdir(parents=True) (package_dir / "codex-resources").mkdir() @@ -571,13 +584,19 @@ def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None: "normalized_release_version": runtime_setup._normalized_package_version( "rust-v0.116.0-alpha.1" ), + "normalized_alpha_hotfix_version": runtime_setup._normalized_package_version( + "rust-v0.116.0-alpha.1.2" + ), "release_tag": runtime_setup._release_tag("0.116.0a1"), + "alpha_hotfix_release_tag": runtime_setup._release_tag("0.116.0a1.post2"), } == { "package_name": "openai-codex-cli-bin", "sdk_template_version": "0.0.0-dev", "runtime_pin": "0.144.4", "normalized_release_version": "0.116.0a1", + "normalized_alpha_hotfix_version": "0.116.0a1.post2", "release_tag": "rust-v0.116.0-alpha.1", + "alpha_hotfix_release_tag": "rust-v0.116.0-alpha.1.2", } @@ -703,11 +722,54 @@ def test_normalize_codex_version_accepts_release_tags_and_pep440_versions() -> N script = _load_update_script_module() assert script.normalize_codex_version("rust-v0.116.0-alpha.1") == "0.116.0a1" + assert script.normalize_codex_version("rust-v0.116.0-alpha.1.2") == "0.116.0a1.post2" 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_release_version_conversions_map_python_versions_to_codex_tags() -> None: + release_version = _load_release_version_module() + + assert { + version: release_version.codex_release_tag(version) + for version in ["0.116.0", "0.116.0a1", "0.116.0a1.post2"] + } == { + "0.116.0": "rust-v0.116.0", + "0.116.0a1": "rust-v0.116.0-alpha.1", + "0.116.0a1.post2": "rust-v0.116.0-alpha.1.2", + } + + +def test_release_version_cli_writes_python_runtime_outputs(tmp_path: Path) -> None: + github_output = tmp_path / "github-output" + + result = subprocess.run( + [ + sys.executable, + str(ROOT / "release_version.py"), + "0.116.0a1.post2", + "--github-output", + str(github_output), + ], + text=True, + capture_output=True, + check=False, + ) + + assert { + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "github_output": github_output.read_text(), + } == { + "returncode": 0, + "stdout": "", + "stderr": "", + "github_output": ("python_version=0.116.0a1.post2\nrelease_tag=rust-v0.116.0-alpha.1.2\n"), + } + + def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None: script = _load_update_script_module() staging_dir = tmp_path / "runtime-stage"