diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 858c7b6dc85..4d9366ae1d3 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -178,6 +178,56 @@ 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 — 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; 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 +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 +528,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 +644,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 +689,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/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 34b3a787e0e..7cf16ab9cd5 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 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,9 +401,18 @@ 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. + 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. @@ -339,6 +421,22 @@ 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. + 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", + "", + *(f"- `{path}`" for path in repinned), + ]) + collapse = action == "release-from-prerelease" categories = category_order(config) if collapse else [] heading_format = title_format(config) @@ -721,20 +819,39 @@ def _release_summary(releases: list[dict[str, str]]) -> str: return ", ".join(f"{r['package']}@{r['next']}" for r in releases) -def _commit_changelogs( - config: Config, releases: list[dict[str, str]], message: str +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]], + repinned: list[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. 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 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. + # 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) @@ -742,14 +859,18 @@ def _commit_changelogs( ] if not changelogs: fail("materialization produced no changelog; nothing to release") - git_run(["add", "--", *changelogs], 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. @@ -758,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. @@ -801,8 +924,8 @@ 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( - config, releases, f"Materialize changelogs for {summary} ({action})" + _commit_materialized( + config, releases, repinned, f"Materialize changelogs for {summary} ({action})" ) git_push(f"HEAD:refs/heads/{branch}", config.root) @@ -851,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. @@ -860,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) @@ -881,8 +1010,8 @@ def cmd_push_prerelease( if remote_branch_exists(config.root, branch): branch = f"{branch}-{run_id}" - _commit_changelogs( - config, releases, f"Materialize changelogs for {summary} ({action})" + _commit_materialized( + 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 5696da3b286..3e2353ebaf7 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 .actions import ReleaseError, echo, fail +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,400 @@ 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] + # ``> 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]: + """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 _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. 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. + original: The requirement as parsed from the file. + replacement: The requirement to write instead. + + Returns: + The updated file content. + """ + 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 {sum(counts)}; re-pin it by hand" + ) + 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]: + """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 [] + + # 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 { + config.package_path(upgrade.package) / "pyproject.toml" + for upgrade in upgrades + } + } + try: + changed = apply_pin_upgrades(config, upgrades) + 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 265f361c6eb..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 @@ -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`. # @@ -91,6 +97,7 @@ jobs: @@PACKAGE_SELECTION@@ run: @@CLI@@ plan - name: Materialize changelogs + id: materialize env: ACTION: ${{ inputs.action }} RELEASES_JSON: ${{ steps.plan.outputs.releases }} @@ -102,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-') }} @@ -110,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 d690d641155..0c4528f9dcf 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,8 +310,8 @@ 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( - config, [{"package": "mypkg", "next": "1.0.0"}], "Materialize changelogs" + commands._commit_materialized( + config, [{"package": "mypkg", "next": "1.0.0"}], [], "Materialize changelogs" ) assert git(repo, "show", "--name-only", "--format=", "HEAD").split() == [ @@ -331,9 +331,10 @@ 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", ) @@ -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 @@ -954,3 +955,140 @@ 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"]) + # 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"}], + json.loads(outputs()["repinned"]), + "Materialize", + ) + + assert sorted(git(repo, "show", "--name-only", "--format=", "HEAD").split()) == [ + "CHANGELOG.md", + "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 7a2d00c6595..4f7fbb34910 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,307 @@ 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) + + +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) + + +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