From 80fbe0140ccf08d50eff5eeb98dff9021c415a21 Mon Sep 17 00:00:00 2001 From: soujasK <121026960+soujasK@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:29 +0530 Subject: [PATCH] fix: resolve ~= specifiers with PEP 440 semantics (#1111) ## Summary `~=` is the PEP 440 compatible release operator, but the resolver tries `semver.validRange()` first, and semver accepts `~=` as a tilde range: | specifier | semver | PEP 440 | |---|---|---| | `~=0.9` | `>=0.9.0 <0.10.0` | `>=0.9, ==0.*` | So with the default `highest` strategy, `version: "~=0.9"` (or `required-version = "~=0.9"` picked up through `version-file`) installs the highest 0.9.x instead of the highest 0.x. The same happens with `manifest-file`, where `tc.evaluateVersions` is semver-based. This skips the semver evaluation for specifiers containing `~=`, so they are only evaluated with `@renovatebot/pep440`. No semver range legitimately uses `~=`. Notes: - Only a bare two-segment `~=X.Y` resolved incorrectly. `~=X.Y.Z` gives the same result in both, and compound specifiers like `~=0.9, !=0.9.3` were already rejected by semver and fell through to PEP 440. - `minSatisfying` gets the same check for consistency. The lower bound is the same under both interpretations, so `lowest` didn't produce wrong results before. - A single-segment `~=1` is invalid PEP 440. semver used to resolve it to 1.x; it now fails with `No version found for ~=1`, same as other invalid specifiers. ## Test plan - Added tests for `~=0.9` with the default manifest and with `manifest-file`. Both returned `0.9.26` instead of `0.10.2` before the change. - `npm run all` on Ubuntu (WSL) with Node 24.19.0: build, Biome (no fixes), packaging, and 161 tests across 15 suites pass. The rebuilt `dist/setup/index.cjs` only differs in the changed lines. Co-authored-by: Claude Opus 5.5 --- __tests__/download/download-version.test.ts | 22 +++++++++++++++++++++ dist/setup/index.cjs | 9 ++++++--- src/version/resolve.ts | 19 +++++++++++++++--- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/__tests__/download/download-version.test.ts b/__tests__/download/download-version.test.ts index b2352dc..25c02d3 100644 --- a/__tests__/download/download-version.test.ts +++ b/__tests__/download/download-version.test.ts @@ -146,6 +146,28 @@ describe("download-version", () => { expect(mockGetAllVersions).not.toHaveBeenCalled(); }); + it("resolves ~= with PEP 440 compatible release semantics", async () => { + mockGetFirstMatchingVersion.mockImplementation( + (predicate: (version: string) => boolean) => + ["1.0.0", "0.10.2", "0.9.26"].find(predicate), + ); + + const version = await resolveVersion("~=0.9", undefined); + + expect(version).toBe("0.10.2"); + }); + + it("resolves ~= with PEP 440 compatible release semantics from manifest-file", async () => { + mockGetAllVersions.mockResolvedValue(["1.0.0", "0.10.2", "0.9.26"]); + + const version = await resolveVersion( + "~=0.9", + "https://example.com/custom.ndjson", + ); + + expect(version).toBe("0.10.2"); + }); + it("treats == exact pins as explicit versions", async () => { const version = await resolveVersion("==0.9.26", undefined); diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index b76c0c6..0b7cf0c 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -101937,7 +101937,7 @@ async function resolveVersion(versionInput, manifestUrl, resolutionStrategy = "h throw new Error(`No version found for ${versionInput}`); } async function findHighestSatisfyingVersion(versionSpecifier) { - const semverRange = semver5.validRange(versionSpecifier); + const semverRange = isCompatibleReleaseSpecifier(versionSpecifier) ? null : semver5.validRange(versionSpecifier); if (semverRange !== null) { const semverMatch = await getFirstMatchingVersion( (version3) => semver5.satisfies(version3, semverRange) @@ -101959,8 +101959,11 @@ async function findHighestSatisfyingVersion(versionSpecifier) { } return pep440Match; } +function isCompatibleReleaseSpecifier(versionSpecifier) { + return versionSpecifier.includes("~="); +} function maxSatisfying2(versions, version3) { - const maxSemver = evaluateVersions(versions, version3); + const maxSemver = isCompatibleReleaseSpecifier(version3) ? "" : evaluateVersions(versions, version3); if (maxSemver !== "") { debug(`Found a version that satisfies the semver range: ${maxSemver}`); return maxSemver; @@ -101975,7 +101978,7 @@ function maxSatisfying2(versions, version3) { return void 0; } function minSatisfying3(versions, version3) { - const minSemver = semver5.minSatisfying(versions, version3); + const minSemver = isCompatibleReleaseSpecifier(version3) ? null : semver5.minSatisfying(versions, version3); if (minSemver !== null) { debug(`Found a version that satisfies the semver range: ${minSemver}`); return minSemver; diff --git a/src/version/resolve.ts b/src/version/resolve.ts index ea54af7..1153c9d 100644 --- a/src/version/resolve.ts +++ b/src/version/resolve.ts @@ -169,7 +169,9 @@ async function findHighestSatisfyingVersion( ): Promise { // This fast path assumes uv releases are semver-monotonic: newer manifest // entries always have higher versions, with no lower-version backports. - const semverRange = semver.validRange(versionSpecifier); + const semverRange = isCompatibleReleaseSpecifier(versionSpecifier) + ? null + : semver.validRange(versionSpecifier); if (semverRange !== null) { const semverMatch = await getFirstMatchingVersion((version) => semver.satisfies(version, semverRange), @@ -193,11 +195,20 @@ async function findHighestSatisfyingVersion( return pep440Match; } +// `~=` is the PEP 440 compatible release operator. semver accepts it as a +// tilde range with a narrower upper bound (`~=0.5` as `<0.6.0` instead of +// `<1.0`), so it must only be evaluated as PEP 440. +function isCompatibleReleaseSpecifier(versionSpecifier: string): boolean { + return versionSpecifier.includes("~="); +} + function maxSatisfying( versions: string[], version: string, ): string | undefined { - const maxSemver = tc.evaluateVersions(versions, version); + const maxSemver = isCompatibleReleaseSpecifier(version) + ? "" + : tc.evaluateVersions(versions, version); if (maxSemver !== "") { core.debug(`Found a version that satisfies the semver range: ${maxSemver}`); return maxSemver; @@ -218,7 +229,9 @@ function minSatisfying( versions: string[], version: string, ): string | undefined { - const minSemver = semver.minSatisfying(versions, version); + const minSemver = isCompatibleReleaseSpecifier(version) + ? null + : semver.minSatisfying(versions, version); if (minSemver !== null) { core.debug(`Found a version that satisfies the semver range: ${minSemver}`); return minSemver;