Skip to content

build(release): pin workspace siblings to the lockstep version#367

Merged
gbrlcustodio merged 11 commits into
devfrom
ci/pin-workspace-siblings
Jul 6, 2026
Merged

build(release): pin workspace siblings to the lockstep version#367
gbrlcustodio merged 11 commits into
devfrom
ci/pin-workspace-siblings

Conversation

@gbrlcustodio

@gbrlcustodio gbrlcustodio commented Jul 4, 2026

Copy link
Copy Markdown
Member

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 public pip install pipefy-cli==X could 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:

  • The Claude plugin manifest (.claude-plugin/plugin.json) carries the version the marketplace shows, but nothing kept it in lockstep. It had already drifted to 0.2.0-beta.1 while the workspace shipped 0.3.0-alpha.1.
  • The release wheel check confirmed each known package built one wheel, but not that no other wheel existed. A new workspace member would build a wheel that never gets staged for PyPI, shipping a partial release unnoticed.

What

  • Pin each package's workspace siblings to the exact lockstep version in its published metadata. The set to pin is derived from each package's real [project.dependencies], so a new inter-package dependency is pinned automatically.
  • Track the plugin manifest version in lockstep: written on every bump, asserted by verify.
  • Fail the release build when dist/ holds a wheel outside the known workspace packages, forcing a deliberate decision before a new package publishes to PyPI.
  • Everything bump_version.py reads 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 root pyproject.toml, and uv.lock into one all-must-match comparison (canonicalized via packaging.version.Version, so ==0.3.0-alpha.1 matches uv's normalized 0.3.0a1). A duplicate or decoy version/pin occurrence fails loudly rather than being silently rewritten. uv lock --locked stays clean, since the pins do not move the lockfile and workspace sources still resolve in dev.

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.
@gbrlcustodio
gbrlcustodio marked this pull request as draft July 4, 2026 05:21
@gbrlcustodio
gbrlcustodio requested a review from adriannoes July 4, 2026 05:21
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.
adriannoes
adriannoes previously approved these changes Jul 4, 2026

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_re serves both writer and verifier — no duplicate hand-maintained patterns.
  • Regex tests cover the scary cases (pipefy vs pipefy-infra, [tool.uv.sources] keys, description prose) plus a guard that WORKSPACE_DEP_PINS never lists a package's own name.
  • Plugin manifest folded into lockstep closes the drift you called out (0.2.0-beta.1 vs 0.3.0-alpha.1).

Also noted

  • The automation table in RELEASE.md still describes the pre-PR verify scope (five __version__ strings + root pyproject.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.
@gbrlcustodio

Copy link
Copy Markdown
Member Author

Good catch on the automation table drift. Fixed in 009d706: the reference now covers the sibling == pins, .claude-plugin/plugin.json, and uv.lock that verify asserts, plus the release workflow's exact-five-wheels guard.

@gbrlcustodio
gbrlcustodio marked this pull request as ready for review July 6, 2026 16:57
@gbrlcustodio
gbrlcustodio requested a review from adriannoes July 6, 2026 16:57
adriannoes
adriannoes previously approved these changes Jul 6, 2026

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@gbrlcustodio
gbrlcustodio requested a review from adriannoes July 6, 2026 18:02

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gbrlcustodio
gbrlcustodio merged commit 6beac41 into dev Jul 6, 2026
2 checks passed
@gbrlcustodio
gbrlcustodio deleted the ci/pin-workspace-siblings branch July 6, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants