Support alpha hotfix release versions (#34463)

## What changed

- Map Python `aN.postM` versions to Codex `-alpha.N.M` release tags through shared release-version helpers.
- Accept alpha hotfix versions in Python runtime workflows, Rust release validation, npm publishing, and the shell and PowerShell installers.

## Testing

- Cover version conversion, workflow output, runtime setup, artifact staging, and installer handling for alpha hotfix releases.

GitOrigin-RevId: b95edb56f7c93b435a8050c10f0202dc117e6669
This commit is contained in:
Michael Bolin
2026-07-21 03:54:37 +00:00
committed by copyberry
parent a30aee8d90
commit 9970cd706f
10 changed files with 193 additions and 74 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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() &&

View File

@@ -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]]."
}
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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"

View File

@@ -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())

View File

@@ -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(

View File

@@ -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"