Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions .github/release-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
78 changes: 76 additions & 2 deletions .github/release_lock.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -13,6 +13,7 @@
import re
import subprocess
import sys
import tempfile
import tomllib
from urllib.parse import unquote, urlsplit

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/tests/fixtures/registry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading
Loading