diff --git a/.github/bump_version.py b/.github/bump_version.py index 779a82e385..b6bbe44ec5 100644 --- a/.github/bump_version.py +++ b/.github/bump_version.py @@ -2,6 +2,7 @@ import re import sys +import tomllib from pathlib import Path @@ -59,6 +60,46 @@ def update_file(path: Path, old_version: str, new_version: str): print(f" Updated {path}") +def get_project_name(pyproject_path: Path) -> str: + """The project name, normalised the way uv writes it in uv.lock.""" + name = tomllib.loads(pyproject_path.read_text())["project"]["name"] + return re.sub(r"[-_.]+", "-", name).lower() + + +def set_lock_version(text: str, package: str, new_version: str) -> str: + """Set the version of the project's own entry in uv.lock text. + + CI installs with `uv sync --locked`, which refuses a lock whose entry for + the project disagrees with pyproject.toml, so the two must move together. + Only that entry changes: a dependency can share the old version string. + The entry must be the editable project itself, as uv writes it. + """ + pattern = re.compile( + rf'^(\[\[package\]\]\nname = "{re.escape(package)}"\nversion = ")[^"\n]*(")$' + r'(?=\nsource = \{ editable = "\." \}$)', + re.MULTILINE, + ) + updated, count = pattern.subn(rf"\g<1>{new_version}\g<2>", text) + if count != 1: + raise ValueError(f"Expected one {package} entry in uv.lock, found {count}") + return updated + + +def update_lock(path: Path, package: str, new_version: str): + if not path.exists(): + print(f"{path} not found; it must move with pyproject.toml", file=sys.stderr) + sys.exit(1) + text = path.read_text() + try: + updated = set_lock_version(text, package, new_version) + except ValueError as error: + print(error, file=sys.stderr) + sys.exit(1) + if updated != text: + path.write_text(updated) + print(f" Updated {path}") + + def main(): root = Path(__file__).resolve().parent.parent pyproject = root / "pyproject.toml" @@ -70,6 +111,9 @@ def main(): print(f"Version: {current} -> {new} ({bump})") + # The lock first: if it is missing or has no entry for this package, stop + # before pyproject.toml changes. + update_lock(root / "uv.lock", get_project_name(pyproject), new) update_file(pyproject, current, new) diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml index 5e4d5be1d9..6d8073790f 100644 --- a/.github/workflows/versioning.yaml +++ b/.github/workflows/versioning.yaml @@ -42,6 +42,11 @@ jobs: run: | python .github/bump_version.py towncrier build --yes --version $(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") + # bump_version.py moves uv.lock's entry for this package with + # pyproject.toml. Refuse to commit a lock that `uv sync --locked` would + # reject, since that would fail every later pull request. + - name: Check uv.lock matches pyproject.toml + run: uv lock --check - name: Update changelog id: commit uses: EndBug/add-and-commit@v10 diff --git a/changelog.d/uv-lock-version-in-step.fixed.md b/changelog.d/uv-lock-version-in-step.fixed.md new file mode 100644 index 0000000000..514e00b6e9 --- /dev/null +++ b/changelog.d/uv-lock-version-in-step.fixed.md @@ -0,0 +1 @@ +- The release version bump now updates uv.lock's entry for policyengine-uk along with pyproject.toml, and checks the lock before committing it. diff --git a/policyengine_uk/tests/code_health/test_version_bump.py b/policyengine_uk/tests/code_health/test_version_bump.py new file mode 100644 index 0000000000..3a2a8878c5 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_version_bump.py @@ -0,0 +1,176 @@ +"""The version bump must keep uv.lock's entry for this package in step. + +CI installs with ``uv sync --locked``, which fails when uv.lock disagrees with +pyproject.toml. The lock records this package's own version, so a release that +bumps pyproject.toml without the lock would fail every later pull request. +.github/bump_version.py therefore rewrites that one entry. These tests check +that the rewrite changes the package's own version and nothing else, including +dependencies that happen to share the old version string, both on the real +uv.lock and on generated ones. + +They read files from the repository checkout, so they skip when the package is +installed without one. +""" + +import importlib.util +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / ".github" / "bump_version.py" +LOCK = ROOT / "uv.lock" +PYPROJECT = ROOT / "pyproject.toml" + +if not (SCRIPT.exists() and LOCK.exists() and PYPROJECT.exists()): + pytest.skip("needs the repository checkout", allow_module_level=True) + +spec = importlib.util.spec_from_file_location("bump_version", SCRIPT) +bump_version = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bump_version) + +PACKAGE = bump_version.get_project_name(PYPROJECT) + +versions = st.tuples(*[st.integers(0, 999)] * 3).map(lambda v: ".".join(map(str, v))) +# Names that share a prefix with this package, plus arbitrary ones. +other_names = st.one_of( + st.sampled_from([f"{PACKAGE}-data", f"{PACKAGE}2", "policyengine-core"]), + st.text("abcdefghijklmnopqrstuvwxyz0123456789-", min_size=1, max_size=12), +).filter(lambda name: name != PACKAGE) + + +def lock_versions(text: str) -> dict: + return { + package["name"]: package.get("version") + for package in tomllib.loads(text)["package"] + } + + +def make_lock(entries) -> str: + blocks = ['version = 1\nrevision = 3\nrequires-python = ">=3.11"\n'] + for name, version, editable in entries: + source = ( + '{ editable = "." }' + if editable + else '{ registry = "https://pypi.org/simple" }' + ) + blocks.append( + f'[[package]]\nname = "{name}"\nversion = "{version}"\nsource = {source}\n' + ) + return "\n".join(blocks) + + +def test_rewriting_the_real_lock_changes_only_this_package(): + text = LOCK.read_text() + updated = bump_version.set_lock_version(text, PACKAGE, "9999.0.0") + before, after = lock_versions(text), lock_versions(updated) + assert after[PACKAGE] == "9999.0.0" + assert {k: v for k, v in after.items() if k != PACKAGE} == { + k: v for k, v in before.items() if k != PACKAGE + } + changed = [ + (old, new) + for old, new in zip(text.splitlines(), updated.splitlines()) + if old != new + ] + assert len(changed) == 1 + assert len(text.splitlines()) == len(updated.splitlines()) + + +def test_a_dependency_sharing_the_old_version_is_left_alone(): + text = make_lock( + [ + ("numpy", "2.113.0", False), + (PACKAGE, "2.113.0", True), + ("pandas", "2.113.0", False), + ] + ) + updated = bump_version.set_lock_version(text, PACKAGE, "2.113.1") + assert lock_versions(updated) == { + "numpy": "2.113.0", + PACKAGE: "2.113.1", + "pandas": "2.113.0", + } + + +@pytest.mark.parametrize("copies", [0, 2]) +def test_a_missing_or_repeated_entry_is_an_error(copies): + text = make_lock([("numpy", "2.1.3", False)] + [(PACKAGE, "1.0.0", True)] * copies) + with pytest.raises(ValueError): + bump_version.set_lock_version(text, PACKAGE, "1.0.1") + + +# Generating unique names with a filter can be slow on a loaded runner; that +# says nothing about the rewrite, so don't fail on it. +@settings(suppress_health_check=[HealthCheck.too_slow], deadline=None) +@given( + st.lists(st.tuples(other_names, versions), max_size=6, unique_by=lambda e: e[0]), + st.integers(0, 6), + versions, + versions, +) +def test_rewrite_sets_only_this_package_and_is_idempotent( + others, position, old_version, new_version +): + entries = [(name, version, False) for name, version in others] + entries.insert(min(position, len(entries)), (PACKAGE, old_version, True)) + text = make_lock(entries) + + updated = bump_version.set_lock_version(text, PACKAGE, new_version) + + expected = dict(lock_versions(text), **{PACKAGE: new_version}) + assert lock_versions(updated) == expected + assert bump_version.set_lock_version(updated, PACKAGE, new_version) == updated + assert bump_version.set_lock_version(updated, PACKAGE, old_version) == text + + +def run_bump(tmp_path, lock_text): + """Run the real script on a scratch repository with a patch fragment.""" + (tmp_path / ".github").mkdir() + script = tmp_path / ".github" / "bump_version.py" + shutil.copy(SCRIPT, script) + (tmp_path / "pyproject.toml").write_text( + f'[project]\nname = "{PACKAGE}"\nversion = "1.2.3"\n' + ) + (tmp_path / "changelog.d").mkdir() + (tmp_path / "changelog.d" / "change.fixed.md").write_text("- A fix.\n") + if lock_text is not None: + (tmp_path / "uv.lock").write_text(lock_text) + return subprocess.run([sys.executable, str(script)], capture_output=True, text=True) + + +def test_main_moves_pyproject_and_lock_together(tmp_path): + # The lock's own entry starts stale, as on main before this change. + lock = make_lock([("numpy", "1.2.3", False), (PACKAGE, "1.2.0", True)]) + result = run_bump(tmp_path, lock) + assert result.returncode == 0, result.stderr + assert 'version = "1.2.4"' in (tmp_path / "pyproject.toml").read_text() + assert lock_versions((tmp_path / "uv.lock").read_text()) == { + "numpy": "1.2.3", + PACKAGE: "1.2.4", + } + + +@pytest.mark.parametrize( + "lock_text", + [None, make_lock([("numpy", "1.2.3", False)])], + ids=["no-lock", "no-entry"], +) +def test_main_stops_before_changing_anything(tmp_path, lock_text): + result = run_bump(tmp_path, lock_text) + assert result.returncode != 0 + assert 'version = "1.2.3"' in (tmp_path / "pyproject.toml").read_text() + if lock_text is not None: + assert (tmp_path / "uv.lock").read_text() == lock_text + + +def test_a_registry_entry_with_this_name_is_not_rewritten(): + text = make_lock([(PACKAGE, "1.0.0", False)]) + with pytest.raises(ValueError): + bump_version.set_lock_version(text, PACKAGE, "1.0.1")