diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index ef24355e2..c21f9ef24 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -29,7 +29,6 @@ jobs: LATEST_NPM=$(curl https://registry.npmjs.org/npm | jq '.["dist-tags"].latest + "+sha1." + .versions[.["dist-tags"].latest].dist.shasum') LATEST_PNPM=$(curl https://registry.npmjs.org/pnpm | jq '.["dist-tags"].latest + "+sha1." + .versions[.["dist-tags"].latest].dist.shasum') - LATEST_YARN=$(curl https://registry.npmjs.org/yarn | jq '.["dist-tags"].latest + "+sha1." + .versions[.["dist-tags"].latest].dist.shasum') LATEST_BERRY=$(jq -n '$version + "+sha224." + $checksum' --arg version "$LATEST_BERRY_VERSION" --arg checksum "$(curl https://repo.yarnpkg.com/"$LATEST_BERRY_VERSION"/packages/yarnpkg-cli/bin/yarn.js | openssl dgst -sha224 | cut -d' ' -f2)") git --no-pager show HEAD:config.json | jq '. * '"{ @@ -41,7 +40,7 @@ jobs: default: $LATEST_PNPM, }, yarn: { - default: $LATEST_YARN, + default: $LATEST_BERRY, transparent: { default: $LATEST_BERRY, }, diff --git a/README.md b/README.md index e2adeb949..97b251093 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,10 @@ When running Corepack within projects that don't list a supported package manager, it will default to a set of Known Good Releases. If there is no Known Good Release for the requested package manager, Corepack -looks up the npm registry for the latest available version and cache it for -future use. +looks up the package manager's registry for the latest available version and +caches it for future use. Yarn defaults to Yarn Modern. Projects that pin Yarn +Classic, or users who select it with `corepack install -g yarn@1`, continue to +use Yarn Classic. The Known Good Releases can be updated system-wide using `corepack install -g`. When Corepack downloads a new version of a given package manager on the same diff --git a/config.json b/config.json index e14f0bb24..1e3b446c3 100644 --- a/config.json +++ b/config.json @@ -113,10 +113,14 @@ } }, "yarn": { - "default": "1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610", + "default": "4.18.0+sha224.5707fce90df5d8720fae4e85a07ab55e90aa20fded8914893e2ba225", "fetchLatestFrom": { - "type": "npm", - "package": "yarn" + "type": "url", + "url": "https://repo.yarnpkg.com/tags", + "fields": { + "tags": "aliases", + "versions": "tags" + } }, "transparent": { "default": "4.18.0+sha224.5707fce90df5d8720fae4e85a07ab55e90aa20fded8914893e2ba225", diff --git a/sources/Engine.ts b/sources/Engine.ts index 31dbe4ea6..6c9fa371a 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -189,7 +189,10 @@ export class Engine { return definition.default; } - const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom); + const registry = process.env.COREPACK_NPM_REGISTRY + ? corepackUtils.getRegistryFromPackageManagerSpec(this.getPackageManagerSpecFor({name: packageManager, reference: definition.default})) + : definition.fetchLatestFrom; + const reference = await corepackUtils.fetchLatestStableVersion(registry); debugUtils.log(`Search for default version: found in remote registry ${packageManager}@${reference}`); try { diff --git a/sources/corepackUtils.ts b/sources/corepackUtils.ts index d38df1c7b..c83c16114 100644 --- a/sources/corepackUtils.ts +++ b/sources/corepackUtils.ts @@ -34,7 +34,10 @@ export function getRegistryFromPackageManagerSpec(spec: PackageManagerSpec) { export async function fetchLatestStableVersion(spec: RegistrySpec): Promise { switch (spec.type) { case `npm`: { - return await npmRegistryUtils.fetchLatestStableVersion(spec.package); + const reference = await npmRegistryUtils.fetchLatestStableVersion(spec.package); + // A registry tarball's hash cannot be used to verify a single file + // extracted from it (such as Yarn Modern's bin/yarn.js). + return spec.bin ? reference.replace(/\+.*/, ``) : reference; } case `url`: { const data = await httpUtils.fetchAsJson(spec.url); @@ -140,7 +143,7 @@ function isValidBinSpec(x: unknown): x is BinSpec { return typeof x === `object` && x !== null && !Array.isArray(x) && Object.keys(x).length > 0; } -async function download(installTarget: string, url: string, algo: string, binPath: string | null = null): Promise { +async function download(installTarget: string, url: string, algo: string, binPath: string | null = null, expectedTarballHash: string | null = null): Promise { // Creating a temporary folder inside the install folder means that we // are sure it'll be in the same drive as the destination, so we can // just move it there atomically once we are done @@ -173,8 +176,17 @@ async function download(installTarget: string, url: string, algo: string, binPat stream.pipe(sendTo); let hash = !binPath ? stream.pipe(createHash(algo)) : null; + const tarballHash = expectedTarballHash ? stream.pipe(createHash(`sha512`)) : null; await once(sendTo, `finish`); + if (tarballHash) { + const actualTarballHash = tarballHash.digest(`hex`); + if (actualTarballHash !== expectedTarballHash) { + await fs.promises.rm(tmpFolder, {recursive: true, force: true}); + throw new Error(`Mismatch hashes. Expected ${expectedTarballHash}, got ${actualTarballHash}`); + } + } + if (binPath) { const downloadedBin = path.join(tmpFolder, binPath); outputFile = path.join(tmpFolder, path.basename(downloadedBin)); @@ -286,7 +298,12 @@ export async function installVersion(installTarget: string, locator: Locator, {s debugUtils.log(`Installing ${locator.name}@${version} from ${url}`); const algo = build[0] ?? `sha512`; - const {tmpFolder, outputFile, hash: actualHash} = await download(installTarget, url, algo, binPath); + // npm metadata describes the tarball, while the locator hash describes + // the extracted binary. Verify both when downloading a single-file manager. + const expectedTarballHash = binPath && integrity! + ? Buffer.from(integrity.slice(`sha512-`.length), `base64`).toString(`hex`) + : null; + const {tmpFolder, outputFile, hash: actualHash} = await download(installTarget, url, algo, binPath, expectedTarballHash); let bin: BinSpec | BinList; const isSingleFile = outputFile !== null; diff --git a/tests/Engine.test.ts b/tests/Engine.test.ts new file mode 100644 index 000000000..001c59251 --- /dev/null +++ b/tests/Engine.test.ts @@ -0,0 +1,63 @@ +import {xfs, npath} from '@yarnpkg/fslib'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import config from '../config.json' with {type: 'json'}; +import {Engine} from '../sources/Engine.ts'; +import * as corepackUtils from '../sources/corepackUtils.ts'; +import type {RegistrySpec} from '../sources/types.ts'; + +vi.mock(`../sources/corepackUtils.ts`); + +describe(`Yarn default version`, () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.mocked(corepackUtils.isSupportedPackageManagerLocator).mockReturnValue(true); + const home = await xfs.mktempPromise(); + process.env.COREPACK_HOME = npath.fromPortablePath(home); + return () => xfs.removePromise(home); + }); + + it(`uses the bundled Modern version when latest lookups are disabled`, async () => { + process.env.COREPACK_DEFAULT_TO_LATEST = `0`; + + await expect(new Engine().getDefaultVersion(`yarn`)).resolves.toBe(config.definitions.yarn.default); + expect(config.definitions.yarn.default).toMatch(/^4\./); + expect(corepackUtils.fetchLatestStableVersion).not.toHaveBeenCalled(); + }); + + it(`looks up the Modern release index on a fresh install`, async () => { + vi.mocked(corepackUtils.fetchLatestStableVersion).mockResolvedValue(`4.99.0`); + + await expect(new Engine().getDefaultVersion(`yarn`)).resolves.toBe(`4.99.0`); + expect(corepackUtils.fetchLatestStableVersion).toHaveBeenCalledWith({ + type: `url`, + url: `https://repo.yarnpkg.com/tags`, + fields: {tags: `aliases`, versions: `tags`}, + }); + // Later calls use the saved Known Good Release, including offline calls. + process.env.COREPACK_ENABLE_NETWORK = `0`; + await expect(new Engine().getDefaultVersion(`yarn`)).resolves.toBe(`4.99.0`); + expect(corepackUtils.fetchLatestStableVersion).toHaveBeenCalledTimes(1); + }); + + it(`preserves an activated Classic release`, async () => { + const engine = new Engine(); + await engine.activatePackageManager({name: `yarn`, reference: `1.22.22`}); + + await expect(engine.getDefaultVersion(`yarn`)).resolves.toBe(`1.22.22`); + expect(corepackUtils.fetchLatestStableVersion).not.toHaveBeenCalled(); + }); + + it(`uses the Modern npm package when a custom registry is configured`, async () => { + process.env.COREPACK_NPM_REGISTRY = `https://registry.example.org`; + const registry = { + type: `npm`, package: `@yarnpkg/cli-dist`, bin: `bin/yarn.js`, + } satisfies RegistrySpec; + vi.mocked(corepackUtils.getRegistryFromPackageManagerSpec).mockReturnValue(registry); + vi.mocked(corepackUtils.fetchLatestStableVersion).mockResolvedValue(`4.99.0`); + + await expect(new Engine().getDefaultVersion(`yarn`)).resolves.toBe(`4.99.0`); + expect(corepackUtils.getRegistryFromPackageManagerSpec).toHaveBeenCalledWith(config.definitions.yarn.ranges[`>=2.0.0`]); + expect(corepackUtils.fetchLatestStableVersion).toHaveBeenCalledWith(registry); + }); +}); diff --git a/tests/corepackUtils.test.ts b/tests/corepackUtils.test.ts index 1af8e7d81..cbe785e74 100644 --- a/tests/corepackUtils.test.ts +++ b/tests/corepackUtils.test.ts @@ -1,6 +1,38 @@ -import {describe, it, expect} from 'vitest'; +import {beforeEach, describe, it, expect, vi} from 'vitest'; -import {shouldSkipIntegrityCheck} from '../sources/corepackUtils.ts'; +import {fetchLatestStableVersion, shouldSkipIntegrityCheck} from '../sources/corepackUtils.ts'; +import * as httpUtils from '../sources/httpUtils.ts'; +import * as npmRegistryUtils from '../sources/npmRegistryUtils.ts'; + +vi.mock(`../sources/httpUtils.ts`); +vi.mock(`../sources/npmRegistryUtils.ts`); + +describe(`latest stable version`, () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it(`selects the stable alias from a URL registry`, async () => { + vi.mocked(httpUtils.fetchAsJson).mockResolvedValue({aliases: {stable: `4.99.0`, canary: `5.0.0-rc.1`}}); + + await expect(fetchLatestStableVersion({ + type: `url`, url: `https://repo.yarnpkg.com/tags`, fields: {tags: `aliases`, versions: `tags`}, + })).resolves.toBe(`4.99.0`); + }); + + it(`does not use a tarball hash to verify an extracted binary`, async () => { + vi.mocked(npmRegistryUtils.fetchLatestStableVersion).mockResolvedValue(`4.99.0+sha512.abcdef`); + + await expect(fetchLatestStableVersion({type: `npm`, package: `@yarnpkg/cli-dist`, bin: `bin/yarn.js`})).resolves.toBe(`4.99.0`); + expect(npmRegistryUtils.fetchLatestStableVersion).toHaveBeenCalledWith(`@yarnpkg/cli-dist`); + }); + + it(`retains the tarball hash for package managers installed as tarballs`, async () => { + vi.mocked(npmRegistryUtils.fetchLatestStableVersion).mockResolvedValue(`12.0.0+sha512.abcdef`); + + await expect(fetchLatestStableVersion({type: `npm`, package: `npm`})).resolves.toBe(`12.0.0+sha512.abcdef`); + }); +}); describe(`corepack utils shouldSkipIntegrityCheck`, () => { it(`should return false if COREPACK_INTEGRITY_KEYS env is not set`, () => { diff --git a/tests/main.test.ts b/tests/main.test.ts index 4bb7f40f4..1e7904102 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1369,12 +1369,29 @@ describe(`should show a warning on stderr before downloading when enable`, () => }); }); -it(`should be able to show the latest version`, async () => { +it(`should offer Yarn Modern on a fresh install, including create`, async () => { + process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT = `1`; + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`yarn`, `create`, `--help`])).resolves.toMatchObject({ + exitCode: 0, + stdout: expect.stringContaining(`yarn create`), + stderr: `! Corepack is about to download https://repo.yarnpkg.com/${config.definitions.yarn.default.split(`+`, 1)[0]}/packages/yarnpkg-cli/bin/yarn.js\n`, + }); + process.env.COREPACK_ENABLE_NETWORK = `0`; + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `${config.definitions.yarn.default.split(`+`, 1)[0]}\n`, + stderr: ``, + }); + }); +}); + +it(`should be able to show the latest Yarn Modern version`, async () => { process.env.COREPACK_DEFAULT_TO_LATEST = `1`; await xfs.mktempPromise(async cwd => { await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, - stdout: /^1\.\d+\.\d+\r?\n$/, + stdout: /^(?:[4-9]|[1-9]\d+)\.\d+\.\d+\r?\n$/, stderr: ``, }); @@ -1382,7 +1399,7 @@ it(`should be able to show the latest version`, async () => { await xfs.rmPromise(process.env.COREPACK_HOME as any, {recursive: true}); await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, - stdout: /^1\.\d+\.\d+\r?\n$/, + stdout: /^(?:[4-9]|[1-9]\d+)\.\d+\.\d+\r?\n$/, stderr: ``, }); }); @@ -1392,6 +1409,9 @@ it(`should download yarn classic from custom registry`, async () => { await xfs.mktempPromise(async cwd => { process.env.COREPACK_NPM_REGISTRY = `https://registry.npmmirror.com`; process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT = `1`; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + packageManager: `yarn@1.22.22`, + }); await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: expect.stringMatching(/^1\.\d+\.\d+\r?\n$/), @@ -1939,6 +1959,26 @@ describe(`handle integrity checks`, () => { process.env.TEST_INTEGRITY = `valid`; // See `_registryServer.mjs` }); + it(`should resolve and cache Yarn Modern by default from a custom registry`, async () => { + process.env.COREPACK_DEFAULT_TO_LATEST = `1`; + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`yarn`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, + }); + const lastKnownGood = await xfs.readJsonPromise(ppath.join(npath.toPortablePath(process.env.COREPACK_HOME!), `lastKnownGood.json`)); + expect(lastKnownGood.yarn).toMatch(/^5\.9999\.9999(?:\+|$)/); + + process.env.COREPACK_ENABLE_NETWORK = `0`; + await expect(runCli(cwd, [`yarn`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, + }); + }); + }); + it(`should return no error when calling 'corepack use'`, async () => { await xfs.mktempPromise(async cwd => { // Skip rest of the test on Windows & Node.js 18.x as it inevitably times out otherwise.