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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ The action:
- The version file is either a python file containing a line
`__version__ = "<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.

Expand Down Expand Up @@ -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 | `<version>.post0` | Version to set after the release |
| `tag_prefix` | no | `v` | Prefix of the release tag |
Expand All @@ -73,7 +76,7 @@ The scripts in `src/draft_release` can also be used directly:
```shell
python -m draft_release.get_version <version_file>
python -m draft_release.parse_version <version> [base]
python -m draft_release.set_version <version_file> <version>
python -m draft_release.set_version <version_file>... <version>
python -m draft_release.update_changelog <version|Unreleased> [changelog_file]
python -m draft_release.extract_changes <version> [changelog_file]
```
Expand Down
8 changes: 5 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/draft_release/_version_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions src/draft_release/set_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
15 changes: 15 additions & 0 deletions test/test_set_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
45 changes: 42 additions & 3 deletions test/test_version_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
):
Expand All @@ -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
):
Expand All @@ -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()
Loading