Skip to content

reflex-release: lift unshippable dependency pins when materializing a release - #6889

Open
masenf wants to merge 3 commits into
mainfrom
claude/reflex-release-dev-pin-upgrades-hluw0c
Open

reflex-release: lift unshippable dependency pins when materializing a release#6889
masenf wants to merge 3 commits into
mainfrom
claude/reflex-release-dev-pin-upgrades-hluw0c

Conversation

@masenf

@masenf masenf commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

The problem

A package that depends on a sibling it is waiting for pins the unreleased version β€” widget-core >= 0.2.0.dev1 β€” so the workspace resolves while the sibling is still unpublished. That pin cannot be published: *.dev versions never reach PyPI, so the metadata would be uninstallable. check-dev-pins rejects it at build time, which means someone has to remember to lift it once the sibling is out β€” and finds out at the publish gate, with the changelog already bumped, when they forget.

What this does

Materialization lifts the pin. Every dependency floor the release cannot ship is rewritten to the earliest published version that satisfies the whole requirement, uv.lock is re-resolved, and both land in the release commit alongside the changelog bump β€” through the same review.

Floor Materializing a prerelease Materializing a final version
>= 0.2.0.dev1 earliest published 0.2.0a1, 0.2.0, … earliest published final 0.2.0, …
>= 0.2.0a1 left alone β€” an alpha may ship it lifted to the earliest published final
>= 0.2.0 left alone left alone

A floor no published version satisfies has nowhere to go, so the package is held back at plan time rather than materialized into a version that could never be published: auto-selected packages are dropped from the batch (a lockstep group whole, since its members only release together) and listed in the run summary, while an explicit selection fails the dispatch. Release the depended-on package first and the next release lifts the pin by itself.

Design notes worth reviewing

  1. "Published" means tagged. Resolution reads the repository's own git tags, not PyPI β€” tags are created only after a successful upload, which is already this pipeline's record of what shipped. Offline, deterministic, no new network dependency in the plan step; the cost is that it only works for siblings, and that the release workflows must check out full history and tags (they do).
  2. Dependencies outside the repository. A *.dev floor on one holds the release back (it is unpublishable regardless, and check-dev-pins would fail the build anyway); a prerelease floor on one is left alone, since its releases are not recorded here and pinning a beta is somebody's deliberate choice.
  3. Lifting prerelease floors on a final release is beyond the literal problem, and closes it. Without it the feature leaks: an alpha release rewrites >= 0.2.0.dev1 to >= 0.2.0a1, check-dev-pins is satisfied, and the subsequent final release ships a floor that resolves users onto an alpha. Happy to scope this back to dev floors only if you would rather.
  4. uv lock is the lock refresh, auto-detected from a root uv.lock and skipped when there is none. Not configurable β€” the tool already assumes uv everywhere (cli-command, setup-uv in every generated job).
  5. Lockstep siblings under pin-exact are skipped, since pin-lockstep rewrites those to == <version> at build time and nothing declared here is shipped.
  6. The upgrade is atomic and narrowly staged. Pin rewrites and the lock refresh succeed together or are rolled back, so a re-run never finds the pins already lifted and skips re-locking. materialize reports the paths it rewrote as a repinned output β€” it runs as a separate process from the delivery step β€” and _commit_changelogs (now _commit_materialized) stages exactly those beside the changelogs, so unrelated worktree edits stay out of the release commit.

dispatch_release.yml changes (the materialize step gains an id, the two delivery steps a REPINNED_JSON env), so adopting repos need a reflex-release sync; the PR drift check enforces it.

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

uv run pytest tests/units/reflex_release β€” 299 passed (34 new here): bound resolution across both prerelease modes, the whole-specifier-set constraint, requirement rewriting that preserves extras/markers/other specifiers and relaxes a strict floor, TOML basic- and literal-string rewriting, the lockstep and outside-dependency exemptions, rollback on a failed lock and on a mid-batch failure, and the plan-time hold-back in explicit, auto and lockstep-group forms. ruff check, ruff format --check and pyright clean.

Rebased onto main now that #6888 has merged; the two features compose cleanly (verified the rendered workflows carry both the post-release dispatch step and the repinned threading).

@masenf
masenf requested a review from a team as a code owner August 14, 2026 16:39
@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Materialization now replaces unshippable dependency floors with the earliest suitable tagged sibling release and refreshes uv.lock.

  • Holds back packages when no suitable published version exists.
  • Propagates rewritten paths through the workflow so dependency metadata and the lock file join the release commit.
  • Adds documentation and unit coverage for resolution, rollback, lockstep, and delivery behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/reflex-release/src/reflex_release/devpins.py Implements tagged-version resolution, requirement rewriting, lock refresh, and rollback without leaving an eligible follow-up issue.
packages/reflex-release/src/reflex_release/commands.py Integrates pin eligibility into planning and stages materialized dependency changes alongside changelogs.
packages/reflex-release/src/reflex_release/cli.py Propagates the materialization output into release delivery commands.
packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml Wires materialization outputs into both prerelease and release-PR delivery paths.
tests/units/reflex_release/test_devpins.py Covers requirement resolution, rewriting, lock refresh, rollback, and blocking behavior.
tests/units/reflex_release/test_commands.py Covers planning holdbacks and release-commit handling for rewritten files.

Reviews (4): Last reviewed commit: "Roll back a partly-applied pin batch, no..." | Re-trigger Greptile

Comment thread packages/reflex-release/src/reflex_release/devpins.py
@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

βœ… 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing claude/reflex-release-dev-pin-upgrades-hluw0c (f113690) with main (092aa4f)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 7 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/reflex-release/src/reflex_release/devpins.py Outdated
Comment thread packages/reflex-release/src/reflex_release/devpins.py
Comment thread packages/reflex-release/src/reflex_release/devpins.py Outdated
Comment thread packages/reflex-release/src/reflex_release/commands.py Outdated
Comment thread packages/reflex-release/src/reflex_release/devpins.py

masenf commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

All four outstanding points addressed in 75e98b5. (The two shallow-clone threads @masenf already closed as by-design β€” agreed, and the README now says so where it explains that "published" means tagged.)

Strict floor rewritten wrong (P1) β€” a real bug, good catch. widget-core > 0.2.0.dev1 admits 0.2.0, so 0.2.0 is what it resolves to, and rewriting the operator verbatim produced > 0.2.0 β€” excluding the very release the requirement had just been lifted onto. Under an upper bound (> 0.2.0.dev1, < 0.3 with only 0.2.0 in range) it becomes unsatisfiable outright. A strict floor over an unreleased version now becomes an inclusive floor over the release above it.

I also added the invariant behind it: rewritten() now checks the result against the version it resolved to and fails rather than returning it. Reverting just the operator mapping, that check fires on its own β€” lifting 'widget-core > 0.2.0.dev1' produced 'widget-core > 0.2.0', which 0.2.0 does not satisfy β€” so a future gap in the operator handling can't publish metadata that resolves to nothing.

TOML escaping (P2) β€” real, and fail-safe rather than corrupting, but it aborts a release for a legal input. Confirmed the mechanism: for "widget-core >= 0.2.0.dev1; python_version > \"3.10\"", tomllib hands back the unescaped value, so the quoted search found 0 occurrences. Both spellings a value can have are now tried β€” an escaped basic string, and a literal string, which cannot escape anything. Not worth a round-tripping TOML dependency on the release path for this.

Non-atomic upgrade (P2) β€” real, though it needs a hand-run to bite. In CI a failed materialize fails the job on a throwaway checkout, so nothing is committed; locally the tree keeps lifted pins beside the old lock, and a re-run finds nothing left to lift, skips the refresh, and could commit exactly that pairing. The pyproject rewrites are now rolled back when uv lock cannot follow them, so a re-run has the same work to do.

Over-broad staging (P2) β€” you're right that the comment overclaimed. The obstacle was that materialize and the delivery step are separate processes, so the committing side could not know what had been rewritten. Fixed by making it known: materialize now emits a repinned output listing the paths, threaded to open-release-pr/push-prerelease the way releases already flows from plan. Only those paths are staged, so an unrelated uncommitted edit to a released package's pyproject.toml stays out of the release commit β€” there's a test for exactly that.

279 passing (6 new); ruff check, ruff format --check and pyright clean. Note dispatch_release.yml changed (the materialize step gained an id and the two delivery steps a REPINNED_JSON env), so this needs a reflex-release sync in any adopting repo β€” the PR drift check enforces it.


Generated by Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 7 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/reflex-release/src/reflex_release/devpins.py
Comment thread packages/reflex-release/src/reflex_release/devpins.py
claude added 3 commits August 15, 2026 00:28
A package that depends on a sibling it is waiting for pins the unreleased
version β€” `widget-core >= 0.2.0.dev1` β€” so the workspace resolves while the
sibling is still unpublished. That pin cannot be published, and `check-dev-pins`
rejects it at build time, which leaves someone to remember to lift it by hand
once the sibling is out.

Materialization does it instead. Every floor the release cannot ship β€” a `*.dev`
floor, and a prerelease floor on a sibling when the version being materialized
is final β€” is rewritten to the earliest published version that satisfies the
whole requirement, `uv.lock` is re-resolved, and both land in the release commit
alongside the changelog bump, through the same review.

"Published" means tagged: tags are created only after a successful upload, so
the repository's own tags are its record of what is on PyPI. A published
prerelease therefore satisfies a floor only when the release being materialized
is itself a prerelease; a final version never floors its users on a sibling's
alpha.

A floor no published version satisfies has nowhere to go, so the package is held
back at plan time rather than materialized into a version that could never be
published: auto-selected packages are dropped from the batch (a lockstep group
whole, since its members only release together) and listed in the run summary,
while an explicit selection fails the dispatch.

Two things are left alone: a floor on a lockstep sibling that `pin-exact`
rewrites at build time anyway, and a prerelease floor on a dependency outside the
repository, whose releases are not recorded here. A `*.dev` floor on an outside
dependency still holds the package back β€” that pin is unpublishable whoever owns
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B13GVXzLdYzmqmMnaKWvyV
Fix a real rewrite bug. `widget-core > 0.2.0.dev1` admits 0.2.0, so 0.2.0 can be
what it resolves to β€” but rewriting the operator verbatim produced
`> 0.2.0`, excluding the very release the requirement had just been lifted onto,
and under an upper bound that could leave it unsatisfiable. A strict floor over
an unreleased version now becomes an inclusive floor over the release above it,
and every rewrite is checked against the version it resolved to before being
written, so a future gap in the operator handling fails loudly instead of
publishing metadata that resolves to nothing.

Handle TOML escaping. A requirement carrying a double-quoted marker is escaped in
the file but comes back from the parser unescaped, so searching for the parsed
value found nothing and aborted materialization. Both spellings a value can have
β€” an escaped basic string and a literal string, which cannot escape anything β€”
are now tried.

Make the upgrade atomic. If `uv lock` failed, the rewritten pyproject files were
already on disk beside the old lock file, and a re-run would find nothing left to
lift, skip the lock refresh, and could commit exactly that pairing. The rewrites
are rolled back when the lock cannot follow them.

Stage only what materialization wrote. `_commit_materialized` promised as much
but built its list from every released package's pyproject plus the lock file
whenever one existed, so an unrelated uncommitted edit to one of those tracked
files would ride along in the release commit. `materialize` now reports the paths
it rewrote as a `repinned` output β€” it runs as a separate process from the
delivery step, which cannot otherwise know β€” and only those are staged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B13GVXzLdYzmqmMnaKWvyV
The rollback guard started one line too late. `apply_pin_upgrades` rewrites
package by package, so a requirement the second package cannot be given β€” one
that appears twice, or a rewrite its resolved version would not satisfy β€” left
the first package's pins lifted in the worktree while the command failed.

Bring the rewrite itself inside the guard, so the whole batch and the lock file
move together or not at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B13GVXzLdYzmqmMnaKWvyV
@masenf
masenf force-pushed the claude/reflex-release-dev-pin-upgrades-hluw0c branch from fc0dc7b to f113690 Compare August 15, 2026 00:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants