Merge pull request #234 from storytold/fix/cut-release-minor-bump

cut_release: a minor bump follows stale path versions and versioned lock names
This commit is contained in:
FredApps authored and GitHub committed 2026-11-15 11:24:50 +01:00
commit 78c3c88191
2 files changed
+200 -159

No files matched your search

+20 -3
View File
@@ -104,6 +104,14 @@ def workspace_version(root: Path) -> str:
raise ValueError("Cargo.toml has no [workspace.package] version")
def _stale_path_version(line: str, old: str) -> str | None:
"""The version a path dependency line names, when it is `old` or another patch of its minor."""
m = re.search(r'version\s*=\s*"([^"]+)"', line)
if not m or not SEMVER.match(m[1]):
return None
return m[1] if m[1].split(".")[:2] == old.split(".")[:2] else None
def bump_cargo(root: Path, new: str, members: set[str] | None = None) -> tuple[str, int, int]:
"""Set the workspace version to `new`. Returns (old version, Cargo.toml lines, lock packages).
@@ -126,12 +134,15 @@ def bump_cargo(root: Path, new: str, members: set[str] | None = None) -> tuple[s
# Only the value: a trailing comment stays.
lines[i] = re.sub(rf'^(version\s*=\s*"){re.escape(old)}"', lambda m: f'{m[1]}{new}"', line)
changed += 1
elif "path = " in line and f'version = "{old}"' in line:
lines[i] = line.replace(f'version = "{old}"', f'version = "{new}"')
elif "path = " in line and (stale := _stale_path_version(line, old)):
# An internal dependency can sit at an earlier patch of the old minor (0.21.0 beside a
# 0.21.1 workspace): it matched 0.21.1, and would not match the new version.
lines[i] = line.replace(f'version = "{stale}"', f'version = "{new}"')
changed += 1
_write(root / "Cargo.toml", "\n".join(lines), crlf)
lock_path, packages = root / "Cargo.lock", 0
bumped: set[str] = set()
if lock_path.exists():
lock, lcrlf = _read(lock_path)
blocks = lock.split("\n[[package]]\n")
@@ -147,9 +158,15 @@ def bump_cargo(root: Path, new: str, members: set[str] | None = None) -> tuple[s
if f == f'version = "{old}"':
fields[j] = f'version = "{new}"'
packages += 1
bumped.add(name)
break
blocks[b] = "\n".join(fields)
_write(lock_path, "\n[[package]]\n".join(blocks), lcrlf)
lock = "\n[[package]]\n".join(blocks)
# Where another package of the same name is in the graph (the app's own crate from an older
# git revision), dependency lists name the version too: `"vectorcraft-geom 0.24.1"`.
for name in bumped:
lock = lock.replace(f'\n "{name} {old}",\n', f'\n "{name} {new}",\n')
_write(lock_path, lock, lcrlf)
return old, changed, packages
+180 -156
View File
@@ -1,156 +1,180 @@
"""scripts/cut_release.py: the version bump a release commits, without git or gh."""
import importlib.util
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("cut_release", ROOT / "scripts/cut_release.py")
cut_release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cut_release)
CARGO_TOML = """[workspace]
members = ["crates/*", "apps/demo"]
[workspace.package]
version = "0.18.1"
edition = "2024"
[workspace.dependencies]
demo-geom = { path = "crates/geom", version = "0.18.1" }
demo-io = { path = "crates/io", version = "0.18.1" }
serde = { version = "0.18.1" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "abc" }
"""
CARGO_LOCK = """# This file is automatically @generated by Cargo.
version = 4
[[package]]
name = "demo"
version = "0.18.1"
dependencies = [
"demo-geom",
]
[[package]]
name = "demo-geom"
version = "0.18.1"
[[package]]
name = "serde"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00"
[[package]]
name = "xtask"
version = "0.1.0"
"""
class NextVersion(unittest.TestCase):
def test_minor_and_patch(self):
self.assertEqual(cut_release.next_version("v0.20.0", "minor"), "0.21.0")
self.assertEqual(cut_release.next_version("0.23.1", "minor"), "0.24.0")
self.assertEqual(cut_release.next_version("v0.23.1", "patch"), "0.23.2")
def test_a_prerelease_patch_is_the_release_itself(self):
self.assertEqual(cut_release.next_version("v0.3.0-rc.1", "patch"), "0.3.0")
def test_rejects_what_is_not_a_version(self):
with self.assertRaises(ValueError):
cut_release.next_version("nightly", "minor")
class Versions(unittest.TestCase):
def test_compared_as_numbers_with_prereleases_first(self):
key = cut_release.version_key
self.assertGreater(key("0.10.0"), key("0.9.0"))
self.assertGreater(key("v0.22.0"), key("0.21.0"))
self.assertLess(key("0.21.0-rc.1"), key("0.21.0"))
self.assertEqual(key("0.21.0"), key("v0.21.0"))
class Arguments(unittest.TestCase):
def test_what_could_reach_git_or_gh_as_an_option_is_refused(self):
ok = dict(repo="storytold/pdfcraft", version="0.21.0", commit="5ff567a4a2", workflow="CI", title="PdfCraft")
cut_release.check_args(**ok)
for field, bad in [("repo", "--upload-pack=x"), ("repo", "storytold"), ("version", "--force"),
("version", "0.21"), ("commit", "--output=/tmp/x"), ("commit", "HEAD~1"),
("workflow", "-x"), ("title", "-m evil"), ("title", "two\nlines")]:
with self.assertRaises(SystemExit, msg=f"{field}={bad!r}"):
cut_release.check_args(**{**ok, field: bad})
class Bump(unittest.TestCase):
def repo(self, toml=CARGO_TOML, lock=CARGO_LOCK, crlf=False):
d = Path(tempfile.mkdtemp())
nl = "\r\n" if crlf else "\n"
(d / "Cargo.toml").write_bytes(toml.replace("\n", nl).encode())
(d / "Cargo.lock").write_bytes(lock.replace("\n", nl).encode())
return d
def test_bumps_the_workspace_its_path_dependencies_and_its_own_lock_entries(self):
d = self.repo()
old, lines, packages = cut_release.bump_cargo(d, "0.21.0")
self.assertEqual((old, lines, packages), ("0.18.1", 3, 2))
toml = (d / "Cargo.toml").read_text()
self.assertIn('[workspace.package]\nversion = "0.21.0"', toml)
self.assertIn('demo-geom = { path = "crates/geom", version = "0.21.0" }', toml)
# A registry dependency that happens to share the version is not the workspace's.
self.assertIn('serde = { version = "0.18.1" }', toml)
lock = (d / "Cargo.lock").read_text()
self.assertIn('name = "demo"\nversion = "0.21.0"', lock)
self.assertIn('name = "demo-geom"\nversion = "0.21.0"', lock)
self.assertIn('name = "serde"\nversion = "0.18.1"\nsource', lock)
self.assertIn('name = "xtask"\nversion = "0.1.0"', lock)
self.assertEqual(cut_release.workspace_version(d), "0.21.0")
def test_keeps_crlf_files_crlf(self):
d = self.repo(crlf=True)
cut_release.bump_cargo(d, "0.21.0")
for name in ("Cargo.toml", "Cargo.lock"):
raw = (d / name).read_bytes()
self.assertNotIn(b"\n", raw.replace(b"\r\n", b""), name)
def test_refuses_a_bump_to_the_same_version_or_a_bad_one(self):
d = self.repo()
with self.assertRaises(ValueError):
cut_release.bump_cargo(d, "0.18.1")
with self.assertRaises(ValueError):
cut_release.bump_cargo(d, "0.21")
def test_runs_the_repositorys_own_hook_with_old_and_new(self):
d = self.repo()
(d / "packaging").mkdir()
(d / cut_release.HOOK).write_text("#!/bin/bash\n")
calls = []
old = cut_release.bump(d, "0.21.0", run=lambda cmd, **kw: calls.append((cmd, kw["cwd"])))
self.assertEqual(old, "0.18.1")
self.assertEqual(calls, [(["bash", cut_release.HOOK, "0.18.1", "0.21.0"], d)])
def test_a_comment_after_the_workspace_version_stays(self):
d = self.repo(toml=CARGO_TOML.replace('version = "0.18.1"\nedition', 'version = "0.18.1" # the release\nedition'))
cut_release.bump_cargo(d, "0.21.0")
self.assertIn('version = "0.21.0" # the release', (d / "Cargo.toml").read_text())
self.assertEqual(cut_release.workspace_version(d), "0.21.0")
def test_given_the_members_only_their_lock_entries_change(self):
# A local path dependency outside the workspace keeps its version even when it matches.
d = self.repo()
_, _, packages = cut_release.bump_cargo(d, "0.21.0", members={"demo"})
lock = (d / "Cargo.lock").read_text()
self.assertEqual(packages, 1)
self.assertIn('name = "demo"\nversion = "0.21.0"', lock)
self.assertIn('name = "demo-geom"\nversion = "0.18.1"', lock)
def test_no_hook_no_call(self):
d = self.repo()
calls = []
cut_release.bump(d, "0.21.0", run=lambda *a, **kw: calls.append(a))
self.assertEqual(calls, [])
if __name__ == "__main__":
unittest.main()
"""scripts/cut_release.py: the version bump a release commits, without git or gh."""
import importlib.util
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("cut_release", ROOT / "scripts/cut_release.py")
cut_release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cut_release)
CARGO_TOML = """[workspace]
members = ["crates/*", "apps/demo"]
[workspace.package]
version = "0.18.1"
edition = "2024"
[workspace.dependencies]
demo-geom = { path = "crates/geom", version = "0.18.1" }
demo-io = { path = "crates/io", version = "0.18.1" }
serde = { version = "0.18.1" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "abc" }
"""
CARGO_LOCK = """# This file is automatically @generated by Cargo.
version = 4
[[package]]
name = "demo"
version = "0.18.1"
dependencies = [
"demo-geom",
]
[[package]]
name = "demo-geom"
version = "0.18.1"
[[package]]
name = "serde"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00"
[[package]]
name = "xtask"
version = "0.1.0"
"""
class NextVersion(unittest.TestCase):
def test_minor_and_patch(self):
self.assertEqual(cut_release.next_version("v0.20.0", "minor"), "0.21.0")
self.assertEqual(cut_release.next_version("0.23.1", "minor"), "0.24.0")
self.assertEqual(cut_release.next_version("v0.23.1", "patch"), "0.23.2")
def test_a_prerelease_patch_is_the_release_itself(self):
self.assertEqual(cut_release.next_version("v0.3.0-rc.1", "patch"), "0.3.0")
def test_rejects_what_is_not_a_version(self):
with self.assertRaises(ValueError):
cut_release.next_version("nightly", "minor")
class Versions(unittest.TestCase):
def test_compared_as_numbers_with_prereleases_first(self):
key = cut_release.version_key
self.assertGreater(key("0.10.0"), key("0.9.0"))
self.assertGreater(key("v0.22.0"), key("0.21.0"))
self.assertLess(key("0.21.0-rc.1"), key("0.21.0"))
self.assertEqual(key("0.21.0"), key("v0.21.0"))
class Arguments(unittest.TestCase):
def test_what_could_reach_git_or_gh_as_an_option_is_refused(self):
ok = dict(repo="storytold/pdfcraft", version="0.21.0", commit="5ff567a4a2", workflow="CI", title="PdfCraft")
cut_release.check_args(**ok)
for field, bad in [("repo", "--upload-pack=x"), ("repo", "storytold"), ("version", "--force"),
("version", "0.21"), ("commit", "--output=/tmp/x"), ("commit", "HEAD~1"),
("workflow", "-x"), ("title", "-m evil"), ("title", "two\nlines")]:
with self.assertRaises(SystemExit, msg=f"{field}={bad!r}"):
cut_release.check_args(**{**ok, field: bad})
class Bump(unittest.TestCase):
def repo(self, toml=CARGO_TOML, lock=CARGO_LOCK, crlf=False):
d = Path(tempfile.mkdtemp())
nl = "\r\n" if crlf else "\n"
(d / "Cargo.toml").write_bytes(toml.replace("\n", nl).encode())
(d / "Cargo.lock").write_bytes(lock.replace("\n", nl).encode())
return d
def test_bumps_the_workspace_its_path_dependencies_and_its_own_lock_entries(self):
d = self.repo()
old, lines, packages = cut_release.bump_cargo(d, "0.21.0")
self.assertEqual((old, lines, packages), ("0.18.1", 3, 2))
toml = (d / "Cargo.toml").read_text()
self.assertIn('[workspace.package]\nversion = "0.21.0"', toml)
self.assertIn('demo-geom = { path = "crates/geom", version = "0.21.0" }', toml)
# A registry dependency that happens to share the version is not the workspace's.
self.assertIn('serde = { version = "0.18.1" }', toml)
lock = (d / "Cargo.lock").read_text()
self.assertIn('name = "demo"\nversion = "0.21.0"', lock)
self.assertIn('name = "demo-geom"\nversion = "0.21.0"', lock)
self.assertIn('name = "serde"\nversion = "0.18.1"\nsource', lock)
self.assertIn('name = "xtask"\nversion = "0.1.0"', lock)
self.assertEqual(cut_release.workspace_version(d), "0.21.0")
def test_keeps_crlf_files_crlf(self):
d = self.repo(crlf=True)
cut_release.bump_cargo(d, "0.21.0")
for name in ("Cargo.toml", "Cargo.lock"):
raw = (d / name).read_bytes()
self.assertNotIn(b"\n", raw.replace(b"\r\n", b""), name)
def test_refuses_a_bump_to_the_same_version_or_a_bad_one(self):
d = self.repo()
with self.assertRaises(ValueError):
cut_release.bump_cargo(d, "0.18.1")
with self.assertRaises(ValueError):
cut_release.bump_cargo(d, "0.21")
def test_runs_the_repositorys_own_hook_with_old_and_new(self):
d = self.repo()
(d / "packaging").mkdir()
(d / cut_release.HOOK).write_text("#!/bin/bash\n")
calls = []
old = cut_release.bump(d, "0.21.0", run=lambda cmd, **kw: calls.append((cmd, kw["cwd"])))
self.assertEqual(old, "0.18.1")
self.assertEqual(calls, [(["bash", cut_release.HOOK, "0.18.1", "0.21.0"], d)])
def test_a_comment_after_the_workspace_version_stays(self):
d = self.repo(toml=CARGO_TOML.replace('version = "0.18.1"\nedition', 'version = "0.18.1" # the release\nedition'))
cut_release.bump_cargo(d, "0.21.0")
self.assertIn('version = "0.21.0" # the release', (d / "Cargo.toml").read_text())
self.assertEqual(cut_release.workspace_version(d), "0.21.0")
def test_given_the_members_only_their_lock_entries_change(self):
# A local path dependency outside the workspace keeps its version even when it matches.
d = self.repo()
_, _, packages = cut_release.bump_cargo(d, "0.21.0", members={"demo"})
lock = (d / "Cargo.lock").read_text()
self.assertEqual(packages, 1)
self.assertIn('name = "demo"\nversion = "0.21.0"', lock)
self.assertIn('name = "demo-geom"\nversion = "0.18.1"', lock)
def test_a_path_dependency_at_an_earlier_patch_of_the_old_minor_is_bumped_too(self):
# pdfcraft at 0.21.1 named its crates at 0.21.0: that matched 0.21.1, and not 0.22.0.
d = self.repo(toml=CARGO_TOML.replace('demo-io = { path = "crates/io", version = "0.18.1" }',
'demo-io = { path = "crates/io", version = "0.18.0" }\n'
'demo-old = { path = "crates/old", version = "0.17.0" }'))
_, lines, _ = cut_release.bump_cargo(d, "0.19.0")
toml = (d / "Cargo.toml").read_text()
self.assertEqual(lines, 3)
self.assertIn('demo-io = { path = "crates/io", version = "0.19.0" }', toml)
self.assertIn('demo-old = { path = "crates/old", version = "0.17.0" }', toml)
def test_lock_dependency_lists_that_name_the_version_follow_the_bump(self):
# vectorcraft also has its own crate from an older git revision in the graph, so Cargo
# writes `"demo-geom 0.18.1"` in dependency lists.
lock = CARGO_LOCK.replace(' "demo-geom",\n', ' "demo-geom 0.18.1",\n') + (
'\n[[package]]\nname = "demo-geom"\nversion = "0.12.0"\nsource = "git+https://example.invalid/demo?rev=abc#abc"\n'
'\n[[package]]\nname = "other"\nversion = "0.3.0"\ndependencies = [\n "demo-geom 0.12.0",\n]\n')
d = self.repo(lock=lock)
cut_release.bump_cargo(d, "0.21.0")
lock = (d / "Cargo.lock").read_text()
self.assertIn(' "demo-geom 0.21.0",\n', lock)
self.assertIn(' "demo-geom 0.12.0",\n', lock)
self.assertNotIn("0.18.1\",", lock)
def test_no_hook_no_call(self):
d = self.repo()
calls = []
cut_release.bump(d, "0.21.0", run=lambda *a, **kw: calls.append(a))
self.assertEqual(calls, [])
if __name__ == "__main__":
unittest.main()