scripts/test_prepare.py
scripts/test_prepare.pyBrowse 3 files
5,206 tokens
25,978 bytes
Token encoding: o200k_base
Snapshot 1d17ca4
← Back to SKILL.md
1#!/usr/bin/env python32"""Focused tests for local release candidate preparation."""3 4from __future__ import annotations5 6import json7import os8import subprocess9import tempfile10import unittest11from pathlib import Path12from unittest import mock13 14import prepare15 16 17def run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:18 return subprocess.run(19 list(args),20 cwd=repo,21 check=check,22 capture_output=True,23 text=True,24 )25 26 27class ReleaseRepository:28 """Create a disposable release repository with a local bare origin."""29 30 def __init__(self, root: Path) -> None:31 self.root = root32 self.repo = root / "repo"33 self.origin = root / "origin.git"34 self.worktree_root = root / "worktrees"35 self.repo.mkdir()36 run(self.repo, "git", "init", "--initial-branch=main")37 run(self.repo, "git", "config", "user.name", "Release Test")38 run(self.repo, "git", "config", "user.email", "release-test@example.com")39 self._write_fixture_files()40 run(self.repo, "git", "add", ".")41 run(self.repo, "git", "commit", "-m", "Initial release source")42 run(root, "git", "init", "--bare", str(self.origin))43 run(self.repo, "git", "remote", "add", "origin", str(self.origin))44 run(self.repo, "git", "push", "--set-upstream", "origin", "main")45 self.base_commit = run(self.repo, "git", "rev-parse", "HEAD").stdout.strip()46 47 def advance_origin(self) -> str:48 updater = self.root / "updater"49 run(self.root, "git", "clone", "--branch", "main", str(self.origin), str(updater))50 run(updater, "git", "config", "user.name", "Release Updater")51 run(updater, "git", "config", "user.email", "release-updater@example.com")52 (updater / "new-source.txt").write_text("new source\n", encoding="utf-8")53 run(updater, "git", "add", "new-source.txt")54 run(updater, "git", "commit", "-m", "Advance main")55 run(updater, "git", "push", "origin", "main")56 return run(updater, "git", "rev-parse", "HEAD").stdout.strip()57 58 def _write_fixture_files(self) -> None:59 (self.repo / "tests/fixtures").mkdir(parents=True)60 (self.repo / "pyproject.toml").write_text(61 '[project]\nname = "openai-agents"\nversion = "0.19.4"\n',62 encoding="utf-8",63 )64 (self.repo / "uv.lock").write_text(65 'version = 1\n\n[[package]]\nname = "openai-agents"\nversion = "0.19.4"\n'66 'source = { editable = "." }\n',67 encoding="utf-8",68 )69 (self.repo / "tests/fixtures/released_api_contract.json").write_text(70 json.dumps(71 {72 "baseline": "v0.19.4",73 "baseline_commit": "a" * 40,74 "callables": {},75 "required_top_level_exports": [],76 },77 indent=2,78 sort_keys=True,79 )80 + "\n",81 encoding="utf-8",82 )83 (self.repo / "fake_release.py").write_text(84 """from __future__ import annotations85 86import json87import os88import re89import subprocess90import sys91from pathlib import Path92 93root = Path(__file__).parent94action = sys.argv[1]95if {'GH_TOKEN', 'GITHUB_TOKEN', 'OPENAI_API_KEY'} & os.environ.keys():96 raise SystemExit('credentials must not reach release subprocesses')97if os.environ.get('UV_DEFAULT_INDEX') != 'https://pypi.org/simple':98 raise SystemExit('UV_DEFAULT_INDEX must use the public package index')99version = re.search(100 r'^version = \"([^\"]+)\"$',101 (root / 'pyproject.toml').read_text(),102 re.MULTILINE,103).group(1)104if action == 'sync':105 path = root / 'uv.lock'106 text = path.read_text()107 text = re.sub(108 r'(name = \"openai-agents\"\\nversion = \")[^\"]+(\")',109 rf'\\g<1>{version}\\g<2>',110 text,111 )112 path.write_text(text)113elif action == 'update':114 expected = sys.argv[2]115 if expected != version:116 raise SystemExit('version mismatch')117 path = root / 'tests/fixtures/released_api_contract.json'118 contract = json.loads(path.read_text())119 contract['baseline'] = f'v{version}'120 contract['baseline_commit'] = subprocess.check_output(121 ['git', 'rev-parse', 'HEAD'], cwd=root, text=True122 ).strip()123 path.write_text(json.dumps(contract, indent=2, sort_keys=True) + '\\n')124elif action == 'check':125 expected = sys.argv[2]126 contract = json.loads(127 (root / 'tests/fixtures/released_api_contract.json').read_text()128 )129 if expected != version or contract['baseline'] != f'v{version}':130 raise SystemExit('contract mismatch')131else:132 raise SystemExit(f'unknown action: {action}')133""",134 encoding="utf-8",135 )136 (self.repo / "Makefile").write_text(137 "sync:\n\tpython fake_release.py sync\n\n"138 "update-released-api-contract:\n\tpython fake_release.py update $(VERSION)\n\n"139 "check-released-api-contract:\n\tpython fake_release.py check $(VERSION)\n",140 encoding="utf-8",141 )142 143 144class VersionTests(unittest.TestCase):145 def test_validate_version_accepts_release_and_prerelease(self) -> None:146 self.assertEqual(prepare.validate_version("0.20.1"), "0.20.1")147 self.assertEqual(prepare.validate_version("0.21.0-rc1"), "0.21.0-rc1")148 149 def test_validate_version_rejects_ambiguous_values(self) -> None:150 for value in ("v0.20.1", "0..20.1", "next", "0.20.1/other"):151 with self.subTest(value=value), self.assertRaises(prepare.ReleasePreparationError):152 prepare.validate_version(value)153 154 def test_replace_project_version_requires_exactly_one_change(self) -> None:155 text = '[project]\nversion = "0.19.4"\n'156 self.assertEqual(157 prepare.replace_project_version_text(text, "0.20.0"),158 '[project]\nversion = "0.20.0"\n',159 )160 with self.assertRaises(prepare.ReleasePreparationError):161 prepare.replace_project_version_text(text, "0.19.4")162 with self.assertRaises(prepare.ReleasePreparationError):163 prepare.replace_project_version_text(164 text + '[tool.example]\nversion = "1.0.0"\n',165 "0.20.0",166 )167 168 def test_validate_commit_requires_full_lowercase_identifier(self) -> None:169 commit = "a" * 40170 self.assertEqual(prepare.validate_commit(commit), commit)171 for value in ("a" * 39, "A" * 40, "main", "a" * 41):172 with self.subTest(value=value), self.assertRaises(prepare.ReleasePreparationError):173 prepare.validate_commit(value)174 175 176class PreparationTests(unittest.TestCase):177 def test_preflight_leaves_clean_main_without_creating_branch(self) -> None:178 with tempfile.TemporaryDirectory() as directory:179 fixture = ReleaseRepository(Path(directory))180 181 source_head = fixture.base_commit182 release_input = prepare.preflight(183 fixture.repo,184 "0.20.0",185 fixture.worktree_root,186 )187 188 self.assertEqual(release_input.base_commit, fixture.base_commit)189 self.assertEqual(release_input.branch, "release/v0.20.0")190 self.assertEqual(191 run(fixture.repo, "git", "branch", "--show-current").stdout.strip(),192 "main",193 )194 self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "")195 self.assertEqual(196 run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(),197 source_head,198 )199 self.assertEqual(200 run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(),201 fixture.base_commit,202 )203 self.assertEqual(204 run(205 release_input.worktree,206 "git",207 "symbolic-ref",208 "--quiet",209 "--short",210 "HEAD",211 check=False,212 ).returncode,213 1,214 )215 self.assertEqual(216 run(release_input.worktree, "git", "status", "--porcelain").stdout,217 "",218 )219 self.assertNotIn(220 "release/v0.20.0",221 run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(),222 )223 224 def test_materialize_creates_branch_and_exact_uncommitted_manifest(self) -> None:225 with tempfile.TemporaryDirectory() as directory:226 fixture = ReleaseRepository(Path(directory))227 release_input = prepare.preflight(228 fixture.repo,229 "0.20.0",230 fixture.worktree_root,231 )232 233 with mock.patch.dict(234 os.environ,235 {236 "GH_TOKEN": "untrusted",237 "GITHUB_TOKEN": "untrusted",238 "OPENAI_API_KEY": "untrusted",239 },240 ):241 candidate = prepare.materialize(242 fixture.repo,243 "0.20.0",244 release_input.base_commit,245 release_input.source_commit,246 release_input.worktree,247 )248 249 self.assertEqual(candidate.base_commit, fixture.base_commit)250 self.assertEqual(candidate.branch, "release/v0.20.0")251 self.assertEqual(set(candidate.changed_paths), prepare.RELEASE_PATHS)252 self.assertEqual(253 run(release_input.worktree, "git", "branch", "--show-current").stdout.strip(),254 "release/v0.20.0",255 )256 self.assertEqual(257 run(258 release_input.worktree,259 "git",260 "rev-list",261 "--count",262 "origin/main..HEAD",263 ).stdout.strip(),264 "0",265 )266 self.assertEqual(267 run(fixture.repo, "git", "branch", "--show-current").stdout.strip(),268 "main",269 )270 self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "")271 remote_heads = run(272 fixture.repo,273 "git",274 "ls-remote",275 "--heads",276 "origin",277 "release/v0.20.0",278 ).stdout279 self.assertEqual(remote_heads, "")280 contract = json.loads(281 (release_input.worktree / "tests/fixtures/released_api_contract.json").read_text()282 )283 self.assertEqual(contract["baseline"], "v0.20.0")284 self.assertEqual(contract["baseline_commit"], fixture.base_commit)285 286 def test_preflight_rejects_dirty_main_without_creating_branch(self) -> None:287 with tempfile.TemporaryDirectory() as directory:288 fixture = ReleaseRepository(Path(directory))289 (fixture.repo / "dirty.txt").write_text("dirty\n", encoding="utf-8")290 291 with self.assertRaisesRegex(292 prepare.ReleasePreparationError,293 "clean working tree",294 ):295 prepare.preflight(fixture.repo, "0.20.0", fixture.worktree_root)296 297 self.assertEqual(298 run(fixture.repo, "git", "branch", "--show-current").stdout.strip(),299 "main",300 )301 302 def test_preflight_keeps_source_main_and_checks_out_refreshed_origin_in_worktree(self) -> None:303 with tempfile.TemporaryDirectory() as directory:304 fixture = ReleaseRepository(Path(directory))305 source_head = fixture.base_commit306 refreshed_base = fixture.advance_origin()307 308 release_input = prepare.preflight(309 fixture.repo,310 "0.20.0",311 fixture.worktree_root,312 )313 314 self.assertEqual(release_input.base_commit, refreshed_base)315 self.assertEqual(316 run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(),317 source_head,318 )319 self.assertFalse((fixture.repo / "new-source.txt").exists())320 self.assertEqual(321 run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(),322 refreshed_base,323 )324 self.assertTrue((release_input.worktree / "new-source.txt").is_file())325 326 def test_existing_remote_release_branch_is_replaced_locally(self) -> None:327 with tempfile.TemporaryDirectory() as directory:328 fixture = ReleaseRepository(Path(directory))329 run(330 fixture.repo,331 "git",332 "push",333 "origin",334 "HEAD:refs/heads/release/v0.20.0",335 )336 old_remote_candidate = fixture.base_commit337 refreshed_base = fixture.advance_origin()338 339 release_input = prepare.preflight(340 fixture.repo,341 "0.20.0",342 fixture.worktree_root,343 )344 candidate = prepare.materialize(345 fixture.repo,346 "0.20.0",347 release_input.base_commit,348 release_input.source_commit,349 release_input.worktree,350 )351 352 self.assertEqual(candidate.branch, "release/v0.20.0")353 self.assertEqual(release_input.base_commit, refreshed_base)354 self.assertEqual(355 run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(),356 refreshed_base,357 )358 self.assertEqual(359 run(360 fixture.repo,361 "git",362 "ls-remote",363 "--heads",364 "origin",365 "release/v0.20.0",366 ).stdout.split()[0],367 old_remote_candidate,368 )369 self.assertEqual(370 run(fixture.repo, "git", "branch", "--show-current").stdout.strip(),371 "main",372 )373 374 def test_existing_local_release_branch_is_replaced_at_reviewed_base(self) -> None:375 with tempfile.TemporaryDirectory() as directory:376 fixture = ReleaseRepository(Path(directory))377 run(fixture.repo, "git", "branch", "release/v0.20.0")378 stale_commit = fixture.base_commit379 refreshed_base = fixture.advance_origin()380 381 release_input = prepare.preflight(382 fixture.repo,383 "0.20.0",384 fixture.worktree_root,385 )386 prepare.materialize(387 fixture.repo,388 "0.20.0",389 release_input.base_commit,390 release_input.source_commit,391 release_input.worktree,392 )393 394 self.assertNotEqual(stale_commit, refreshed_base)395 self.assertEqual(release_input.base_commit, refreshed_base)396 self.assertEqual(397 run(398 fixture.repo,399 "git",400 "rev-parse",401 "refs/heads/release/v0.20.0",402 ).stdout.strip(),403 refreshed_base,404 )405 406 def test_preflight_rejects_release_branch_checked_out_in_another_worktree(self) -> None:407 with tempfile.TemporaryDirectory() as directory:408 fixture = ReleaseRepository(Path(directory))409 colliding_worktree = fixture.root / "existing-release"410 run(411 fixture.repo,412 "git",413 "worktree",414 "add",415 "-b",416 "release/v0.20.0",417 str(colliding_worktree),418 fixture.base_commit,419 )420 421 with self.assertRaisesRegex(422 prepare.ReleasePreparationError,423 "is checked out in",424 ):425 prepare.preflight(fixture.repo, "0.20.0", fixture.worktree_root)426 427 self.assertEqual(428 run(colliding_worktree, "git", "branch", "--show-current").stdout.strip(),429 "release/v0.20.0",430 )431 432 def test_materialize_rejects_stale_preflight_before_creating_branch(self) -> None:433 with tempfile.TemporaryDirectory() as directory:434 fixture = ReleaseRepository(Path(directory))435 release_input = prepare.preflight(436 fixture.repo,437 "0.20.0",438 fixture.worktree_root,439 )440 refreshed_base = fixture.advance_origin()441 442 with self.assertRaisesRegex(443 prepare.ReleasePreparationError,444 f"Preflight reviewed {release_input.base_commit}, but refreshed origin/main is "445 f"{refreshed_base}",446 ):447 prepare.materialize(448 fixture.repo,449 "0.20.0",450 release_input.base_commit,451 release_input.source_commit,452 release_input.worktree,453 )454 455 self.assertEqual(456 run(fixture.repo, "git", "branch", "--show-current").stdout.strip(),457 "main",458 )459 self.assertEqual(460 run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(),461 fixture.base_commit,462 )463 self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "")464 self.assertEqual(465 run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(),466 fixture.base_commit,467 )468 self.assertEqual(469 run(470 release_input.worktree,471 "git",472 "symbolic-ref",473 "--quiet",474 "--short",475 "HEAD",476 check=False,477 ).returncode,478 1,479 )480 self.assertNotIn(481 "release/v0.20.0",482 run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(),483 )484 485 def test_preflight_chooses_a_new_path_without_reusing_a_worktree_collision(self) -> None:486 with tempfile.TemporaryDirectory() as directory:487 fixture = ReleaseRepository(Path(directory))488 collision = fixture.worktree_root / f"{fixture.repo.name}-release-v0.20.0"489 collision.parent.mkdir(parents=True)490 run(491 fixture.repo,492 "git",493 "worktree",494 "add",495 "--detach",496 str(collision),497 fixture.base_commit,498 )499 500 release_input = prepare.preflight(501 fixture.repo,502 "0.20.0",503 fixture.worktree_root,504 )505 506 self.assertEqual(507 release_input.worktree,508 (fixture.worktree_root / f"{fixture.repo.name}-release-v0.20.0-2").resolve(),509 )510 self.assertTrue(collision.is_dir())511 self.assertEqual(512 run(collision, "git", "rev-parse", "HEAD").stdout.strip(),513 fixture.base_commit,514 )515 516 def test_preflight_rejects_a_worktree_root_inside_the_source_checkout(self) -> None:517 with tempfile.TemporaryDirectory() as directory:518 fixture = ReleaseRepository(Path(directory))519 nested_root = fixture.repo / ".release-worktrees"520 521 with self.assertRaisesRegex(522 prepare.ReleasePreparationError,523 "must be outside the source checkout",524 ):525 prepare.preflight(fixture.repo, "0.20.0", nested_root)526 527 self.assertFalse(nested_root.exists())528 self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "")529 530 def test_materialize_failure_preserves_detached_evidence_and_source_checkout(self) -> None:531 with tempfile.TemporaryDirectory() as directory:532 fixture = ReleaseRepository(Path(directory))533 release_input = prepare.preflight(534 fixture.repo,535 "0.20.0",536 fixture.worktree_root,537 )538 real_run_command = prepare.run_command539 540 def fail_sync(541 repo: Path,542 args: list[str] | tuple[str, ...],543 **kwargs: object,544 ) -> subprocess.CompletedProcess[str]:545 if list(args) == ["make", "sync"]:546 raise prepare.ReleasePreparationError("simulated make sync failure")547 return real_run_command(repo, args, **kwargs)548 549 with mock.patch.object(prepare, "run_command", side_effect=fail_sync):550 with self.assertRaisesRegex(551 prepare.ReleasePreparationError,552 "simulated make sync failure",553 ):554 prepare.materialize(555 fixture.repo,556 "0.20.0",557 release_input.base_commit,558 release_input.source_commit,559 release_input.worktree,560 )561 562 self.assertEqual(563 run(fixture.repo, "git", "branch", "--show-current").stdout.strip(),564 "main",565 )566 self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "")567 self.assertEqual(568 run(569 release_input.worktree,570 "git",571 "symbolic-ref",572 "--quiet",573 "--short",574 "HEAD",575 check=False,576 ).returncode,577 1,578 )579 self.assertIn(580 "pyproject.toml",581 run(release_input.worktree, "git", "status", "--porcelain").stdout,582 )583 self.assertEqual(prepare.project_version(release_input.worktree), "0.20.0")584 585 def test_materialize_failure_does_not_replace_existing_local_release_branch(self) -> None:586 with tempfile.TemporaryDirectory() as directory:587 fixture = ReleaseRepository(Path(directory))588 run(fixture.repo, "git", "branch", "release/v0.20.0")589 existing_candidate = fixture.base_commit590 fixture.advance_origin()591 release_input = prepare.preflight(592 fixture.repo,593 "0.20.0",594 fixture.worktree_root,595 )596 real_run_command = prepare.run_command597 598 def fail_sync(599 repo: Path,600 args: list[str] | tuple[str, ...],601 **kwargs: object,602 ) -> subprocess.CompletedProcess[str]:603 if list(args) == ["make", "sync"]:604 raise prepare.ReleasePreparationError("simulated make sync failure")605 return real_run_command(repo, args, **kwargs)606 607 with mock.patch.object(prepare, "run_command", side_effect=fail_sync):608 with self.assertRaisesRegex(609 prepare.ReleasePreparationError,610 "simulated make sync failure",611 ):612 prepare.materialize(613 fixture.repo,614 "0.20.0",615 release_input.base_commit,616 release_input.source_commit,617 release_input.worktree,618 )619 620 self.assertEqual(621 run(622 fixture.repo,623 "git",624 "rev-parse",625 "refs/heads/release/v0.20.0",626 ).stdout.strip(),627 existing_candidate,628 )629 self.assertEqual(630 run(631 release_input.worktree,632 "git",633 "symbolic-ref",634 "--quiet",635 "--short",636 "HEAD",637 check=False,638 ).returncode,639 1,640 )641 642 def test_materialize_rejects_a_source_checkout_head_change(self) -> None:643 with tempfile.TemporaryDirectory() as directory:644 fixture = ReleaseRepository(Path(directory))645 release_input = prepare.preflight(646 fixture.repo,647 "0.20.0",648 fixture.worktree_root,649 )650 (fixture.repo / "local-only.txt").write_text("local\n", encoding="utf-8")651 run(fixture.repo, "git", "add", "local-only.txt")652 run(fixture.repo, "git", "commit", "-m", "Move source checkout")653 654 with self.assertRaisesRegex(655 prepare.ReleasePreparationError,656 "Source checkout HEAD changed",657 ):658 prepare.materialize(659 fixture.repo,660 "0.20.0",661 release_input.base_commit,662 release_input.source_commit,663 release_input.worktree,664 )665 666 self.assertEqual(667 run(668 release_input.worktree,669 "git",670 "symbolic-ref",671 "--quiet",672 "--short",673 "HEAD",674 check=False,675 ).returncode,676 1,677 )678 self.assertNotIn(679 "release/v0.20.0",680 run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(),681 )682 683 684if __name__ == "__main__":685 unittest.main()686