From 066c6cce022bf89e5ea54dc248ec86f6301cf386 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 5 Sep 2025 21:57:11 -0700 Subject: [PATCH 1/3] chore: change create_github_release to create a fresh clone in a temp directory (#3228) Ran: ``` ./codex-rs/scripts/create_github_release 0.31.0-alpha.1 ``` which appeared to work as expected: - workflow https://github.com/openai/codex/actions/runs/17508403922 - release https://github.com/openai/codex/releases/tag/rust-v0.31.0-alpha.1 --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3228). * #3231 * #3230 * __->__ #3228 * #3226 --- codex-rs/scripts/create_github_release | 103 ++++++++++--------------- 1 file changed, 39 insertions(+), 64 deletions(-) diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index 4bbfcde768..59a2adcd18 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -1,17 +1,13 @@ #!/usr/bin/env python3 import argparse -import os import re import subprocess import sys +import tempfile from pathlib import Path -ROOT_DIR = Path(__file__).resolve().parent.parent -CARGO_TOML = ROOT_DIR / "Cargo.toml" - - def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Create a tagged Codex release.") parser.add_argument( @@ -22,87 +18,62 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str]) -> int: - os.chdir(ROOT_DIR) args = parse_args(argv) try: - ensure_clean_worktree() - branch = current_branch() - ensure_on_main(branch) - ensure_on_origin_main() - create_release(args.version, branch) + with tempfile.TemporaryDirectory() as temp_dir: + repo_dir = Path(temp_dir) / "codex" + clone_repository(repo_dir) + branch = current_branch(repo_dir) + create_release(args.version, branch, repo_dir) except ReleaseError as error: print(f"ERROR: {error}", file=sys.stderr) return 1 return 0 -def ensure_clean_worktree() -> None: - commands = [ - ["diff", "--quiet"], - ["diff", "--cached", "--quiet"], - ] - for command in commands: - result = run_git(command, check=False) - if result.returncode != 0: - raise ReleaseError("You have uncommitted changes.") - - untracked = run_git(["ls-files", "--others", "--exclude-standard"], capture_output=True) - if untracked.stdout.strip(): - raise ReleaseError("You have untracked files.") - - -def ensure_on_main(branch: str) -> None: - if branch != "main": - raise ReleaseError( - f"Releases must be created from the 'main' branch (current: '{branch}')." - ) - - -def ensure_on_origin_main() -> None: - try: - run_git(["fetch", "--quiet", "origin", "main"]) - except ReleaseError as error: - raise ReleaseError( - "Failed to fetch 'origin/main'. Ensure the 'origin' remote is configured and reachable." - ) from error - - result = run_git(["merge-base", "--is-ancestor", "HEAD", "origin/main"], check=False) - if result.returncode != 0: - raise ReleaseError( - "Your local 'main' HEAD commit is not present on 'origin/main'. " - "Please push first (git push origin main) or check out a commit on 'origin/main'." - ) - - -def current_branch() -> str: - result = run_git(["symbolic-ref", "--short", "-q", "HEAD"], capture_output=True, check=False) +def current_branch(repo_dir: Path) -> str: + result = run_git( + repo_dir, + ["symbolic-ref", "--short", "-q", "HEAD"], + capture_output=True, + check=False, + ) branch = result.stdout.strip() if result.returncode != 0 or not branch: raise ReleaseError("Could not determine the current branch (detached HEAD?).") return branch -def update_version(version: str) -> None: - content = CARGO_TOML.read_text(encoding="utf-8") +def update_version(version: str, cargo_toml: Path) -> None: + content = cargo_toml.read_text(encoding="utf-8") new_content, matches = re.subn( r'^version = "[^"]+"', f'version = "{version}"', content, count=1, flags=re.MULTILINE ) if matches != 1: raise ReleaseError("Unable to update version in Cargo.toml.") - CARGO_TOML.write_text(new_content, encoding="utf-8") + cargo_toml.write_text(new_content, encoding="utf-8") -def create_release(version: str, branch: str) -> None: +def create_release(version: str, branch: str, repo_dir: Path) -> None: tag = f"rust-v{version}" - run_git(["checkout", "-b", tag]) + run_git(repo_dir, ["checkout", "-b", tag]) try: - update_version(version) - run_git(["add", "Cargo.toml"]) - run_git(["commit", "-m", f"Release {version}"]) - run_git(["tag", "-a", tag, "-m", f"Release {version}"]) - run_git(["push", "origin", f"refs/tags/{tag}"]) + update_version(version, repo_dir / "codex-rs" / "Cargo.toml") + run_git(repo_dir, ["add", "codex-rs/Cargo.toml"]) + run_git(repo_dir, ["commit", "-m", f"Release {version}"]) + run_git(repo_dir, ["tag", "-a", tag, "-m", f"Release {version}"]) + run_git(repo_dir, ["push", "origin", f"refs/tags/{tag}"]) finally: - run_git(["checkout", branch]) + run_git(repo_dir, ["checkout", branch]) + + +def clone_repository(destination: Path) -> None: + result = subprocess.run( + ["gh", "repo", "clone", "openai/codex", str(destination), "--", "--depth", "1"], + text=True, + ) + if result.returncode != 0: + raise ReleaseError("Failed to clone openai/codex using gh.") class ReleaseError(RuntimeError): @@ -110,11 +81,15 @@ class ReleaseError(RuntimeError): def run_git( - args: list[str], *, capture_output: bool = False, check: bool = True + repo_dir: Path, + args: list[str], + *, + capture_output: bool = False, + check: bool = True, ) -> subprocess.CompletedProcess: result = subprocess.run( ["git", *args], - cwd=ROOT_DIR, + cwd=repo_dir, text=True, capture_output=capture_output, ) From e93155af18f25ad667db110facd726b9003a91c9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 5 Sep 2025 21:57:32 -0700 Subject: [PATCH 2/3] chore: use gh instead of git to do work to avoid overhead of a local clone --- codex-rs/scripts/create_github_release | 243 ++++++++++++++++++------- 1 file changed, 173 insertions(+), 70 deletions(-) diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index 59a2adcd18..08f0b11fc7 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -1,11 +1,16 @@ #!/usr/bin/env python3 import argparse +import base64 +import json import re import subprocess import sys -import tempfile -from pathlib import Path + + +REPO = "openai/codex" +BRANCH_REF = "heads/main" +CARGO_TOML_PATH = "codex-rs/Cargo.toml" def parse_args(argv: list[str]) -> argparse.Namespace: @@ -20,85 +25,183 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str]) -> int: args = parse_args(argv) try: - with tempfile.TemporaryDirectory() as temp_dir: - repo_dir = Path(temp_dir) / "codex" - clone_repository(repo_dir) - branch = current_branch(repo_dir) - create_release(args.version, branch, repo_dir) + print("Fetching branch head...") + base_commit = get_branch_head() + print(f"Base commit: {base_commit}") + print("Fetching commit tree...") + base_tree = get_commit_tree(base_commit) + print(f"Base tree: {base_tree}") + print("Fetching Cargo.toml...") + current_contents = fetch_file_contents(base_commit) + print("Updating version...") + updated_contents = replace_version(current_contents, args.version) + print("Creating blob...") + blob_sha = create_blob(updated_contents) + print(f"Blob SHA: {blob_sha}") + print("Creating tree...") + tree_sha = create_tree(base_tree, blob_sha) + print(f"Tree SHA: {tree_sha}") + print("Creating commit...") + commit_sha = create_commit(args.version, tree_sha, base_commit) + print(f"Commit SHA: {commit_sha}") + print("Creating tag...") + tag_sha = create_tag(args.version, commit_sha) + print(f"Tag SHA: {tag_sha}") + print("Creating tag ref...") + create_tag_ref(args.version, tag_sha) + print("Done.") except ReleaseError as error: print(f"ERROR: {error}", file=sys.stderr) return 1 return 0 -def current_branch(repo_dir: Path) -> str: - result = run_git( - repo_dir, - ["symbolic-ref", "--short", "-q", "HEAD"], - capture_output=True, - check=False, - ) - branch = result.stdout.strip() - if result.returncode != 0 or not branch: - raise ReleaseError("Could not determine the current branch (detached HEAD?).") - return branch - - -def update_version(version: str, cargo_toml: Path) -> None: - content = cargo_toml.read_text(encoding="utf-8") - new_content, matches = re.subn( - r'^version = "[^"]+"', f'version = "{version}"', content, count=1, flags=re.MULTILINE - ) - if matches != 1: - raise ReleaseError("Unable to update version in Cargo.toml.") - cargo_toml.write_text(new_content, encoding="utf-8") - - -def create_release(version: str, branch: str, repo_dir: Path) -> None: - tag = f"rust-v{version}" - run_git(repo_dir, ["checkout", "-b", tag]) - try: - update_version(version, repo_dir / "codex-rs" / "Cargo.toml") - run_git(repo_dir, ["add", "codex-rs/Cargo.toml"]) - run_git(repo_dir, ["commit", "-m", f"Release {version}"]) - run_git(repo_dir, ["tag", "-a", tag, "-m", f"Release {version}"]) - run_git(repo_dir, ["push", "origin", f"refs/tags/{tag}"]) - finally: - run_git(repo_dir, ["checkout", branch]) - - -def clone_repository(destination: Path) -> None: - result = subprocess.run( - ["gh", "repo", "clone", "openai/codex", str(destination), "--", "--depth", "1"], - text=True, - ) - if result.returncode != 0: - raise ReleaseError("Failed to clone openai/codex using gh.") - - class ReleaseError(RuntimeError): pass +def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = None) -> dict: + print(f"Running gh api {method} {endpoint}") + command = [ + "gh", + "api", + endpoint, + "--method", + method, + "-H", + "Accept: application/vnd.github+json", + ] + json_payload = None + if payload is not None: + json_payload = json.dumps(payload) + print(f"Payload: {json_payload}") + command.extend(["-H", "Content-Type: application/json", "--input", "-"]) + result = subprocess.run(command, text=True, capture_output=True, input=json_payload) + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "gh api call failed" + raise ReleaseError(message) + try: + return json.loads(result.stdout or "{}") + except json.JSONDecodeError as error: + raise ReleaseError("Failed to parse response from gh api.") from error -def run_git( - repo_dir: Path, - args: list[str], - *, - capture_output: bool = False, - check: bool = True, -) -> subprocess.CompletedProcess: - result = subprocess.run( - ["git", *args], - cwd=repo_dir, - text=True, - capture_output=capture_output, + +def get_branch_head() -> str: + response = run_gh_api(f"/repos/{REPO}/git/refs/{BRANCH_REF}") + try: + return response["object"]["sha"] + except KeyError as error: + raise ReleaseError("Unable to determine branch head.") from error + + +def get_commit_tree(commit_sha: str) -> str: + response = run_gh_api(f"/repos/{REPO}/git/commits/{commit_sha}") + try: + return response["tree"]["sha"] + except KeyError as error: + raise ReleaseError("Commit response missing tree SHA.") from error + + +def fetch_file_contents(ref_sha: str) -> str: + response = run_gh_api(f"/repos/{REPO}/contents/{CARGO_TOML_PATH}?ref={ref_sha}") + try: + encoded_content = response["content"].replace("\n", "") + encoding = response.get("encoding", "") + except KeyError as error: + raise ReleaseError("Failed to fetch Cargo.toml contents.") from error + + if encoding != "base64": + raise ReleaseError(f"Unexpected Cargo.toml encoding: {encoding}") + + try: + return base64.b64decode(encoded_content).decode("utf-8") + except (ValueError, UnicodeDecodeError) as error: + raise ReleaseError("Failed to decode Cargo.toml contents.") from error + + +def replace_version(contents: str, version: str) -> str: + updated, matches = re.subn( + r'^version = "[^"]+"', f'version = "{version}"', contents, count=1, flags=re.MULTILINE + ) + if matches != 1: + raise ReleaseError("Unable to update version in Cargo.toml.") + return updated + + +def create_blob(content: str) -> str: + response = run_gh_api( + f"/repos/{REPO}/git/blobs", + method="POST", + payload={"content": content, "encoding": "utf-8"}, + ) + try: + return response["sha"] + except KeyError as error: + raise ReleaseError("Blob creation response missing SHA.") from error + + +def create_tree(base_tree_sha: str, blob_sha: str) -> str: + response = run_gh_api( + f"/repos/{REPO}/git/trees", + method="POST", + payload={ + "base_tree": base_tree_sha, + "tree": [ + { + "path": CARGO_TOML_PATH, + "mode": "100644", + "type": "blob", + "sha": blob_sha, + } + ], + }, + ) + try: + return response["sha"] + except KeyError as error: + raise ReleaseError("Tree creation response missing SHA.") from error + + +def create_commit(version: str, tree_sha: str, parent_sha: str) -> str: + response = run_gh_api( + f"/repos/{REPO}/git/commits", + method="POST", + payload={ + "message": f"Release {version}", + "tree": tree_sha, + "parents": [parent_sha], + }, + ) + try: + return response["sha"] + except KeyError as error: + raise ReleaseError("Commit creation response missing SHA.") from error + + +def create_tag(version: str, commit_sha: str) -> str: + tag_name = f"rust-v{version}" + response = run_gh_api( + f"/repos/{REPO}/git/tags", + method="POST", + payload={ + "tag": tag_name, + "message": f"Release {version}", + "object": commit_sha, + "type": "commit", + }, + ) + try: + return response["sha"] + except KeyError as error: + raise ReleaseError("Tag creation response missing SHA.") from error + + +def create_tag_ref(version: str, tag_sha: str) -> None: + tag_ref = f"refs/tags/rust-v{version}" + run_gh_api( + f"/repos/{REPO}/git/refs", + method="POST", + payload={"ref": tag_ref, "sha": tag_sha}, ) - if check and result.returncode != 0: - stderr = result.stderr.strip() if result.stderr else "" - stdout = result.stdout.strip() if result.stdout else "" - message = stderr if stderr else stdout - raise ReleaseError(message or f"git {' '.join(args)} failed") - return result if __name__ == "__main__": From a25a43cc1df297535cce7297cb72840ce48878bb Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 5 Sep 2025 21:57:32 -0700 Subject: [PATCH 3/3] fix: change create_github_release to take either --create-alpha or --create-release --- codex-rs/scripts/create_github_release | 102 +++++++++++++++++++++++-- docs/release_management.md | 19 +++-- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index 08f0b11fc7..120e063541 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -14,10 +14,24 @@ CARGO_TOML_PATH = "codex-rs/Cargo.toml" def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Create a tagged Codex release.") + parser = argparse.ArgumentParser(description="Publish a tagged Codex release.") parser.add_argument( - "version", - help="Version string used for Cargo.toml and the Git tag (e.g. 0.1.0-alpha.4).", + "-n", + "--dry-run", + action="store_true", + help="Print the version that would be used and exit before making changes.", + ) + + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--publish-alpha", + action="store_true", + help="Publish the next alpha release for the upcoming minor version.", + ) + group.add_argument( + "--publish-release", + action="store_true", + help="Publish the next stable release by bumping the minor version.", ) return parser.parse_args(argv[1:]) @@ -25,6 +39,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str]) -> int: args = parse_args(argv) try: + version = determine_version(args) + print(f"Publishing version {version}") + if args.dry_run: + return 0 + print("Fetching branch head...") base_commit = get_branch_head() print(f"Base commit: {base_commit}") @@ -34,7 +53,7 @@ def main(argv: list[str]) -> int: print("Fetching Cargo.toml...") current_contents = fetch_file_contents(base_commit) print("Updating version...") - updated_contents = replace_version(current_contents, args.version) + updated_contents = replace_version(current_contents, version) print("Creating blob...") blob_sha = create_blob(updated_contents) print(f"Blob SHA: {blob_sha}") @@ -42,13 +61,13 @@ def main(argv: list[str]) -> int: tree_sha = create_tree(base_tree, blob_sha) print(f"Tree SHA: {tree_sha}") print("Creating commit...") - commit_sha = create_commit(args.version, tree_sha, base_commit) + commit_sha = create_commit(version, tree_sha, base_commit) print(f"Commit SHA: {commit_sha}") print("Creating tag...") - tag_sha = create_tag(args.version, commit_sha) + tag_sha = create_tag(version, commit_sha) print(f"Tag SHA: {tag_sha}") print("Creating tag ref...") - create_tag_ref(args.version, tag_sha) + create_tag_ref(version, tag_sha) print("Done.") except ReleaseError as error: print(f"ERROR: {error}", file=sys.stderr) @@ -59,6 +78,7 @@ def main(argv: list[str]) -> int: class ReleaseError(RuntimeError): pass + def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = None) -> dict: print(f"Running gh api {method} {endpoint}") command = [ @@ -204,5 +224,73 @@ def create_tag_ref(version: str, tag_sha: str) -> None: ) +def determine_version(args: argparse.Namespace) -> str: + latest_version = get_latest_release_version() + major, minor, patch = parse_semver(latest_version) + next_minor_version = format_version(major, minor + 1, patch) + + if args.publish_release: + return next_minor_version + + alpha_prefix = f"{next_minor_version}-alpha." + releases = list_releases() + highest_alpha = 0 + found_alpha = False + for release in releases: + tag = release.get("tag_name", "") + candidate = strip_tag_prefix(tag) + if candidate and candidate.startswith(alpha_prefix): + suffix = candidate[len(alpha_prefix) :] + try: + alpha_number = int(suffix) + except ValueError: + continue + highest_alpha = max(highest_alpha, alpha_number) + found_alpha = True + + if found_alpha: + return f"{alpha_prefix}{highest_alpha + 1}" + return f"{alpha_prefix}1" + + +def get_latest_release_version() -> str: + response = run_gh_api(f"/repos/{REPO}/releases/latest") + tag = response.get("tag_name") + version = strip_tag_prefix(tag) + if not version: + raise ReleaseError("Latest release tag has unexpected format.") + return version + + +def list_releases() -> list[dict]: + response = run_gh_api(f"/repos/{REPO}/releases?per_page=100") + if not isinstance(response, list): + raise ReleaseError("Unexpected response when listing releases.") + return response + + +def strip_tag_prefix(tag: str | None) -> str | None: + if not tag: + return None + prefix = "rust-v" + if not tag.startswith(prefix): + return None + return tag[len(prefix) :] + + +def parse_semver(version: str) -> tuple[int, int, int]: + parts = version.split(".") + if len(parts) != 3: + raise ReleaseError(f"Unexpected version format: {version}") + try: + return int(parts[0]), int(parts[1]), int(parts[2]) + except ValueError as error: + raise ReleaseError(f"Version components must be integers: {version}") from error + + +def format_version(major: int, minor: int, patch: int) -> str: + return f"{major}.{minor}.{patch}" + + if __name__ == "__main__": sys.exit(main(sys.argv)) diff --git a/docs/release_management.md b/docs/release_management.md index 1b81bc3eb2..ed12de6e4a 100644 --- a/docs/release_management.md +++ b/docs/release_management.md @@ -8,16 +8,23 @@ Currently, we made Codex binaries available in three places: # Cutting a Release -Currently, choosing the version number for the next release is a manual process. In general, just go to https://github.com/openai/codex/releases/latest and see what the latest release is and increase the minor version by `1`, so if the current release is `0.20.0`, then the next release should be `0.21.0`. +Run the `codex-rs/scripts/create_github_release` script in the repository to publish a new release. The script will choose the appropriate version number depending on the type of release you are creating. -Assuming you are trying to publish `0.21.0`, first you would run: +To cut a new alpha release from `main` (feel free to cut alphas liberally): -```shell -VERSION=0.21.0 -./codex-rs/scripts/create_github_release.sh "$VERSION" +``` +./codex-rs/scripts/create_github_release --publish-alpha ``` -This will kick off a GitHub Action to build the release, so go to https://github.com/openai/codex/actions/workflows/rust-release.yml to find the corresponding workflow. (Note: we should automate finding the workflow URL with `gh`.) +To cut a new _public_ release from `main` (which requires more caution), run: + +``` +./codex-rs/scripts/create_github_release --publish-release +``` + +TIP: Add the `--dry-run` flag to report the next version number for the respective release and exit. + +Running the publishing script will kick off a GitHub Action to build the release, so go to https://github.com/openai/codex/actions/workflows/rust-release.yml to find the corresponding workflow. (Note: we should automate finding the workflow URL with `gh`.) When the workflow finishes, the GitHub Release is "done," but you still have to consider npm and Homebrew.