Publish Python packages after stable CLI releases (#44067)

## What changed

Add a downstream workflow that builds the Python SDK and runtime from the stable CLI release commit, using the CLI version for both packages and the SDK's exact runtime dependency. Publish and verify the runtime on PyPI before publishing the SDK.

Require a successful CLI `release` job, an unchanged release tag, and complete runtime assets. Skip CLI prereleases and allow publication despite unrelated publisher failures. Support retries by CLI workflow run ID and accept existing PyPI uploads while verifying the complete release.

Document release setup, retry procedures, and independent SDK releases.

## Testing

Add resolver unit tests covering tag resolution, prerelease skipping, partial reruns, pagination, invalid runs, moved tags, missing assets, and equivalent automatic and manual release resolution.

GitOrigin-RevId: 4ec6b2e77c94c851507dbe7200ea420994fce36e
This commit is contained in:
Ahmed Ibrahim
2026-09-09 05:39:52 +00:00
committed by copyberry
parent 5a9aec40a5
commit b4d42052cd
9 changed files with 586 additions and 3 deletions
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Bind downstream Python publication to a published stable CLI release."""
import argparse
import itertools
import json
import re
import subprocess
from pathlib import Path
def github_api(path: str):
return json.loads(subprocess.check_output(["gh", "api", path], text=True))
def github_items(path: str, key: str | None = None):
separator = "&" if "?" in path else "?"
for page in itertools.count(1):
response = github_api(f"{path}{separator}per_page=100&page={page}")
items = response[key] if key else response
yield from items
if len(items) < 100:
return
def resolve_release(
repository: str, run_id: str, *, run: dict | None = None
) -> dict[str, str] | None:
if repository != "openai/codex" or not re.fullmatch(r"[1-9][0-9]*", run_id):
raise ValueError("Expected an openai/codex Rust release run ID")
prefix = f"repos/{repository}"
if run is None:
run = github_api(f"{prefix}/actions/runs/{run_id}")
if (
str(run["id"]) != run_id
or run["path"] != ".github/workflows/rust-release.yml"
or run["event"] != "push"
or run["status"] != "completed"
or run["head_repository"]["full_name"] != repository
):
raise ValueError("Python publication requires a completed Rust release run")
tag = run["head_branch"]
if not isinstance(tag, str) or not tag.startswith("rust-v"):
raise ValueError("The Rust release run must identify a release tag")
match = re.fullmatch(r"rust-v([0-9]+\.[0-9]+\.[0-9]+)", tag)
if match is None:
return None # CLI prereleases do not publish the Python SDK.
revision = run["head_sha"]
if not re.fullmatch(r"[0-9a-f]{40}", revision):
raise ValueError("The Rust release run must identify an exact commit")
# A partial rerun of an unrelated publisher may not contain the release job.
# Inspect all attempts and use the last release job that actually ran.
release_job = max(
(
job
for job in github_items(
f"{prefix}/actions/runs/{run_id}/jobs?filter=all", "jobs"
)
if job["name"] == "release" and job["conclusion"] != "skipped"
),
key=lambda job: (job["run_attempt"], job["id"]),
default=None,
)
if release_job is None or release_job["conclusion"] != "success":
raise ValueError("Python publication requires a successful release job")
target = github_api(f"{prefix}/git/ref/tags/{tag}")["object"]
for _ in range(8):
if target["type"] != "tag":
break
target = github_api(f"{prefix}/git/tags/{target['sha']}")["object"]
if target.get("type") != "commit" or target.get("sha") != revision:
raise ValueError("The release tag no longer matches the successful CLI run")
release = github_api(f"{prefix}/releases/tags/{tag}")
if release["draft"] or release["prerelease"] or release["tag_name"] != tag:
raise ValueError("The CLI release must be published and stable")
# The runtime builder downloads these six wheels and builds the two musl
# wheels from the release's package archives.
required_assets = {
f"openai_codex_cli_bin-{match[1]}-py3-none-{platform}.whl"
for platform in (
"macosx_10_9_x86_64",
"macosx_11_0_arm64",
"manylinux_2_17_aarch64",
"manylinux_2_17_x86_64",
"win_amd64",
"win_arm64",
)
} | {
f"codex-package-{arch}-unknown-linux-musl.tar.gz"
for arch in ("aarch64", "x86_64")
}
available_assets = {
asset["name"]
for asset in github_items(f"{prefix}/releases/{release['id']}/assets")
if asset["state"] == "uploaded" and asset["size"] > 0
}
if missing := required_assets - available_assets:
raise ValueError(f"The CLI release is missing Python inputs: {sorted(missing)}")
return {"source_sha": revision, "release_tag": tag, "version": match[1]}
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("run_id")
parser.add_argument("--repository", required=True)
parser.add_argument("--event-path", type=Path)
parser.add_argument("--github-output", type=Path, required=True)
args = parser.parse_args(argv)
event = json.loads(args.event_path.read_text()) if args.event_path else {}
release = resolve_release(
args.repository, args.run_id, run=event.get("workflow_run")
)
with args.github_output.open("a") as output:
print(f"publish={str(release is not None).lower()}", file=output)
for name, value in (release or {}).items():
print(f"{name}={value}", file=output)
if __name__ == "__main__":
main()
@@ -0,0 +1,250 @@
import copy
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import resolve_python_cli_release as resolver
class ResolvePythonCliReleaseTest(unittest.TestCase):
def setUp(self) -> None:
self.revision = "a" * 40
self.run = {
"id": 123,
"path": ".github/workflows/rust-release.yml",
"event": "push",
"status": "completed",
"conclusion": "success",
"head_repository": {"full_name": "openai/codex"},
"head_branch": "rust-v1.2.3",
"head_sha": self.revision,
}
self.job = {
"id": 456,
"run_attempt": 1,
"name": "release",
"conclusion": "success",
}
self.commit = {"type": "commit", "sha": self.revision}
self.release = {
"id": 789,
"draft": False,
"prerelease": False,
"tag_name": "rust-v1.2.3",
}
self.assets = [
{"name": name, "state": "uploaded", "size": 100}
for name in [
"openai_codex_cli_bin-1.2.3-py3-none-macosx_10_9_x86_64.whl",
"openai_codex_cli_bin-1.2.3-py3-none-macosx_11_0_arm64.whl",
"openai_codex_cli_bin-1.2.3-py3-none-manylinux_2_17_aarch64.whl",
"openai_codex_cli_bin-1.2.3-py3-none-manylinux_2_17_x86_64.whl",
"openai_codex_cli_bin-1.2.3-py3-none-win_amd64.whl",
"openai_codex_cli_bin-1.2.3-py3-none-win_arm64.whl",
"codex-package-aarch64-unknown-linux-musl.tar.gz",
"codex-package-x86_64-unknown-linux-musl.tar.gz",
]
]
self.expected = {
"source_sha": self.revision,
"release_tag": "rust-v1.2.3",
"version": "1.2.3",
}
def responses(self) -> list:
return copy.deepcopy(
[
self.run,
{"jobs": [self.job]},
{"object": self.commit},
self.release,
self.assets,
]
)
def test_resolves_lightweight_and_annotated_tags_to_the_run_revision(self) -> None:
for annotated in (False, True):
with self.subTest(annotated=annotated):
responses = self.responses()
if annotated:
responses.insert(2, {"object": {"type": "tag", "sha": "b" * 40}})
with patch.object(resolver, "github_api", side_effect=responses):
self.assertEqual(
resolver.resolve_release("openai/codex", "123"), self.expected
)
def test_skips_cli_prereleases_before_reading_jobs_or_assets(self) -> None:
for suffix in ("-alpha", "-alpha.1", "-alpha.1.2", "-beta", "-beta.1"):
with self.subTest(suffix=suffix):
run = {**self.run, "head_branch": f"rust-v1.2.3{suffix}"}
with patch.object(resolver, "github_api", return_value=run) as api:
self.assertIsNone(resolver.resolve_release("openai/codex", "123"))
api.assert_called_once_with("repos/openai/codex/actions/runs/123")
def test_rejects_unrelated_or_incomplete_runs(self) -> None:
for field, value in (
("id", 999),
("path", ".github/workflows/sdk.yml"),
("event", "pull_request"),
("status", "in_progress"),
("head_branch", "main"),
("head_sha", "main"),
("head_repository", {"full_name": "other/codex"}),
):
with self.subTest(field=field, value=value):
with patch.object(
resolver, "github_api", return_value={**self.run, field: value}
):
with self.assertRaises(ValueError):
resolver.resolve_release("openai/codex", "123")
def test_accepts_failed_ancillary_publisher_and_its_partial_rerun(self) -> None:
for overall in ("failure", "cancelled"):
with self.subTest(overall=overall):
responses = self.responses()
responses[0]["conclusion"] = overall
responses[1]["jobs"].extend(
[
{
"id": 999,
"run_attempt": 2,
"name": "publish-winget",
"conclusion": "failure",
},
{
**self.job,
"id": 998,
"run_attempt": 2,
"conclusion": "skipped",
},
]
)
with patch.object(resolver, "github_api", side_effect=responses):
self.assertEqual(
resolver.resolve_release("openai/codex", "123"), self.expected
)
def test_requires_success_from_latest_executed_release_job(self) -> None:
for jobs in (
[],
[{**self.job, "conclusion": "skipped"}],
[{**self.job, "conclusion": "failure"}],
[{**self.job, "conclusion": "cancelled"}],
[
{**self.job, "id": 999, "run_attempt": 2, "conclusion": "failure"},
self.job,
],
):
with self.subTest(jobs=jobs):
with patch.object(
resolver, "github_api", side_effect=[self.run, {"jobs": jobs}]
):
with self.assertRaisesRegex(ValueError, "successful release job"):
resolver.resolve_release("openai/codex", "123")
def test_reads_all_pages_of_jobs_and_assets(self) -> None:
responses = self.responses()
responses.insert(1, {"jobs": [{**self.job, "name": "other"}] * 100})
responses.insert(-1, [{"name": "other", "state": "uploaded", "size": 1}] * 100)
with patch.object(resolver, "github_api", side_effect=responses) as api:
self.assertEqual(
resolver.resolve_release("openai/codex", "123"), self.expected
)
api.assert_any_call(
"repos/openai/codex/actions/runs/123/jobs?filter=all&per_page=100&page=2"
)
api.assert_any_call(
"repos/openai/codex/releases/789/assets?per_page=100&page=2"
)
def test_rejects_a_moved_tag(self) -> None:
responses = self.responses()
responses[2] = {"object": {"type": "commit", "sha": "b" * 40}}
with patch.object(resolver, "github_api", side_effect=responses):
with self.assertRaisesRegex(ValueError, "no longer matches"):
resolver.resolve_release("openai/codex", "123")
def test_bounds_annotated_tag_resolution(self) -> None:
tag = {"object": {"type": "tag", "sha": "b" * 40}}
with patch.object(
resolver,
"github_api",
side_effect=[self.run, {"jobs": [self.job]}, *([tag] * 9)],
) as api:
with self.assertRaisesRegex(ValueError, "no longer matches"):
resolver.resolve_release("openai/codex", "123")
self.assertEqual(api.call_count, 11)
def test_requires_a_published_stable_release_for_the_tag(self) -> None:
for field, value in (
("draft", True),
("prerelease", True),
("tag_name", "rust-v9.9.9"),
):
responses = self.responses()
responses[3][field] = value
with self.subTest(field=field):
with patch.object(resolver, "github_api", side_effect=responses):
with self.assertRaisesRegex(ValueError, "published and stable"):
resolver.resolve_release("openai/codex", "123")
def test_requires_all_runtime_inputs_to_be_uploaded_and_nonempty(self) -> None:
for assets in (
self.assets[:-1],
[*self.assets[:-1], {**self.assets[-1], "size": 0}],
[*self.assets[:-1], {**self.assets[-1], "state": "new"}],
):
with self.subTest(assets=assets):
responses = self.responses()
responses[-1] = assets
with patch.object(resolver, "github_api", side_effect=responses):
with self.assertRaisesRegex(ValueError, "missing Python inputs"):
resolver.resolve_release("openai/codex", "123")
def test_rejects_invalid_inputs_before_github_access(self) -> None:
with patch.object(resolver, "github_api") as api:
for repository, run_id in (
("other/codex", "123"),
("openai/codex", "../123"),
):
with self.assertRaises(ValueError):
resolver.resolve_release(repository, run_id)
api.assert_not_called()
def test_event_and_manual_retry_emit_the_same_revision_and_version(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "output"
event = Path(directory) / "event.json"
for automatic in (True, False):
output.write_text("")
event.write_text(
json.dumps({"workflow_run": self.run} if automatic else {})
)
responses = self.responses()[1:] if automatic else self.responses()
with patch.object(resolver, "github_api", side_effect=responses) as api:
resolver.main(
[
"123",
"--repository",
"openai/codex",
"--event-path",
str(event),
"--github-output",
str(output),
]
)
self.assertEqual(
output.read_text(),
f"publish=true\nsource_sha={self.revision}\nrelease_tag=rust-v1.2.3\nversion=1.2.3\n",
)
if automatic:
self.assertNotIn(
unittest.mock.call("repos/openai/codex/actions/runs/123"),
api.call_args_list,
)
if __name__ == "__main__":
unittest.main()