From 06320e313bee42b68cf35c928152027b12c144a5 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 4 Jul 2026 02:05:45 -0300 Subject: [PATCH 01/11] ci(release): fail the build when dist/ holds an unexpected wheel The per-prefix check confirms each of the five known packages built a wheel, but not that nothing else did. A new workspace member would build a sixth wheel that is not staged for PyPI, shipping a partial release unnoticed. Assert the wheel count equals the expected set so adding a package forces updating the list. --- .github/workflows/release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6cfd942..043453ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,8 @@ jobs: from pathlib import Path dist = Path("dist") - for prefix in ("pipefy", "pipefy_mcp_server", "pipefy_cli", "pipefy_auth", "pipefy_infra"): + expected = ("pipefy", "pipefy_mcp_server", "pipefy_cli", "pipefy_auth", "pipefy_infra") + for prefix in expected: wheels = list(dist.glob(f"{prefix}-*.whl")) if len(wheels) != 1: print( @@ -81,6 +82,17 @@ jobs: file=sys.stderr, ) sys.exit(1) + # Bound the set: a new workspace member builds a wheel that is not in + # `expected` and so never gets staged for PyPI, shipping a partial + # release. Fail here so adding a package forces updating this list. + all_wheels = list(dist.glob("*.whl")) + if len(all_wheels) != len(expected): + print( + f"expected exactly {len(expected)} wheels in dist/, got " + f"{sorted(w.name for w in all_wheels)!r}", + file=sys.stderr, + ) + sys.exit(1) PY - name: Create GitHub Release uses: softprops/action-gh-release@v2 From 5b8b81bd8d42e86f620507ef0b8ccc450c59c19f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 4 Jul 2026 02:06:18 -0300 Subject: [PATCH 02/11] build(release): pin workspace siblings to the lockstep version Published wheels declared bare, unpinned sibling deps (pipefy, pipefy-auth, pipefy-infra), so a public `pip install pipefy-cli==X` could resolve a sibling to any newer version on PyPI rather than the X built alongside it. The [tool.uv.sources] workspace mapping hides this in-repo but does not apply to published metadata. Pin each package's workspace siblings to the exact lockstep version. bump_version.py now rewrites these pins on every bump and asserts them in verify mode, keeping them in lockstep with __version__ automatically. --- packages/auth/pyproject.toml | 2 +- packages/cli/pyproject.toml | 4 +-- packages/mcp/pyproject.toml | 6 ++-- packages/sdk/pyproject.toml | 2 +- scripts/bump_version.py | 57 ++++++++++++++++++++++++++++++++++++ tests/test_bump_version.py | 53 +++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 7 deletions(-) diff --git a/packages/auth/pyproject.toml b/packages/auth/pyproject.toml index 7100ae43..720644a0 100644 --- a/packages/auth/pyproject.toml +++ b/packages/auth/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "httpx-auth>=0.23.1", "keyring>=24", "keyrings.alt>=5", - "pipefy-infra", + "pipefy-infra==0.3.0-alpha.1", # RS256 validation of inbound bearers (resource-server role); [crypto] pulls cryptography. "pyjwt[crypto]>=2.8", "pydantic>=2.13.4,<3", diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index b3278a35..e0d48615 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -5,8 +5,8 @@ description = "Typer CLI for Pipefy (pipefy)." readme = "README.md" requires-python = ">=3.11" dependencies = [ - "pipefy", - "pipefy-auth", + "pipefy==0.3.0-alpha.1", + "pipefy-auth==0.3.0-alpha.1", "typer>=0.12", "rich>=13", "pydantic-settings>=2.8.1", diff --git a/packages/mcp/pyproject.toml b/packages/mcp/pyproject.toml index bf4112f6..9ed0f2b0 100644 --- a/packages/mcp/pyproject.toml +++ b/packages/mcp/pyproject.toml @@ -8,9 +8,9 @@ dependencies = [ "httpx>=0.27.0", "mcp[cli]>=1.25.0", # Workspace resolves versions in dev; PyPI releases stage these wheels alongside CLI/MCP (see release workflow). - "pipefy-auth", - "pipefy-infra", - "pipefy", + "pipefy-auth==0.3.0-alpha.1", + "pipefy-infra==0.3.0-alpha.1", + "pipefy==0.3.0-alpha.1", "pydantic>=2.13.4,<3", "pydantic-settings>=2.8.1", ] diff --git a/packages/sdk/pyproject.toml b/packages/sdk/pyproject.toml index 69598f6a..af56b344 100644 --- a/packages/sdk/pyproject.toml +++ b/packages/sdk/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "gql[httpx]>=4.0.0,<5", "httpx>=0.27.0", "openpyxl>=3.1.5", - "pipefy-infra", + "pipefy-infra==0.3.0-alpha.1", "pydantic>=2.13.4,<3", "pydantic-settings>=2.8.1", "rapidfuzz>=3.14.3", diff --git a/scripts/bump_version.py b/scripts/bump_version.py index e6b7ebcd..6dbd8eb3 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -28,6 +28,23 @@ REPO_ROOT / "packages/infra/src/pipefy_infra/__init__.py", ) +# Each published package pins its workspace siblings to the exact lockstep +# version in its built metadata, so `pip install pipefy-cli==X` pulls its +# siblings at X rather than a newer sibling that happens to be on PyPI. The +# [tool.uv.sources] workspace mapping overrides these pins for in-repo dev. +# Keys are dependency (distribution) names, not import names: the SDK's +# distribution is `pipefy`. +WORKSPACE_DEP_PINS: dict[Path, tuple[str, ...]] = { + REPO_ROOT / "packages/sdk/pyproject.toml": ("pipefy-infra",), + REPO_ROOT / "packages/auth/pyproject.toml": ("pipefy-infra",), + REPO_ROOT / "packages/cli/pyproject.toml": ("pipefy", "pipefy-auth"), + REPO_ROOT / "packages/mcp/pyproject.toml": ( + "pipefy", + "pipefy-auth", + "pipefy-infra", + ), +} + # Anchored to the [project] table. The middle alternation walks lines that # don't start with `[` (so a sibling [tool.X] header ends the run), but the # line CONTENTS can contain `[` (so `classifiers = [...]`, `dependencies = @@ -91,6 +108,36 @@ def write_version_to_all_files(new_version: str) -> None: ROOT_PYPROJECT.write_text(new_root, encoding="utf-8") +def workspace_dep_pin_re(dep_name: str) -> re.Pattern[str]: + """Match a quoted ``dep_name`` requirement, with or without an existing ``==`` pin. + + The name must fill the entire quoted string, so an unquoted + ``[tool.uv.sources]`` key (``pipefy = { workspace = true }``) and a bare + mention inside a description are not matched, and ``pipefy`` does not match + ``"pipefy-infra"`` (the trailing quote must follow immediately). A package's + own ``name = "..."`` field IS a full quoted string and would match; that + collision is avoided by ``WORKSPACE_DEP_PINS`` never listing a package's + own name (a package cannot depend on itself). + """ + return re.compile(rf'(["\']){re.escape(dep_name)}(?:\s*==[^"\']*)?(["\'])') + + +def write_dep_pins(new_version: str) -> None: + """Pin each package's workspace-sibling dependencies to ``new_version``.""" + for path, deps in WORKSPACE_DEP_PINS.items(): + text = path.read_text(encoding="utf-8") + for dep in deps: + text, count = workspace_dep_pin_re(dep).subn( + rf"\g<1>{dep}=={new_version}\g<2>", + text, + count=1, + ) + if count != 1: + msg = f"Expected one {dep!r} dependency in {path}, replaced {count}" + raise ValueError(msg) + path.write_text(text, encoding="utf-8") + + def parse_core(version: str) -> tuple[int, int, int]: """Parse leading ``X.Y.Z`` from a version string.""" m = CORE_VER_RE.match(version.strip()) @@ -226,6 +273,15 @@ def verify_lockstep() -> int: return 1 raw[str(path.relative_to(REPO_ROOT))] = m.group(2) + for path, deps in WORKSPACE_DEP_PINS.items(): + text = path.read_text(encoding="utf-8") + for dep in deps: + m = re.search(rf'(["\']){re.escape(dep)}==([^"\']+)\1', text) + if not m: + print(f"missing pinned {dep!r} dependency in {path}", file=sys.stderr) + return 1 + raw[f"{path.relative_to(REPO_ROOT)}::{dep}"] = m.group(2) + try: raw["pyproject.toml"] = read_root_pyproject_version() except (KeyError, tomllib.TOMLDecodeError, OSError) as exc: @@ -285,6 +341,7 @@ def main() -> int: return 2 write_version_to_all_files(new_version) + write_dep_pins(new_version) refresh_lockfile() print(f"Bumped {current} -> {new_version}") return 0 diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index e7043ad9..b1c2eccf 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -85,3 +85,56 @@ def test_root_project_version_re_rejects_missing_version(pyproject: str) -> None r'\1"REPLACED"', pyproject, count=1 ) assert count == 0, f"expected no match in {pyproject!r}" + + +@pytest.mark.parametrize( + ("dep", "text"), + [ + # Unpinned dependency + ("pipefy", ' "pipefy",\n'), + # Already pinned (re-pin to a new version) + ("pipefy", ' "pipefy==0.1.0",\n'), + ("pipefy-auth", ' "pipefy-auth==0.2.0-beta.3",\n'), + ], +) +def test_workspace_dep_pin_re_matches_and_repins(dep: str, text: str) -> None: + new_text, count = _bump.workspace_dep_pin_re(dep).subn( + rf"\g<1>{dep}==9.9.9\g<2>", text, count=1 + ) + assert count == 1 + assert f'"{dep}==9.9.9"' in new_text + + +@pytest.mark.parametrize( + "text", + [ + # `pipefy` must not match the longer sibling name + ' "pipefy-infra",\n', + ' "pipefy-infra==0.1.0",\n', + # Unquoted [tool.uv.sources] key + "pipefy = { workspace = true }\n", + # A package's own name field is `pipefy-cli`, not a bare `pipefy` + 'name = "pipefy-cli"\n', + # Bare mention inside a quoted description + 'description = "Typer CLI for Pipefy (pipefy)."\n', + ], +) +def test_workspace_dep_pin_re_pipefy_does_not_overmatch(text: str) -> None: + _new_text, count = _bump.workspace_dep_pin_re("pipefy").subn( + r"\g<1>pipefy==9.9.9\g<2>", text, count=1 + ) + assert count == 0, f"expected no match in {text!r}" + + +def test_workspace_dep_pins_never_list_own_name() -> None: + # The re matches a package's own `name = "..."`, so a self-listing would + # rewrite the name field. Guard the invariant the docstring relies on. + own_names = { + "packages/sdk/pyproject.toml": "pipefy", + "packages/auth/pyproject.toml": "pipefy-auth", + "packages/cli/pyproject.toml": "pipefy-cli", + "packages/mcp/pyproject.toml": "pipefy-mcp-server", + } + for path, deps in _bump.WORKSPACE_DEP_PINS.items(): + rel = str(path.relative_to(_bump.REPO_ROOT)) + assert own_names[rel] not in deps From fe711cd76bc98f7223231f28f86d8d6d83925e2e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 4 Jul 2026 02:18:59 -0300 Subject: [PATCH 03/11] refactor(release): share one pin regex between write and verify verify_lockstep hand-built a second pattern for a pinned workspace dep, duplicating the quote and name-boundary logic that workspace_dep_pin_re already carries (and untested, unlike the helper). Make the helper's == group capturing so it captures the pinned version (None when unpinned) and have both write_dep_pins and verify_lockstep go through it. --- scripts/bump_version.py | 16 ++++++++++------ tests/test_bump_version.py | 21 +++++++++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 6dbd8eb3..ee197628 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -109,17 +109,21 @@ def write_version_to_all_files(new_version: str) -> None: def workspace_dep_pin_re(dep_name: str) -> re.Pattern[str]: - """Match a quoted ``dep_name`` requirement, with or without an existing ``==`` pin. + """Match a quoted ``dep_name`` requirement; group 2 captures its ``==`` pin. + + Group 2 is the pinned version, or ``None`` when the requirement is unpinned + (``"pipefy"``), so this one pattern serves both the writer (rewrites the + pin) and the verifier (reads it, treating ``None`` as unpinned). The name must fill the entire quoted string, so an unquoted ``[tool.uv.sources]`` key (``pipefy = { workspace = true }``) and a bare mention inside a description are not matched, and ``pipefy`` does not match - ``"pipefy-infra"`` (the trailing quote must follow immediately). A package's + ``"pipefy-infra"`` (the closing quote must follow immediately). A package's own ``name = "..."`` field IS a full quoted string and would match; that collision is avoided by ``WORKSPACE_DEP_PINS`` never listing a package's own name (a package cannot depend on itself). """ - return re.compile(rf'(["\']){re.escape(dep_name)}(?:\s*==[^"\']*)?(["\'])') + return re.compile(rf'(["\']){re.escape(dep_name)}(?:\s*==([^"\']*))?\1') def write_dep_pins(new_version: str) -> None: @@ -128,7 +132,7 @@ def write_dep_pins(new_version: str) -> None: text = path.read_text(encoding="utf-8") for dep in deps: text, count = workspace_dep_pin_re(dep).subn( - rf"\g<1>{dep}=={new_version}\g<2>", + rf"\g<1>{dep}=={new_version}\g<1>", text, count=1, ) @@ -276,8 +280,8 @@ def verify_lockstep() -> int: for path, deps in WORKSPACE_DEP_PINS.items(): text = path.read_text(encoding="utf-8") for dep in deps: - m = re.search(rf'(["\']){re.escape(dep)}==([^"\']+)\1', text) - if not m: + m = workspace_dep_pin_re(dep).search(text) + if not m or m.group(2) is None: print(f"missing pinned {dep!r} dependency in {path}", file=sys.stderr) return 1 raw[f"{path.relative_to(REPO_ROOT)}::{dep}"] = m.group(2) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index b1c2eccf..4eb145d9 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -99,7 +99,7 @@ def test_root_project_version_re_rejects_missing_version(pyproject: str) -> None ) def test_workspace_dep_pin_re_matches_and_repins(dep: str, text: str) -> None: new_text, count = _bump.workspace_dep_pin_re(dep).subn( - rf"\g<1>{dep}==9.9.9\g<2>", text, count=1 + rf"\g<1>{dep}==9.9.9\g<1>", text, count=1 ) assert count == 1 assert f'"{dep}==9.9.9"' in new_text @@ -121,11 +121,28 @@ def test_workspace_dep_pin_re_matches_and_repins(dep: str, text: str) -> None: ) def test_workspace_dep_pin_re_pipefy_does_not_overmatch(text: str) -> None: _new_text, count = _bump.workspace_dep_pin_re("pipefy").subn( - r"\g<1>pipefy==9.9.9\g<2>", text, count=1 + r"\g<1>pipefy==9.9.9\g<1>", text, count=1 ) assert count == 0, f"expected no match in {text!r}" +@pytest.mark.parametrize( + ("text", "expected_version"), + [ + (' "pipefy==0.3.0-alpha.1",\n', "0.3.0-alpha.1"), + (' "pipefy==1.2.3",\n', "1.2.3"), + # Unpinned: matches, but group 2 is None (verify treats this as missing) + (' "pipefy",\n', None), + ], +) +def test_workspace_dep_pin_re_captures_pin( + text: str, expected_version: str | None +) -> None: + m = _bump.workspace_dep_pin_re("pipefy").search(text) + assert m is not None + assert m.group(2) == expected_version + + def test_workspace_dep_pins_never_list_own_name() -> None: # The re matches a package's own `name = "..."`, so a self-listing would # rewrite the name field. Guard the invariant the docstring relies on. From a1bd5b95e71f8ba08be209850bcde7f948b2e1fc Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 4 Jul 2026 02:27:27 -0300 Subject: [PATCH 04/11] build(release): track the plugin manifest version in lockstep The Claude plugin manifest carries the release version the marketplace shows, but nothing kept it moving with releases: it was stuck at 0.2.0-beta.1 while the workspace shipped 0.3.0-alpha.1. Fold plugin.json into the lockstep set so write_version_to_all_files rewrites it and verify_lockstep asserts it, and bring the stale value up to the current version. --- .claude-plugin/plugin.json | 2 +- scripts/bump_version.py | 32 +++++++++++++++++++++++++++++++- tests/test_bump_version.py | 25 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 963ead4a..325c969d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "pipefy", "displayName": "Pipefy", - "version": "0.2.0-beta.1", + "version": "0.3.0-alpha.1", "description": "Pipefy workflow integration for Claude Code.", "author": { "name": "Pipefy", "url": "https://github.com/pipefy" }, "homepage": "https://github.com/pipefy/ai-toolkit", diff --git a/scripts/bump_version.py b/scripts/bump_version.py index ee197628..e24445d2 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Bump the lockstep workspace version across SDK, MCP, CLI, Auth, Infra, and root workspace meta. +"""Bump the lockstep workspace version across SDK, MCP, CLI, Auth, Infra, the Claude plugin manifest, and root workspace meta. After rewriting the version strings, runs ``uv lock`` so the workspace lockfile's ``pipefy-workspace`` entry tracks the new version. @@ -20,6 +20,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" +PLUGIN_MANIFEST = REPO_ROOT / ".claude-plugin/plugin.json" INIT_PATHS = ( REPO_ROOT / "packages/sdk/src/pipefy_sdk/__init__.py", REPO_ROOT / "packages/mcp/src/pipefy_mcp/__init__.py", @@ -62,6 +63,13 @@ re.MULTILINE, ) +# The Claude plugin manifest carries the release version too (it is what the +# marketplace shows), so it moves with every bump. plugin.json has a single +# top-level "version" key. +PLUGIN_MANIFEST_VERSION_RE = re.compile( + r'(?P"version"\s*:\s*")(?P[^"]+)(?P")', +) + CORE_VER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)") PRERELEASE_SUFFIX_RE = re.compile( r"^(a|alpha|b|beta|rc)\.?(\d+)$", @@ -107,6 +115,17 @@ def write_version_to_all_files(new_version: str) -> None: raise ValueError(msg) ROOT_PYPROJECT.write_text(new_root, encoding="utf-8") + manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") + new_manifest, manifest_count = PLUGIN_MANIFEST_VERSION_RE.subn( + rf"\g{new_version}\g", + manifest_text, + count=1, + ) + if manifest_count != 1: + msg = f'Expected one "version" key in {PLUGIN_MANIFEST}, replaced {manifest_count}' + raise ValueError(msg) + PLUGIN_MANIFEST.write_text(new_manifest, encoding="utf-8") + def workspace_dep_pin_re(dep_name: str) -> re.Pattern[str]: """Match a quoted ``dep_name`` requirement; group 2 captures its ``==`` pin. @@ -304,6 +323,17 @@ def verify_lockstep() -> int: ) return 1 + try: + manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") + except OSError as exc: + print(f"could not read {PLUGIN_MANIFEST}: {exc}", file=sys.stderr) + return 1 + m = PLUGIN_MANIFEST_VERSION_RE.search(manifest_text) + if not m: + print(f'missing "version" in {PLUGIN_MANIFEST}', file=sys.stderr) + return 1 + raw[str(PLUGIN_MANIFEST.relative_to(REPO_ROOT))] = m.group("value") + canonical = {label: str(Version(v)) for label, v in raw.items()} if len(set(canonical.values())) != 1: print(f"version mismatch across packages: {raw}", file=sys.stderr) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index 4eb145d9..14688faf 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -143,6 +143,31 @@ def test_workspace_dep_pin_re_captures_pin( assert m.group(2) == expected_version +@pytest.mark.parametrize( + "manifest", + [ + '{\n "name": "pipefy",\n "version": "0.2.0-beta.1"\n}\n', + # version before name, and with extra whitespace around the colon + '{\n "version" : "0.2.0-beta.1",\n "name": "pipefy"\n}\n', + ], +) +def test_plugin_manifest_version_re_replaces_version(manifest: str) -> None: + new_text, count = _bump.PLUGIN_MANIFEST_VERSION_RE.subn( + r"\g9.9.9\g", manifest, count=1 + ) + assert count == 1 + assert '"version"' in new_text and "9.9.9" in new_text + # The name key must not be rewritten. + assert '"pipefy"' in new_text + + +def test_plugin_manifest_version_matches_real_manifest() -> None: + text = _bump.PLUGIN_MANIFEST.read_text(encoding="utf-8") + m = _bump.PLUGIN_MANIFEST_VERSION_RE.search(text) + assert m is not None + assert m.group("value") == _bump.read_sdk_version() + + def test_workspace_dep_pins_never_list_own_name() -> None: # The re matches a package's own `name = "..."`, so a self-listing would # rewrite the name field. Guard the invariant the docstring relies on. From 009d7069c31aa3f3447b82392a03512fda923fa8 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 6 Jul 2026 13:50:57 -0300 Subject: [PATCH 05/11] docs(pr-review): expand RELEASE.md automation table to match verify scope The automation reference described the pre-PR verify scope (five __version__ strings + root pyproject.toml). Update it to cover the sibling == pins, .claude-plugin/plugin.json, and uv.lock that verify now asserts, plus the release workflow's exact-five-wheels guard. --- RELEASE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 9d5d26a2..870a1960 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -96,6 +96,6 @@ Same steps as above. PyPI publishing already runs on every tag; a **`v1.`** tag | Piece | Role | | --- | --- | -| `scripts/bump_version.py` | Reads the SDK `__version__`, applies the bump, writes the same value to SDK, MCP, CLI, Auth, and Infra `__init__.py` plus the root `pyproject.toml`'s `[project].version`, then runs `uv lock` to refresh `uv.lock`. Also exposes a `verify` mode that asserts every version-bearing file agrees. | -| `.github/workflows/ci.yml` | Invokes `scripts/bump_version.py verify` to assert the five `__version__` strings and root `pyproject.toml` `[project].version` all match. | -| `.github/workflows/release.yml` | On `v*` tags: asserts the tag matches SDK `__version__`, extracts the matching `CHANGELOG.md` section as the GitHub Release body, builds wheels and sdists with `uv build --all-packages -o dist --wheel --sdist`, attaches wheels to the GitHub Release, and uploads all five wheels to PyPI via Trusted Publishing on every `v*` tag. | +| `scripts/bump_version.py` | Reads the SDK `__version__`, applies the bump, writes the same value to SDK, MCP, CLI, Auth, and Infra `__init__.py`, the root `pyproject.toml`'s `[project].version`, the `.claude-plugin/plugin.json` `version`, and each published package's sibling `==` pins, then runs `uv lock` to refresh `uv.lock`. Also exposes a `verify` mode that asserts every version-bearing file agrees. | +| `.github/workflows/ci.yml` | Invokes `scripts/bump_version.py verify` to assert that all version-bearing files match: the five `__version__` strings, the root `pyproject.toml` `[project].version`, `uv.lock`, the `.claude-plugin/plugin.json` `version`, and the sibling `==` pins in each published package's `pyproject.toml`. | +| `.github/workflows/release.yml` | On `v*` tags: asserts the tag matches SDK `__version__`, extracts the matching `CHANGELOG.md` section as the GitHub Release body, builds wheels and sdists with `uv build --all-packages -o dist --wheel --sdist`, guards that `dist/` holds exactly five wheels (one per workspace member) so a sixth member cannot ship unnoticed, attaches wheels to the GitHub Release, and uploads all five wheels to PyPI via Trusted Publishing on every `v*` tag. | From ec5009173e28bd117f8577de0920b823770e0437 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 6 Jul 2026 14:12:18 -0300 Subject: [PATCH 06/11] feat(release): reconcile WORKSPACE_DEP_PINS against declared deps in verify The pin map was hand-maintained: a workspace-sibling dependency added to a package without a matching map edit would ship unpinned in the built wheel. verify now parses each package's [project.dependencies], keeps the entries that name a workspace member, and asserts they match WORKSPACE_DEP_PINS in both directions (unpinned and stale), so drift is a CI failure. The package set is derived from [tool.uv.workspace].members rather than a second hardcoded list, so adding a member cannot skip the reconciliation. --- scripts/bump_version.py | 106 ++++++++++++++++++++++++++++++++++++- tests/test_bump_version.py | 89 +++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index e24445d2..49a0518e 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -29,12 +29,38 @@ REPO_ROOT / "packages/infra/src/pipefy_infra/__init__.py", ) + +def _workspace_pyprojects() -> tuple[Path, ...]: + """Resolve package pyproject paths from the root ``[tool.uv.workspace]`` members. + + uv is the source of truth for workspace membership: a package it does not + know about is never built, locked, or published. Deriving from that list + (rather than a second hand-maintained copy) means adding a member cannot + silently escape the pin-map reconciliation below. Members may be literal + paths or globs (``packages/*``); both resolve here. + """ + data = tomllib.loads(ROOT_PYPROJECT.read_text(encoding="utf-8")) + members = data["tool"]["uv"]["workspace"]["members"] + paths: list[Path] = [] + for pattern in members: + paths.extend(sorted(REPO_ROOT.glob(f"{pattern}/pyproject.toml"))) + return tuple(paths) + + +# Every workspace package, derived from uv's member list. verify reconciles +# WORKSPACE_DEP_PINS against the sibling dependencies each of these actually +# declares, so a new inter-package dependency that skips the pin map is a CI +# failure rather than a silently-unpinned sibling in the built wheel. +PACKAGE_PYPROJECTS: tuple[Path, ...] = _workspace_pyprojects() + # Each published package pins its workspace siblings to the exact lockstep # version in its built metadata, so `pip install pipefy-cli==X` pulls its # siblings at X rather than a newer sibling that happens to be on PyPI. The # [tool.uv.sources] workspace mapping overrides these pins for in-repo dev. # Keys are dependency (distribution) names, not import names: the SDK's -# distribution is `pipefy`. +# distribution is `pipefy`. A package with no workspace-sibling dependency (the +# infra leaf) has no entry here. verify_pin_map keeps this in sync with the +# real dependency lists. WORKSPACE_DEP_PINS: dict[Path, tuple[str, ...]] = { REPO_ROOT / "packages/sdk/pyproject.toml": ("pipefy-infra",), REPO_ROOT / "packages/auth/pyproject.toml": ("pipefy-infra",), @@ -161,6 +187,76 @@ def write_dep_pins(new_version: str) -> None: path.write_text(text, encoding="utf-8") +def workspace_members() -> set[str]: + """Canonical distribution names of the five workspace packages.""" + from packaging.utils import canonicalize_name + + return { + canonicalize_name( + tomllib.loads(path.read_text(encoding="utf-8"))["project"]["name"] + ) + for path in PACKAGE_PYPROJECTS + } + + +def declared_sibling_deps(path: Path, members: set[str]) -> set[str]: + """Canonical names of the workspace siblings a package's ``[project]`` depends on. + + Reads ``[project.dependencies]`` and keeps the requirements whose + distribution name is another workspace member, so it sees exactly the deps + that ought to appear in ``WORKSPACE_DEP_PINS`` for this package. + """ + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name + + data = tomllib.loads(path.read_text(encoding="utf-8")) + deps = data.get("project", {}).get("dependencies", []) + found: set[str] = set() + for spec in deps: + name = canonicalize_name(Requirement(spec).name) + if name in members: + found.add(name) + return found + + +def verify_pin_map() -> list[str]: + """Assert ``WORKSPACE_DEP_PINS`` matches the sibling deps each package declares. + + The pin map is hand-maintained, so a sibling dependency added to (or + removed from) a package's ``pyproject.toml`` without a matching map edit + would go unpinned in the built wheel (or leave a pin for a dep that no + longer exists). This compares both directions for every package and returns + a list of human-readable errors, empty when consistent. + """ + from packaging.utils import canonicalize_name + + members = workspace_members() + errors: list[str] = [] + + for path in WORKSPACE_DEP_PINS: + if path not in PACKAGE_PYPROJECTS: + rel = path.relative_to(REPO_ROOT) + errors.append(f"WORKSPACE_DEP_PINS key {rel} is not a workspace package") + + for path in PACKAGE_PYPROJECTS: + declared = declared_sibling_deps(path, members) + mapped = {canonicalize_name(dep) for dep in WORKSPACE_DEP_PINS.get(path, ())} + rel = path.relative_to(REPO_ROOT) + unpinned = declared - mapped + stale = mapped - declared + if unpinned: + errors.append( + f"{rel}: sibling deps declared but missing from WORKSPACE_DEP_PINS: " + f"{sorted(unpinned)}" + ) + if stale: + errors.append( + f"{rel}: WORKSPACE_DEP_PINS lists deps the package no longer declares: " + f"{sorted(stale)}" + ) + return errors + + def parse_core(version: str) -> tuple[int, int, int]: """Parse leading ``X.Y.Z`` from a version string.""" m = CORE_VER_RE.match(version.strip()) @@ -280,6 +376,8 @@ def read_uv_lock_workspace_version() -> str: def verify_lockstep() -> int: """Assert every version-bearing file holds the same version; print mismatches. + First reconciles ``WORKSPACE_DEP_PINS`` against the sibling dependencies + each package declares (see ``verify_pin_map``), then checks the versions. Returns 0 on success, 1 on any mismatch or missing field. Compares canonical PEP 440 forms via ``packaging.version.Version`` so the pyproject string ``0.2.0-beta.2.dev1`` matches uv.lock's normalized @@ -287,6 +385,12 @@ def verify_lockstep() -> int: """ from packaging.version import Version + pin_map_errors = verify_pin_map() + if pin_map_errors: + for err in pin_map_errors: + print(err, file=sys.stderr) + return 1 + raw: dict[str, str] = {} for path in INIT_PATHS: text = path.read_text(encoding="utf-8") diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index 14688faf..265b60bf 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -180,3 +180,92 @@ def test_workspace_dep_pins_never_list_own_name() -> None: for path, deps in _bump.WORKSPACE_DEP_PINS.items(): rel = str(path.relative_to(_bump.REPO_ROOT)) assert own_names[rel] not in deps + + +def test_package_pyprojects_derived_from_workspace() -> None: + # Derived from [tool.uv.workspace].members, not a second hand-maintained + # list, so a new member cannot skip the pin-map reconciliation. + assert _bump.PACKAGE_PYPROJECTS + for path in _bump.PACKAGE_PYPROJECTS: + assert path.exists() + + +def test_verify_pin_map_consistent_with_repo() -> None: + # Guards the invariant in CI: WORKSPACE_DEP_PINS lists exactly the + # workspace siblings each package actually depends on. + assert _bump.verify_pin_map() == [] + + +def test_declared_sibling_deps_keeps_only_workspace_members(tmp_path: Path) -> None: + members = {"pipefy", "pipefy-auth", "pipefy-infra"} + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + "[project]\n" + 'name = "pipefy-cli"\n' + 'version = "0.1.0"\n' + "dependencies = [\n" + ' "pipefy==0.3.0-alpha.1",\n' + ' "pipefy-auth==0.3.0-alpha.1",\n' + ' "typer>=0.12",\n' + "]\n", + encoding="utf-8", + ) + assert _bump.declared_sibling_deps(pyproject, members) == {"pipefy", "pipefy-auth"} + + +def _write_pkg(path: Path, name: str, deps: tuple[str, ...]) -> None: + dep_lines = "".join(f' "{d}",\n' for d in deps) + path.write_text( + f'[project]\nname = "{name}"\nversion = "0.1.0"\n' + f"dependencies = [\n{dep_lines}]\n", + encoding="utf-8", + ) + + +def test_verify_pin_map_flags_unpinned_sibling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + sdk = tmp_path / "sdk.toml" + infra = tmp_path / "infra.toml" + # sdk depends on pipefy-infra, but the pin map has no entry for it. + _write_pkg(sdk, "pipefy", ("pipefy-infra==0.1.0",)) + _write_pkg(infra, "pipefy-infra", ()) + monkeypatch.setattr(_bump, "REPO_ROOT", tmp_path) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, infra)) + monkeypatch.setattr(_bump, "WORKSPACE_DEP_PINS", {}) + + errors = _bump.verify_pin_map() + + assert any("missing from WORKSPACE_DEP_PINS" in e for e in errors) + + +def test_verify_pin_map_flags_stale_pin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + sdk = tmp_path / "sdk.toml" + infra = tmp_path / "infra.toml" + # The map pins pipefy-infra for sdk, but sdk no longer declares it. + _write_pkg(sdk, "pipefy", ()) + _write_pkg(infra, "pipefy-infra", ()) + monkeypatch.setattr(_bump, "REPO_ROOT", tmp_path) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, infra)) + monkeypatch.setattr(_bump, "WORKSPACE_DEP_PINS", {sdk: ("pipefy-infra",)}) + + errors = _bump.verify_pin_map() + + assert any("no longer declares" in e for e in errors) + + +def test_verify_pin_map_flags_unknown_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + sdk = tmp_path / "sdk.toml" + stray = tmp_path / "stray.toml" + _write_pkg(sdk, "pipefy", ()) + monkeypatch.setattr(_bump, "REPO_ROOT", tmp_path) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk,)) + monkeypatch.setattr(_bump, "WORKSPACE_DEP_PINS", {stray: ("pipefy",)}) + + errors = _bump.verify_pin_map() + + assert any("is not a workspace package" in e for e in errors) From 79402b1bc391d8d7700c49d95cf73ce3bd5c34d4 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 6 Jul 2026 14:29:17 -0300 Subject: [PATCH 07/11] refactor(release): derive sibling pins from declared deps, drop the pin map verify_pin_map reconciled a hand-maintained WORKSPACE_DEP_PINS against each package's real dependencies, but declared_sibling_deps already computes that set from [project.dependencies]. That made the map a redundant second source of truth. Derive the pins directly: write_dep_pins and verify_lockstep both read each package's declared workspace siblings, so a new inter-package dependency is pinned and checked automatically with nothing to keep in sync. WORKSPACE_DEP_PINS and verify_pin_map are removed. The own-name collision the pin regex guarded against is now structurally impossible, since pinned names come from a package's own dependency list. Also folds the repeated tomllib.loads(read_text()) idiom into a _load_toml helper. --- scripts/bump_version.py | 153 +++++++++++++------------------------ tests/test_bump_version.py | 104 ++++++------------------- 2 files changed, 77 insertions(+), 180 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 49a0518e..bf5f0935 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -30,6 +30,11 @@ ) +def _load_toml(path: Path) -> dict: + """Parse a TOML file into a dict.""" + return tomllib.loads(path.read_text(encoding="utf-8")) + + def _workspace_pyprojects() -> tuple[Path, ...]: """Resolve package pyproject paths from the root ``[tool.uv.workspace]`` members. @@ -39,39 +44,19 @@ def _workspace_pyprojects() -> tuple[Path, ...]: silently escape the pin-map reconciliation below. Members may be literal paths or globs (``packages/*``); both resolve here. """ - data = tomllib.loads(ROOT_PYPROJECT.read_text(encoding="utf-8")) - members = data["tool"]["uv"]["workspace"]["members"] + members = _load_toml(ROOT_PYPROJECT)["tool"]["uv"]["workspace"]["members"] paths: list[Path] = [] for pattern in members: paths.extend(sorted(REPO_ROOT.glob(f"{pattern}/pyproject.toml"))) return tuple(paths) -# Every workspace package, derived from uv's member list. verify reconciles -# WORKSPACE_DEP_PINS against the sibling dependencies each of these actually -# declares, so a new inter-package dependency that skips the pin map is a CI -# failure rather than a silently-unpinned sibling in the built wheel. +# Every workspace package, derived from uv's member list. The bump writer and +# the verifier both read each package's declared workspace-sibling dependencies +# from here (see declared_sibling_deps), so pinning follows the real dependency +# lists with no hand-maintained pin map to keep in sync. PACKAGE_PYPROJECTS: tuple[Path, ...] = _workspace_pyprojects() -# Each published package pins its workspace siblings to the exact lockstep -# version in its built metadata, so `pip install pipefy-cli==X` pulls its -# siblings at X rather than a newer sibling that happens to be on PyPI. The -# [tool.uv.sources] workspace mapping overrides these pins for in-repo dev. -# Keys are dependency (distribution) names, not import names: the SDK's -# distribution is `pipefy`. A package with no workspace-sibling dependency (the -# infra leaf) has no entry here. verify_pin_map keeps this in sync with the -# real dependency lists. -WORKSPACE_DEP_PINS: dict[Path, tuple[str, ...]] = { - REPO_ROOT / "packages/sdk/pyproject.toml": ("pipefy-infra",), - REPO_ROOT / "packages/auth/pyproject.toml": ("pipefy-infra",), - REPO_ROOT / "packages/cli/pyproject.toml": ("pipefy", "pipefy-auth"), - REPO_ROOT / "packages/mcp/pyproject.toml": ( - "pipefy", - "pipefy-auth", - "pipefy-infra", - ), -} - # Anchored to the [project] table. The middle alternation walks lines that # don't start with `[` (so a sibling [tool.X] header ends the run), but the # line CONTENTS can contain `[` (so `classifiers = [...]`, `dependencies = @@ -165,17 +150,30 @@ def workspace_dep_pin_re(dep_name: str) -> re.Pattern[str]: mention inside a description are not matched, and ``pipefy`` does not match ``"pipefy-infra"`` (the closing quote must follow immediately). A package's own ``name = "..."`` field IS a full quoted string and would match; that - collision is avoided by ``WORKSPACE_DEP_PINS`` never listing a package's - own name (a package cannot depend on itself). + collision cannot arise because the pinned names come from a package's own + ``[project.dependencies]``, which never lists itself. """ return re.compile(rf'(["\']){re.escape(dep_name)}(?:\s*==([^"\']*))?\1') def write_dep_pins(new_version: str) -> None: - """Pin each package's workspace-sibling dependencies to ``new_version``.""" - for path, deps in WORKSPACE_DEP_PINS.items(): + """Pin every workspace-sibling dependency each package declares to ``new_version``. + + Each published package pins its workspace siblings to the exact lockstep + version in its built metadata, so ``pip install pipefy-cli==X`` pulls its + siblings at X rather than a newer sibling that happens to be on PyPI (the + ``[tool.uv.sources]`` workspace mapping overrides these pins for in-repo + dev). The set to pin is derived from each package's real + ``[project.dependencies]``, so a newly added inter-package dependency is + pinned automatically with no hand-maintained list to update. + """ + members = workspace_members() + for path in PACKAGE_PYPROJECTS: + deps = declared_sibling_deps(path, members) + if not deps: + continue text = path.read_text(encoding="utf-8") - for dep in deps: + for dep in sorted(deps): text, count = workspace_dep_pin_re(dep).subn( rf"\g<1>{dep}=={new_version}\g<1>", text, @@ -188,73 +186,32 @@ def write_dep_pins(new_version: str) -> None: def workspace_members() -> set[str]: - """Canonical distribution names of the five workspace packages.""" + """Canonical distribution names of every workspace package.""" from packaging.utils import canonicalize_name return { - canonicalize_name( - tomllib.loads(path.read_text(encoding="utf-8"))["project"]["name"] - ) + canonicalize_name(_load_toml(path)["project"]["name"]) for path in PACKAGE_PYPROJECTS } def declared_sibling_deps(path: Path, members: set[str]) -> set[str]: - """Canonical names of the workspace siblings a package's ``[project]`` depends on. + """Names of the workspace siblings a package's ``[project]`` depends on. Reads ``[project.dependencies]`` and keeps the requirements whose - distribution name is another workspace member, so it sees exactly the deps - that ought to appear in ``WORKSPACE_DEP_PINS`` for this package. + canonicalized name is another workspace member. Returns the names as + written in the file, so callers can match them against the raw dependency + text; a package never depends on itself, so its own name is never returned. """ from packaging.requirements import Requirement from packaging.utils import canonicalize_name - data = tomllib.loads(path.read_text(encoding="utf-8")) - deps = data.get("project", {}).get("dependencies", []) - found: set[str] = set() - for spec in deps: - name = canonicalize_name(Requirement(spec).name) - if name in members: - found.add(name) - return found - - -def verify_pin_map() -> list[str]: - """Assert ``WORKSPACE_DEP_PINS`` matches the sibling deps each package declares. - - The pin map is hand-maintained, so a sibling dependency added to (or - removed from) a package's ``pyproject.toml`` without a matching map edit - would go unpinned in the built wheel (or leave a pin for a dep that no - longer exists). This compares both directions for every package and returns - a list of human-readable errors, empty when consistent. - """ - from packaging.utils import canonicalize_name - - members = workspace_members() - errors: list[str] = [] - - for path in WORKSPACE_DEP_PINS: - if path not in PACKAGE_PYPROJECTS: - rel = path.relative_to(REPO_ROOT) - errors.append(f"WORKSPACE_DEP_PINS key {rel} is not a workspace package") - - for path in PACKAGE_PYPROJECTS: - declared = declared_sibling_deps(path, members) - mapped = {canonicalize_name(dep) for dep in WORKSPACE_DEP_PINS.get(path, ())} - rel = path.relative_to(REPO_ROOT) - unpinned = declared - mapped - stale = mapped - declared - if unpinned: - errors.append( - f"{rel}: sibling deps declared but missing from WORKSPACE_DEP_PINS: " - f"{sorted(unpinned)}" - ) - if stale: - errors.append( - f"{rel}: WORKSPACE_DEP_PINS lists deps the package no longer declares: " - f"{sorted(stale)}" - ) - return errors + deps = _load_toml(path).get("project", {}).get("dependencies", []) + return { + req.name + for spec in deps + if canonicalize_name((req := Requirement(spec)).name) in members + } def parse_core(version: str) -> tuple[int, int, int]: @@ -355,8 +312,7 @@ def refresh_lockfile() -> None: def read_root_pyproject_version() -> str: """Return ``[project].version`` from the root pyproject.toml.""" - data = tomllib.loads(ROOT_PYPROJECT.read_text(encoding="utf-8")) - return data["project"]["version"] + return _load_toml(ROOT_PYPROJECT)["project"]["version"] def read_uv_lock_workspace_version() -> str: @@ -366,7 +322,7 @@ def read_uv_lock_workspace_version() -> str: stores ``0.2.0b2.dev1`` for a pyproject value of ``0.2.0-beta.2.dev1``). Callers must normalize the comparison side, not this one. """ - data = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + data = _load_toml(REPO_ROOT / "uv.lock") for pkg in data.get("package", []): if pkg.get("name") == "pipefy-workspace": return pkg["version"] @@ -376,21 +332,14 @@ def read_uv_lock_workspace_version() -> str: def verify_lockstep() -> int: """Assert every version-bearing file holds the same version; print mismatches. - First reconciles ``WORKSPACE_DEP_PINS`` against the sibling dependencies - each package declares (see ``verify_pin_map``), then checks the versions. - Returns 0 on success, 1 on any mismatch or missing field. Compares - canonical PEP 440 forms via ``packaging.version.Version`` so the - pyproject string ``0.2.0-beta.2.dev1`` matches uv.lock's normalized - ``0.2.0b2.dev1``. + The version-bearing files include each package's declared workspace-sibling + ``==`` pins, so an unpinned sibling dependency fails here too. Returns 0 on + success, 1 on any mismatch or missing field. Compares canonical PEP 440 + forms via ``packaging.version.Version`` so the pyproject string + ``0.2.0-beta.2.dev1`` matches uv.lock's normalized ``0.2.0b2.dev1``. """ from packaging.version import Version - pin_map_errors = verify_pin_map() - if pin_map_errors: - for err in pin_map_errors: - print(err, file=sys.stderr) - return 1 - raw: dict[str, str] = {} for path in INIT_PATHS: text = path.read_text(encoding="utf-8") @@ -400,9 +349,13 @@ def verify_lockstep() -> int: return 1 raw[str(path.relative_to(REPO_ROOT))] = m.group(2) - for path, deps in WORKSPACE_DEP_PINS.items(): + members = workspace_members() + for path in PACKAGE_PYPROJECTS: + deps = declared_sibling_deps(path, members) + if not deps: + continue text = path.read_text(encoding="utf-8") - for dep in deps: + for dep in sorted(deps): m = workspace_dep_pin_re(dep).search(text) if not m or m.group(2) is None: print(f"missing pinned {dep!r} dependency in {path}", file=sys.stderr) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index 265b60bf..d4b7ff19 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -168,104 +168,48 @@ def test_plugin_manifest_version_matches_real_manifest() -> None: assert m.group("value") == _bump.read_sdk_version() -def test_workspace_dep_pins_never_list_own_name() -> None: - # The re matches a package's own `name = "..."`, so a self-listing would - # rewrite the name field. Guard the invariant the docstring relies on. - own_names = { - "packages/sdk/pyproject.toml": "pipefy", - "packages/auth/pyproject.toml": "pipefy-auth", - "packages/cli/pyproject.toml": "pipefy-cli", - "packages/mcp/pyproject.toml": "pipefy-mcp-server", - } - for path, deps in _bump.WORKSPACE_DEP_PINS.items(): - rel = str(path.relative_to(_bump.REPO_ROOT)) - assert own_names[rel] not in deps +def _write_pkg(path: Path, name: str, deps: tuple[str, ...]) -> None: + dep_lines = "".join(f' "{d}",\n' for d in deps) + path.write_text( + f'[project]\nname = "{name}"\nversion = "0.1.0"\n' + f"dependencies = [\n{dep_lines}]\n", + encoding="utf-8", + ) def test_package_pyprojects_derived_from_workspace() -> None: - # Derived from [tool.uv.workspace].members, not a second hand-maintained - # list, so a new member cannot skip the pin-map reconciliation. + # Derived from [tool.uv.workspace].members, not a hand-maintained list, so a + # new member cannot skip sibling-dependency pinning. assert _bump.PACKAGE_PYPROJECTS for path in _bump.PACKAGE_PYPROJECTS: assert path.exists() -def test_verify_pin_map_consistent_with_repo() -> None: - # Guards the invariant in CI: WORKSPACE_DEP_PINS lists exactly the - # workspace siblings each package actually depends on. - assert _bump.verify_pin_map() == [] - - def test_declared_sibling_deps_keeps_only_workspace_members(tmp_path: Path) -> None: members = {"pipefy", "pipefy-auth", "pipefy-infra"} pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - "[project]\n" - 'name = "pipefy-cli"\n' - 'version = "0.1.0"\n' - "dependencies = [\n" - ' "pipefy==0.3.0-alpha.1",\n' - ' "pipefy-auth==0.3.0-alpha.1",\n' - ' "typer>=0.12",\n' - "]\n", - encoding="utf-8", + _write_pkg( + pyproject, + "pipefy-cli", + ("pipefy==0.3.0-alpha.1", "pipefy-auth==0.3.0-alpha.1", "typer>=0.12"), ) assert _bump.declared_sibling_deps(pyproject, members) == {"pipefy", "pipefy-auth"} -def _write_pkg(path: Path, name: str, deps: tuple[str, ...]) -> None: - dep_lines = "".join(f' "{d}",\n' for d in deps) - path.write_text( - f'[project]\nname = "{name}"\nversion = "0.1.0"\n' - f"dependencies = [\n{dep_lines}]\n", - encoding="utf-8", - ) - - -def test_verify_pin_map_flags_unpinned_sibling( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - sdk = tmp_path / "sdk.toml" - infra = tmp_path / "infra.toml" - # sdk depends on pipefy-infra, but the pin map has no entry for it. - _write_pkg(sdk, "pipefy", ("pipefy-infra==0.1.0",)) - _write_pkg(infra, "pipefy-infra", ()) - monkeypatch.setattr(_bump, "REPO_ROOT", tmp_path) - monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, infra)) - monkeypatch.setattr(_bump, "WORKSPACE_DEP_PINS", {}) - - errors = _bump.verify_pin_map() - - assert any("missing from WORKSPACE_DEP_PINS" in e for e in errors) - - -def test_verify_pin_map_flags_stale_pin( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - sdk = tmp_path / "sdk.toml" - infra = tmp_path / "infra.toml" - # The map pins pipefy-infra for sdk, but sdk no longer declares it. - _write_pkg(sdk, "pipefy", ()) - _write_pkg(infra, "pipefy-infra", ()) - monkeypatch.setattr(_bump, "REPO_ROOT", tmp_path) - monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, infra)) - monkeypatch.setattr(_bump, "WORKSPACE_DEP_PINS", {sdk: ("pipefy-infra",)}) - - errors = _bump.verify_pin_map() - - assert any("no longer declares" in e for e in errors) - - -def test_verify_pin_map_flags_unknown_key( +def test_write_dep_pins_pins_declared_siblings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + # The set to pin is derived from each package's real dependencies: the + # sibling `pipefy` gets pinned (even though it was unpinned), the non-member + # `typer` is left alone, and no hand-maintained map is consulted. sdk = tmp_path / "sdk.toml" - stray = tmp_path / "stray.toml" + cli = tmp_path / "cli.toml" _write_pkg(sdk, "pipefy", ()) - monkeypatch.setattr(_bump, "REPO_ROOT", tmp_path) - monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk,)) - monkeypatch.setattr(_bump, "WORKSPACE_DEP_PINS", {stray: ("pipefy",)}) + _write_pkg(cli, "pipefy-cli", ("pipefy", "typer>=0.12")) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, cli)) - errors = _bump.verify_pin_map() + _bump.write_dep_pins("9.9.9") - assert any("is not a workspace package" in e for e in errors) + cli_text = cli.read_text(encoding="utf-8") + assert '"pipefy==9.9.9"' in cli_text + assert '"typer>=0.12"' in cli_text From b256c78dc909b0931a1d2eba638fdf093cf12b1a Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 6 Jul 2026 14:33:01 -0300 Subject: [PATCH 08/11] refactor(release): derive INIT_PATHS from each package's hatch version-path INIT_PATHS was a hardcoded tuple of the five __version__ files, the last workspace list not derived from a source of truth. Derive it from each package's [tool.hatch.version].path, so a new member's __version__ file is bumped and verified without a manual edit. read_sdk_version no longer trusts INIT_PATHS[0] to be the SDK: it locates the pipefy package by distribution name, so the source-of-truth read stays correct regardless of workspace member order. --- scripts/bump_version.py | 49 +++++++++++++++++++++++++++++--------- tests/test_bump_version.py | 34 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index bf5f0935..ea197e9c 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -21,13 +21,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1] ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" PLUGIN_MANIFEST = REPO_ROOT / ".claude-plugin/plugin.json" -INIT_PATHS = ( - REPO_ROOT / "packages/sdk/src/pipefy_sdk/__init__.py", - REPO_ROOT / "packages/mcp/src/pipefy_mcp/__init__.py", - REPO_ROOT / "packages/cli/src/pipefy_cli/__init__.py", - REPO_ROOT / "packages/auth/src/pipefy_auth/__init__.py", - REPO_ROOT / "packages/infra/src/pipefy_infra/__init__.py", -) + +# The SDK distribution; its __version__ is the lockstep source of truth every +# other version-bearing file is compared against. +SDK_DIST_NAME = "pipefy" def _load_toml(path: Path) -> dict: @@ -41,8 +38,8 @@ def _workspace_pyprojects() -> tuple[Path, ...]: uv is the source of truth for workspace membership: a package it does not know about is never built, locked, or published. Deriving from that list (rather than a second hand-maintained copy) means adding a member cannot - silently escape the pin-map reconciliation below. Members may be literal - paths or globs (``packages/*``); both resolve here. + silently escape sibling-dependency pinning or version bumping. Members may + be literal paths or globs (``packages/*``); both resolve here. """ members = _load_toml(ROOT_PYPROJECT)["tool"]["uv"]["workspace"]["members"] paths: list[Path] = [] @@ -57,6 +54,22 @@ def _workspace_pyprojects() -> tuple[Path, ...]: # lists with no hand-maintained pin map to keep in sync. PACKAGE_PYPROJECTS: tuple[Path, ...] = _workspace_pyprojects() + +def _hatch_version_path(pyproject: Path) -> Path: + """Resolve a package's ``__version__`` file from its ``[tool.hatch.version]`` path. + + hatch already declares which file holds the version (``path`` under + ``[tool.hatch.version]``, relative to the package directory), so deriving + from it keeps the version files in step with the packages, no second list. + """ + rel = _load_toml(pyproject)["tool"]["hatch"]["version"]["path"] + return pyproject.parent / rel + + +# Each package's __version__ file, derived from hatch's version-path config so a +# new workspace member is bumped and verified without editing a hardcoded list. +INIT_PATHS: tuple[Path, ...] = tuple(_hatch_version_path(p) for p in PACKAGE_PYPROJECTS) + # Anchored to the [project] table. The middle alternation walks lines that # don't start with `[` (so a sibling [tool.X] header ends the run), but the # line CONTENTS can contain `[` (so `classifiers = [...]`, `dependencies = @@ -88,12 +101,26 @@ def _workspace_pyprojects() -> tuple[Path, ...]: ) +def _sdk_pyproject() -> Path: + """The SDK package pyproject, located by distribution name, not position. + + Workspace member order is not guaranteed (a ``packages/*`` glob sorts + alphabetically), so the source-of-truth package is found by name. + """ + for pyproject in PACKAGE_PYPROJECTS: + if _load_toml(pyproject)["project"]["name"] == SDK_DIST_NAME: + return pyproject + msg = f"workspace has no {SDK_DIST_NAME!r} package" + raise ValueError(msg) + + def read_sdk_version() -> str: """Return the current ``__version__`` from the SDK package (source of truth).""" - text = INIT_PATHS[0].read_text(encoding="utf-8") + path = _hatch_version_path(_sdk_pyproject()) + text = path.read_text(encoding="utf-8") m = VERSION_ASSIGN_RE.search(text) if not m: - msg = f"No __version__ assignment found in {INIT_PATHS[0]}" + msg = f"No __version__ assignment found in {path}" raise ValueError(msg) return m.group(2) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index d4b7ff19..3cb10dc9 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -185,6 +185,40 @@ def test_package_pyprojects_derived_from_workspace() -> None: assert path.exists() +def test_init_paths_derived_from_hatch_config() -> None: + # Derived from each package's [tool.hatch.version].path, not a hardcoded + # list, so a new member's __version__ file is bumped and verified too. + assert len(_bump.INIT_PATHS) == len(_bump.PACKAGE_PYPROJECTS) + for path in _bump.INIT_PATHS: + assert path.exists() + assert path.name == "__init__.py" + + +def test_read_sdk_version_locates_sdk_by_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A non-SDK package is listed first; read_sdk_version must still find pipefy + # by name rather than trusting member order. + def _pkg(name: str, version: str) -> Path: + (tmp_path / name / "src").mkdir(parents=True) + (tmp_path / name / "src" / "__init__.py").write_text( + f'__version__ = "{version}"\n', encoding="utf-8" + ) + pyproject = tmp_path / name / "pyproject.toml" + pyproject.write_text( + f'[project]\nname = "{name}"\nversion = "0.0.0"\n' + '[tool.hatch.version]\npath = "src/__init__.py"\n', + encoding="utf-8", + ) + return pyproject + + auth = _pkg("pipefy-auth", "1.1.1") + sdk = _pkg("pipefy", "2.2.2") + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (auth, sdk)) + + assert _bump.read_sdk_version() == "2.2.2" + + def test_declared_sibling_deps_keeps_only_workspace_members(tmp_path: Path) -> None: members = {"pipefy", "pipefy-auth", "pipefy-infra"} pyproject = tmp_path / "pyproject.toml" From 2f1d9f798593c706a97694aaf7f9006c7f7eaddc Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 6 Jul 2026 14:37:10 -0300 Subject: [PATCH 09/11] refactor(release): share workspace-sibling iteration, canonicalize SDK lookup write_dep_pins and verify_lockstep walked the workspace with an identical five-line skeleton (members, loop packages, declared_sibling_deps, skip-if-empty, sorted). Extract _packages_with_sibling_deps so both share one traversal and differ only in the per-dep operation. _sdk_pyproject matched the SDK name with a plain == while the sibling checks canonicalize. The boundary that justified it (keep packaging off the write path) does not exist: the bump path already imports packaging via workspace_members. Canonicalize the SDK compare too so all name matching uses one rule. --- scripts/bump_version.py | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index ea197e9c..f2ff5fb3 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -15,7 +15,7 @@ import subprocess import sys import tomllib -from collections.abc import Callable +from collections.abc import Callable, Iterator from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -105,10 +105,14 @@ def _sdk_pyproject() -> Path: """The SDK package pyproject, located by distribution name, not position. Workspace member order is not guaranteed (a ``packages/*`` glob sorts - alphabetically), so the source-of-truth package is found by name. + alphabetically), so the source-of-truth package is found by name, matched + with the same canonicalization the sibling-dependency checks use. """ + from packaging.utils import canonicalize_name + + target = canonicalize_name(SDK_DIST_NAME) for pyproject in PACKAGE_PYPROJECTS: - if _load_toml(pyproject)["project"]["name"] == SDK_DIST_NAME: + if canonicalize_name(_load_toml(pyproject)["project"]["name"]) == target: return pyproject msg = f"workspace has no {SDK_DIST_NAME!r} package" raise ValueError(msg) @@ -194,13 +198,9 @@ def write_dep_pins(new_version: str) -> None: ``[project.dependencies]``, so a newly added inter-package dependency is pinned automatically with no hand-maintained list to update. """ - members = workspace_members() - for path in PACKAGE_PYPROJECTS: - deps = declared_sibling_deps(path, members) - if not deps: - continue + for path, deps in _packages_with_sibling_deps(): text = path.read_text(encoding="utf-8") - for dep in sorted(deps): + for dep in deps: text, count = workspace_dep_pin_re(dep).subn( rf"\g<1>{dep}=={new_version}\g<1>", text, @@ -241,6 +241,19 @@ def declared_sibling_deps(path: Path, members: set[str]) -> set[str]: } +def _packages_with_sibling_deps() -> Iterator[tuple[Path, list[str]]]: + """Yield each package and its declared workspace-sibling deps, skipping none. + + Shared by the pin writer and the verifier so both walk the workspace and + resolve siblings the same way. + """ + members = workspace_members() + for path in PACKAGE_PYPROJECTS: + deps = sorted(declared_sibling_deps(path, members)) + if deps: + yield path, deps + + def parse_core(version: str) -> tuple[int, int, int]: """Parse leading ``X.Y.Z`` from a version string.""" m = CORE_VER_RE.match(version.strip()) @@ -376,13 +389,9 @@ def verify_lockstep() -> int: return 1 raw[str(path.relative_to(REPO_ROOT))] = m.group(2) - members = workspace_members() - for path in PACKAGE_PYPROJECTS: - deps = declared_sibling_deps(path, members) - if not deps: - continue + for path, deps in _packages_with_sibling_deps(): text = path.read_text(encoding="utf-8") - for dep in sorted(deps): + for dep in deps: m = workspace_dep_pin_re(dep).search(text) if not m or m.group(2) is None: print(f"missing pinned {dep!r} dependency in {path}", file=sys.stderr) From 2c96c7dac59dc0cc17c528256eadf26df344c9ae Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 6 Jul 2026 14:56:19 -0300 Subject: [PATCH 10/11] fix(release): fail on duplicate version/pin matches instead of silently taking the first The writers used subn(count=1) and the readers used search(), so a second occurrence of a token (a decoy __version__ in a docstring, a sibling dep also quoted in a dependency group) was silently rewritten-first-and-left or read-first. The count != 1 assertion only caught a missing match, never a duplicate. Drop count=1 from the writers so the existing count != 1 assertion also fails on duplicates (the write still runs only after the check, so no partial write). Replace search() in read_sdk_version and verify_lockstep with a _sole_match helper that returns a match only when exactly one exists. --- scripts/bump_version.py | 35 +++++++++++++++++++-------- tests/test_bump_version.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index f2ff5fb3..0542f2f1 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -101,6 +101,22 @@ def _hatch_version_path(pyproject: Path) -> Path: ) +def _sole_match(pattern: re.Pattern[str], text: str) -> re.Match[str] | None: + """Return the single match of ``pattern`` in ``text``, or ``None`` for zero or many. + + The tokens this script reads (a package's ``__version__``, a quoted sibling + dependency) must be unique in their file. Resolving to the first hit would + let a decoy (a second ``__version__`` line, the same dep quoted in a + dependency group) be silently read; treating "more than one" as no match + surfaces it as a failure instead. + """ + matches = pattern.finditer(text) + first = next(matches, None) + if first is None or next(matches, None) is not None: + return None + return first + + def _sdk_pyproject() -> Path: """The SDK package pyproject, located by distribution name, not position. @@ -122,9 +138,9 @@ def read_sdk_version() -> str: """Return the current ``__version__`` from the SDK package (source of truth).""" path = _hatch_version_path(_sdk_pyproject()) text = path.read_text(encoding="utf-8") - m = VERSION_ASSIGN_RE.search(text) + m = _sole_match(VERSION_ASSIGN_RE, text) if not m: - msg = f"No __version__ assignment found in {path}" + msg = f"Expected exactly one __version__ assignment in {path}" raise ValueError(msg) return m.group(2) @@ -136,7 +152,6 @@ def write_version_to_all_files(new_version: str) -> None: new_text, count = VERSION_ASSIGN_RE.subn( rf'\1"{new_version}"', text, - count=1, ) if count != 1: msg = f"Expected one __version__ assignment in {path}, replaced {count}" @@ -147,7 +162,6 @@ def write_version_to_all_files(new_version: str) -> None: new_root, root_count = ROOT_PROJECT_VERSION_RE.subn( rf'\1"{new_version}"', root_text, - count=1, ) if root_count != 1: msg = ( @@ -161,7 +175,6 @@ def write_version_to_all_files(new_version: str) -> None: new_manifest, manifest_count = PLUGIN_MANIFEST_VERSION_RE.subn( rf"\g{new_version}\g", manifest_text, - count=1, ) if manifest_count != 1: msg = f'Expected one "version" key in {PLUGIN_MANIFEST}, replaced {manifest_count}' @@ -204,7 +217,6 @@ def write_dep_pins(new_version: str) -> None: text, count = workspace_dep_pin_re(dep).subn( rf"\g<1>{dep}=={new_version}\g<1>", text, - count=1, ) if count != 1: msg = f"Expected one {dep!r} dependency in {path}, replaced {count}" @@ -383,18 +395,21 @@ def verify_lockstep() -> int: raw: dict[str, str] = {} for path in INIT_PATHS: text = path.read_text(encoding="utf-8") - m = VERSION_ASSIGN_RE.search(text) + m = _sole_match(VERSION_ASSIGN_RE, text) if not m: - print(f"missing __version__ in {path}", file=sys.stderr) + print(f"expected exactly one __version__ in {path}", file=sys.stderr) return 1 raw[str(path.relative_to(REPO_ROOT))] = m.group(2) for path, deps in _packages_with_sibling_deps(): text = path.read_text(encoding="utf-8") for dep in deps: - m = workspace_dep_pin_re(dep).search(text) + m = _sole_match(workspace_dep_pin_re(dep), text) if not m or m.group(2) is None: - print(f"missing pinned {dep!r} dependency in {path}", file=sys.stderr) + print( + f"missing or ambiguous pinned {dep!r} dependency in {path}", + file=sys.stderr, + ) return 1 raw[f"{path.relative_to(REPO_ROOT)}::{dep}"] = m.group(2) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index 3cb10dc9..f9ede915 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -247,3 +247,52 @@ def test_write_dep_pins_pins_declared_siblings( cli_text = cli.read_text(encoding="utf-8") assert '"pipefy==9.9.9"' in cli_text assert '"typer>=0.12"' in cli_text + + +def test_sole_match_treats_zero_or_many_as_no_match() -> None: + pat = _bump.VERSION_ASSIGN_RE + assert _bump._sole_match(pat, "") is None + assert _bump._sole_match(pat, '__version__ = "1.0.0"\n').group(2) == "1.0.0" + two = '__version__ = "1.0.0"\n__version__ = "2.0.0"\n' + assert _bump._sole_match(pat, two) is None + + +def test_read_sdk_version_rejects_duplicate_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A decoy second __version__ (e.g. in a docstring example) must fail loudly, + # not resolve to the first hit. + (tmp_path / "sdk" / "src").mkdir(parents=True) + (tmp_path / "sdk" / "src" / "__init__.py").write_text( + '__version__ = "1.0.0"\n__version__ = "2.0.0"\n', encoding="utf-8" + ) + pyproject = tmp_path / "sdk" / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "pipefy"\nversion = "0.0.0"\n' + '[tool.hatch.version]\npath = "src/__init__.py"\n', + encoding="utf-8", + ) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (pyproject,)) + + with pytest.raises(ValueError, match="Expected exactly one __version__"): + _bump.read_sdk_version() + + +def test_write_dep_pins_rejects_duplicate_dep_occurrence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # `pipefy` is quoted in both [project.dependencies] and a dependency group; + # pinning the first and leaving the other would drift, so it must fail. + sdk = tmp_path / "sdk.toml" + cli = tmp_path / "cli.toml" + _write_pkg(sdk, "pipefy", ()) + cli.write_text( + '[project]\nname = "pipefy-cli"\nversion = "0.1.0"\n' + 'dependencies = ["pipefy"]\n' + '[dependency-groups]\ndev = ["pipefy==0.0.0"]\n', + encoding="utf-8", + ) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, cli)) + + with pytest.raises(ValueError, match="Expected one 'pipefy' dependency"): + _bump.write_dep_pins("9.9.9") From 47bb6ba86115d5a99875e59f682945e7712a6388 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 6 Jul 2026 15:00:36 -0300 Subject: [PATCH 11/11] refactor(release): extract _sub_exactly_one, route manifest read through _sole_match The write side of the exactly-one invariant was open-coded four times (subn + count != 1 + bespoke raise). Extract _sub_exactly_one as the write-side twin of _sole_match; the noun in each message becomes a label argument. verify_lockstep's plugin-manifest read still used search() (first hit) while every other reader went through _sole_match; route it through the helper too. Fold the two tests' duplicated hatch-package fixtures into a _write_hatch_pkg helper. --- scripts/bump_version.py | 55 +++++++++++++++++++++++--------------- tests/test_bump_version.py | 45 +++++++++++++++---------------- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 0542f2f1..2ba8df81 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -117,6 +117,22 @@ def _sole_match(pattern: re.Pattern[str], text: str) -> re.Match[str] | None: return first +def _sub_exactly_one( + pattern: re.Pattern[str], repl: str, text: str, *, what: str, where: Path +) -> str: + """Substitute the one expected match of ``pattern`` in ``text``; raise otherwise. + + The write-side twin of ``_sole_match``: replacing every match and asserting + the count is exactly one means a decoy occurrence fails loudly instead of + being silently rewritten (first hit) or left behind. + """ + new_text, count = pattern.subn(repl, text) + if count != 1: + msg = f"Expected one {what} in {where}, replaced {count}" + raise ValueError(msg) + return new_text + + def _sdk_pyproject() -> Path: """The SDK package pyproject, located by distribution name, not position. @@ -149,36 +165,33 @@ def write_version_to_all_files(new_version: str) -> None: """Replace ``__version__`` in each package ``__init__.py`` and root workspace meta.""" for path in INIT_PATHS: text = path.read_text(encoding="utf-8") - new_text, count = VERSION_ASSIGN_RE.subn( + new_text = _sub_exactly_one( + VERSION_ASSIGN_RE, rf'\1"{new_version}"', text, + what="__version__ assignment", + where=path, ) - if count != 1: - msg = f"Expected one __version__ assignment in {path}, replaced {count}" - raise ValueError(msg) path.write_text(new_text, encoding="utf-8") root_text = ROOT_PYPROJECT.read_text(encoding="utf-8") - new_root, root_count = ROOT_PROJECT_VERSION_RE.subn( + new_root = _sub_exactly_one( + ROOT_PROJECT_VERSION_RE, rf'\1"{new_version}"', root_text, + what="[project] version assignment", + where=ROOT_PYPROJECT, ) - if root_count != 1: - msg = ( - f"Expected one [project] version assignment in {ROOT_PYPROJECT}, " - f"replaced {root_count}" - ) - raise ValueError(msg) ROOT_PYPROJECT.write_text(new_root, encoding="utf-8") manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") - new_manifest, manifest_count = PLUGIN_MANIFEST_VERSION_RE.subn( + new_manifest = _sub_exactly_one( + PLUGIN_MANIFEST_VERSION_RE, rf"\g{new_version}\g", manifest_text, + what='"version" key', + where=PLUGIN_MANIFEST, ) - if manifest_count != 1: - msg = f'Expected one "version" key in {PLUGIN_MANIFEST}, replaced {manifest_count}' - raise ValueError(msg) PLUGIN_MANIFEST.write_text(new_manifest, encoding="utf-8") @@ -214,13 +227,13 @@ def write_dep_pins(new_version: str) -> None: for path, deps in _packages_with_sibling_deps(): text = path.read_text(encoding="utf-8") for dep in deps: - text, count = workspace_dep_pin_re(dep).subn( + text = _sub_exactly_one( + workspace_dep_pin_re(dep), rf"\g<1>{dep}=={new_version}\g<1>", text, + what=f"{dep!r} dependency", + where=path, ) - if count != 1: - msg = f"Expected one {dep!r} dependency in {path}, replaced {count}" - raise ValueError(msg) path.write_text(text, encoding="utf-8") @@ -436,9 +449,9 @@ def verify_lockstep() -> int: except OSError as exc: print(f"could not read {PLUGIN_MANIFEST}: {exc}", file=sys.stderr) return 1 - m = PLUGIN_MANIFEST_VERSION_RE.search(manifest_text) + m = _sole_match(PLUGIN_MANIFEST_VERSION_RE, manifest_text) if not m: - print(f'missing "version" in {PLUGIN_MANIFEST}', file=sys.stderr) + print(f'expected exactly one "version" in {PLUGIN_MANIFEST}', file=sys.stderr) return 1 raw[str(PLUGIN_MANIFEST.relative_to(REPO_ROOT))] = m.group("value") diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index f9ede915..8834f172 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -177,6 +177,23 @@ def _write_pkg(path: Path, name: str, deps: tuple[str, ...]) -> None: ) +def _write_hatch_pkg(root: Path, name: str, init_body: str) -> Path: + """Write a hatch package (src/__init__.py + pyproject) under root/name. + + Returns the pyproject path. Used by tests that exercise version reading via + the [tool.hatch.version] path. + """ + (root / name / "src").mkdir(parents=True) + (root / name / "src" / "__init__.py").write_text(init_body, encoding="utf-8") + pyproject = root / name / "pyproject.toml" + pyproject.write_text( + f'[project]\nname = "{name}"\nversion = "0.0.0"\n' + '[tool.hatch.version]\npath = "src/__init__.py"\n', + encoding="utf-8", + ) + return pyproject + + def test_package_pyprojects_derived_from_workspace() -> None: # Derived from [tool.uv.workspace].members, not a hand-maintained list, so a # new member cannot skip sibling-dependency pinning. @@ -199,21 +216,8 @@ def test_read_sdk_version_locates_sdk_by_name( ) -> None: # A non-SDK package is listed first; read_sdk_version must still find pipefy # by name rather than trusting member order. - def _pkg(name: str, version: str) -> Path: - (tmp_path / name / "src").mkdir(parents=True) - (tmp_path / name / "src" / "__init__.py").write_text( - f'__version__ = "{version}"\n', encoding="utf-8" - ) - pyproject = tmp_path / name / "pyproject.toml" - pyproject.write_text( - f'[project]\nname = "{name}"\nversion = "0.0.0"\n' - '[tool.hatch.version]\npath = "src/__init__.py"\n', - encoding="utf-8", - ) - return pyproject - - auth = _pkg("pipefy-auth", "1.1.1") - sdk = _pkg("pipefy", "2.2.2") + auth = _write_hatch_pkg(tmp_path, "pipefy-auth", '__version__ = "1.1.1"\n') + sdk = _write_hatch_pkg(tmp_path, "pipefy", '__version__ = "2.2.2"\n') monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (auth, sdk)) assert _bump.read_sdk_version() == "2.2.2" @@ -262,15 +266,8 @@ def test_read_sdk_version_rejects_duplicate_version( ) -> None: # A decoy second __version__ (e.g. in a docstring example) must fail loudly, # not resolve to the first hit. - (tmp_path / "sdk" / "src").mkdir(parents=True) - (tmp_path / "sdk" / "src" / "__init__.py").write_text( - '__version__ = "1.0.0"\n__version__ = "2.0.0"\n', encoding="utf-8" - ) - pyproject = tmp_path / "sdk" / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "pipefy"\nversion = "0.0.0"\n' - '[tool.hatch.version]\npath = "src/__init__.py"\n', - encoding="utf-8", + pyproject = _write_hatch_pkg( + tmp_path, "pipefy", '__version__ = "1.0.0"\n__version__ = "2.0.0"\n' ) monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (pyproject,))