From 7f730b9de748fd4671b4e30b9c5ea851b6c9e0bb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 20:03:54 -0400 Subject: [PATCH 1/7] Track the release lock rehearsal work in PROGRESS.md Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 00000000000..2c84436eee8 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,28 @@ +# Release lock rehearsal — progress + +Branch `max/release-lock-rehearsal-20260911`, stacked on `max/release-lock-uv-toolchain-20260911` +(PR #9444, head a6aef60a, still open on 2026-09-11). + +## State + +Closing the gap #9444 left: PR CI runs `uv lock --check`, which accepts a lock written by a +different uv, so a contributor can regenerate `uv.lock` with any uv, pass CI, and break the next +`Update versioning` run on main. Add a PR-time rehearsal of the release refresh. + +## Done + +- Read `.github/release_lock.py`, `.github/bump_version.py`, `.github/tests/test_release_lock.py`, + the three workflows, `.github/release-lock.md`, `docs/spm.md`. +- Probe: `uv lock` succeeds on a directory holding only copied `pyproject.toml` and `uv.lock` + (no package sources, no README); the root metadata is static, so uv never builds the project. + The only line that changes is the root version. + +## Next + +1. `--rehearse` mode in `.github/release_lock.py`. +2. `Rehearse the release version refresh` step in the `ReleaseLock` job of `.github/workflows/pr.yaml`. +3. Unit tests in `.github/tests/test_release_lock.py`, including the real-uv probe. +4. Refresh `.github/release-lock.md` and `docs/spm.md`. +5. Changelog fragment. +6. Local verification with uv 0.12.13, including the failing pre-#9444 lock. +7. Push to `upstream`, open a draft PR. From bf2f2ebecbc41a4b4aa55aeaa6e141299b9fa1a6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 20:07:30 -0400 Subject: [PATCH 2/7] Rehearse the release version refresh on a temporary project copy `uv lock --check` takes uv's satisfies fast path, so it accepts a lock that a different uv version wrote; the release bump re-resolves and can then rewrite the reviewed graph, which only shows up on main. `--rehearse` copies pyproject.toml and uv.lock into a temporary directory, applies the patch bump bump_version.py would apply, and runs the existing refresh against the copy. uv resolves that copy from project metadata alone, because the root declares no dynamic metadata and needs no package tree. The mode reuses check_release_lock, so it runs the same uv command, the same validation and the same restore. It reads the checkout and asserts afterwards that neither file changed, and reports the guard's own error with a pointer to the release-lock document. Co-Authored-By: Claude Fable 5.1 --- .github/release_lock.py | 73 ++++++++++++- .github/tests/test_release_lock.py | 169 ++++++++++++++++++++++++++++- 2 files changed, 236 insertions(+), 6 deletions(-) diff --git a/.github/release_lock.py b/.github/release_lock.py index ad698f31fdc..c7aa092dd73 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 @@ -290,6 +291,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 +356,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/test_release_lock.py b/.github/tests/test_release_lock.py index 75c7e0c2474..51f5f75f6c4 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 @@ -397,6 +400,145 @@ 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()) + + class CommittedLockTests(RegistryFixture): def setUp(self): super().setUp() @@ -449,9 +591,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 +603,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 +626,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() From 12ccf6e38fdc472b19e0c0b33019c0f9726c2ef5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 20:07:43 -0400 Subject: [PATCH 3/7] Rehearse the release refresh in the pull request lock job The ReleaseLock job checked the committed lock but never re-resolved it, so a lock regenerated with an unpinned uv passed the pull request and failed the next automatic bump on main. The rehearsal runs in the same job, after the committed check, with the pinned uv the release job uses. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/pr.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 08446d9bcba..662a2fa53f7 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 From 5bee681dc2a4143b821f04f6a2f63959ebc3b38d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 20:09:17 -0400 Subject: [PATCH 4/7] Document the rehearsal and the resolved calculator release Tell contributors to regenerate the lock with the pinned uv rather than the uv they happen to have, describe `--rehearse` and why it must run under that same pin, and replace the paragraphs that still said the country lock was blocked until spm-calculator 1.0.0 reached PyPI. The committed lock resolves that release from PyPI with hashed artifacts. Co-Authored-By: Claude Fable 5.1 --- .github/release-lock.md | 45 ++++++++++++++++----- changelog.d/release-lock-rehearsal.added.md | 1 + docs/spm.md | 7 ++-- 3 files changed, 39 insertions(+), 14 deletions(-) create mode 100644 changelog.d/release-lock-rehearsal.added.md diff --git a/.github/release-lock.md b/.github/release-lock.md index 6a862f89c0e..85148d19728 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,14 +43,32 @@ 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: + +```sh +uvx --from 'uv==0.12.13' uv --version # the release toolchain +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; subsequent commands use that environment without synchronizing again. `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_lock.py -v @@ -54,10 +79,10 @@ The opt-in probe creates an isolated, minimal project with a standard PyPI dependency and exercises actual uv locking. CI enables it. These commands do not install or import the country model. -For the SPM integration, the committed country lock is intentionally still -blocked until `spm-calculator==1.0.0` is available on PyPI and a production -registry lock is regenerated and reviewed separately. A stale root version or -older calculator resolution must fail the full PR and publication gates. -Passing guard tests does not clear that release prerequisite. Do not use local -wheel links or edit lock fields to manufacture a passing lock; the automatic -root-version refresh is not a general dependency update command. +The committed country lock resolves `spm-calculator==1.0.0` from PyPI, with the +same hashed artifacts the guard requires of every other dependency, so the SPM +integration no longer holds the lock back. The ordinary gates still apply: a +stale root version or a dependency change outside a reviewed lock fails the full +PR and publication gates, and passing guard tests does not stand in for them. Do +not use local wheel links or edit lock fields to manufacture a passing lock; the +automatic root-version refresh is not a general dependency update command. 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. diff --git a/docs/spm.md b/docs/spm.md index 231dc38b197..541e3e989fd 100644 --- a/docs/spm.md +++ b/docs/spm.md @@ -72,7 +72,6 @@ bytes exist and pass independent readback. The unpublished candidate embeds the same URI; local candidate checks do not establish production default availability. The published country wheel requires exactly `spm-calculator==1.0.0`; it contains -no Git or local-path dependency. For coordinated development before registry -resolution is available, install the local calculator wheel into an isolated -environment and run tests with `uv run --no-sync`. Refresh the registry lockfile -when the calculator release is available to the resolver. +no Git or local-path dependency. The committed `uv.lock` resolves that release +from PyPI, so `uv sync --locked --extra dev` installs the calculator with every +other dependency and needs no local wheel. From 995c5ff99ff0d7f580ca654985d88cecc4ffa442 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 20:11:04 -0400 Subject: [PATCH 5/7] Record the verified state before the review pass Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 2c84436eee8..09ef01eed7f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -5,24 +5,27 @@ Branch `max/release-lock-rehearsal-20260911`, stacked on `max/release-lock-uv-to ## State -Closing the gap #9444 left: PR CI runs `uv lock --check`, which accepts a lock written by a -different uv, so a contributor can regenerate `uv.lock` with any uv, pass CI, and break the next -`Update versioning` run on main. Add a PR-time rehearsal of the release refresh. +Implementation, tests, docs and changelog are committed and verified locally. An adversarial +review workflow is running over the five dimensions (correctness, bypass, tests, CI, docs). +Remaining: act on confirmed findings, drop this file, push, open the draft PR. ## Done -- Read `.github/release_lock.py`, `.github/bump_version.py`, `.github/tests/test_release_lock.py`, - the three workflows, `.github/release-lock.md`, `docs/spm.md`. -- Probe: `uv lock` succeeds on a directory holding only copied `pyproject.toml` and `uv.lock` - (no package sources, no README); the root metadata is static, so uv never builds the project. - The only line that changes is the root version. +- `.github/release_lock.py`: `--rehearse`, `bumped_project`, `require_unchanged_checkout`, + `rehearse_release_lock`, CLI wiring (commit 2dc2f0c9). +- `.github/workflows/pr.yaml`: `Rehearse the release version refresh` step in `ReleaseLock` + (commit 7e4dbd78). +- `.github/tests/test_release_lock.py`: `RehearsalTests`, `RehearsalCommandTests`, and a real-uv + rehearsal probe (commit 2dc2f0c9). +- `.github/release-lock.md` and `docs/spm.md`: pinned-uv regeneration command, `--rehearse` + description, stale spm-calculator paragraphs replaced (commit 409cc58c). +- `changelog.d/release-lock-rehearsal.added.md`. +- Verification transcript in the scratchpad `verification.txt`: 33 unit tests pass with and + without `RELEASE_LOCK_REAL_UV=1`; `--rehearse` exits 0 on this branch's lock under uv 0.12.13 + and 2 on the pre-#9444 lock, where the plain `uv lock --check` still exits 0. ## Next -1. `--rehearse` mode in `.github/release_lock.py`. -2. `Rehearse the release version refresh` step in the `ReleaseLock` job of `.github/workflows/pr.yaml`. -3. Unit tests in `.github/tests/test_release_lock.py`, including the real-uv probe. -4. Refresh `.github/release-lock.md` and `docs/spm.md`. -5. Changelog fragment. -6. Local verification with uv 0.12.13, including the failing pre-#9444 lock. -7. Push to `upstream`, open a draft PR. +1. Resolve confirmed review findings. +2. Drop PROGRESS.md, as the previous lanes in this repo did. +3. `git push upstream max/release-lock-rehearsal-20260911`, open the draft PR against `main`. From 94fd43047bca8b8739d91f0bbd17afc26561af52 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 20:26:15 -0400 Subject: [PATCH 6/7] Drop the lane's progress record from the branch Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index 09ef01eed7f..00000000000 --- a/PROGRESS.md +++ /dev/null @@ -1,31 +0,0 @@ -# Release lock rehearsal — progress - -Branch `max/release-lock-rehearsal-20260911`, stacked on `max/release-lock-uv-toolchain-20260911` -(PR #9444, head a6aef60a, still open on 2026-09-11). - -## State - -Implementation, tests, docs and changelog are committed and verified locally. An adversarial -review workflow is running over the five dimensions (correctness, bypass, tests, CI, docs). -Remaining: act on confirmed findings, drop this file, push, open the draft PR. - -## Done - -- `.github/release_lock.py`: `--rehearse`, `bumped_project`, `require_unchanged_checkout`, - `rehearse_release_lock`, CLI wiring (commit 2dc2f0c9). -- `.github/workflows/pr.yaml`: `Rehearse the release version refresh` step in `ReleaseLock` - (commit 7e4dbd78). -- `.github/tests/test_release_lock.py`: `RehearsalTests`, `RehearsalCommandTests`, and a real-uv - rehearsal probe (commit 2dc2f0c9). -- `.github/release-lock.md` and `docs/spm.md`: pinned-uv regeneration command, `--rehearse` - description, stale spm-calculator paragraphs replaced (commit 409cc58c). -- `changelog.d/release-lock-rehearsal.added.md`. -- Verification transcript in the scratchpad `verification.txt`: 33 unit tests pass with and - without `RELEASE_LOCK_REAL_UV=1`; `--rehearse` exits 0 on this branch's lock under uv 0.12.13 - and 2 on the pre-#9444 lock, where the plain `uv lock --check` still exits 0. - -## Next - -1. Resolve confirmed review findings. -2. Drop PROGRESS.md, as the previous lanes in this repo did. -3. `git push upstream max/release-lock-rehearsal-20260911`, open the draft PR against `main`. From 874e5699a97246fae9854af3bb768b9b0fa69997 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 20:39:07 -0400 Subject: [PATCH 7/7] Reject dynamic metadata, test the rehearsal's failure path, and pin the documented uv Review nits on the rehearsal: the two-file copy is only valid while the project declares static metadata, so the project validator now rejects a `dynamic` key; the command's failure path (exit 2 with the hint) has a test; the documented rehearsal command now actually puts the pinned uv first on PATH instead of printing its version beside an unpinned run; and the fixture README no longer calls the production lock pending. Co-Authored-By: Claude Fable 5.1 --- .github/release-lock.md | 7 ++++--- .github/release_lock.py | 5 +++++ .github/tests/fixtures/registry/README.md | 2 +- .github/tests/test_release_lock.py | 23 +++++++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/release-lock.md b/.github/release-lock.md index 85148d19728..166a31cdc38 100644 --- a/.github/release-lock.md +++ b/.github/release-lock.md @@ -54,11 +54,12 @@ 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: +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 -uvx --from 'uv==0.12.13' uv --version # the release toolchain -python .github/release_lock.py --rehearse +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 diff --git a/.github/release_lock.py b/.github/release_lock.py index c7aa092dd73..83335d21f94 100644 --- a/.github/release_lock.py +++ b/.github/release_lock.py @@ -48,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): 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 51f5f75f6c4..2e595b9f440 100644 --- a/.github/tests/test_release_lock.py +++ b/.github/tests/test_release_lock.py @@ -246,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" @@ -538,6 +545,22 @@ def test_rehearse_cannot_combine_with_the_other_modes(self): 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):