diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e22567..b1969d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ what has already shipped, and for nothing else. See `RELEASING.md`. ## [Unreleased] +### Fixed + +- **`bump_version.py` left `uv.lock` behind.** `pyproject.toml` holds the version statically, so the + lock records it too, and the bump moved only the other two files: on 2026-10-17 Quality refused the + 2026.47 bump with "The lockfile at `uv.lock` needs to be updated", and the lock was moved by hand on + its branch. The bump now moves the project's own entry, and nothing that only shares the number, and + commits it; a test holds the lock to the version `pyproject.toml` states, so the suite the bump runs + refuses one that falls behind. + ## [2026.47] — 2026-10-17 ### Added diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 23b946f..d5a8a10 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -107,6 +107,29 @@ def update_init(old: str, new: str) -> None: print(f"✅ __init__.py: {old} → {new}") +LOCK = PYPROJECT.parent / "uv.lock" + +# What the bump commit carries. uv.lock is there because pyproject.toml holds the +# version statically, so the lock records it as well: on 2026-10-17 the first bump +# since uv.lock arrived, 2026.47, moved the other two, and Quality refused it. +BUMPED_FILES = ["pyproject.toml", str(INIT.relative_to(PYPROJECT.parent)), "uv.lock"] + + +def update_lock(old: str, new: str, path: Path = LOCK) -> None: + """The project's own entry in uv.lock, and nothing else: a dependency can + carry the same number. A tree without a lock has nothing to move.""" + if not path.is_file(): + return + with path.open(encoding="utf-8", newline="") as f: + content = f.read() + entry = re.compile(r'(name = "patchradar"\r?\nversion = ")' + re.escape(old) + '"') + updated, count = entry.subn(lambda m: f'{m.group(1)}{new}"', content, count=1) + if count: + with path.open("w", encoding="utf-8", newline="") as f: + f.write(updated) + print(f"✅ uv.lock: {old} → {new}") + + # What the gate runs. `sys.executable` rather than "python3": whoever releases is # in a virtual environment, and that is the interpreter that has the suite. SUITE_COMMAND = [sys.executable, "-m", "pytest", "-q"] @@ -149,7 +172,7 @@ def git_commit(version: str, files: list[str] | None = None, cwd: Path | None = the ruleset lost its bypass. The bump travels like any other change. """ subprocess.run(["git", "switch", "-c", release_branch(version)], check=True, cwd=cwd) - subprocess.run(["git", "add", *(files or ["pyproject.toml", str(INIT)])], check=True, cwd=cwd) + subprocess.run(["git", "add", *(files or BUMPED_FILES)], check=True, cwd=cwd) subprocess.run(["git", "commit", "-m", f"chore: bump version to {version}"], check=True, cwd=cwd) print(f"✅ Git commit on {release_branch(version)}: chore: bump version to {version}") @@ -228,12 +251,13 @@ def main(): update_pyproject(current, new) update_init(current, new) + update_lock(current, new) if not check_suite(): print( f"\n❌ The suite fails with {new} in place. Nothing was committed:\n" - f" pyproject.toml and the package __init__ are left modified so you\n" - f" can see what broke. `git checkout` on both undoes the bump." + f" pyproject.toml, the package __init__ and uv.lock are left modified\n" + f" so you can see what broke. `git checkout` on them undoes the bump." ) sys.exit(1) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index 72e4283..458dfd6 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -188,6 +188,7 @@ def answer(prompt: str) -> str: monkeypatch.setattr(bump, "get_current_version", lambda: "2026.43") monkeypatch.setattr(bump, "update_pyproject", lambda *a: calls.append("pyproject")) monkeypatch.setattr(bump, "update_init", lambda *a: calls.append("init")) + monkeypatch.setattr(bump, "update_lock", lambda *a: calls.append("lock")) monkeypatch.setattr(bump, "git_commit", lambda *a: calls.append("commit")) monkeypatch.setattr(bump, "check_suite", lambda *a, **k: suite_passes) monkeypatch.setattr(sys, "argv", ["bump_version.py"]) @@ -205,7 +206,7 @@ def test_main_writes_the_version_and_then_refuses_to_commit(self, monkeypatch): assert exit_code.value.code == 1 assert "commit" not in calls, "it committed a version its suite refuses" - assert calls == ["pyproject", "init"], ( + assert calls == ["pyproject", "init", "lock"], ( "the version has to be written before the suite runs, or the suite " "cannot see what the bump breaks" ) @@ -215,9 +216,51 @@ def test_main_commits_when_the_suite_passes(self, monkeypatch): bump.main() - assert calls == ["pyproject", "init", "commit"] + assert calls == ["pyproject", "init", "lock", "commit"] def test_the_default_command_is_this_project_suite(self): """Read off the module rather than written here, so renaming it fails.""" assert bump.SUITE_COMMAND[:2] == [sys.executable, "-m"] assert "pytest" in bump.SUITE_COMMAND + + +class TestLock: + """pyproject.toml holds the version statically, so uv.lock records it too. + + The bump moved pyproject.toml and __init__.py and left the lock behind: on + 2026-10-17 the first bump since uv.lock arrived, 2026.47, was refused by + Quality with "The lockfile at `uv.lock` needs to be updated". + """ + + LOCK = ( + 'version = 1\n' + '[[package]]\nname = "other"\nversion = "2026.46"\n\n' + '[[package]]\nname = "patchradar"\nversion = "2026.46"\nsource = { editable = "." }\n' + ) + + def test_the_project_s_own_entry_moves(self, tmp_path): + lock = tmp_path / "uv.lock" + lock.write_text(self.LOCK, encoding="utf-8", newline="\n") + + bump.update_lock("2026.46", "2026.47", lock) + + text = lock.read_text(encoding="utf-8") + assert 'name = "patchradar"\nversion = "2026.47"' in text + assert 'name = "other"\nversion = "2026.46"' in text, "a dependency on the same number moved" + + def test_without_a_lock_there_is_nothing_to_move(self, tmp_path): + bump.update_lock("2026.46", "2026.47", tmp_path / "uv.lock") + + assert not (tmp_path / "uv.lock").exists() + + def test_the_lock_is_committed_with_the_bump(self): + assert "uv.lock" in bump.BUMPED_FILES + + def test_the_lock_carries_the_version_pyproject_states(self): + """Here as well as in Quality, so the gate in bump_version.py, which runs + the suite after writing the version, refuses a bump that leaves it behind.""" + root = Path(__file__).resolve().parents[1] + lock = (root / "uv.lock").read_text(encoding="utf-8").replace("\r\n", "\n") + + assert f'name = "patchradar"\nversion = "{bump.get_current_version()}"' in lock +