diff --git a/.github/release-lock.md b/.github/release-lock.md index 7a2db0ebf99..9b58a23b624 100644 --- a/.github/release-lock.md +++ b/.github/release-lock.md @@ -24,7 +24,14 @@ The lock is generated by the uv version pinned in every `astral-sh/setup-uv` ste the workflows (currently 0.12.13). Different uv releases normalize resolution markers differently when they re-resolve, so an unpinned runner rewrites the reviewed graph the first time the root version changes and `--refresh` fails closed (2026-09-11, the 2.0.0 -release). To upgrade uv: change the pin in all three workflows and regenerate the lock +release). Regenerate the lock with that version rather than whichever uv you have +installed: + +```sh +uvx --from 'uv==0.12.13' uv lock +``` + +To upgrade uv: change the pin in all three workflows and regenerate the lock with the new version in the same reviewed PR, forcing a re-resolution by bumping the root version, locking, restoring the version, and locking again. @@ -36,6 +43,25 @@ exact previous lock bytes. A uv failure also restores those bytes. Solver upgrades that change the graph fail closed and require a separate lock review. The failed workflow cannot reach the automatic commit. +`--rehearse` runs that refresh before the merge instead of after it. It copies +`pyproject.toml` and `uv.lock` into a temporary directory, bumps the copied root +version by one patch level, and calls the same refresh there. The copy holds +only those two files: the project declares no dynamic metadata, so uv resolves +it without the package tree. The rehearsal never writes to the checkout, and +reads both files again at the end to confirm it. The pull request `ReleaseLock` +job runs it after the committed check, so a lock that the next automatic bump +would rewrite fails the pull request rather than the push to `main`. + +Run the rehearsal with the pinned uv, because it reports on the uv that runs it. +A different version can fail on a good lock, and can pass a lock the release job +will reject. The script calls whatever `uv` is first on `PATH`, so put the pinned +release toolchain there before running it, for example through a shim directory: + +```sh +mkdir -p /tmp/uv-release && printf '#!/bin/bash\nexec uvx --from "uv==0.12.13" uv "$@"\n' > /tmp/uv-release/uv && chmod +x /tmp/uv-release/uv +PATH=/tmp/uv-release:$PATH python .github/release_lock.py --rehearse +``` + Pull request model jobs depend on `ReleaseLock`. Sentinel push model jobs have the same prerequisite, and `Publish` checks its own committed checkout again before installation or build. Model environments use locked synchronization; @@ -63,8 +89,8 @@ runtime lock. Both builds use those same pins and the frontend version in comparison and downstream qualification remain separate release checks. `ReleaseLockTests` has no dependency on a model job or the country lock check. -It uses only the Python standard library and uv, so it can test the guard while -the country dependency registry is incomplete: +It uses only the Python standard library and uv, so it tests the guard without +resolving or installing the country dependency graph: ```sh python -m unittest discover -s .github/tests -p 'test_release_*.py' -v diff --git a/.github/release_lock.py b/.github/release_lock.py index ad698f31fdc..83335d21f94 100644 --- a/.github/release_lock.py +++ b/.github/release_lock.py @@ -1,4 +1,4 @@ -"""Validate a release lock, or refresh only its automatic version bump. +"""Validate a release lock, rehearse its refresh, or refresh a version bump. Dependency changes belong in a reviewed registry lock before versioning runs. This helper uses only the standard library and never imports the country model. @@ -13,6 +13,7 @@ import re import subprocess import sys +import tempfile import tomllib from urllib.parse import unquote, urlsplit @@ -47,6 +48,11 @@ def validate_registry_project(project: dict) -> None: raise ValueError( "Release project must use PyPI without source/index/workspace overrides" ) + if project.get("project", {}).get("dynamic"): + raise ValueError( + "Release project must declare static metadata; dynamic fields would make " + "the two-file release rehearsal resolve a different project" + ) def validate_requirements(value): if isinstance(value, str): @@ -290,6 +296,58 @@ def check_release_lock( lock_path.write_bytes(before_bytes) +def bumped_project(text: str) -> tuple[str, str]: + """Apply one patch bump to project text, exactly as bump_version.py does. + + The release job picks the bump level from the changelog fragments. A patch + bump rehearses it faithfully: the refresh guard drops the root version + before it compares the graphs, so only the fact of the change matters. + """ + match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE) + if not match: + raise ValueError("Release project has no version to bump") + previous = match.group(1) + major, minor, patch = (int(part) for part in previous.split(".")) + bumped = f"{major}.{minor}.{patch + 1}" + return text.replace(f'version = "{previous}"', f'version = "{bumped}"'), bumped + + +def require_unchanged_checkout(root: Path, sources: dict[str, bytes]) -> None: + """The rehearsal reads the checkout; no resolver may write back to it.""" + for name, before in sources.items(): + if (root / name).read_bytes() != before: + raise ValueError( + f"Release rehearsal must not change {name} in the checkout" + ) + + +def rehearse_release_lock(root: Path = REPO_ROOT) -> str: + """Run the release refresh against a temporary copy of this checkout. + + `uv lock --check` accepts a lock another uv version wrote, but the release + bump re-resolves, so drift only surfaces on main. This runs that refresh + early on a copy. Only pyproject.toml and uv.lock are copied: the project + declares no dynamic metadata, so uv resolves it without the package tree. + """ + reject_parent_workspaces(root) + sources = {} + for name in ("pyproject.toml", "uv.lock"): + path = root / name + if path.is_symlink(): + raise ValueError(f"Release rehearsal requires a regular {name}") + sources[name] = path.read_bytes() + project, version = bumped_project(sources["pyproject.toml"].decode("utf-8")) + with tempfile.TemporaryDirectory(prefix="release-lock-rehearsal-") as temporary: + rehearsal = Path(temporary) + (rehearsal / "pyproject.toml").write_text(project, encoding="utf-8") + (rehearsal / "uv.lock").write_bytes(sources["uv.lock"]) + try: + check_release_lock(rehearsal, refresh=True) + finally: + require_unchanged_checkout(root, sources) + return version + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) mode = parser.add_mutually_exclusive_group() @@ -303,10 +361,26 @@ def main() -> int: action="store_true", help="Refresh only the root version after the automatic bump", ) + mode.add_argument( + "--rehearse", + action="store_true", + help="Rehearse that refresh on a temporary copy, before the merge", + ) args = parser.parse_args() try: - check_release_lock(refresh=args.refresh, committed=args.committed) + if args.rehearse: + version = rehearse_release_lock() + print(f"Rehearsed the release refresh at version {version}") + else: + check_release_lock(refresh=args.refresh, committed=args.committed) except (ValueError, OSError, KeyError, subprocess.CalledProcessError) as exc: + if args.rehearse: + print( + "The release version refresh failed on a copy of this checkout." + " See .github/release-lock.md; if the dependency graph changed," + " regenerate uv.lock with the pinned uv.", + file=sys.stderr, + ) parser.error(str(exc)) return 0 diff --git a/.github/tests/fixtures/registry/README.md b/.github/tests/fixtures/registry/README.md index 9ce6148b2ed..832865d6680 100644 --- a/.github/tests/fixtures/registry/README.md +++ b/.github/tests/fixtures/registry/README.md @@ -2,7 +2,7 @@ This minimal graph was generated by uv 0.11.7 from the adjacent `pyproject.toml` with the standard PyPI index and no source or configuration overrides. The `idna==3.10` artifact URLs and hashes are real registry metadata; the fixture project is an editable local root. This fixture is unrelated to the country -package's pending production lock. +package's production lock. Generation command: diff --git a/.github/tests/test_release_lock.py b/.github/tests/test_release_lock.py index 75c7e0c2474..2e595b9f440 100644 --- a/.github/tests/test_release_lock.py +++ b/.github/tests/test_release_lock.py @@ -4,12 +4,15 @@ Set RELEASE_LOCK_REAL_UV=1 to exercise the installed uv against ordinary PyPI. """ +import contextlib import copy import importlib.util +import io import os from pathlib import Path import shutil import subprocess +import sys import tempfile import tomllib import unittest @@ -243,6 +246,13 @@ def test_resolver_environment_discards_inherited_uv_and_pip_overrides(self): self.assertNotIn(key, env) +class DynamicMetadataTests(RegistryFixture): + def test_dynamic_project_metadata_is_rejected(self): + project = copy.deepcopy(self.project) + project["project"]["dynamic"] = ["version"] + self.assert_rejected(release_lock.validate_registry_project, project) + + class ReleaseTransactionTests(RegistryFixture): def test_parent_workspace_cannot_redirect_the_lock_check(self): child = self.root / "members" / "child" @@ -397,6 +407,161 @@ def test_invalid_previous_artifact_is_rejected_without_resolver(self): self.assertEqual((self.root / "uv.lock").read_bytes(), invalid) +class RehearsalTests(RegistryFixture): + """The rehearsal runs the release refresh on a copy, never on the checkout.""" + + def copy_resolver(self, mutate=None): + """Stand in for uv: write the refreshed lock wherever it is invoked.""" + self.calls = [] + + def resolve(command, **kwargs): + cwd = Path(kwargs["cwd"]) + self.calls.append( + { + "command": command, + "cwd": cwd, + "contents": sorted(entry.name for entry in cwd.iterdir()), + "project": (cwd / "pyproject.toml").read_text(), + "lock": (cwd / "uv.lock").read_bytes(), + } + ) + if "--check" not in command: + refreshed = self.refreshed_bytes() + (cwd / "uv.lock").write_bytes( + refreshed if mutate is None else mutate(refreshed) + ) + return subprocess.CompletedProcess(command, 0) + + return resolve + + def assert_checkout_untouched(self): + self.assertEqual((self.root / "uv.lock").read_bytes(), self.original) + self.assertIn('version = "1.0.0"', (self.root / "pyproject.toml").read_text()) + + def test_rehearsal_bumps_a_copy_and_accepts_a_root_only_refresh(self): + with patch.object( + release_lock.subprocess, "run", side_effect=self.copy_resolver() + ): + version = release_lock.rehearse_release_lock(self.root) + self.assertEqual(version, "1.0.1") + self.assert_checkout_untouched() + self.assertEqual(len(self.calls), 2) + for call in self.calls: + self.assertNotEqual(call["cwd"], self.root) + # uv resolves the copy from project metadata alone, with no + # package tree, and the copy is removed when the run ends. + self.assertEqual(call["contents"], ["pyproject.toml", "uv.lock"]) + self.assertFalse(call["cwd"].exists()) + self.assertIn('version = "1.0.1"', call["project"]) + self.assertIn("--no-config", call["command"]) + self.assertIn("--no-sources", call["command"]) + self.assertIn("https://pypi.org/simple", call["command"]) + self.assertNotIn("--check", self.calls[0]["command"]) + self.assertEqual(self.calls[0]["lock"], self.original) + self.assertIn("--check", self.calls[1]["command"]) + self.assertEqual(self.calls[1]["lock"], self.refreshed_bytes()) + + def test_rehearsal_rejects_a_resolver_that_rewrites_resolution_markers(self): + # The 2026-09-11 release failure: a lock written by one uv version, and + # re-resolved by another, keeps every package but renormalizes markers. + def renormalize(data): + return data.replace( + b'requires-python = ">=3.11"\n', + b'requires-python = ">=3.11"\n' + b"resolution-markers = [\n" + b" \"python_full_version >= '3.12'\",\n" + b" \"python_full_version < '3.12'\",\n" + b"]\n", + ) + + with patch.object( + release_lock.subprocess, "run", side_effect=self.copy_resolver(renormalize) + ): + with self.assertRaisesRegex( + ValueError, "changed the reviewed dependency graph" + ): + release_lock.rehearse_release_lock(self.root) + self.assert_checkout_untouched() + + def test_rehearsal_reports_a_resolver_failure_without_touching_the_checkout(self): + with patch.object( + release_lock.subprocess, + "run", + side_effect=subprocess.CalledProcessError(1, ["uv", "lock"]), + ): + self.assert_rejected(release_lock.rehearse_release_lock, self.root) + self.assert_checkout_untouched() + + def test_rehearsal_rejects_a_resolver_that_writes_to_the_checkout(self): + def stray(command, **kwargs): + (self.root / "uv.lock").write_bytes(self.original + b"\n# stray write\n") + (Path(kwargs["cwd"]) / "uv.lock").write_bytes(self.refreshed_bytes()) + return subprocess.CompletedProcess(command, 0) + + with patch.object(release_lock.subprocess, "run", side_effect=stray): + with self.assertRaisesRegex(ValueError, "must not change uv.lock"): + release_lock.rehearse_release_lock(self.root) + + def test_rehearsal_refuses_a_checkout_inside_a_uv_workspace(self): + child = self.root / "members" / "child" + child.mkdir(parents=True) + for name in ("pyproject.toml", "uv.lock"): + shutil.copyfile(FIXTURE / name, child / name) + (self.root / "pyproject.toml").write_text( + '[tool.uv.workspace]\nmembers = ["members/*"]\n' + ) + with patch.object(release_lock.subprocess, "run") as run: + self.assert_rejected(release_lock.rehearse_release_lock, child) + run.assert_not_called() + + def test_rehearsed_bump_matches_the_release_bump(self): + """The rehearsal applies the patch bump bump_version.py would apply.""" + spec = importlib.util.spec_from_file_location( + "bump_version", HERE.parent / "bump_version.py" + ) + bump_version = importlib.util.module_from_spec(spec) + spec.loader.exec_module(bump_version) + probe = self.root / "probe-pyproject.toml" + for source in (FIXTURE / "pyproject.toml", HERE.parents[1] / "pyproject.toml"): + with self.subTest(source=source.parent.name): + shutil.copyfile(source, probe) + current = bump_version.get_current_version(probe) + expected = bump_version.bump_version(current, "patch") + with contextlib.redirect_stdout(io.StringIO()): + bump_version.update_file(probe, current, expected) + project, version = release_lock.bumped_project(source.read_text()) + self.assertEqual(version, expected) + self.assertEqual(project, probe.read_text()) + + +class RehearsalCommandTests(unittest.TestCase): + def test_rehearse_cannot_combine_with_the_other_modes(self): + for flags in (["--rehearse", "--committed"], ["--rehearse", "--refresh"]): + with self.subTest(flags=flags): + with patch.object(sys, "argv", ["release_lock.py", *flags]): + with contextlib.redirect_stderr(io.StringIO()) as reported: + with self.assertRaises(SystemExit) as raised: + release_lock.main() + self.assertEqual(raised.exception.code, 2) + self.assertIn("not allowed with argument", reported.getvalue()) + + def test_rehearsal_failure_exits_two_with_the_hint(self): + with patch.object( + release_lock, + "rehearse_release_lock", + side_effect=ValueError("Versioning changed the reviewed dependency graph"), + ): + with patch.object(sys, "argv", ["release_lock.py", "--rehearse"]): + with contextlib.redirect_stderr(io.StringIO()) as reported: + with self.assertRaises(SystemExit) as raised: + release_lock.main() + self.assertEqual(raised.exception.code, 2) + self.assertIn("regenerate uv.lock with the pinned uv", reported.getvalue()) + self.assertIn( + "Versioning changed the reviewed dependency graph", reported.getvalue() + ) + + class CommittedLockTests(RegistryFixture): def setUp(self): super().setUp() @@ -449,9 +614,8 @@ def test_committed_check_rejects_uncommitted_lock_and_project(self): "set RELEASE_LOCK_REAL_UV=1 for the real registry resolver probe", ) class RealUvTests(RegistryFixture): - def test_actual_registry_root_only_refresh(self): - """Resolve genuine PyPI idna, bump only root, and check the final graph.""" - env = release_lock.resolver_environment() + def lock_with_installed_uv(self): + """Write the fixture lock as the installed uv version normalizes it.""" command = [ "uv", "lock", @@ -462,7 +626,17 @@ def test_actual_registry_root_only_refresh(self): "--cache-dir", str(self.root / "cache"), ] - subprocess.run(command, cwd=self.root, env=env, check=True, timeout=60) + subprocess.run( + command, + cwd=self.root, + env=release_lock.resolver_environment(), + check=True, + timeout=60, + ) + + def test_actual_registry_root_only_refresh(self): + """Resolve genuine PyPI idna, bump only root, and check the final graph.""" + self.lock_with_installed_uv() before = tomllib.loads((self.root / "uv.lock").read_text()) release_lock.check_release_lock(self.root) self.bump_project() @@ -475,6 +649,16 @@ def test_actual_registry_root_only_refresh(self): ) release_lock.check_release_lock(self.root) + def test_actual_registry_rehearsal_leaves_the_checkout_alone(self): + """Rehearse the release bump against real PyPI, on a copy of the root.""" + self.lock_with_installed_uv() + before = (self.root / "uv.lock").read_bytes() + project = (self.root / "pyproject.toml").read_bytes() + version = release_lock.rehearse_release_lock(self.root) + self.assertEqual(version, "1.0.1") + self.assertEqual((self.root / "uv.lock").read_bytes(), before) + self.assertEqual((self.root / "pyproject.toml").read_bytes(), project) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 3e3a91d52d2..9fd725d2060 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -22,6 +22,8 @@ jobs: version: "0.12.13" - name: Check committed registry lock run: python .github/release_lock.py --committed + - name: Rehearse the release version refresh + run: python .github/release_lock.py --rehearse ReleaseLockTests: name: Release lock guard tests runs-on: ubuntu-latest diff --git a/changelog.d/release-lock-rehearsal.added.md b/changelog.d/release-lock-rehearsal.added.md new file mode 100644 index 00000000000..9fdc0331d57 --- /dev/null +++ b/changelog.d/release-lock-rehearsal.added.md @@ -0,0 +1 @@ +Rehearse the release version refresh in pull request CI, so a lock that the next automatic bump would rewrite fails before it reaches main.