diff --git a/README.md b/README.md index e2adeb949..a8dd3c9c5 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,12 @@ The utility commands detailed in the next section. ## Utility Commands +When resolving version ranges from a registry, Corepack follows semver rules: +prereleases are excluded unless the range explicitly includes them. For example, +`pnpm@12` selects stable releases, while `pnpm@^12.0.0-rc.0` also allows compatible +prereleases. Exact prerelease versions and tags pointing to prereleases are +supported as well. + ### `corepack [@] [... args]` This meta-command runs the specified package manager in the local folder. You @@ -283,6 +289,10 @@ Retrieve the latest available version for the current major release line of the package manager used in the local project, and update the project to use it. +Stable versions stay on stable releases. If the selected version is a prerelease, +`up` also allows newer prereleases with the same major, minor, and patch numbers, +as well as newer stable releases within the same major. + Unlike `corepack use` this command doesn't take a package manager name nor a version range, as it will always select the latest available version from the range specified in `devEngines.packageManager.version`, or fallback to the diff --git a/sources/Engine.ts b/sources/Engine.ts index 31dbe4ea6..50da45a07 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import process from 'process'; import semverRcompare from 'semver/functions/rcompare.js'; +import semverSatisfies from 'semver/functions/satisfies.js'; import semverValid from 'semver/functions/valid.js'; import semverValidRange from 'semver/ranges/valid.js'; @@ -424,7 +425,7 @@ export class Engine { const registry = corepackUtils.getRegistryFromPackageManagerSpec(packageManagerSpec); const versions = await corepackUtils.fetchAvailableVersions(registry); - return versions.filter(version => semverUtils.satisfiesWithPrereleases(version, finalDescriptor.range)); + return versions.filter(version => semverSatisfies(version, finalDescriptor.range)); })); const highestVersion = [...new Set(versions.flat())].sort(semverRcompare); diff --git a/sources/commands/Up.ts b/sources/commands/Up.ts index a6fb52aeb..9e8d08ab2 100644 --- a/sources/commands/Up.ts +++ b/sources/commands/Up.ts @@ -1,5 +1,6 @@ import {Command, UsageError} from 'clipanion'; import semverMajor from 'semver/functions/major.js'; +import semverPrerelease from 'semver/functions/prerelease.js'; import semverValid from 'semver/functions/valid.js'; import semverValidRange from 'semver/ranges/valid.js'; @@ -43,7 +44,10 @@ export class UpCommand extends BaseCommand { throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`); const majorVersion = semverMajor(resolved.reference); - const majorDescriptor = {name: descriptor.name as SupportedPackageManagers, range: `^${majorVersion}.0.0`}; + const range = semverPrerelease(resolved.reference) + ? `>=${resolved.reference} <${majorVersion + 1}.0.0-0` + : `^${majorVersion}.0.0`; + const majorDescriptor = {name: descriptor.name as SupportedPackageManagers, range}; const highestVersion = await this.context.engine.resolveDescriptor(majorDescriptor, {useCache: false}); if (!highestVersion) diff --git a/tests/_registryServer.mjs b/tests/_registryServer.mjs index cb4553d04..e86be459b 100644 --- a/tests/_registryServer.mjs +++ b/tests/_registryServer.mjs @@ -84,6 +84,9 @@ const registry = { customPkgManager: [`1.0.0`], }; +if (process.env.TEST_PRERELEASES === `1`) + registry.pnpm.unshift(`2.0.0-rc.1`, `1.9999.0-beta.1`); + function generateSignature(packageName, version) { if (privateKey == null) return undefined; const sign = createSign(`SHA256`).end(`${packageName}@${version}:${integrity}`); @@ -133,6 +136,7 @@ const server = createServer((req, res) => { if (req.url === `/${packageName}`) { res.end(JSON.stringify({"dist-tags": { latest: registry[packageName].at(-1), + ...(process.env.TEST_PRERELEASES === `1` && packageName === `pnpm` ? {next: registry.pnpm[0]} : {}), }, versions: Object.fromEntries(registry[packageName].map(version => [version, generateVersionMetadata(packageName, version)], ))})); @@ -141,9 +145,10 @@ const server = createServer((req, res) => { const isDownloadingRequest = req.url.slice(packageName.length + 1, packageName.length + 4) === `/-/`; let version; if (isDownloadingRequest) { - const match = /^(.+)-(.+)\.tgz$/.exec(req.url.slice(packageName.length + 4)); - if (match?.[1] === packageName) { - version = match[2]; + const fileName = req.url.slice(packageName.length + 4); + const prefix = `${packageName}-`; + if (fileName.startsWith(prefix) && fileName.endsWith(`.tgz`)) { + version = fileName.slice(prefix.length, -4); } } else { version = req.url.slice(packageName.length + 2); diff --git a/tests/prereleases.test.ts b/tests/prereleases.test.ts new file mode 100644 index 000000000..b14558fec --- /dev/null +++ b/tests/prereleases.test.ts @@ -0,0 +1,133 @@ +import {ppath, xfs, npath} from '@yarnpkg/fslib'; +import process from 'node:process'; +import {beforeEach, describe, expect, it} from 'vitest'; + +import {runCli} from './_runCli.ts'; + +beforeEach(async () => { + const home = await xfs.mktempPromise(); + process.env.COREPACK_HOME = npath.fromPortablePath(home); + process.env.COREPACK_DEFAULT_TO_LATEST = `0`; + process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; + process.env.TEST_INTEGRITY = `valid`; + process.env.TEST_PRERELEASES = `1`; + + return async () => { + await xfs.removePromise(home, {recursive: true}); + }; +}); + +describe(`prerelease resolution`, () => { + it.each([`pnpm`, `pnpm@1`])(`should select a stable version for use %s`, async pattern => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {}); + + await expect(runCli(cwd, [`use`, pattern], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `Installing pnpm@1.9998.9999 in the project...\n\npnpm: Hello from custom registry\n`, + stderr: ``, + }); + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + packageManager: expect.stringMatching(/^pnpm@1\.9998\.9999\+sha512\.[0-9a-f]{128}$/), + }); + }); + }); + + it(`should install a stable global default`, async () => { + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`install`, `-g`, `pnpm`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `Installing pnpm@1.9998.9999...\n`, + stderr: ``, + }); + const home = npath.toPortablePath(process.env.COREPACK_HOME!); + await expect(xfs.readJsonPromise(ppath.join(home, `lastKnownGood.json`))).resolves.toEqual({ + pnpm: `1.9998.9999`, + }); + }); + }); + + it(`should keep upgrades from a stable version on stable releases`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + packageManager: `pnpm@1.0.0`, + }); + + await expect(runCli(cwd, [`up`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `Installing pnpm@1.9998.9999 in the project...\n\npnpm: Hello from custom registry\n`, + stderr: ``, + }); + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + packageManager: expect.stringMatching(/^pnpm@1\.9998\.9999\+sha512\.[0-9a-f]{128}$/), + }); + }); + }); + + it.each([ + {current: `2.0.0-rc.0`, version: `2.0.0-rc.1`}, + {current: `1.9999.0-beta.0`, version: `1.9999.0-beta.1`}, + {current: `1.9998.9999-beta.0`, version: `1.9998.9999`}, + ])(`should upgrade an explicitly selected prerelease from $current to $version`, async ({current, version}) => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + packageManager: `pnpm@${current}`, + }); + + await expect(runCli(cwd, [`up`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `Installing pnpm@${version} in the project...\n\npnpm: Hello from custom registry\n`, + stderr: ``, + }); + const manifest = await xfs.readJsonPromise(ppath.join(cwd, `package.json`)); + expect(manifest.packageManager.split(`+`, 1)[0]).toBe(`pnpm@${version}`); + }); + }); + + it(`should not downgrade an explicitly selected prerelease`, async () => { + await xfs.mktempPromise(async cwd => { + const manifest = {packageManager: `pnpm@1.9999.0-beta.2`}; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), manifest); + + await expect(runCli(cwd, [`up`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining(`Failed to find the highest release for pnpm 1.x`), + stderr: ``, + }); + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toEqual(manifest); + }); + }); + + it.each([ + {range: `2.0.0-rc.1`, version: `2.0.0-rc.1`}, + {range: `^2.0.0-rc.0`, version: `2.0.0-rc.1`}, + {range: `1.9999.0-beta.0 - 1.9999.0-beta.9`, version: `1.9999.0-beta.1`}, + {range: `next`, version: `2.0.0-rc.1`}, + ])(`should allow explicitly requested prereleases with $range`, async ({range, version}) => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {}); + + await expect(runCli(cwd, [`use`, `pnpm@${range}`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `Installing pnpm@${version} in the project...\n\npnpm: Hello from custom registry\n`, + stderr: ``, + }); + const manifest = await xfs.readJsonPromise(ppath.join(cwd, `package.json`)); + expect(manifest.packageManager.split(`+`, 1)[0]).toBe(`pnpm@${version}`); + }); + }); + + it.each([`^2`, `^2.0.0-rc.2`])(`should not use a prerelease outside %s`, async range => { + await xfs.mktempPromise(async cwd => { + const manifest = {packageManager: `pnpm@1.0.0`}; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), manifest); + + await expect(runCli(cwd, [`use`, `pnpm@${range}`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining(`Failed to successfully resolve '${range}' to a valid pnpm release`), + stderr: ``, + }); + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toEqual(manifest); + }); + }); +});