scripts/prepare.py
scripts/prepare.pyBrowse 3 files
4,808 tokens
21,508 bytes
Token encoding: o200k_base
Snapshot 1d17ca4
← Back to SKILL.md
1#!/usr/bin/env python32"""Preflight and materialize an isolated release candidate from exact origin/main."""3 4from __future__ import annotations5 6import argparse7import json8import os9import re10import shlex11import subprocess12import sys13from dataclasses import dataclass14from pathlib import Path15 16if sys.version_info >= (3, 11):17 import tomllib18else:19 import tomli as tomllib20 21from collections.abc import Sequence22 23ROOT = Path(__file__).resolve().parents[4]24VERSION_PATTERN = re.compile(r"\d+\.\d+(?:\.\d+)*(?:[A-Za-z0-9.-]+)?\Z")25COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z")26PROJECT_VERSION_PATTERN = re.compile(r'(?m)^version\s*=\s*"[^"]+"')27RELEASE_PATHS = frozenset(28 {29 "pyproject.toml",30 "tests/fixtures/released_api_contract.json",31 "uv.lock",32 }33)34 35 36class ReleasePreparationError(RuntimeError):37 """Report a safe, actionable release preparation failure."""38 39 40@dataclass(frozen=True)41class ReleasePreflight:42 """Describe an isolated branch-free release readiness input."""43 44 base_commit: str45 branch: str46 source_commit: str47 version: str48 worktree: Path49 50 51@dataclass(frozen=True)52class PreparedCandidate:53 """Describe the successfully prepared isolated candidate."""54 55 base_commit: str56 branch: str57 changed_paths: tuple[str, ...]58 source_commit: str59 version: str60 worktree: Path61 62 63def _release_environment() -> dict[str, str]:64 env = os.environ.copy()65 for name in ("GH_TOKEN", "GITHUB_TOKEN", "OPENAI_API_KEY"):66 env.pop(name, None)67 env["UV_DEFAULT_INDEX"] = "https://pypi.org/simple"68 return env69 70 71def _command_text(args: Sequence[str]) -> str:72 return shlex.join(str(arg) for arg in args)73 74 75def run_command(76 repo: Path,77 args: Sequence[str],78 *,79 env: dict[str, str] | None = None,80 announce: bool = False,81 check: bool = True,82) -> subprocess.CompletedProcess[str]:83 """Run one command and preserve useful output for failures."""84 85 if announce:86 print(f"+ {_command_text(args)}", flush=True)87 effective_env = _release_environment() if env is None else env.copy()88 for name in ("GH_TOKEN", "GITHUB_TOKEN", "OPENAI_API_KEY"):89 effective_env.pop(name, None)90 effective_env["UV_DEFAULT_INDEX"] = "https://pypi.org/simple"91 result = subprocess.run(92 [str(arg) for arg in args],93 cwd=repo,94 env=effective_env,95 check=False,96 capture_output=True,97 text=True,98 )99 if announce:100 if result.stdout:101 print(result.stdout, end="")102 if result.stderr:103 print(result.stderr, end="", file=sys.stderr)104 if check and result.returncode != 0:105 detail = result.stderr.strip() or result.stdout.strip() or "unknown command failure"106 raise ReleasePreparationError(f"{_command_text(args)} failed: {detail}")107 return result108 109 110def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:111 """Run a Git inspection command."""112 113 return run_command(repo, ["git", *args], check=check)114 115 116def validate_version(version: str) -> str:117 """Validate the release version accepted by the existing contract updater."""118 119 if version.startswith("v") or ".." in version or VERSION_PATTERN.fullmatch(version) is None:120 raise ReleasePreparationError(121 "Version must be semver-like without a leading v, for example 0.20.1 or 0.21.0-rc1."122 )123 return version124 125 126def validate_commit(commit: str) -> str:127 """Validate an exact lowercase Git commit identifier."""128 129 if COMMIT_PATTERN.fullmatch(commit) is None:130 raise ReleasePreparationError(131 "Expected base must be a full 40-character lowercase Git commit identifier."132 )133 return commit134 135 136def project_version(repo: Path) -> str:137 """Read the project version from pyproject.toml."""138 139 data = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8"))140 version = data.get("project", {}).get("version")141 if not isinstance(version, str):142 raise ReleasePreparationError("pyproject.toml is missing project.version.")143 return version144 145 146def project_version_at(repo: Path, commit: str) -> str:147 """Read the project version from one exact commit without changing a checkout."""148 149 text = git(repo, "show", f"{commit}:pyproject.toml").stdout150 data = tomllib.loads(text)151 version = data.get("project", {}).get("version")152 if not isinstance(version, str):153 raise ReleasePreparationError("pyproject.toml is missing project.version.")154 return version155 156 157def replace_project_version_text(text: str, version: str) -> str:158 """Replace the repository's single project version declaration."""159 160 updated, count = PROJECT_VERSION_PATTERN.subn(f'version = "{version}"', text)161 if count != 1:162 raise ReleasePreparationError(163 f"Expected exactly one version declaration in pyproject.toml, found {count}."164 )165 if updated == text:166 raise ReleasePreparationError(f"pyproject.toml already declares version {version}.")167 return updated168 169 170def replace_project_version(repo: Path, version: str) -> None:171 """Update pyproject.toml while preserving all unrelated text."""172 173 path = repo / "pyproject.toml"174 text = path.read_text(encoding="utf-8")175 path.write_text(replace_project_version_text(text, version), encoding="utf-8")176 177 178def _current_branch(repo: Path) -> str:179 result = git(repo, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)180 if result.returncode != 0:181 raise ReleasePreparationError("Release preparation requires a named main branch.")182 return result.stdout.strip()183 184 185def _status(repo: Path) -> str:186 return git(repo, "status", "--porcelain=v1", "--untracked-files=all").stdout187 188 189def _require_repository_root(repo: Path) -> None:190 top_level = Path(git(repo, "rev-parse", "--show-toplevel").stdout.strip()).resolve()191 if top_level != repo.resolve():192 raise ReleasePreparationError(193 f"Run release preparation from the repository root {top_level}, not {repo.resolve()}."194 )195 196 197def _require_clean_main(repo: Path) -> None:198 branch = _current_branch(repo)199 if branch != "main":200 raise ReleasePreparationError(201 f"Release preparation requires branch 'main', found {branch!r}."202 )203 if _status(repo):204 raise ReleasePreparationError("Release preparation requires a clean working tree.")205 206 207def _require_source_head(repo: Path, expected_source_head: str) -> None:208 """Require the user's source checkout to remain at its preflight commit."""209 210 source_head = git(repo, "rev-parse", "HEAD").stdout.strip()211 if source_head != expected_source_head:212 raise ReleasePreparationError(213 f"Source checkout HEAD changed from {expected_source_head} to {source_head}; "214 "leave both checkouts intact and restart release preparation."215 )216 217 218def _registered_worktrees(repo: Path) -> set[Path]:219 """Return canonical paths registered in the repository worktree inventory."""220 221 paths: set[Path] = set()222 for line in git(repo, "worktree", "list", "--porcelain").stdout.splitlines():223 if line.startswith("worktree "):224 paths.add(Path(line.removeprefix("worktree ")).resolve())225 return paths226 227 228def _choose_worktree_path(repo: Path, worktree_root: Path, version: str) -> Path:229 """Choose a unique release worktree path without reusing or deleting collisions."""230 231 worktree_root = worktree_root.expanduser().resolve()232 if worktree_root == repo or worktree_root.is_relative_to(repo):233 raise ReleasePreparationError(234 "The release worktree root must be outside the source checkout."235 )236 237 registered = _registered_worktrees(repo)238 stem = f"{repo.name}-release-v{version}"239 suffix = 1240 while True:241 name = stem if suffix == 1 else f"{stem}-{suffix}"242 candidate = worktree_root / name243 if not candidate.exists() and candidate.resolve() not in registered:244 return candidate245 suffix += 1246 247 248def _require_registered_detached_worktree(249 source_repo: Path,250 worktree: Path,251 expected_base: str,252) -> None:253 """Require a clean registered detached worktree at the reviewed base."""254 255 worktree = worktree.expanduser().resolve()256 if worktree not in _registered_worktrees(source_repo):257 raise ReleasePreparationError(258 f"Release worktree {worktree} is not registered for this repository."259 )260 if not worktree.is_dir():261 raise ReleasePreparationError(f"Release worktree path does not exist: {worktree}.")262 _require_repository_root(worktree)263 branch = git(worktree, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)264 if branch.returncode == 0:265 raise ReleasePreparationError(266 f"Release worktree must remain detached before materialization, found "267 f"{branch.stdout.strip()!r}."268 )269 if branch.returncode != 1:270 raise ReleasePreparationError("Unable to inspect the release worktree branch state.")271 head = git(worktree, "rev-parse", "HEAD").stdout.strip()272 if head != expected_base:273 raise ReleasePreparationError(274 f"Release worktree HEAD is {head}, expected reviewed base {expected_base}."275 )276 if _status(worktree):277 raise ReleasePreparationError("Release worktree must be clean before materialization.")278 279 280def _worktrees_using_branch(repo: Path, branch: str) -> tuple[Path, ...]:281 """Return registered worktrees that currently check out one local branch."""282 283 matches: list[Path] = []284 worktree: Path | None = None285 for line in [*git(repo, "worktree", "list", "--porcelain").stdout.splitlines(), ""]:286 if line.startswith("worktree "):287 worktree = Path(line.removeprefix("worktree ")).resolve()288 elif line == f"branch refs/heads/{branch}" and worktree is not None:289 matches.append(worktree)290 elif not line:291 worktree = None292 return tuple(matches)293 294 295def _require_branch_replaceable(repo: Path, branch: str) -> None:296 """Require an existing release branch to be safe to replace locally."""297 298 local = git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False)299 if local.returncode not in (0, 1):300 raise ReleasePreparationError(f"Unable to inspect local branch {branch!r}.")301 if local.returncode == 0:302 worktrees = _worktrees_using_branch(repo, branch)303 if worktrees:304 locations = ", ".join(str(path) for path in worktrees)305 raise ReleasePreparationError(306 f"Local branch {branch!r} is checked out in {locations}; switch that worktree "307 "away from the branch before replacing the release candidate."308 )309 310 remote = git(repo, "ls-remote", "--exit-code", "--heads", "origin", branch, check=False)311 if remote.returncode not in (0, 2):312 detail = remote.stderr.strip() or remote.stdout.strip() or "unknown remote error"313 raise ReleasePreparationError(f"Unable to inspect remote branch {branch!r}: {detail}")314 315 316def _changed_paths(repo: Path) -> set[str]:317 changed: set[str] = set()318 for args in (319 ("diff", "--name-only"),320 ("diff", "--name-only", "--cached"),321 ("ls-files", "--others", "--exclude-standard"),322 ):323 changed.update(line for line in git(repo, *args).stdout.splitlines() if line)324 return changed325 326 327def _locked_project_version(repo: Path) -> str:328 data = tomllib.loads((repo / "uv.lock").read_text(encoding="utf-8"))329 packages = data.get("package", [])330 matches = [331 package332 for package in packages333 if package.get("name") == "openai-agents" and package.get("source") == {"editable": "."}334 ]335 if len(matches) != 1:336 raise ReleasePreparationError(337 "uv.lock must contain exactly one editable openai-agents package with a version."338 )339 locked_version = matches[0].get("version")340 if not isinstance(locked_version, str):341 raise ReleasePreparationError(342 "uv.lock must contain exactly one editable openai-agents package with a version."343 )344 return locked_version345 346 347def _validate_prepared_files(repo: Path, version: str, base_commit: str) -> tuple[str, ...]:348 changed = _changed_paths(repo)349 if changed != RELEASE_PATHS:350 missing = sorted(RELEASE_PATHS - changed)351 unexpected = sorted(changed - RELEASE_PATHS)352 raise ReleasePreparationError(353 "Prepared release paths do not match the required manifest; "354 f"missing={missing!r}, unexpected={unexpected!r}."355 )356 if project_version(repo) != version:357 raise ReleasePreparationError("pyproject.toml does not contain the requested version.")358 if _locked_project_version(repo) != version:359 raise ReleasePreparationError("uv.lock does not contain the requested project version.")360 361 contract = json.loads(362 (repo / "tests/fixtures/released_api_contract.json").read_text(encoding="utf-8")363 )364 if contract.get("baseline") != f"v{version}":365 raise ReleasePreparationError(366 "The released API contract baseline does not match the version."367 )368 if contract.get("baseline_commit") != base_commit:369 raise ReleasePreparationError(370 "The released API contract baseline_commit does not match "371 "the origin/main source commit."372 )373 if git(repo, "diff", "--cached", "--quiet", check=False).returncode != 0:374 raise ReleasePreparationError("The helper must leave all release changes unstaged.")375 return tuple(sorted(changed))376 377 378def preflight(repo: Path, version: str, worktree_root: Path) -> ReleasePreflight:379 """Refresh exact main and create an isolated branch-free readiness checkout."""380 381 repo = repo.resolve()382 version = validate_version(version)383 _require_repository_root(repo)384 _require_clean_main(repo)385 source_commit = git(repo, "rev-parse", "HEAD").stdout.strip()386 branch = f"release/v{version}"387 _require_branch_replaceable(repo, branch)388 env = _release_environment()389 run_command(390 repo,391 [392 "git",393 "fetch",394 "origin",395 "refs/heads/main:refs/remotes/origin/main",396 "--prune",397 ],398 env=env,399 announce=True,400 )401 base_commit = git(repo, "rev-parse", "origin/main").stdout.strip()402 if project_version_at(repo, base_commit) == version:403 raise ReleasePreparationError(f"Refreshed origin/main already declares version {version}.")404 405 _require_branch_replaceable(repo, branch)406 if _status(repo):407 raise ReleasePreparationError(408 "Release preflight must leave the source main working tree clean."409 )410 worktree = _choose_worktree_path(repo, worktree_root, version)411 worktree.parent.mkdir(parents=True, exist_ok=True)412 run_command(413 repo,414 ["git", "worktree", "add", "--detach", str(worktree), base_commit],415 env=env,416 announce=True,417 )418 _require_registered_detached_worktree(repo, worktree, base_commit)419 _require_clean_main(repo)420 _require_source_head(repo, source_commit)421 return ReleasePreflight(422 base_commit=base_commit,423 branch=branch,424 source_commit=source_commit,425 version=version,426 worktree=worktree,427 )428 429 430def materialize(431 repo: Path,432 version: str,433 expected_base: str,434 expected_source_head: str,435 worktree: Path,436) -> PreparedCandidate:437 """Create the three-file candidate in the reviewed isolated worktree."""438 439 expected_base = validate_commit(expected_base)440 expected_source_head = validate_commit(expected_source_head)441 repo = repo.resolve()442 version = validate_version(version)443 worktree = worktree.expanduser().resolve()444 _require_repository_root(repo)445 _require_clean_main(repo)446 _require_source_head(repo, expected_source_head)447 _require_registered_detached_worktree(repo, worktree, expected_base)448 449 env = _release_environment()450 branch = f"release/v{version}"451 _require_branch_replaceable(repo, branch)452 run_command(453 repo,454 [455 "git",456 "fetch",457 "origin",458 "refs/heads/main:refs/remotes/origin/main",459 "--prune",460 ],461 env=env,462 announce=True,463 )464 base_commit = git(repo, "rev-parse", "origin/main").stdout.strip()465 if base_commit != expected_base:466 raise ReleasePreparationError(467 f"Preflight reviewed {expected_base}, but refreshed origin/main is {base_commit}; "468 "leave the detached worktree intact and rerun preflight plus both readiness gates."469 )470 _require_clean_main(repo)471 _require_source_head(repo, expected_source_head)472 _require_branch_replaceable(repo, branch)473 _require_registered_detached_worktree(repo, worktree, expected_base)474 if project_version(worktree) == version:475 raise ReleasePreparationError(f"Project version is already {version}.")476 477 replace_project_version(worktree, version)478 run_command(worktree, ["make", "sync"], env=env, announce=True)479 run_command(480 worktree,481 ["make", "update-released-api-contract", f"VERSION={version}"],482 env=env,483 announce=True,484 )485 run_command(486 worktree,487 ["make", "check-released-api-contract", f"VERSION={version}"],488 env=env,489 announce=True,490 )491 changed_paths = _validate_prepared_files(worktree, version, base_commit)492 _require_clean_main(repo)493 _require_source_head(repo, expected_source_head)494 _require_branch_replaceable(repo, branch)495 run_command(496 worktree,497 ["git", "switch", "--no-track", "-C", branch, expected_base],498 env=env,499 announce=True,500 )501 return PreparedCandidate(502 base_commit=base_commit,503 branch=branch,504 changed_paths=changed_paths,505 source_commit=expected_source_head,506 version=version,507 worktree=worktree,508 )509 510 511def parse_args() -> argparse.Namespace:512 parser = argparse.ArgumentParser(513 description="Preflight or materialize an isolated release candidate from exact origin/main."514 )515 subparsers = parser.add_subparsers(dest="phase", required=True)516 preflight_parser = subparsers.add_parser(517 "preflight",518 help="Refresh exact main and validate inputs without creating a release branch.",519 )520 preflight_parser.add_argument(521 "--version",522 required=True,523 help="Release version without a leading v, for example 0.20.1.",524 )525 preflight_parser.add_argument(526 "--worktree-root",527 type=Path,528 default=Path(os.environ.get("CODEX_WORKTREE_ROOT", Path.home() / ".codex/worktrees")),529 help="Directory under which to create a unique detached release worktree.",530 )531 materialize_parser = subparsers.add_parser(532 "materialize",533 help="Create an uncommitted candidate from the reviewed preflight commit.",534 )535 materialize_parser.add_argument(536 "--version",537 required=True,538 help="Release version without a leading v, for example 0.20.1.",539 )540 materialize_parser.add_argument(541 "--expected-base",542 required=True,543 help="Exact 40-character origin/main commit approved by release preflight.",544 )545 materialize_parser.add_argument(546 "--expected-source-head",547 required=True,548 help="Exact source-checkout HEAD recorded by release preflight.",549 )550 materialize_parser.add_argument(551 "--worktree",552 type=Path,553 required=True,554 help="Detached worktree created by the matching preflight.",555 )556 return parser.parse_args()557 558 559def main() -> int:560 args = parse_args()561 try:562 if args.phase == "preflight":563 release_input = preflight(ROOT, args.version, args.worktree_root)564 else:565 candidate = materialize(566 ROOT,567 args.version,568 args.expected_base,569 args.expected_source_head,570 args.worktree,571 )572 except (OSError, ReleasePreparationError, tomllib.TOMLDecodeError, json.JSONDecodeError) as exc:573 print(f"Release preparation failed: {exc}", file=sys.stderr)574 return 1575 576 if args.phase == "preflight":577 print("Release preflight passed without changing the source main checkout.")578 print(f"Base commit: {release_input.base_commit}")579 print(f"Source commit: {release_input.source_commit}")580 print(f"Planned branch: {release_input.branch}")581 print(f"Version: {release_input.version}")582 print(f"Worktree: {release_input.worktree}")583 print("Run both readiness gates from this detached worktree against the base commit.")584 return 0585 586 print("Release candidate prepared in its dedicated worktree and left uncommitted.")587 print(f"Base commit: {candidate.base_commit}")588 print(f"Source commit: {candidate.source_commit}")589 print(f"Branch: {candidate.branch}")590 print(f"Version: {candidate.version}")591 print(f"Worktree: {candidate.worktree}")592 print("Changed paths:")593 for path in candidate.changed_paths:594 print(f"- {path}")595 print("Review the diff before staging the three release-owned files.")596 return 0597 598 599if __name__ == "__main__":600 raise SystemExit(main())601