chore: use gh instead of git to do work to avoid overhead of a local clone (#3230)
The advantage of this implementation is that it can be run from "anywhere" so long as the user has `gh` installed with the appropriate credentials to write to the `openai/codex` repo. Unlike the previous implementation, it avoids the overhead of creating a local clone of the repo. Ran: ``` ./codex-rs/scripts/create_github_release 0.31.0-alpha.2 ``` which appeared to work as expected: - workflow https://github.com/openai/codex/actions/runs/17508564352 - release https://github.com/openai/codex/releases/tag/rust-v0.31.0-alpha.2 --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3230). * #3231 * __->__ #3230 * #3228 * #3226
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user