build(release): pin workspace siblings to the lockstep version#367
Conversation
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.
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.
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.
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.
There was a problem hiding this comment.
Solid release-metadata fix.
Pinning workspace siblings to the lockstep version in published pyproject.toml metadata closes a real gap: [tool.uv.sources] only applies in-repo, so a public pip install pipefy-cli==X could previously pull newer siblings from PyPI. I checked out the branch locally — 35 unit tests pass, bump_version.py verify is clean, and the built pipefy_cli wheel carries Requires-Dist: pipefy==0.3.0-alpha.1 (and pipefy-auth) as intended.
Sharing one regex between write_dep_pins and verify_lockstep is a nice maintainability win, and the release workflow wheel-count bound is a cheap guard against a sixth workspace member shipping unnoticed.
What worked well
workspace_dep_pin_reserves both writer and verifier — no duplicate hand-maintained patterns.- Regex tests cover the scary cases (
pipefyvspipefy-infra,[tool.uv.sources]keys, description prose) plus a guard thatWORKSPACE_DEP_PINSnever lists a package's own name. - Plugin manifest folded into lockstep closes the drift you called out (
0.2.0-beta.1vs0.3.0-alpha.1).
Also noted
- The automation table in
RELEASE.mdstill describes the pre-PRverifyscope (five__version__strings + rootpyproject.toml). Worth a quick update to mention sibling==pins,.claude-plugin/plugin.json, and the release workflow's exact-five-wheels guard — saves the next release cutter a head-scratch when verify fails on a pin.
…cope 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.
|
Good catch on the automation table drift. Fixed in 009d706: the reference now covers the sibling |
adriannoes
left a comment
There was a problem hiding this comment.
Thanks for the follow-up — the RELEASE.md automation table now matches the expanded verify scope (sibling pins, plugin manifest, uv.lock, and the five-wheel guard). No further notes from my side; still good to merge.
…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.
…in 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.
…n-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.
…K 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.
…ly 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.
…ugh _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.
adriannoes
left a comment
There was a problem hiding this comment.
Looked at the six commits since the docs follow-up. The move to derive package lists, INIT_PATHS, and sibling pins from uv.workspace / hatch config / declared [project.dependencies] removes the hand-maintained maps we were worried about, and _sole_match / _sub_exactly_one close the silent-first-match hole. CI green; bump_version.py verify passes locally. Good to merge.
Why
Published wheels declared their workspace siblings unpinned (
pipefy,pipefy-auth,pipefy-infra). The[tool.uv.sources]workspace mapping only applies in-repo, so a publicpip install pipefy-cli==Xcould resolve a sibling to any newer version on PyPI instead of the one built and tested alongside it. All five distributions share one lockstep version and are only ever tested together at that version, so the published dependency closure has to resolve to it too.Two related gaps let a release ship wrong with no signal:
.claude-plugin/plugin.json) carries the version the marketplace shows, but nothing kept it in lockstep. It had already drifted to0.2.0-beta.1while the workspace shipped0.3.0-alpha.1.What
[project.dependencies], so a new inter-package dependency is pinned automatically.verify.dist/holds a wheel outside the known workspace packages, forcing a deliberate decision before a new package publishes to PyPI.bump_version.pyreads or writes is derived from a source of truth another tool already enforces: workspace membership from[tool.uv.workspace], each__version__file from[tool.hatch.version].path, sibling pins from[project.dependencies]. Adding a member needs no hand-maintained list edits.CI runs
bump_version.py verify, which folds the sibling pins, the plugin manifest, the rootpyproject.toml, anduv.lockinto one all-must-match comparison (canonicalized viapackaging.version.Version, so==0.3.0-alpha.1matches uv's normalized0.3.0a1). A duplicate or decoy version/pin occurrence fails loudly rather than being silently rewritten.uv lock --lockedstays clean, since the pins do not move the lockfile and workspace sources still resolve in dev.