Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <binary name>[@<version>] [... args]`

This meta-command runs the specified package manager in the local folder. You
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion sources/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion sources/commands/Up.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions tests/_registryServer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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)],
))}));
Expand All @@ -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);
Expand Down
133 changes: 133 additions & 0 deletions tests/prereleases.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});