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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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}")

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

Expand Down
47 changes: 45 additions & 2 deletions tests/test_bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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"
)
Expand All @@ -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

Loading