diff --git a/scripts/cut_release.py b/scripts/cut_release.py index dddc31c..e465463 100644 --- a/scripts/cut_release.py +++ b/scripts/cut_release.py @@ -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 diff --git a/tests/test_cut_release.py b/tests/test_cut_release.py index a449c8d..af3decc 100644 --- a/tests/test_cut_release.py +++ b/tests/test_cut_release.py @@ -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()