From 9b2f561ab82a24458ebb869e199fa6818c249fa2 Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 4 Sep 2026 23:25:21 -0400 Subject: [PATCH 1/4] Apply version-files to Rust projects RustConfig gains version_files, read from [tool.fastship].version-files by the same rule as ShipConfig: each listed file must contain the old version exactly once, every file is checked before any is written, and the copies are rewritten after Cargo.toml. The read-and-check and write halves are shared helpers now, used by the Python flow as before. A bare crate has no pyproject to declare copies in, so CrateConfig carries an empty list. Claude-Session: https://claude.ai/code/session_01KCKXyYZu5R6fnPbTx3pMmo --- fastship/release.py | 44 +++++++++++++++++++++++++++++++------------- tests/test_rs.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/fastship/release.py b/fastship/release.py index c5caf6d..90b1bd2 100644 --- a/fastship/release.py +++ b/fastship/release.py @@ -11,7 +11,7 @@ "ship_rs_new", "ship_rs_init", "ship_rs_build", "ship_rs_bump", "ship_zig_new", "ship_zig_build"] import os, re, sys, json, shutil, subprocess, ast, importlib.resources, shlex -from dataclasses import dataclass +from dataclasses import dataclass, field try: import tomllib except ImportError: import tomli as tomllib # pragma: no cover @@ -200,6 +200,7 @@ class RustConfig: branch: str changelog_file: Path label_groups: dict + version_files: list[Path] @property def version(self) -> str: return _cargo_version(self.manifest_path) @@ -221,13 +222,9 @@ def get_config(start: str | Path | None = None) -> ShipConfig: branch = ship.get("branch") or os.getenv("FASTSHIP_BRANCH") or _git_branch() label_groups = _load_release_yml(root) or ship.get("label_groups") or DEFAULT_LABEL_GROUPS wheel_only = ship.get("wheel-only", False) - version_files = ship.get("version-files") or [] - if not isinstance(version_files, list) or not all(isinstance(o, str) for o in version_files): - raise ValueError("[tool.fastship].version-files must be a list of paths") - return ShipConfig(root=root, pyproject=pyproj, data=data, pkg=pkg, pkg_path=pkg_path, init_file=init_file, changelog_file=changelog_file, branch=branch, label_groups=label_groups, - wheel_only=wheel_only, version_files=[root/o for o in version_files]) + wheel_only=wheel_only, version_files=_version_files(root, ship)) def get_rs_config(start: str | Path | None = None) -> RustConfig: @@ -246,7 +243,15 @@ def get_rs_config(start: str | Path | None = None) -> RustConfig: changelog_file = root / ship.get("changelog_file", "CHANGELOG.md") label_groups = _load_release_yml(root) or ship.get("label_groups") or DEFAULT_LABEL_GROUPS return RustConfig(root=root, pyproject=pyproj, data=data, manifest_path=manifest_path, branch=branch, - changelog_file=changelog_file, label_groups=label_groups) + changelog_file=changelog_file, label_groups=label_groups, version_files=_version_files(root, ship)) + + +def _version_files(root:Path, ship:dict) -> list[Path]: + "Synchronized version copies from `[tool.fastship].version-files`, resolved against `root`." + files = ship.get("version-files") or [] + if not isinstance(files, list) or not all(isinstance(o, str) for o in files): + raise ValueError("[tool.fastship].version-files must be a list of paths") + return [root/o for o in files] def _project_type(root:Path, data:dict)->str: @@ -292,6 +297,7 @@ class CrateConfig: manifest_path: Path name: str branch: str + version_files: list[Path] = field(default_factory=list) @property def version(self) -> str: return _cargo_version(self.manifest_path) @@ -308,8 +314,10 @@ def get_crate_config(start: str | Path | None = None) -> CrateConfig: def _cargo_bump(cfg, part:int = None, unbump:bool = False): "Bump `[package].version` in Cargo.toml, printing old and new." print(f"Old version: {cfg.version}") - new = bump_version(cfg.version, part=part, unbump=unbump) + old, new = cfg.version, bump_version(cfg.version, part=part, unbump=unbump) + copies = _read_copies(cfg.version_files, old) _replace_toml_section_key(cfg.manifest_path, "package", "version", new) + _write_copies(copies, old, new) print(f"New version: {new}") return new @@ -371,15 +379,25 @@ def _write_version(init_file: Path, version: str): def _write_config_version(cfg:ShipConfig, version:str): "Write the version back to the source used by this project." old = cfg.version + copies = _read_copies(cfg.version_files, old) + if (_load_toml(cfg.pyproject).get("project") or {}).get("version") is not None: + _replace_toml_section_key(cfg.pyproject, "project", "version", version) + else: _write_version(cfg.init_file, version) + _write_copies(copies, old, version) + + +def _read_copies(paths:list[Path], old:str) -> dict[Path,str]: + "Contents of each version copy, checked to hold `old` exactly once before anything is written." copies = {} - for path in cfg.version_files: + for path in paths: text = path.read_text(encoding="utf-8") if text.count(old) != 1: raise ValueError(f"Expected exactly one {old!r} in {path}") copies[path] = text - if (_load_toml(cfg.pyproject).get("project") or {}).get("version") is not None: - _replace_toml_section_key(cfg.pyproject, "project", "version", version) - else: _write_version(cfg.init_file, version) - for path, text in copies.items(): path.write_text(text.replace(old, version), encoding="utf-8") + return copies + + +def _write_copies(copies:dict[Path,str], old:str, new:str): + for path, text in copies.items(): path.write_text(text.replace(old, new), encoding="utf-8") def bump_version(version: str, part: int = None, unbump: bool = False) -> str: diff --git a/tests/test_rs.py b/tests/test_rs.py index 0768afe..44cbf48 100644 --- a/tests/test_rs.py +++ b/tests/test_rs.py @@ -66,6 +66,36 @@ def test_ship_bump_uses_static_project_version_when_present(tmp_path, monkeypatc assert '__version__ = "0.1.2"' in (pkg / "__init__.py").read_text(encoding="utf-8") +def test_rs_version_files_bump_with_cargo(tmp_path, monkeypatch): + _make_rs_project(tmp_path) + (tmp_path / "pyproject.toml").write_text( + (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + 'version-files = ["wasm/package.json"]\n', encoding="utf-8") + (tmp_path / "wasm").mkdir() + (tmp_path / "wasm" / "package.json").write_text('{\n "name": "@acme/exhash",\n "version": "0.1.2"\n}\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) + + relmod.ship_bump(part=2) + + assert 'version = "0.1.3"' in (tmp_path / "Cargo.toml").read_text(encoding="utf-8") + assert '"version": "0.1.3"' in (tmp_path / "wasm" / "package.json").read_text(encoding="utf-8") + + +def test_rs_version_files_refuse_stale_copy(tmp_path, monkeypatch): + _make_rs_project(tmp_path) + (tmp_path / "pyproject.toml").write_text( + (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + 'version-files = ["wasm/package.json"]\n', encoding="utf-8") + (tmp_path / "wasm").mkdir() + (tmp_path / "wasm" / "package.json").write_text('{"version": "0.0.9"}\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) + + import pytest + with pytest.raises(ValueError, match="exactly one"): + relmod.ship_bump(part=2) + assert 'version = "0.1.2"' in (tmp_path / "Cargo.toml").read_text(encoding="utf-8") # nothing written + + def test_ship_tag_release_needs_no_token_or_flags(tmp_path, monkeypatch): # The rust flow is pure tag-push: no changelog machinery, no GitHub client, no prompts. _make_rs_project(tmp_path) From 4f94f8c79b14d9732b7d5bdaa0770fee8993145c Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 4 Sep 2026 23:25:57 -0400 Subject: [PATCH 2/4] Read and bump workspace-inherited Cargo versions When [package] has version.workspace = true, the version lives in [workspace.package]. _cargo_version_section names the section that holds it, and both the read and the bump go through it, so a workspace whose crates inherit one version is released like a single crate. A single crate with a literal [package].version is unchanged. The README records that, and that version-files applies to Rust projects. Claude-Session: https://claude.ai/code/session_01KCKXyYZu5R6fnPbTx3pMmo --- README.md | 2 ++ fastship/release.py | 17 ++++++++++++----- tests/test_rs.py | 27 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index eaff23e..f7a7838 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ name = "my_project" dynamic = ["version"] ``` +A Cargo workspace whose crates inherit `version.workspace = true` is read and bumped through `[workspace.package]`, and `[tool.fastship].version-files` lists synchronized copies of the version (an npm `package.json` beside a wasm crate, say) that `ship-bump` rewrites with the same exactly-once check as Python projects. + Commands: ```bash diff --git a/fastship/release.py b/fastship/release.py index 90b1bd2..1cff62b 100644 --- a/fastship/release.py +++ b/fastship/release.py @@ -312,11 +312,11 @@ def get_crate_config(start: str | Path | None = None) -> CrateConfig: def _cargo_bump(cfg, part:int = None, unbump:bool = False): - "Bump `[package].version` in Cargo.toml, printing old and new." + "Bump the version in Cargo.toml (`[package]`, or `[workspace.package]` when inherited), printing old and new." print(f"Old version: {cfg.version}") old, new = cfg.version, bump_version(cfg.version, part=part, unbump=unbump) copies = _read_copies(cfg.version_files, old) - _replace_toml_section_key(cfg.manifest_path, "package", "version", new) + _replace_toml_section_key(cfg.manifest_path, _cargo_version_section(cfg.manifest_path), "version", new) _write_copies(copies, old, new) print(f"New version: {new}") return new @@ -452,10 +452,17 @@ def _is_maturin_project(data:dict) -> bool: return "maturin" in build_backend or bool(nested_idx(data, "tool", "maturin")) -def _cargo_version(manifest:Path) -> str: - "Read `[package].version` from Cargo.toml." +def _cargo_version_section(manifest:Path) -> str: + "The Cargo.toml section holding the version: `package`, or `workspace.package` when the package inherits it." ver = (_load_toml(manifest).get("package") or {}).get("version") - if not ver: raise ValueError(f"Could not find [package].version in {manifest}") + return "package" if isinstance(ver, str) else "workspace.package" + + +def _cargo_version(manifest:Path) -> str: + "Read the version from Cargo.toml, following `version.workspace = true` to `[workspace.package]`." + sec = _cargo_version_section(manifest) + ver = nested_idx(_load_toml(manifest), *sec.split("."), "version") + if not isinstance(ver, str) or not ver: raise ValueError(f"Could not find [{sec}].version in {manifest}") return ver diff --git a/tests/test_rs.py b/tests/test_rs.py index 44cbf48..c80e68a 100644 --- a/tests/test_rs.py +++ b/tests/test_rs.py @@ -96,6 +96,33 @@ def test_rs_version_files_refuse_stale_copy(tmp_path, monkeypatch): assert 'version = "0.1.2"' in (tmp_path / "Cargo.toml").read_text(encoding="utf-8") # nothing written +_ws_cargo = """[workspace] +members = ["py"] + +[workspace.package] +version = "0.1.2" + +[package] +name = "exhash" +version.workspace = true +edition = "2024" +""" + + +def test_workspace_version_read_and_bump(tmp_path, monkeypatch): + _make_rs_project(tmp_path) + (tmp_path / "Cargo.toml").write_text(_ws_cargo, encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) + + assert relmod.get_rs_config(tmp_path).version == "0.1.2" + relmod.ship_bump(part=2) + + cargo = (tmp_path / "Cargo.toml").read_text(encoding="utf-8") + assert '[workspace.package]\nversion = "0.1.3"' in cargo + assert "version.workspace = true" in cargo # [package] untouched + + def test_ship_tag_release_needs_no_token_or_flags(tmp_path, monkeypatch): # The rust flow is pure tag-push: no changelog machinery, no GitHub client, no prompts. _make_rs_project(tmp_path) From 52bd001d066abed066fbcc2ef4d3b35bb3b7dde8 Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 4 Sep 2026 23:35:24 -0400 Subject: [PATCH 3/4] Report [package].version for a single crate that lacks one `_cargo_version_section` now picks `[workspace.package]` only when the crate inherits its version or the manifest declares a workspace package, so a plain crate missing its version gets the same error text as before. Also: read `cfg.version` once in `_cargo_bump`, docstrings on `_write_copies` and `ship_rs_bump`, a shared version-file setup helper in the tests with a `[package]`-first workspace case, and a plainer README. Claude-Session: https://claude.ai/code/session_01KCKXyYZu5R6fnPbTx3pMmo --- README.md | 2 +- fastship/release.py | 14 +++++++++----- tests/test_rs.py | 44 +++++++++++++++++++++++++------------------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f7a7838..9ee2b79 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ name = "my_project" dynamic = ["version"] ``` -A Cargo workspace whose crates inherit `version.workspace = true` is read and bumped through `[workspace.package]`, and `[tool.fastship].version-files` lists synchronized copies of the version (an npm `package.json` beside a wasm crate, say) that `ship-bump` rewrites with the same exactly-once check as Python projects. +A Cargo workspace whose crates inherit `version.workspace = true` is read and bumped through `[workspace.package]`. `[tool.fastship].version-files` lists synchronized copies of the version, such as an npm `package.json` beside a wasm crate, and `ship-bump` rewrites them with the same exactly-once check as Python projects. Commands: diff --git a/fastship/release.py b/fastship/release.py index 1cff62b..82e3798 100644 --- a/fastship/release.py +++ b/fastship/release.py @@ -313,8 +313,9 @@ def get_crate_config(start: str | Path | None = None) -> CrateConfig: def _cargo_bump(cfg, part:int = None, unbump:bool = False): "Bump the version in Cargo.toml (`[package]`, or `[workspace.package]` when inherited), printing old and new." - print(f"Old version: {cfg.version}") - old, new = cfg.version, bump_version(cfg.version, part=part, unbump=unbump) + old = cfg.version + print(f"Old version: {old}") + new = bump_version(old, part=part, unbump=unbump) copies = _read_copies(cfg.version_files, old) _replace_toml_section_key(cfg.manifest_path, _cargo_version_section(cfg.manifest_path), "version", new) _write_copies(copies, old, new) @@ -397,6 +398,7 @@ def _read_copies(paths:list[Path], old:str) -> dict[Path,str]: def _write_copies(copies:dict[Path,str], old:str, new:str): + "Rewrite each copy read by `_read_copies` with `old` replaced by `new`." for path, text in copies.items(): path.write_text(text.replace(old, new), encoding="utf-8") @@ -454,8 +456,10 @@ def _is_maturin_project(data:dict) -> bool: def _cargo_version_section(manifest:Path) -> str: "The Cargo.toml section holding the version: `package`, or `workspace.package` when the package inherits it." - ver = (_load_toml(manifest).get("package") or {}).get("version") - return "package" if isinstance(ver, str) else "workspace.package" + data = _load_toml(manifest) + ver = (data.get("package") or {}).get("version") + inherited = isinstance(ver, dict) or "package" in (data.get("workspace") or {}) + return "workspace.package" if inherited else "package" def _cargo_version(manifest:Path) -> str: @@ -952,7 +956,7 @@ def ship_zig_build( def ship_rs_bump(part: int = 2, unbump: bool = False): - "Bump `[package].version` in Cargo.toml, then refresh the local editable install." + "Bump the version in Cargo.toml (`[package]`, or `[workspace.package]` when inherited), then refresh the local editable install." cfg = get_rs_config() _cargo_bump(cfg, part=part, unbump=unbump) os.chdir(cfg.root) diff --git a/tests/test_rs.py b/tests/test_rs.py index c80e68a..4f9e166 100644 --- a/tests/test_rs.py +++ b/tests/test_rs.py @@ -1,6 +1,6 @@ from pathlib import Path -import fastship.release as relmod +import pytest, fastship.release as relmod def _make_rs_project(root: Path, configured: bool = True, dynamic: bool = True): @@ -66,12 +66,16 @@ def test_ship_bump_uses_static_project_version_when_present(tmp_path, monkeypatc assert '__version__ = "0.1.2"' in (pkg / "__init__.py").read_text(encoding="utf-8") +def _add_version_file(root: Path, text: str): + pyproj = root / "pyproject.toml" + pyproj.write_text(pyproj.read_text(encoding="utf-8") + 'version-files = ["wasm/package.json"]\n', encoding="utf-8") + (root / "wasm").mkdir() + (root / "wasm" / "package.json").write_text(text, encoding="utf-8") + + def test_rs_version_files_bump_with_cargo(tmp_path, monkeypatch): _make_rs_project(tmp_path) - (tmp_path / "pyproject.toml").write_text( - (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + 'version-files = ["wasm/package.json"]\n', encoding="utf-8") - (tmp_path / "wasm").mkdir() - (tmp_path / "wasm" / "package.json").write_text('{\n "name": "@acme/exhash",\n "version": "0.1.2"\n}\n', encoding="utf-8") + _add_version_file(tmp_path, '{\n "name": "@acme/exhash",\n "version": "0.1.2"\n}\n') monkeypatch.chdir(tmp_path) monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) @@ -83,35 +87,37 @@ def test_rs_version_files_bump_with_cargo(tmp_path, monkeypatch): def test_rs_version_files_refuse_stale_copy(tmp_path, monkeypatch): _make_rs_project(tmp_path) - (tmp_path / "pyproject.toml").write_text( - (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + 'version-files = ["wasm/package.json"]\n', encoding="utf-8") - (tmp_path / "wasm").mkdir() - (tmp_path / "wasm" / "package.json").write_text('{"version": "0.0.9"}\n', encoding="utf-8") + _add_version_file(tmp_path, '{"version": "0.0.9"}\n') monkeypatch.chdir(tmp_path) monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) - import pytest - with pytest.raises(ValueError, match="exactly one"): - relmod.ship_bump(part=2) + with pytest.raises(ValueError, match="exactly one"): relmod.ship_bump(part=2) assert 'version = "0.1.2"' in (tmp_path / "Cargo.toml").read_text(encoding="utf-8") # nothing written -_ws_cargo = """[workspace] -members = ["py"] +def test_single_crate_missing_version_names_package(tmp_path): + _make_rs_project(tmp_path) + (tmp_path / "Cargo.toml").write_text('[package]\nname = "exhash"\n', encoding="utf-8") + with pytest.raises(ValueError, match=r"\[package\]\.version"): relmod.get_rs_config(tmp_path).version -[workspace.package] -version = "0.1.2" -[package] +_ws_package = """[package] name = "exhash" version.workspace = true edition = "2024" """ +_ws_root = """[workspace] +members = ["py"] + +[workspace.package] +version = "0.1.2" +""" -def test_workspace_version_read_and_bump(tmp_path, monkeypatch): +@pytest.mark.parametrize("cargo", [_ws_root + "\n" + _ws_package, _ws_package + "\n" + _ws_root]) +def test_workspace_version_read_and_bump(tmp_path, monkeypatch, cargo): _make_rs_project(tmp_path) - (tmp_path / "Cargo.toml").write_text(_ws_cargo, encoding="utf-8") + (tmp_path / "Cargo.toml").write_text(cargo, encoding="utf-8") monkeypatch.chdir(tmp_path) monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) From 2bd1300078890f6ae34eea58a0739ce60ee72ccc Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Sun, 6 Sep 2026 09:46:22 -0400 Subject: [PATCH 4/4] raise --- fastship/release.py | 10 +++++++--- tests/test_rs.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/fastship/release.py b/fastship/release.py index 82e3798..a5cfd66 100644 --- a/fastship/release.py +++ b/fastship/release.py @@ -455,11 +455,15 @@ def _is_maturin_project(data:dict) -> bool: def _cargo_version_section(manifest:Path) -> str: - "The Cargo.toml section holding the version: `package`, or `workspace.package` when the package inherits it." + "The version section in Cargo.toml; workspace root packages must inherit the shared version." data = _load_toml(manifest) ver = (data.get("package") or {}).get("version") - inherited = isinstance(ver, dict) or "package" in (data.get("workspace") or {}) - return "workspace.package" if inherited else "package" + inherited = isinstance(ver, dict) and ver.get("workspace") is True + workspace = "workspace" in data + if workspace and "package" in data and not inherited: + raise ValueError(f"Fastship requires shared versioning in Cargo workspaces: {manifest}. " + "Set version.workspace = true in [package] and define the version in [workspace.package].") + return "workspace.package" if workspace or inherited else "package" def _cargo_version(manifest:Path) -> str: diff --git a/tests/test_rs.py b/tests/test_rs.py index 4f9e166..1bb75fa 100644 --- a/tests/test_rs.py +++ b/tests/test_rs.py @@ -114,6 +114,18 @@ def test_single_crate_missing_version_names_package(tmp_path): """ +@pytest.mark.parametrize("version", ["0.1.2", "9.8.7"]) +def test_workspace_rejects_independent_package_version(tmp_path, version): + _make_rs_project(tmp_path) + manifest = tmp_path / "Cargo.toml" + cargo = _ws_root + _ws_package.replace("version.workspace = true", f'version = "{version}"') + manifest.write_text(cargo, encoding="utf-8") + cfg = relmod.get_rs_config(tmp_path) + + with pytest.raises(ValueError, match=r"version\.workspace = true"): relmod._cargo_bump(cfg, part=2) + assert manifest.read_text(encoding="utf-8") == cargo + + @pytest.mark.parametrize("cargo", [_ws_root + "\n" + _ws_package, _ws_package + "\n" + _ws_root]) def test_workspace_version_read_and_bump(tmp_path, monkeypatch, cargo): _make_rs_project(tmp_path)