From 1c45493496d770381942e3eeb258a85401fc1e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:38:40 +0000 Subject: [PATCH 1/3] Lift unshippable dependency pins when materializing a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01B13GVXzLdYzmqmMnaKWvyV --- packages/reflex-release/README.md | 48 +- .../news/+dev-pin-upgrades.feature.md | 1 + .../src/reflex_release/commands.py | 124 ++++- .../src/reflex_release/devpins.py | 431 +++++++++++++++++- .../templates/workflows/dispatch_release.yml | 6 + tests/units/reflex_release/test_commands.py | 108 ++++- tests/units/reflex_release/test_devpins.py | 243 +++++++++- 7 files changed, 927 insertions(+), 34 deletions(-) create mode 100644 packages/reflex-release/news/+dev-pin-upgrades.feature.md diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 858c7b6dc85..48826541686 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -178,6 +178,45 @@ This gives you, for free: `pin-exact` rewrites the requirement in the publishing package's `pyproject.toml` at build time only; it is never committed. +### Dependency pins across a release + +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. + +Materialization does it instead. When *Dispatch release* plans a release, each +selected package's published dependencies are checked for a floor the release +cannot ship, and the floor is lifted to the **earliest published version that +satisfies the whole requirement**: + +| 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 | + +"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. The rewritten +`pyproject.toml` files and the re-resolved `uv.lock` are part of the release +commit, so they land through the same review as the changelog bump. + +A floor nothing published satisfies has nowhere to go, and the package is +**held back** 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; +an explicitly selected one fails the dispatch. Release the depended-on package +first and the next release lifts the pin by itself. + +Two things are deliberately 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 and +whose pin is somebody's deliberate choice. A `*.dev` floor on an outside +dependency still holds the package back — that pin is unpublishable whoever +owns it. + ## Adding towncrier `init` writes this for you if `[tool.towncrier]` is absent. If you configure it @@ -478,6 +517,10 @@ comma-separated text field; see `dispatch-package-inputs`. | `release-patch` / `-minor` / `-major` | Final version straight from `main`. Opens a PR. | | `release-post` | `1.2.3.post1`, for packaging-only fixes. Opens a PR. | +A package whose dependency pins no published version satisfies is held back and +listed in the run summary — see +[Dependency pins across a release](#dependency-pins-across-a-release). + Release actions open a pull request; **merging it is what publishes.** The push to `main` triggers `release_from_changelog`, which builds every untagged changelog version and waits for the `pypi` approval before uploading. Only then @@ -590,7 +633,7 @@ a flag for running the same command by hand. | `create [--package P] NAME` | Create a news fragment. | | `packages` | List releasable packages. | | `plan` | Compute the next version of each selected package. | -| `materialize` | Run towncrier and (for `release-from-prerelease`) collapse alphas. | +| `materialize` | Run towncrier, lift unshippable dependency pins, collapse alphas. | | `open-release-pr` / `push-prerelease` | Commit the changelogs and deliver them. | | `detect` | List packages whose newest changelog version has no tag. | | `prepare-publish` | Validate a package/version and emit build metadata. | @@ -635,7 +678,8 @@ a flag for running the same command by hand. artifact that was built and validated before the approval. - **Detection fails closed.** A broken lockstep pair, a version the branch may not publish, or a `*.dev` pin stops the batch rather than shipping something - uninstallable. + uninstallable. A pin a published version *can* satisfy is lifted in the + release commit instead, so the same rule does not turn into busywork. ## License diff --git a/packages/reflex-release/news/+dev-pin-upgrades.feature.md b/packages/reflex-release/news/+dev-pin-upgrades.feature.md new file mode 100644 index 00000000000..356e4773c8a --- /dev/null +++ b/packages/reflex-release/news/+dev-pin-upgrades.feature.md @@ -0,0 +1 @@ +Materialization now lifts dependency pins a release cannot ship. A `*.dev` floor — and, for a final version, a prerelease floor on a sibling package — is rewritten to the earliest published version that satisfies the requirement, `uv.lock` is re-resolved, and both land in the release commit alongside the changelog bump. "Published" means tagged, which is this pipeline's record of what reached PyPI, so a prerelease satisfies a floor only when the version being materialized is itself a prerelease. A floor no published version satisfies has nowhere to go, so the package is held back at plan time — dropped from an auto-selection (a lockstep group whole), an error for an explicit one — rather than materialized into a version that could never be published. diff --git a/packages/reflex-release/src/reflex_release/commands.py b/packages/reflex-release/src/reflex_release/commands.py index 34b3a787e0e..a81951cbb81 100644 --- a/packages/reflex-release/src/reflex_release/commands.py +++ b/packages/reflex-release/src/reflex_release/commands.py @@ -33,6 +33,7 @@ parse_sections, ) from .config import POST_RELEASE_INPUTS, POST_RELEASE_WORKFLOW_KEY, Config, is_final +from .devpins import LOCK_FILE, blocking_pins, describe_blockers, upgrade_dev_pins from .discovery import ( alpha_train_packages, build_changelog, @@ -61,7 +62,7 @@ remote_branch_exists, tag_exists, ) -from .versions import ACTIONS, next_version, release_date_today +from .versions import ACTIONS, FINAL_ACTIONS, next_version, release_date_today #: Filename of the scaffolded workflow that publishes untagged changelog versions. RELEASE_WORKFLOW = "release_from_changelog.yml" @@ -237,6 +238,63 @@ def cmd_detect(config: Config, ref_name: str) -> None: fail("lockstep invariant violated; no package was published") +def _drop_unpublishable_pins( + config: Config, packages: list[str], action: str, explicit: bool +) -> tuple[list[str], list[str]]: + """Hold back packages whose dependency pins no published version satisfies. + + Materialization lifts a ``*.dev`` (and, for a final version, a prerelease) + dependency floor to the earliest published version that satisfies it. A + floor nothing published satisfies has nowhere to go, so the package is not + releasable yet — releasing it would either publish an uninstallable pin or + stop at the publish-time gate with the changelog already bumped. + + A lockstep group is held back whole: its members only ever release together. + + Args: + config: The repository configuration. + packages: The selected packages, lockstep groups already expanded. + action: The release action being planned. + explicit: Whether the selection was made by hand. An explicit selection + that cannot be released is an error; an auto-selected package is + simply left out of the batch. + + Returns: + The releasable packages and the human-readable reasons the others were + held back. + """ + blocked = blocking_pins( + config, packages, allow_prereleases=action not in FINAL_ACTIONS + ) + if not blocked: + return packages, [] + + reasons = describe_blockers(blocked) + if explicit: + listing = "\n".join(f" {line}" for line in reasons) + fail( + "the selected package(s) declare dependency pins that no published " + f"version satisfies:\n{listing}\n\nRelease the depended-on package(s) " + "first; the next release lifts these pins automatically." + ) + + held = { + member + for package in blocked + for member in (package, *config.lockstep_partners(package)) + } + for line in reasons: + notice(f"held back from this release — {line}") + remaining = [package for package in packages if package not in held] + if not remaining: + fail( + "every auto-selected package declares a dependency pin that no " + "published version satisfies:\n" + + "\n".join(f" {line}" for line in reasons) + ) + return remaining, reasons + + def cmd_plan(config: Config, action: str, selection: str) -> None: """Plan the next version for each selected package. @@ -244,7 +302,8 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: ``$GITHUB_OUTPUT``. An empty selection auto-detects the packages to release: those with pending news fragments — or, for ``release-from-prerelease``, those whose changelog is topped by an alpha (their fragments are already - consumed). + consumed). A package whose dependency pins no published version satisfies is + not eligible either way (see :func:`_drop_unpublishable_pins`). Args: config: The repository configuration. @@ -284,6 +343,10 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: if partner not in packages ) + packages, disqualified = _drop_unpublishable_pins( + config, packages, action, explicit=how == "explicit" + ) + releases: list[dict[str, str]] = [] for package in packages: group = config.lockstep_group(package) @@ -321,6 +384,16 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: for r in releases ], ), + *( + [ + "", + "### Held back", + "", + *(f"- {line}" for line in disqualified), + ] + if disqualified + else [] + ), ]) write_outputs(releases=json.dumps(releases)) @@ -328,6 +401,11 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: def cmd_materialize(config: Config, action: str, releases_json: str) -> None: """Write the planned versions into the changelogs via towncrier. + Also lifts every dependency pin the release cannot ship — a ``*.dev`` floor, + or a prerelease floor when the release is final — to the earliest published + version that satisfies it, and re-locks the repository, so the release + carries pins that resolve instead of failing the publish-time gate. + For ``release-from-prerelease``, collapses the alpha sections of each changelog into the single final-version section after building it. @@ -339,6 +417,20 @@ def cmd_materialize(config: Config, action: str, releases_json: str) -> None: releases: list[dict[str, str]] = json.loads(releases_json) if not releases: fail("nothing to materialize: the release plan is empty") + + # Before towncrier: a pin that cannot be lifted stops the release while the + # news fragments it would have consumed are still on disk. + if repinned := upgrade_dev_pins( + config, + [release["package"] for release in releases], + allow_prereleases=action not in FINAL_ACTIONS, + ): + write_summary([ + "## Dependency pins lifted", + "", + *(f"- `{path}`" for path in repinned), + ]) + collapse = action == "release-from-prerelease" categories = category_order(config) if collapse else [] heading_format = title_format(config) @@ -721,10 +813,10 @@ def _release_summary(releases: list[dict[str, str]]) -> str: return ", ".join(f"{r['package']}@{r['next']}" for r in releases) -def _commit_changelogs( +def _commit_materialized( config: Config, releases: list[dict[str, str]], message: str ) -> None: - """Stage and commit the changelogs materialized for a release. + """Stage and commit everything materialization wrote for a release. Args: config: The repository configuration. @@ -732,9 +824,11 @@ def _commit_changelogs( message: The commit message. """ configure_bot_identity(config.root) - # Only the changelogs of the packages being released, so nothing else in the - # worktree can ride along in the release commit. towncrier has already - # staged the deletion of every fragment it consumed. + # Only what materialization writes for the packages being released — their + # changelogs and the dependency pins in their own pyproject.toml, plus the + # lock file those pins are resolved in — so nothing else in the worktree can + # ride along in the release commit. towncrier has already staged the + # deletion of every fragment it consumed. changelogs = [ path.relative_to(config.root).as_posix() for path in (config.changelog_path(r["package"]) for r in releases) @@ -742,7 +836,17 @@ def _commit_changelogs( ] if not changelogs: fail("materialization produced no changelog; nothing to release") - git_run(["add", "--", *changelogs], config.root) + pins = [ + path.relative_to(config.root).as_posix() + for path in ( + config.package_path(r["package"]) / "pyproject.toml" for r in releases + ) + if path.is_file() + ] + lock = config.root / LOCK_FILE + if lock.is_file(): + pins.append(LOCK_FILE) + git_run(["add", "--", *changelogs, *pins], config.root) if not git(["diff", "--cached", "--name-only"], config.root).strip(): fail("materialization produced no changes; nothing to release") git_run(["commit", "-m", message], config.root) @@ -801,7 +905,7 @@ def cmd_open_release_pr( body_file = Path(os.environ.get("RUNNER_TEMP", ".")) / "release_pr_body.md" body_file.write_text(body, encoding="utf-8") - _commit_changelogs( + _commit_materialized( config, releases, f"Materialize changelogs for {summary} ({action})" ) git_push(f"HEAD:refs/heads/{branch}", config.root) @@ -881,7 +985,7 @@ def cmd_push_prerelease( if remote_branch_exists(config.root, branch): branch = f"{branch}-{run_id}" - _commit_changelogs( + _commit_materialized( config, releases, f"Materialize changelogs for {summary} ({action})" ) git_push(f"HEAD:refs/heads/{branch}", config.root) diff --git a/packages/reflex-release/src/reflex_release/devpins.py b/packages/reflex-release/src/reflex_release/devpins.py index 5696da3b286..496589641e3 100644 --- a/packages/reflex-release/src/reflex_release/devpins.py +++ b/packages/reflex-release/src/reflex_release/devpins.py @@ -1,21 +1,41 @@ -"""The development-release dependency pin gate. +"""The dependency pin gate, and the pin upgrades that clear it. Development releases (``*.dev``) are not published to PyPI, so a package whose -published metadata pins one cannot be installed by downstream users. This gate -keeps such pins out of a release. Only each package's *own* published -dependencies are inspected — siblings are not followed — so the usual leaf-first -release flow (publish the depended-on package, then drop the dev pin in the -dependent) is never deadlocked by a pin in another package. +published metadata pins one cannot be installed by downstream users. +:func:`check_dev_pins` is the gate that keeps such pins out of a release. Only +each package's *own* published dependencies are inspected — siblings are not +followed — so the usual leaf-first release flow (publish the depended-on +package, then drop the dev pin in the dependent) is never deadlocked by a pin in +another package. + +Dropping the pin by hand is the step that flow keeps forgetting, so +materialization does it: :func:`upgrade_dev_pins` lifts every lower bound the +release cannot ship to the earliest published version that satisfies it, and +re-locks the repository, landing both in the release commit. A bound that no +published version satisfies has no upgrade, and the package is held back from +the release instead (see :func:`blocking_pins`) rather than materialized into a +version that could never be published. + +"Published" means tagged: tags are created only after a successful upload, so +the repository's own tags are the record of what is on PyPI. A dependency +outside the repository has no such record here, which is why only a *dev* bound +on one blocks a release — that pin is unpublishable whoever owns it — while a +prerelease bound on an outside dependency is left alone. """ from __future__ import annotations +import dataclasses +import re +import subprocess + from packaging.requirements import InvalidRequirement, Requirement from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version from .actions import echo, fail -from .config import Config, load_pyproject +from .config import Config, is_final, load_pyproject +from .gitutil import tag_versions # PEP 440 operators that establish a version floor the resolved version must meet # or match. A development release under one of these is an unpublished @@ -23,6 +43,47 @@ # (``!=``) leaves the requirement resolvable from PyPI, so it is not a dev pin. _LOWER_BOUND_OPERATORS = frozenset({"===", "==", "~=", ">=", ">"}) +#: The lock file re-resolved after a pin upgrade, and the tool that rewrites it. +LOCK_FILE = "uv.lock" + +# One version specifier of a PEP 508 requirement, matched so a single bound can +# be lifted in place without disturbing extras, markers or the other +# specifiers. The operator alternation is longest-first: ``>=`` has to win over +# ``>``, and ``===`` over ``==``. +_SPECIFIER_RE = re.compile( + r"(?P===|==|~=|>=|>)(?P\s*)(?P[^\s,;\]]+)" +) + + +def _unshippable_bounds( + parsed: Requirement, allow_prereleases: bool +) -> tuple[str, ...]: + """Return the lower bounds of a requirement a release cannot ship as they are. + + Args: + parsed: The parsed requirement. + allow_prereleases: Whether a prerelease floor is shippable. Dev releases + never are; a prerelease floor is fine for a release that is itself a + prerelease, and unwanted in a final release, whose dependency floors + should not drag users onto an alpha. + + Returns: + The offending versions exactly as written in the requirement, so they can + be found again in the source text. + """ + bounds: list[str] = [] + for specifier in parsed.specifier: + if specifier.operator not in _LOWER_BOUND_OPERATORS: + continue + try: + bound = Version(specifier.version) + except InvalidVersion: + # A prefix match such as ``==1.2.*`` has no concrete version to inspect. + continue + if bound.is_devrelease or (bound.is_prerelease and not allow_prereleases): + bounds.append(specifier.version) + return tuple(bounds) + def parse_requirement(requirement: str) -> tuple[str, bool]: """Split a PEP 508 requirement into its canonical name and dev-pin status. @@ -39,17 +100,10 @@ def parse_requirement(requirement: str) -> tuple[str, bool]: parsed = Requirement(requirement) except InvalidRequirement: return "", False - name = canonicalize_name(parsed.name) - for specifier in parsed.specifier: - if specifier.operator not in _LOWER_BOUND_OPERATORS: - continue - try: - if Version(specifier.version).is_devrelease: - return name, True - except InvalidVersion: - # A prefix match such as ``==1.2.*`` has no concrete version to inspect. - continue - return name, False + return ( + canonicalize_name(parsed.name), + bool(_unshippable_bounds(parsed, allow_prereleases=True)), + ) def published_dependencies(project: dict) -> list[str]: @@ -100,3 +154,344 @@ def check_dev_pins(config: Config, packages: list[str]) -> None: "publishing." ) echo(f"No development-release dependency pins found in {len(targets)} package(s).") + + +@dataclasses.dataclass(frozen=True) +class PinUpgrade: + """One dependency lower bound a release has to lift before it can publish. + + Attributes: + package: The package declaring the requirement. + requirement: The requirement string, verbatim as written in + ``pyproject.toml``. + dependency: The canonical distribution name it depends on. + bounds: The offending lower-bound versions, as written. + resolved: The earliest published version that satisfies the requirement, + or None when no published version does — which disqualifies the + package from the release. + reason: Why no published version satisfies it (empty when one does). + """ + + package: str + requirement: str + dependency: str + bounds: tuple[str, ...] + resolved: Version | None + reason: str = "" + + def rewritten(self) -> str: + """Return the requirement with its offending bounds lifted. + + Returns: + The requirement string to write back, preserving extras, markers, + spacing and every other specifier. + """ + if self.resolved is None: + fail(f"{self.requirement!r} has no published version to lift it to") + # Only the specifier part: a marker such as ``; python_version > '3.10'`` + # holds comparisons of its own that are not version specifiers. + head, separator, marker = self.requirement.partition(";") + version = self.resolved + + def lift(match: re.Match[str]) -> str: + if match["version"] not in self.bounds: + return match[0] + return f"{match['op']}{match['space']}{version}" + + return _SPECIFIER_RE.sub(lift, head) + separator + marker + + +def _distribution_index(config: Config) -> dict[str, str]: + """Map every repository package's distribution name to its package name. + + Args: + config: The repository configuration. + + Returns: + Canonical distribution name to package (directory) name, which is what + turns a requirement into the sibling whose tags record its releases. + """ + return { + canonicalize_name(config.distribution_name(package)): package + for package in config.all_packages() + } + + +def _pin_upgrades( + config: Config, package: str, allow_prereleases: bool, index: dict[str, str] +) -> list[PinUpgrade]: + """List the dependency lower bounds a package must lift to be releasable. + + Args: + config: The repository configuration. + package: The package whose published dependencies to inspect. + allow_prereleases: Whether published prereleases count as releases — + true when materializing a prerelease, so an alpha may depend on a + sibling's alpha, and false for a final version. + index: The repository's distribution index, built once by the caller + because every package in the repository has to be read to build it. + + Returns: + One entry per offending requirement, each carrying either the version to + lift it to or the reason there is none. An empty list means the package's + published metadata is releasable as it stands. + """ + project = load_pyproject(config.package_path(package) / "pyproject.toml").get( + "project", {} + ) + # Lockstep siblings pinned exactly are rewritten to the released version at + # build time by pin-lockstep, so whatever they say here is not shipped. + exact = { + canonicalize_name(config.distribution_name(target)) + for target in config.exact_pin_targets(package) + } + + upgrades: list[PinUpgrade] = [] + for requirement in published_dependencies(project): + try: + parsed = Requirement(requirement) + except InvalidRequirement: + continue + name = canonicalize_name(parsed.name) + if name in exact: + continue + sibling = index.get(name) + # A prerelease floor is lifted only for siblings: the releases of an + # outside dependency are not recorded here, and pinning one is a + # deliberate choice this tool has no business overriding. A *dev* floor + # is unpublishable whoever owns the dependency, so it always counts. + bounds = _unshippable_bounds(parsed, allow_prereleases or sibling is None) + if not bounds: + continue + if sibling is None: + upgrades.append( + PinUpgrade( + package, + requirement, + name, + bounds, + None, + f"{parsed.name} is not a package in this repository, so its " + "published versions are not known here; re-pin it by hand", + ) + ) + continue + candidates = [ + version + for version in tag_versions(config, sibling) + if allow_prereleases or is_final(version) + ] + satisfying = [ + version + for version in candidates + if parsed.specifier.contains(version, prereleases=True) + ] + if satisfying: + upgrades.append( + PinUpgrade(package, requirement, name, bounds, min(satisfying)) + ) + continue + kind = "" if allow_prereleases else "final " + upgrades.append( + PinUpgrade( + package, + requirement, + name, + bounds, + None, + f"no {kind}release of {sibling} satisfies it " + + ( + f"(newest tagged: {max(candidates)})" + if candidates + else f"({sibling} has no {kind}releases yet)" + ), + ) + ) + return upgrades + + +def pin_upgrades( + config: Config, package: str, allow_prereleases: bool +) -> list[PinUpgrade]: + """List the dependency lower bounds a package must lift to be releasable. + + Args: + config: The repository configuration. + package: The package whose published dependencies to inspect. + allow_prereleases: Whether published prereleases count as releases — + true when materializing a prerelease, so an alpha may depend on a + sibling's alpha, and false for a final version. + + Returns: + One entry per offending requirement, each carrying either the version to + lift it to or the reason there is none. An empty list means the package's + published metadata is releasable as it stands. + """ + return _pin_upgrades( + config, package, allow_prereleases, _distribution_index(config) + ) + + +def _upgrades_for( + config: Config, packages: list[str], allow_prereleases: bool +) -> list[PinUpgrade]: + """Collect the pin upgrades of a whole release batch. + + Args: + config: The repository configuration. + packages: The packages being considered for a release. + allow_prereleases: Whether published prereleases count as releases. + + Returns: + Every offending requirement across the batch, in package order. + """ + index = _distribution_index(config) + return [ + upgrade + for package in packages + for upgrade in _pin_upgrades(config, package, allow_prereleases, index) + ] + + +def blocking_pins( + config: Config, packages: list[str], allow_prereleases: bool +) -> dict[str, list[PinUpgrade]]: + """Group the pin upgrades that have no published version to lift them to. + + Args: + config: The repository configuration. + packages: The packages being considered for a release. + allow_prereleases: Whether published prereleases count as releases. + + Returns: + Package name to its unresolvable pins, for the packages that have any. + Those packages cannot be released until the pins are satisfiable. + """ + blocked: dict[str, list[PinUpgrade]] = {} + for upgrade in _upgrades_for(config, packages, allow_prereleases): + if upgrade.resolved is None: + blocked.setdefault(upgrade.package, []).append(upgrade) + return blocked + + +def describe_blockers(blocked: dict[str, list[PinUpgrade]]) -> list[str]: + """Render one human-readable line per unresolvable pin. + + Args: + blocked: The mapping returned by :func:`blocking_pins`. + + Returns: + The lines, in package order. + """ + return [ + f"{package}: {upgrade.requirement!r} — {upgrade.reason}" + for package, upgrades in blocked.items() + for upgrade in upgrades + ] + + +def _replace_requirement(text: str, original: str, replacement: str) -> str: + """Replace one quoted requirement string in a ``pyproject.toml``. + + The requirement is matched as the whole quoted TOML value it was read from, + so nothing else that happens to contain the same substring is touched. + + Args: + text: The file content. + original: The requirement as parsed from the file. + replacement: The requirement to write instead. + + Returns: + The updated file content. + """ + quoted = {quote: f"{quote}{original}{quote}" for quote in ('"', "'")} + occurrences = {quote: text.count(needle) for quote, needle in quoted.items()} + total = sum(occurrences.values()) + if total != 1: + fail( + f"expected exactly one quoted {original!r} requirement to upgrade, " + f"found {total}; re-pin it by hand" + ) + quote = next(quote for quote, count in occurrences.items() if count) + return text.replace(quoted[quote], f"{quote}{replacement}{quote}", 1) + + +def apply_pin_upgrades(config: Config, upgrades: list[PinUpgrade]) -> list[str]: + """Write resolved pin upgrades back into the packages' ``pyproject.toml``. + + Args: + config: The repository configuration. + upgrades: The upgrades to apply; every one must be resolved. + + Returns: + The repo-relative paths that were rewritten. + """ + by_package: dict[str, list[PinUpgrade]] = {} + for upgrade in upgrades: + by_package.setdefault(upgrade.package, []).append(upgrade) + + changed: list[str] = [] + for package, entries in by_package.items(): + pyproject = config.package_path(package) / "pyproject.toml" + text = pyproject.read_text(encoding="utf-8") + for entry in entries: + rewritten = entry.rewritten() + text = _replace_requirement(text, entry.requirement, rewritten) + echo(f"{package}: {entry.requirement} -> {rewritten}") + pyproject.write_text(text, encoding="utf-8") + changed.append(pyproject.relative_to(config.root).as_posix()) + return changed + + +def refresh_lock_file(config: Config) -> str | None: + """Re-resolve the repository lock file after a dependency pin changed. + + Args: + config: The repository configuration. + + Returns: + The repo-relative lock file path, or None when the repository has none. + """ + if not (config.root / LOCK_FILE).is_file(): + return None + echo(f"$ uv lock # {LOCK_FILE} follows the upgraded pins") + if subprocess.run(["uv", "lock"], cwd=config.root, check=False).returncode != 0: + fail( + f"`uv lock` failed after upgrading dependency pins; {LOCK_FILE} would " + "be left describing the old pins" + ) + return LOCK_FILE + + +def upgrade_dev_pins( + config: Config, packages: list[str], allow_prereleases: bool +) -> list[str]: + """Lift every unpublishable dependency bound of the packages being released. + + Args: + config: The repository configuration. + packages: The packages being released. + allow_prereleases: Whether published prereleases count as releases. + + Returns: + The repo-relative paths that changed — the rewritten ``pyproject.toml`` + files, plus the lock file when the repository has one. + """ + upgrades = _upgrades_for(config, packages, allow_prereleases) + blocked: dict[str, list[PinUpgrade]] = {} + for upgrade in upgrades: + if upgrade.resolved is None: + blocked.setdefault(upgrade.package, []).append(upgrade) + if blocked: + listing = "\n".join(f" {line}" for line in describe_blockers(blocked)) + fail( + "dependency pins that no published version satisfies cannot be " + f"materialized into a release:\n{listing}\n\nRelease the depended-on " + "package(s) first; the next release lifts these pins automatically." + ) + if not upgrades: + return [] + changed = apply_pin_upgrades(config, upgrades) + if (lock := refresh_lock_file(config)) is not None: + changed.append(lock) + return changed diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml index 265f361c6eb..2ab06d0be13 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml @@ -12,6 +12,12 @@ name: Dispatch release # topped by an alpha (the train's fragments are already consumed). Lockstep # groups share one checkbox: their members only ever release together. # +# Materializing also lifts each released package's unshippable dependency +# floors — a *.dev pin, and a prerelease pin when the version is final — to the +# earliest published version that satisfies them, re-locking the repository so +# the pins and the lock file land with the changelog bump. A package whose +# floor no published version satisfies is held back from the release instead. +# # The package list is generated from [tool.reflex-release] — after adding or # removing a package, re-run `@@CLI@@ sync`. # diff --git a/tests/units/reflex_release/test_commands.py b/tests/units/reflex_release/test_commands.py index d690d641155..c10b0011c24 100644 --- a/tests/units/reflex_release/test_commands.py +++ b/tests/units/reflex_release/test_commands.py @@ -298,7 +298,7 @@ def test_materialize_writes_an_empty_entry_for_a_lockstep_partner( assert outputs()["any"] == "true" -def test_commit_changelogs_leaves_unrelated_work_alone( +def test_commit_materialized_leaves_unrelated_work_alone( config: Config, repo: Path ) -> None: """A release commit carries the changelogs and nothing a human was mid-way through.""" @@ -310,7 +310,7 @@ def test_commit_changelogs_leaves_unrelated_work_alone( set_changelog(config, "widget-core", "## v0.9.0 (2026-01-01)\n\nNot mine.\n") fragment(config, "widget-core", "3.bugfix.md") - commands._commit_changelogs( + commands._commit_materialized( config, [{"package": "mypkg", "next": "1.0.0"}], "Materialize changelogs" ) @@ -331,7 +331,7 @@ def test_release_commit_removes_the_fragments_it_consumed( commands.cmd_plan(config, "release-minor", "widget-core") commands.cmd_materialize(config, "release-minor", outputs()["releases"]) - commands._commit_changelogs( + commands._commit_materialized( config, [{"package": "widget-core", "next": "0.1.0"}], "Materialize changelogs", @@ -954,3 +954,105 @@ def test_post_release_rejects_an_unknown_package( monkeypatch.setattr(commands, "gh_run", lambda *args, **kwargs: 0) with pytest.raises(ReleaseError, match="unknown package"): commands.cmd_post_release(reloaded, "v1.2.3", "ghost", "1.2.3") + + +def dev_pin(repo: Path, requirement: str) -> Config: + """Replace the root package's dependency and reload the configuration. + + Args: + repo: The repository root. + requirement: The requirement string to declare instead. + + Returns: + The reloaded configuration. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + '"widget-core >= 0.1.0"', f'"{requirement}"' + ), + encoding="utf-8", + ) + return load_config(repo) + + +def test_plan_holds_back_an_auto_selected_unsatisfiable_pin( + config: Config, repo: Path, outputs: Outputs, summary: Callable[[], str] +) -> None: + reloaded = dev_pin(repo, "widget-core >= 9.9.9.dev1") + fragment(reloaded, "mypkg", "1.feature.md") + fragment(reloaded, "widget-core", "2.feature.md") + commands.cmd_plan(reloaded, "release-minor", "") + # The dependency can still be released; only its dependent is held back. + assert [r["package"] for r in json.loads(outputs()["releases"])] == ["widget-core"] + assert "### Held back" in summary() + + +def test_plan_rejects_an_explicit_unsatisfiable_pin( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 9.9.9.dev1") + with pytest.raises(ReleaseError, match="no published version satisfies"): + commands.cmd_plan(reloaded, "release-minor", "mypkg") + + +def test_plan_holds_back_a_whole_lockstep_group( + config: Config, repo: Path, outputs: Outputs +) -> None: + """Members only ever release together, so one blocker holds back the group.""" + write_lockstep(repo) + # Not the lockstep sibling, which pin-lockstep rewrites at build time. + reloaded = dev_pin(repo, "third-party >= 9.9.9.dev1") + fragment(reloaded, "widget-core", "2.feature.md") + with pytest.raises(ReleaseError, match="every auto-selected package"): + commands.cmd_plan(reloaded, "release-minor", "") + + +def test_plan_accepts_a_pin_a_prerelease_can_satisfy( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0a1") + fragment(reloaded, "mypkg", "1.feature.md") + # A final version cannot take the alpha; the alpha train can. + with pytest.raises(ReleaseError, match="no published version satisfies"): + commands.cmd_plan(reloaded, "release-minor", "mypkg") + commands.cmd_plan(reloaded, "new-prerelease-minor", "mypkg") + assert [r["package"] for r in json.loads(outputs()["releases"])] == ["mypkg"] + + +def test_materialize_lifts_the_dev_pin_it_can_resolve( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + fragment(reloaded, "mypkg", "4.feature.md", "Something.") + commit_all(repo) + + commands.cmd_plan(reloaded, "release-minor", "mypkg") + commands.cmd_materialize(reloaded, "release-minor", outputs()["releases"]) + + assert '"widget-core >= 0.2.0"' in (repo / "pyproject.toml").read_text( + encoding="utf-8" + ) + + +def test_release_commit_carries_the_lifted_pins( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + fragment(reloaded, "mypkg", "5.feature.md", "Something.") + commit_all(repo) + + commands.cmd_plan(reloaded, "release-minor", "mypkg") + commands.cmd_materialize(reloaded, "release-minor", outputs()["releases"]) + commands._commit_materialized( + reloaded, [{"package": "mypkg", "next": "0.1.0"}], "Materialize" + ) + + assert sorted(git(repo, "show", "--name-only", "--format=", "HEAD").split()) == [ + "CHANGELOG.md", + "news/5.feature.md", + "pyproject.toml", + ] diff --git a/tests/units/reflex_release/test_devpins.py b/tests/units/reflex_release/test_devpins.py index 7a2d00c6595..cee71b1d081 100644 --- a/tests/units/reflex_release/test_devpins.py +++ b/tests/units/reflex_release/test_devpins.py @@ -2,17 +2,46 @@ from __future__ import annotations +import subprocess from pathlib import Path import pytest +from packaging.version import Version from reflex_release.actions import ReleaseError -from reflex_release.config import Config +from reflex_release.config import Config, load_config from reflex_release.devpins import ( + LOCK_FILE, + PinUpgrade, + blocking_pins, check_dev_pins, parse_requirement, + pin_upgrades, published_dependencies, + upgrade_dev_pins, ) +from .conftest import git, write_lockstep + + +def set_root_dependency(repo: Path, requirement: str) -> Config: + """Replace the root package's single dependency and reload the configuration. + + Args: + repo: The repository root. + requirement: The requirement string to declare instead. + + Returns: + The reloaded configuration. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + '"widget-core >= 0.1.0"', f'"{requirement}"' + ), + encoding="utf-8", + ) + return load_config(repo) + @pytest.mark.parametrize( ("requirement", "expected"), @@ -76,3 +105,215 @@ def test_check_dev_pins_is_scoped_to_the_selected_package( def test_check_dev_pins_rejects_unknown_packages(config: Config) -> None: with pytest.raises(ReleaseError, match="unknown package"): check_dev_pins(config, ["ghost"]) + + +@pytest.mark.parametrize( + ("requirement", "bounds", "expected"), + [ + ("widget-core >= 0.2.0.dev1", ("0.2.0.dev1",), "widget-core >= 0.2.0"), + ("widget-core>=0.2.0.dev1", ("0.2.0.dev1",), "widget-core>=0.2.0"), + # Extras, the other specifiers and the marker all survive the lift. + ( + "widget-core[extra] >= 0.2.0.dev1, < 1.0 ; python_version > '3.10'", + ("0.2.0.dev1",), + "widget-core[extra] >= 0.2.0, < 1.0 ; python_version > '3.10'", + ), + # An upper bound naming a dev release is resolvable as it stands. + ( + "widget-core >= 0.2.0a1, != 0.3.0.dev1", + ("0.2.0a1",), + "widget-core >= 0.2.0, != 0.3.0.dev1", + ), + # A second lower bound that is already publishable stays put. + ( + "widget-core >= 0.2.0.dev1, > 0.1.0", + ("0.2.0.dev1",), + "widget-core >= 0.2.0, > 0.1.0", + ), + ], +) +def test_pin_upgrade_rewrites_only_the_offending_bound( + requirement: str, bounds: tuple[str, ...], expected: str +) -> None: + upgrade = PinUpgrade("mypkg", requirement, "widget-core", bounds, Version("0.2.0")) + assert upgrade.rewritten() == expected + + +def test_pin_upgrades_resolves_to_the_earliest_published_version( + repo: Path, +) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + for tag in ("widget-core-v0.1.9", "widget-core-v0.2.0", "widget-core-v0.3.0"): + git(repo, "tag", tag) + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved == Version("0.2.0") + assert upgrade.rewritten() == "widget-core >= 0.2.0" + + +def test_pin_upgrades_ignores_a_published_lower_bound(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.1.0") + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) == [] + + +def test_pin_upgrades_holds_back_an_unreleased_dev_pin(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.1.9") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved is None + assert "newest tagged: 0.1.9" in upgrade.reason + + +def test_pin_upgrades_only_takes_a_prerelease_for_a_prerelease(repo: Path) -> None: + """An alpha may depend on a sibling's alpha; a final version may not.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0a1") + + (final,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert final.resolved is None + assert "no final release of widget-core" in final.reason + + (alpha,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=True) + assert alpha.resolved == Version("0.2.0a1") + + +def test_pin_upgrades_lifts_a_prerelease_floor_for_a_final_release(repo: Path) -> None: + """A final version must not floor its users on a sibling's alpha.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0a1") + git(repo, "tag", "widget-core-v0.2.0a1") + git(repo, "tag", "widget-core-v0.2.0") + + (final,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert final.resolved == Version("0.2.0") + # The same floor is fine in a prerelease, which may ship it as it stands. + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=True) == [] + + +def test_pin_upgrades_leaves_an_outside_prerelease_pin_alone(repo: Path) -> None: + """Only siblings' releases are recorded here; an outside pin is deliberate.""" + reloaded = set_root_dependency(repo, "third-party >= 2.0b1") + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) == [] + + +def test_pin_upgrades_holds_back_an_outside_dev_pin(repo: Path) -> None: + """A dev pin is unpublishable whoever owns the dependency.""" + reloaded = set_root_dependency(repo, "third-party >= 2.0.dev1") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=True) + assert upgrade.resolved is None + assert "not a package in this repository" in upgrade.reason + + +def test_pin_upgrades_skips_exactly_pinned_lockstep_siblings(repo: Path) -> None: + """pin-lockstep rewrites those at build time, so nothing here is shipped.""" + write_lockstep(repo) + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + assert reloaded.exact_pin_targets("mypkg") == ("widget-core",) + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) == [] + + +def test_pin_upgrades_respects_the_whole_specifier_set(repo: Path) -> None: + """The lifted version has to satisfy the upper bound too.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1, < 0.3") + git(repo, "tag", "widget-core-v0.3.0") + (blocked,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert blocked.resolved is None + + git(repo, "tag", "widget-core-v0.2.5") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved == Version("0.2.5") + + +def test_blocking_pins_groups_by_package(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + blocked = blocking_pins(reloaded, ["mypkg", "widget-core"], allow_prereleases=False) + assert list(blocked) == ["mypkg"] + + +def test_upgrade_dev_pins_rewrites_the_pyproject(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + assert upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) == [ + "pyproject.toml" + ] + assert '"widget-core >= 0.2.0"' in (repo / "pyproject.toml").read_text( + encoding="utf-8" + ) + # The gate the publish job runs is satisfied by the rewrite. + check_dev_pins(reloaded, ["mypkg"]) + + +def stub_uv_lock(monkeypatch: pytest.MonkeyPatch, returncode: int) -> list[list[str]]: + """Answer ``uv lock`` with a fixed status, letting git run for real. + + Args: + monkeypatch: The pytest monkeypatch fixture. + returncode: The status ``uv lock`` should report. + + Returns: + The list the intercepted commands are recorded in. + """ + real_run = subprocess.run + recorded: list[list[str]] = [] + + def run(cmd, **kwargs): + if cmd[:2] != ["uv", "lock"]: + return real_run(cmd, **kwargs) + recorded.append(cmd) + return subprocess.CompletedProcess(cmd, returncode) + + monkeypatch.setattr(subprocess, "run", run) + return recorded + + +def test_upgrade_dev_pins_refreshes_the_lock_file( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (repo / LOCK_FILE).write_text("version = 1\n", encoding="utf-8") + recorded = stub_uv_lock(monkeypatch, 0) + assert upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) == [ + "pyproject.toml", + LOCK_FILE, + ] + assert recorded == [["uv", "lock"]] + + +def test_upgrade_dev_pins_fails_when_the_lock_cannot_be_refreshed( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (repo / LOCK_FILE).write_text("version = 1\n", encoding="utf-8") + stub_uv_lock(monkeypatch, 1) + with pytest.raises(ReleaseError, match="uv lock` failed"): + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + + +def test_upgrade_dev_pins_refuses_an_unsatisfiable_pin(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + with pytest.raises(ReleaseError, match="no published version satisfies"): + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + + +def test_upgrade_dev_pins_is_a_no_op_without_pins(repo: Path) -> None: + reloaded = load_config(repo) + before = (repo / "pyproject.toml").read_text(encoding="utf-8") + assert upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) == [] + assert (repo / "pyproject.toml").read_text(encoding="utf-8") == before + + +def test_upgrade_dev_pins_refuses_an_ambiguous_requirement(repo: Path) -> None: + """The same string twice gives the rewrite nowhere unambiguous to land.""" + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + 'dependencies = ["widget-core >= 0.1.0"]', + 'dependencies = ["widget-core >= 0.2.0.dev1"]\n' + '[project.optional-dependencies]\nextra = ["widget-core >= 0.2.0.dev1"]', + ), + encoding="utf-8", + ) + reloaded = load_config(repo) + git(repo, "tag", "widget-core-v0.2.0") + with pytest.raises(ReleaseError, match="expected exactly one quoted"): + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) From 972188a3764a0af82f13f39203050b685cf2e1ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:05:03 +0000 Subject: [PATCH 2/3] Address review feedback on the dependency pin lifting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01B13GVXzLdYzmqmMnaKWvyV --- packages/reflex-release/README.md | 15 +++- .../reflex-release/src/reflex_release/cli.py | 14 +++- .../src/reflex_release/commands.py | 73 +++++++++++------ .../src/reflex_release/devpins.py | 81 ++++++++++++++++--- .../templates/workflows/dispatch_release.yml | 3 + tests/units/reflex_release/test_commands.py | 48 +++++++++-- tests/units/reflex_release/test_devpins.py | 68 ++++++++++++++++ 7 files changed, 255 insertions(+), 47 deletions(-) diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 48826541686..4d9366ae1d3 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -199,9 +199,20 @@ satisfies the whole requirement**: | `>= 0.2.0` | left alone | left alone | "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. The rewritten +so the repository's own tags are its record of what is on PyPI — which is why +the release workflows check out with full history and tags. The rewritten `pyproject.toml` files and the re-resolved `uv.lock` are part of the release -commit, so they land through the same review as the changelog bump. +commit, so they land through the same review as the changelog bump; nothing else +is staged, not even an unrelated edit to a file the upgrade happened not to +touch. + +The lifted floor is always one the resolved version satisfies — a strict +`> 0.2.0.dev1` becomes `>= 0.2.0`, since `> 0.2.0` would exclude the very +release it resolved to — and the rewrite is verified against the resolved +version before it is written. Pins and lock file move together: if `uv lock` +cannot follow the new pins, the `pyproject.toml` rewrites are rolled back, so a +re-run has the same work to do rather than finding the pins already lifted and +skipping the lock. A floor nothing published satisfies has nowhere to go, and the package is **held back** rather than materialized into a version that could never be diff --git a/packages/reflex-release/src/reflex_release/cli.py b/packages/reflex-release/src/reflex_release/cli.py index 8bbec798a21..cc345823962 100644 --- a/packages/reflex-release/src/reflex_release/cli.py +++ b/packages/reflex-release/src/reflex_release/cli.py @@ -213,6 +213,11 @@ def build_parser() -> argparse.ArgumentParser: help="Branch the workflow was dispatched on.", ) pr.add_argument("--releases", default=_env("RELEASES_JSON"), help="The plan JSON.") + pr.add_argument( + "--repinned", + default=_env("REPINNED_JSON"), + help="Paths the pin upgrade rewrote, as emitted by materialize.", + ) prerelease = sub.add_parser( "push-prerelease", help="Commit the changelogs and push the prerelease branch." @@ -226,6 +231,11 @@ def build_parser() -> argparse.ArgumentParser: prerelease.add_argument( "--releases", default=_env("RELEASES_JSON"), help="The plan JSON." ) + prerelease.add_argument( + "--repinned", + default=_env("REPINNED_JSON"), + help="Paths the pin upgrade rewrote, as emitted by materialize.", + ) push_tag = sub.add_parser("push-tag", help="Push the tag of a published version.") push_tag.add_argument("--tag", default=_env("TAG"), help="The tag to push.") @@ -335,11 +345,11 @@ def dispatch(args: argparse.Namespace, config: Config) -> None: commands.cmd_detect_internal(config, args.base, args.head, args.package) case "open-release-pr": commands.cmd_open_release_pr( - config, args.action, args.ref_name, args.releases + config, args.action, args.ref_name, args.releases, args.repinned ) case "push-prerelease": commands.cmd_push_prerelease( - config, args.action, args.ref_name, args.releases + config, args.action, args.ref_name, args.releases, args.repinned ) case "push-tag": commands.cmd_push_tag(config, args.tag) diff --git a/packages/reflex-release/src/reflex_release/commands.py b/packages/reflex-release/src/reflex_release/commands.py index a81951cbb81..7cf16ab9cd5 100644 --- a/packages/reflex-release/src/reflex_release/commands.py +++ b/packages/reflex-release/src/reflex_release/commands.py @@ -33,7 +33,7 @@ parse_sections, ) from .config import POST_RELEASE_INPUTS, POST_RELEASE_WORKFLOW_KEY, Config, is_final -from .devpins import LOCK_FILE, blocking_pins, describe_blockers, upgrade_dev_pins +from .devpins import blocking_pins, describe_blockers, upgrade_dev_pins from .discovery import ( alpha_train_packages, build_changelog, @@ -409,6 +409,10 @@ def cmd_materialize(config: Config, action: str, releases_json: str) -> None: For ``release-from-prerelease``, collapses the alpha sections of each changelog into the single final-version section after building it. + Writes ``repinned`` (a JSON array of the paths the pin upgrade rewrote) to + ``$GITHUB_OUTPUT``, which is what the delivery step stages beside the + changelogs — it runs as a separate process and cannot otherwise know. + Args: config: The repository configuration. action: The release action the plan was made for. @@ -420,11 +424,13 @@ def cmd_materialize(config: Config, action: str, releases_json: str) -> None: # Before towncrier: a pin that cannot be lifted stops the release while the # news fragments it would have consumed are still on disk. - if repinned := upgrade_dev_pins( + repinned = upgrade_dev_pins( config, [release["package"] for release in releases], allow_prereleases=action not in FINAL_ACTIONS, - ): + ) + write_outputs(repinned=json.dumps(repinned)) + if repinned: write_summary([ "## Dependency pins lifted", "", @@ -813,22 +819,39 @@ def _release_summary(releases: list[dict[str, str]]) -> str: return ", ".join(f"{r['package']}@{r['next']}" for r in releases) +def _repinned_paths(repinned_json: str) -> list[str]: + """Parse the ``repinned`` output of :func:`cmd_materialize`. + + Args: + repinned_json: The JSON array of rewritten paths, or an empty string + when the pin upgrade rewrote nothing. + + Returns: + The repo-relative paths. + """ + return json.loads(repinned_json) if repinned_json.strip() else [] + + def _commit_materialized( - config: Config, releases: list[dict[str, str]], message: str + config: Config, + releases: list[dict[str, str]], + repinned: list[str], + message: str, ) -> None: """Stage and commit everything materialization wrote for a release. Args: config: The repository configuration. releases: The releases that were materialized. + repinned: The paths the pin upgrade rewrote, from :func:`cmd_materialize`. message: The commit message. """ configure_bot_identity(config.root) - # Only what materialization writes for the packages being released — their - # changelogs and the dependency pins in their own pyproject.toml, plus the - # lock file those pins are resolved in — so nothing else in the worktree can - # ride along in the release commit. towncrier has already staged the - # deletion of every fragment it consumed. + # Exactly what materialization wrote: the changelogs of the packages being + # released, and the files the pin upgrade reported rewriting. Nothing else + # in the worktree can ride along in the release commit — a package's + # pyproject.toml is staged only when a pin in it actually moved. towncrier + # has already staged the deletion of every fragment it consumed. changelogs = [ path.relative_to(config.root).as_posix() for path in (config.changelog_path(r["package"]) for r in releases) @@ -836,24 +859,18 @@ def _commit_materialized( ] if not changelogs: fail("materialization produced no changelog; nothing to release") - pins = [ - path.relative_to(config.root).as_posix() - for path in ( - config.package_path(r["package"]) / "pyproject.toml" for r in releases - ) - if path.is_file() - ] - lock = config.root / LOCK_FILE - if lock.is_file(): - pins.append(LOCK_FILE) - git_run(["add", "--", *changelogs, *pins], config.root) + git_run(["add", "--", *changelogs, *repinned], config.root) if not git(["diff", "--cached", "--name-only"], config.root).strip(): fail("materialization produced no changes; nothing to release") git_run(["commit", "-m", message], config.root) def cmd_open_release_pr( - config: Config, action: str, ref_name: str, releases_json: str + config: Config, + action: str, + ref_name: str, + releases_json: str, + repinned_json: str, ) -> None: """Commit the materialized changelogs and open the release pull request. @@ -862,8 +879,10 @@ def cmd_open_release_pr( action: The release action that was materialized. ref_name: The branch the workflow was dispatched on. releases_json: The ``releases`` JSON emitted by :func:`cmd_plan`. + repinned_json: The ``repinned`` JSON emitted by :func:`cmd_materialize`. """ releases: list[dict[str, str]] = json.loads(releases_json) + repinned = _repinned_paths(repinned_json) run_id = os.environ.get("GITHUB_RUN_ID", "manual") # Final versions publish from the main branch — except hotfix trains, which # publish directly from their own branch, so the PR targets it instead. @@ -906,7 +925,7 @@ def cmd_open_release_pr( body_file.write_text(body, encoding="utf-8") _commit_materialized( - config, releases, f"Materialize changelogs for {summary} ({action})" + config, releases, repinned, f"Materialize changelogs for {summary} ({action})" ) git_push(f"HEAD:refs/heads/{branch}", config.root) @@ -955,7 +974,11 @@ def cmd_open_release_pr( def cmd_push_prerelease( - config: Config, action: str, ref_name: str, releases_json: str + config: Config, + action: str, + ref_name: str, + releases_json: str, + repinned_json: str, ) -> None: """Commit the materialized changelogs and push the prerelease branch. @@ -964,8 +987,10 @@ def cmd_push_prerelease( action: The release action that was materialized. ref_name: The branch the workflow was dispatched on. releases_json: The ``releases`` JSON emitted by :func:`cmd_plan`. + repinned_json: The ``repinned`` JSON emitted by :func:`cmd_materialize`. """ releases: list[dict[str, str]] = json.loads(releases_json) + repinned = _repinned_paths(repinned_json) run_id = os.environ.get("GITHUB_RUN_ID", "manual") summary = _release_summary(releases) @@ -986,7 +1011,7 @@ def cmd_push_prerelease( branch = f"{branch}-{run_id}" _commit_materialized( - config, releases, f"Materialize changelogs for {summary} ({action})" + config, releases, repinned, f"Materialize changelogs for {summary} ({action})" ) git_push(f"HEAD:refs/heads/{branch}", config.root) diff --git a/packages/reflex-release/src/reflex_release/devpins.py b/packages/reflex-release/src/reflex_release/devpins.py index 496589641e3..37d4964661e 100644 --- a/packages/reflex-release/src/reflex_release/devpins.py +++ b/packages/reflex-release/src/reflex_release/devpins.py @@ -33,7 +33,7 @@ from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version -from .actions import echo, fail +from .actions import ReleaseError, echo, fail from .config import Config, is_final, load_pyproject from .gitutil import tag_versions @@ -196,9 +196,23 @@ def rewritten(self) -> str: def lift(match: re.Match[str]) -> str: if match["version"] not in self.bounds: return match[0] - return f"{match['op']}{match['space']}{version}" - - return _SPECIFIER_RE.sub(lift, head) + separator + marker + # ``> 0.2.0.dev1`` admits 0.2.0, so 0.2.0 can be what it resolves + # to — but ``> 0.2.0`` would then exclude the very release the + # requirement was lifted onto. A strict floor over an unreleased + # version becomes an inclusive floor over the release above it. + operator = ">=" if match["op"] == ">" else match["op"] + return f"{operator}{match['space']}{version}" + + lifted = _SPECIFIER_RE.sub(lift, head) + separator + marker + # The point of the rewrite is a requirement the resolved version + # satisfies; anything else would publish metadata that resolves to + # something other than what was checked, or to nothing at all. + if not Requirement(lifted).specifier.contains(version, prereleases=True): + fail( + f"lifting {self.requirement!r} produced {lifted!r}, which " + f"{version} does not satisfy; re-pin it by hand" + ) + return lifted def _distribution_index(config: Config) -> dict[str, str]: @@ -390,11 +404,29 @@ def describe_blockers(blocked: dict[str, list[PinUpgrade]]) -> list[str]: ] +def _toml_basic(value: str) -> str: + """Render a string as a TOML basic (double-quoted) value. + + Args: + value: The string as parsed back out of the document. + + Returns: + The quoted spelling, with the two characters a requirement can plausibly + carry escaped. A basic string cannot hold a bare ``"``, so this — not + the parsed value — is what a requirement with a double-quoted marker + looks like in the file. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + def _replace_requirement(text: str, original: str, replacement: str) -> str: """Replace one quoted requirement string in a ``pyproject.toml``. The requirement is matched as the whole quoted TOML value it was read from, - so nothing else that happens to contain the same substring is touched. + so nothing else that happens to contain the same substring is touched. Both + ways TOML can spell it are tried: a basic string, whose quotes and + backslashes are escaped, and a literal string, which cannot escape anything. Args: text: The file content. @@ -404,16 +436,20 @@ def _replace_requirement(text: str, original: str, replacement: str) -> str: Returns: The updated file content. """ - quoted = {quote: f"{quote}{original}{quote}" for quote in ('"', "'")} - occurrences = {quote: text.count(needle) for quote, needle in quoted.items()} - total = sum(occurrences.values()) - if total != 1: + spellings = [ + (_toml_basic(original), _toml_basic(replacement)), + (f"'{original}'", f"'{replacement}'"), + ] + counts = [text.count(needle) for needle, _ in spellings] + if sum(counts) != 1: fail( f"expected exactly one quoted {original!r} requirement to upgrade, " - f"found {total}; re-pin it by hand" + f"found {sum(counts)}; re-pin it by hand" ) - quote = next(quote for quote, count in occurrences.items() if count) - return text.replace(quoted[quote], f"{quote}{replacement}{quote}", 1) + needle, substitute = next( + pair for pair, count in zip(spellings, counts, strict=True) if count == 1 + ) + return text.replace(needle, substitute, 1) def apply_pin_upgrades(config: Config, upgrades: list[PinUpgrade]) -> list[str]: @@ -491,7 +527,26 @@ def upgrade_dev_pins( ) if not upgrades: return [] + + # Pins and lock file move together or not at all. A half-applied upgrade + # would leave the working tree with lifted pins and a lock file describing + # the old ones — and a re-run, finding nothing left to lift, would not + # re-lock and could commit exactly that. + snapshot = { + path: path.read_text(encoding="utf-8") + for path in { + config.package_path(upgrade.package) / "pyproject.toml" + for upgrade in upgrades + } + } changed = apply_pin_upgrades(config, upgrades) - if (lock := refresh_lock_file(config)) is not None: + try: + lock = refresh_lock_file(config) + except ReleaseError: + for path, text in snapshot.items(): + path.write_text(text, encoding="utf-8") + echo(f"restored {len(snapshot)} pyproject.toml file(s); no pin was lifted") + raise + if lock is not None: changed.append(lock) return changed diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml index 2ab06d0be13..6be2569d930 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml @@ -97,6 +97,7 @@ jobs: @@PACKAGE_SELECTION@@ run: @@CLI@@ plan - name: Materialize changelogs + id: materialize env: ACTION: ${{ inputs.action }} RELEASES_JSON: ${{ steps.plan.outputs.releases }} @@ -108,6 +109,7 @@ jobs: ACTION: ${{ inputs.action }} REF_NAME: ${{ github.ref_name }} RELEASES_JSON: ${{ steps.plan.outputs.releases }} + REPINNED_JSON: ${{ steps.materialize.outputs.repinned }} run: @@CLI@@ push-prerelease - name: Open release pull request if: ${{ startsWith(inputs.action, 'release-') }} @@ -116,4 +118,5 @@ jobs: ACTION: ${{ inputs.action }} REF_NAME: ${{ github.ref_name }} RELEASES_JSON: ${{ steps.plan.outputs.releases }} + REPINNED_JSON: ${{ steps.materialize.outputs.repinned }} run: @@CLI@@ open-release-pr diff --git a/tests/units/reflex_release/test_commands.py b/tests/units/reflex_release/test_commands.py index c10b0011c24..0c4528f9dcf 100644 --- a/tests/units/reflex_release/test_commands.py +++ b/tests/units/reflex_release/test_commands.py @@ -311,7 +311,7 @@ def test_commit_materialized_leaves_unrelated_work_alone( fragment(config, "widget-core", "3.bugfix.md") commands._commit_materialized( - config, [{"package": "mypkg", "next": "1.0.0"}], "Materialize changelogs" + config, [{"package": "mypkg", "next": "1.0.0"}], [], "Materialize changelogs" ) assert git(repo, "show", "--name-only", "--format=", "HEAD").split() == [ @@ -334,6 +334,7 @@ def test_release_commit_removes_the_fragments_it_consumed( commands._commit_materialized( config, [{"package": "widget-core", "next": "0.1.0"}], + [], "Materialize changelogs", ) @@ -730,7 +731,7 @@ def test_push_prerelease_summary_links_the_branch( config: Config, dispatched: pytest.MonkeyPatch, summary: Callable[[], str] ) -> None: commands.cmd_push_prerelease( - config, "new-prerelease-minor", "main", json.dumps(PLAN) + config, "new-prerelease-minor", "main", json.dumps(PLAN), "" ) text = summary() branch = next( @@ -753,7 +754,7 @@ def test_push_prerelease_annotates_the_branch_url( capsys: pytest.CaptureFixture, ) -> None: commands.cmd_push_prerelease( - config, "new-prerelease-minor", "main", json.dumps(PLAN) + config, "new-prerelease-minor", "main", json.dumps(PLAN), "" ) notices = [ line @@ -769,7 +770,7 @@ def test_dispatch_summaries_degrade_outside_actions( ) -> None: dispatched.delenv("GITHUB_REPOSITORY") commands.cmd_push_prerelease( - config, "new-prerelease-minor", "main", json.dumps(PLAN) + config, "new-prerelease-minor", "main", json.dumps(PLAN), "" ) text = summary() assert "](" not in text @@ -784,7 +785,7 @@ def test_open_release_pr_summary_links_the_pull_request( ) -> None: url = "https://github.example.com/acme/widgets/pull/42" dispatched.setattr(commands, "gh_output", lambda *args, **kwargs: url) - commands.cmd_open_release_pr(config, "release-minor", "main", json.dumps(PLAN)) + commands.cmd_open_release_pr(config, "release-minor", "main", json.dumps(PLAN), "") text = summary() assert f"Pull request: [#42]({url})" in text assert "/tree/release/release-minor-" in text @@ -1047,8 +1048,13 @@ def test_release_commit_carries_the_lifted_pins( commands.cmd_plan(reloaded, "release-minor", "mypkg") commands.cmd_materialize(reloaded, "release-minor", outputs()["releases"]) + # The commit runs as its own process, so materialize hands it the paths. + assert json.loads(outputs()["repinned"]) == ["pyproject.toml"] commands._commit_materialized( - reloaded, [{"package": "mypkg", "next": "0.1.0"}], "Materialize" + reloaded, + [{"package": "mypkg", "next": "0.1.0"}], + json.loads(outputs()["repinned"]), + "Materialize", ) assert sorted(git(repo, "show", "--name-only", "--format=", "HEAD").split()) == [ @@ -1056,3 +1062,33 @@ def test_release_commit_carries_the_lifted_pins( "news/5.feature.md", "pyproject.toml", ] + + +def test_release_commit_leaves_an_unrepinned_pyproject_alone( + config: Config, repo: Path, outputs: Outputs +) -> None: + """Only what the pin upgrade actually rewrote is staged beside the changelogs.""" + fragment(config, "mypkg", "6.feature.md", "Something.") + commit_all(repo) + commands.cmd_plan(config, "release-minor", "mypkg") + commands.cmd_materialize(config, "release-minor", outputs()["releases"]) + assert json.loads(outputs()["repinned"]) == [] + + # A human mid-way through an unrelated edit to the same tracked file. + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8") + "\n# work in progress\n", + encoding="utf-8", + ) + commands._commit_materialized( + config, + [{"package": "mypkg", "next": "0.1.0"}], + json.loads(outputs()["repinned"]), + "Materialize", + ) + + assert ( + "pyproject.toml" + not in git(repo, "show", "--name-only", "--format=", "HEAD").split() + ) + assert "# work in progress" in pyproject.read_text(encoding="utf-8") diff --git a/tests/units/reflex_release/test_devpins.py b/tests/units/reflex_release/test_devpins.py index cee71b1d081..106587253c2 100644 --- a/tests/units/reflex_release/test_devpins.py +++ b/tests/units/reflex_release/test_devpins.py @@ -317,3 +317,71 @@ def test_upgrade_dev_pins_refuses_an_ambiguous_requirement(repo: Path) -> None: git(repo, "tag", "widget-core-v0.2.0") with pytest.raises(ReleaseError, match="expected exactly one quoted"): upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + + +def test_pin_upgrade_relaxes_a_strict_floor(repo: Path) -> None: + """`> 0.2.0.dev1` admits 0.2.0, so `> 0.2.0` would exclude what it resolved to.""" + reloaded = set_root_dependency(repo, "widget-core > 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved == Version("0.2.0") + assert upgrade.rewritten() == "widget-core >= 0.2.0" + + +def test_pin_upgrade_rejects_a_rewrite_its_version_would_not_satisfy() -> None: + """The guard against a future operator gap shipping an unsatisfiable pin.""" + upgrade = PinUpgrade( + "mypkg", + # A bound the rewrite cannot lift, since 0.2.0 is not below 0.3. + "widget-core >= 0.2.0.dev1, < 0.3", + "widget-core", + ("0.2.0.dev1",), + Version("0.4.0"), + ) + with pytest.raises(ReleaseError, match="does not satisfy"): + upgrade.rewritten() + + +def test_upgrade_dev_pins_rewrites_an_escaped_toml_marker(repo: Path) -> None: + """A basic string escapes its quotes; the parsed value does not carry them.""" + requirement = 'widget-core >= 0.2.0.dev1; python_version > \\"3.10\\"' + reloaded = set_root_dependency(repo, requirement) + git(repo, "tag", "widget-core-v0.2.0") + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + assert 'widget-core >= 0.2.0; python_version > \\"3.10\\"' in ( + repo / "pyproject.toml" + ).read_text(encoding="utf-8") + + +def test_upgrade_dev_pins_rewrites_a_literal_toml_string(repo: Path) -> None: + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + '"widget-core >= 0.1.0"', "'widget-core >= 0.2.0.dev1'" + ), + encoding="utf-8", + ) + reloaded = load_config(repo) + git(repo, "tag", "widget-core-v0.2.0") + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + assert "'widget-core >= 0.2.0'" in pyproject.read_text(encoding="utf-8") + + +def test_upgrade_dev_pins_rolls_back_when_the_lock_cannot_follow( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pins and lock file move together: a half-applied upgrade would be committed.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (repo / LOCK_FILE).write_text("version = 1\n", encoding="utf-8") + pyproject = repo / "pyproject.toml" + before = pyproject.read_text(encoding="utf-8") + stub_uv_lock(monkeypatch, 1) + + with pytest.raises(ReleaseError, match="uv lock` failed"): + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + + assert pyproject.read_text(encoding="utf-8") == before + # A re-run therefore still has the pin to lift, rather than finding nothing + # to do and leaving the stale lock file to be committed. + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) From f113690ec452cdbee9d0b02bc15f96ef8349ab41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:10:40 +0000 Subject: [PATCH 3/3] Roll back a partly-applied pin batch, not just a failed lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01B13GVXzLdYzmqmMnaKWvyV --- .../src/reflex_release/devpins.py | 11 +++++---- tests/units/reflex_release/test_devpins.py | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/reflex-release/src/reflex_release/devpins.py b/packages/reflex-release/src/reflex_release/devpins.py index 37d4964661e..3e2353ebaf7 100644 --- a/packages/reflex-release/src/reflex_release/devpins.py +++ b/packages/reflex-release/src/reflex_release/devpins.py @@ -528,10 +528,11 @@ def upgrade_dev_pins( if not upgrades: return [] - # Pins and lock file move together or not at all. A half-applied upgrade - # would leave the working tree with lifted pins and a lock file describing - # the old ones — and a re-run, finding nothing left to lift, would not - # re-lock and could commit exactly that. + # Every pin in the batch and the lock file move together or not at all: the + # rewrites run package by package, so a requirement the second package + # cannot be given would otherwise strand the first one's — and a lock file + # left describing the old pins is worse still, because a re-run finds + # nothing left to lift, does not re-lock, and could commit that pairing. snapshot = { path: path.read_text(encoding="utf-8") for path in { @@ -539,8 +540,8 @@ def upgrade_dev_pins( for upgrade in upgrades } } - changed = apply_pin_upgrades(config, upgrades) try: + changed = apply_pin_upgrades(config, upgrades) lock = refresh_lock_file(config) except ReleaseError: for path, text in snapshot.items(): diff --git a/tests/units/reflex_release/test_devpins.py b/tests/units/reflex_release/test_devpins.py index 106587253c2..4f7fbb34910 100644 --- a/tests/units/reflex_release/test_devpins.py +++ b/tests/units/reflex_release/test_devpins.py @@ -385,3 +385,27 @@ def test_upgrade_dev_pins_rolls_back_when_the_lock_cannot_follow( # A re-run therefore still has the pin to lift, rather than finding nothing # to do and leaving the stale lock file to be committed. assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + + +def test_upgrade_dev_pins_rolls_back_an_earlier_package(repo: Path) -> None: + """The rewrites run package by package; one that fails must strand none.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + git(repo, "tag", "v1.0") + # The sibling's own pin resolves, but names the same requirement twice, so + # rewriting it is ambiguous — and it is rewritten after the root package's. + sub = repo / "packages" / "widget-core" / "pyproject.toml" + sub.write_text( + sub.read_text(encoding="utf-8") + + 'dependencies = ["mypkg >= 1.0.dev1"]\n' + + '[project.optional-dependencies]\nextra = ["mypkg >= 1.0.dev1"]\n', + encoding="utf-8", + ) + reloaded = load_config(repo) + root = repo / "pyproject.toml" + before = root.read_text(encoding="utf-8") + + with pytest.raises(ReleaseError, match="expected exactly one quoted"): + upgrade_dev_pins(reloaded, ["mypkg", "widget-core"], allow_prereleases=False) + + assert root.read_text(encoding="utf-8") == before