diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af85ef..e65ef4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Fix: update the workspace uv.lock when setting the version of a workspace member +- Feat: support multiple version files, e.g. uv workspace members + ## 1.0.1 - 2026-09-17 - Fix: insert the Unreleased changelog header before the first version section diff --git a/README.md b/README.md index 7523e96..0c715de 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,10 @@ The action: - The version file is either a python file containing a line `__version__ = ""` or a `pyproject.toml` with a static `[project]` `version`. A `pyproject.toml` is updated with `uv version`, which also updates - the root package version in `uv.lock` if the lock file exists. + the package version in `uv.lock` if the project or its uv workspace has a lock + file. +- Multiple version files, e.g. the members of a uv workspace, can be given one + per line. All of them are set to the same version. - The changelog must start with `# CHANGELOG` and contain a `## Unreleased` header. - The workflow needs `contents: write` permission. @@ -52,7 +55,7 @@ jobs: | Name | Required | Default | Description | | ---------------- | -------- | ------------------- | ------------------------------------------------------------- | | `version` | yes | | Release version without the tag prefix (e.g. `1.0.0`) | -| `version_file` | yes | | Path to the file containing `__version__` or `pyproject.toml` | +| `version_file` | yes | | Path(s) to `__version__` files or `pyproject.toml`s | | `changelog_file` | no | `CHANGELOG.md` | Path to the changelog file | | `next_version` | no | `.post0` | Version to set after the release | | `tag_prefix` | no | `v` | Prefix of the release tag | @@ -73,7 +76,7 @@ The scripts in `src/draft_release` can also be used directly: ```shell python -m draft_release.get_version python -m draft_release.parse_version [base] -python -m draft_release.set_version +python -m draft_release.set_version ... python -m draft_release.update_changelog [changelog_file] python -m draft_release.extract_changes [changelog_file] ``` diff --git a/action.yml b/action.yml index 34949c2..2c6b522 100644 --- a/action.yml +++ b/action.yml @@ -5,7 +5,9 @@ inputs: description: Release version without the tag prefix (e.g. "1.0.0") required: true version_file: - description: Path to the python file containing the __version__ attribute or to a pyproject.toml with a static project version + description: >- + Path to the python file containing the __version__ attribute or to a pyproject.toml with a static project version. + Multiple files (e.g. uv workspace members) can be given one per line required: true changelog_file: description: Path to the changelog file @@ -65,7 +67,7 @@ runs: CHANGELOG_FILE: ${{ inputs.changelog_file }} run: | "$PYTHON" -m draft_release.update_changelog "$VERSION" "$CHANGELOG_FILE" - "$PYTHON" -m draft_release.set_version "$VERSION_FILE" "$VERSION" + "$PYTHON" -m draft_release.set_version $VERSION_FILE "$VERSION" "$PYTHON" -m draft_release.extract_changes "$VERSION" "$CHANGELOG_FILE" > "$RUNNER_TEMP/release-changes.md" - name: Create release commit @@ -84,7 +86,7 @@ runs: CHANGELOG_FILE: ${{ inputs.changelog_file }} run: | "$PYTHON" -m draft_release.update_changelog Unreleased "$CHANGELOG_FILE" - "$PYTHON" -m draft_release.set_version "$VERSION_FILE" "$NEXT_VERSION" + "$PYTHON" -m draft_release.set_version $VERSION_FILE "$NEXT_VERSION" - name: Create next version commit uses: EndBug/add-and-commit@cc9c08ba6c8df3b93a8f2db63e89b98368ae2ae8 # v11.1.1 diff --git a/src/draft_release/_version_file.py b/src/draft_release/_version_file.py index bf4aa38..792b82e 100644 --- a/src/draft_release/_version_file.py +++ b/src/draft_release/_version_file.py @@ -32,13 +32,23 @@ def _read_pyproject_version(pyproject_file: Path) -> Version: raise ValueError(msg) from error +def _has_lock_file(project_dir: Path) -> bool: + """Check for uv.lock in the project or its workspace up to the repository root.""" + for directory in (project_dir, *project_dir.resolve().parents): + if (directory / "uv.lock").exists(): + return True + if (directory / ".git").exists(): + return False + return False + + def _write_pyproject_version(pyproject_file: Path, version: Version) -> None: uv = shutil.which("uv") if uv is None: msg = "uv is required to set the version in pyproject.toml" raise RuntimeError(msg) project_dir = pyproject_file.parent - lock_mode = "--no-sync" if (project_dir / "uv.lock").exists() else "--frozen" + lock_mode = "--no-sync" if _has_lock_file(project_dir) else "--frozen" subprocess.run( # noqa: S603 [uv, "version", "--project", str(project_dir), lock_mode, str(version)], check=True, diff --git a/src/draft_release/set_version.py b/src/draft_release/set_version.py index 9b5daf8..3a54333 100644 --- a/src/draft_release/set_version.py +++ b/src/draft_release/set_version.py @@ -38,12 +38,15 @@ def set_version(version_file: Path, version: str) -> Version: def main(argv: Sequence[str] | None = None) -> None: - """Set the version in the given init file or pyproject.toml.""" + """Set the version in the given init files or pyproject.toml files.""" parser = argparse.ArgumentParser(description=main.__doc__) - parser.add_argument("version_file", type=Path) + parser.add_argument("version_files", type=Path, nargs="+", metavar="version_file") parser.add_argument("version") args = parser.parse_args(argv) - set_version(args.version_file, args.version) + for version_file in args.version_files: + validate_version(Version(args.version), read_version(version_file)) + for version_file in args.version_files: + set_version(version_file, args.version) if __name__ == "__main__": diff --git a/test/test_set_version.py b/test/test_set_version.py index 9e172f0..3e5792a 100644 --- a/test/test_set_version.py +++ b/test/test_set_version.py @@ -93,3 +93,18 @@ def test_validate_version_invalid(new_version: str, current_version: str): def test_main(init_file: Path): main([str(init_file), "1.1.0"]) assert get_version(init_file) == Version("1.1.0") + + +def test_main_with_multiple_files(init_file: Path, tmp_path: Path): + other_file = tmp_path / "other.py" + other_file.write_text('__version__ = "1.0.0.post0"\n', encoding="utf-8") + main([str(init_file), str(other_file), "1.1.0"]) + assert get_version(init_file) == get_version(other_file) == Version("1.1.0") + + +def test_main_validates_all_before_writing(init_file: Path, tmp_path: Path): + other_file = tmp_path / "other.py" + other_file.write_text('__version__ = "2.0.0"\n', encoding="utf-8") + with pytest.raises(ValueError, match="must be higher"): + main([str(init_file), str(other_file), "1.1.0"]) + assert get_version(init_file) == Version("1.0.0.post0") diff --git a/test/test_version_file.py b/test/test_version_file.py index df84646..9dd538c 100644 --- a/test/test_version_file.py +++ b/test/test_version_file.py @@ -3,7 +3,6 @@ # This file is part of the Pinta. # Licensed under the MIT License; see the repository LICENSE file. -import shutil import subprocess from pathlib import Path @@ -109,7 +108,6 @@ def test_set_version_pyproject_validates_before_writing( uv_run.assert_not_called() -@pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed") def test_set_version_pyproject_with_uv( pyproject_file: Path, monkeypatch: pytest.MonkeyPatch ): @@ -122,7 +120,6 @@ def test_set_version_pyproject_with_uv( assert get_version(pyproject_file) == Version("1.1.0") -@pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed") def test_set_version_pyproject_updates_lock( pyproject_file: Path, monkeypatch: pytest.MonkeyPatch ): @@ -132,3 +129,45 @@ def test_set_version_pyproject_updates_lock( lock = (pyproject_file.parent / "uv.lock").read_text(encoding="utf-8") assert 'name = "demo"\nversion = "1.1.0"' in lock assert not (pyproject_file.parent / ".venv").exists() + + +WORKSPACE_ROOT_PYPROJECT = """[project] +name = "root" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [] + +[tool.uv.workspace] +members = ["packages/*"] +""" + + +@pytest.fixture +def workspace_member_pyproject(tmp_path: Path) -> Path: + (tmp_path / "pyproject.toml").write_text(WORKSPACE_ROOT_PYPROJECT, encoding="utf-8") + file = tmp_path / "packages" / "demo" / "pyproject.toml" + file.parent.mkdir(parents=True) + file.write_text(PYPROJECT, encoding="utf-8") + return file + + +def test_write_pyproject_version_ignores_lock_outside_repository( + workspace_member_pyproject: Path, uv_run: MockType +): + (workspace_member_pyproject.parent / ".git").mkdir() + (workspace_member_pyproject.parents[2] / "uv.lock").touch() + write_version(workspace_member_pyproject, Version("1.1.0")) + assert "--frozen" in uv_run.call_args.args[0] + + +def test_set_version_workspace_member_updates_workspace_lock( + workspace_member_pyproject: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + workspace_root = workspace_member_pyproject.parents[2] + subprocess.run(["uv", "lock", "--project", workspace_root], check=True) + set_version(workspace_member_pyproject, "1.1.0") + lock = (workspace_root / "uv.lock").read_text(encoding="utf-8") + assert 'name = "demo"\nversion = "1.1.0"' in lock + assert not (workspace_member_pyproject.parent / "uv.lock").exists() + assert not (workspace_root / ".venv").exists()