Skip to content
Closed
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,21 @@ same major line. Should you need to upgrade to a new major, use an explicit
- `COREPACK_NPM_REGISTRY` sets the registry base url used when retrieving
package managers from npm. Default value is `https://registry.npmjs.org`

- `COREPACK_MINIMUM_RELEASE_AGE` sets the minimum publish age, in integer
minutes, when resolving tags (including `latest`) or version ranges. For
example, `COREPACK_MINIMUM_RELEASE_AGE=1440 corepack install -g pnpm@latest`
selects the highest eligible version published at least one day ago.
Unset, empty, or `0` disables the gate; invalid or negative values cause an
error. Explicit exact versions bypass the gate. Registry metadata must
provide valid publish timestamps in its `time` field; versions with missing
or invalid timestamps are excluded, and resolution fails if none qualify.
This also applies to cached versions selected through ranges. Registries
without publish timestamps (such as Yarn's default modern release index)
cannot resolve releases with the gate enabled; use an exact version instead.
Tag fallback never exceeds the tagged version: stable tags select stable
releases, and prerelease tags stay within the same release and prerelease
channel. Version ranges retain their existing prerelease matching behavior.

- `COREPACK_NPM_TOKEN` sets a Bearer token authorization header when connecting
to a npm type registry.

Expand Down
22 changes: 14 additions & 8 deletions sources/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as corepackUtils from './corepackUtils.ts'
import * as debugUtils from './debugUtils.ts';
import * as folderUtils from './folderUtils.ts';
import type {NodeError} from './nodeUtils.ts';
import * as releaseAgeUtils from './releaseAgeUtils.ts';
import * as semverUtils from './semverUtils.ts';
import * as specUtils from './specUtils.ts';
import type {Config, Descriptor, LazyLocator, Locator} from './types.ts';
Expand Down Expand Up @@ -386,6 +387,9 @@ export class Engine {
if (typeof definition === `undefined`)
throw new UsageError(`This package manager (${descriptor.name}) isn't supported by this corepack build`);

// An explicit version is a deliberate choice. Tags and ranges must still
// be checked against registry timestamps, even when a version is cached.
const minimumReleaseAge = semverValid(descriptor.range) ? 0 : releaseAgeUtils.getMinimumReleaseAge();
let finalDescriptor = descriptor;
if (!semverValid(descriptor.range) && !semverValidRange(descriptor.range)) {
if (!allowTags)
Expand All @@ -398,21 +402,20 @@ export class Engine {
const packageManagerSpec = definition.ranges[tagRange];
const registry = corepackUtils.getRegistryFromPackageManagerSpec(packageManagerSpec);

const tags = await corepackUtils.fetchAvailableTags(registry);
if (!Object.hasOwn(tags, descriptor.range))
throw new UsageError(`Tag not found (${descriptor.range})`);

finalDescriptor = {
name: descriptor.name,
range: tags[descriptor.range],
range: await corepackUtils.fetchTagVersion(registry, descriptor.range),
};
}

// If a compatible version is already installed, no need to query one
// from the remote listings
const cachedVersion = await corepackUtils.findInstalledVersion(folderUtils.getInstallFolder(), finalDescriptor);
if (cachedVersion !== null && useCache)
return {name: finalDescriptor.name, reference: cachedVersion};
if (useCache && (minimumReleaseAge === 0 || semverValid(finalDescriptor.range))) {
const cachedVersion = await corepackUtils.findInstalledVersion(folderUtils.getInstallFolder(), finalDescriptor);
if (cachedVersion !== null) {
return {name: finalDescriptor.name, reference: cachedVersion};
}
}

// If the user asked for a specific version, no need to request the list of
// available versions from the registry.
Expand All @@ -428,6 +431,9 @@ export class Engine {
}));

const highestVersion = [...new Set(versions.flat())].sort(semverRcompare);
if (highestVersion.length === 0 && minimumReleaseAge > 0)
throw new UsageError(`No ${descriptor.name} release older than ${minimumReleaseAge} minutes is available for range ${descriptor.range}`);

if (highestVersion.length === 0)
return null;

Expand Down
19 changes: 13 additions & 6 deletions sources/corepackUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as folderUtils from './fold
import * as httpUtils from './httpUtils.ts';
import * as nodeUtils from './nodeUtils.ts';
import * as npmRegistryUtils from './npmRegistryUtils.ts';
import * as releaseAgeUtils from './releaseAgeUtils.ts';
import type {RegistrySpec, Descriptor, Locator, PackageManagerSpec} from './types.ts';
import type {BinList, BinSpec, InstallSpec, DownloadSpec} from './types.ts';

Expand All @@ -37,23 +38,28 @@ export async function fetchLatestStableVersion(spec: RegistrySpec): Promise<stri
return await npmRegistryUtils.fetchLatestStableVersion(spec.package);
}
case `url`: {
const data = await httpUtils.fetchAsJson(spec.url);
return data[spec.fields.tags].stable;
return await fetchTagVersion(spec, `stable`);
}
default: {
throw new Error(`Unsupported specification ${JSON.stringify(spec)}`);
}
}
}

export async function fetchAvailableTags(spec: RegistrySpec): Promise<Record<string, string>> {
export async function fetchTagVersion(spec: RegistrySpec, tag: string): Promise<string> {
switch (spec.type) {
case `npm`: {
return await npmRegistryUtils.fetchAvailableTags(spec.package);
return await npmRegistryUtils.fetchTagVersion(spec.package, tag);
}
case `url`: {
const minimumReleaseAge = releaseAgeUtils.getMinimumReleaseAge();
const data = await httpUtils.fetchAsJson(spec.url);
return data[spec.fields.tags];
const field = data[spec.fields.versions];
return releaseAgeUtils.resolveTag(spec.url, tag, {
tags: data[spec.fields.tags],
versions: minimumReleaseAge > 0 ? (Array.isArray(field) ? field : Object.keys(field)) : [],
time: data.time,
}, minimumReleaseAge);
}
default: {
throw new Error(`Unsupported specification ${JSON.stringify(spec)}`);
Expand All @@ -67,9 +73,10 @@ export async function fetchAvailableVersions(spec: RegistrySpec): Promise<Array<
return await npmRegistryUtils.fetchAvailableVersions(spec.package);
}
case `url`: {
const minimumReleaseAge = releaseAgeUtils.getMinimumReleaseAge();
const data = await httpUtils.fetchAsJson(spec.url);
const field = data[spec.fields.versions];
return Array.isArray(field) ? field : Object.keys(field);
return releaseAgeUtils.filterVersionsByAge(Array.isArray(field) ? field : Object.keys(field), data.time, minimumReleaseAge);
}
default: {
throw new Error(`Unsupported specification ${JSON.stringify(spec)}`);
Expand Down
37 changes: 29 additions & 8 deletions sources/npmRegistryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import defaultConfig from '../config.json' with {type: 'json'};

import {shouldSkipIntegrityCheck} from './corepackUtils.ts';
import * as httpUtils from './httpUtils.ts';
import * as releaseAgeUtils from './releaseAgeUtils.ts';

// load abbreviated metadata as that's all we need for these calls
// Load abbreviated metadata unless publish timestamps are needed.
// see: https://github.com/npm/registry/blob/cfe04736f34db9274a780184d1cdb2fb3e4ead2a/docs/responses/package-metadata.md
export const DEFAULT_HEADERS: Record<string, string> = {
[`Accept`]: `application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8`,
};
export const DEFAULT_NPM_REGISTRY_URL = `https://registry.npmjs.org`;

export async function fetchAsJson(packageName: string, version?: string) {
export async function fetchAsJson(packageName: string, version?: string, {fullMetadata = false}: {fullMetadata?: boolean} = {}) {
// Strip any trailing slashes so a `COREPACK_NPM_REGISTRY` with a trailing
// slash does not produce a double slash in the request URL (some registries,
// e.g. registry.npmmirror.com, reject `//package` with a 404).
Expand All @@ -23,6 +24,8 @@ export async function fetchAsJson(packageName: string, version?: string) {
throw new UsageError(`Network access disabled by the environment; can't reach npm repository ${npmRegistryUrl}`);

const headers = {...DEFAULT_HEADERS};
if (fullMetadata)
headers.Accept = `application/json`;

if (`COREPACK_NPM_TOKEN` in process.env) {
headers.authorization = `Bearer ${process.env.COREPACK_NPM_TOKEN}`;
Expand Down Expand Up @@ -77,7 +80,19 @@ export async function verifySignature({signatures, integrity, packageName, versi
}

export async function fetchLatestStableVersion(packageName: string) {
const metadata = await fetchAsJson(packageName, `latest`);
const minimumReleaseAge = releaseAgeUtils.getMinimumReleaseAge();
let metadata;
if (minimumReleaseAge > 0) {
const rootMetadata = await fetchAsJson(packageName, undefined, {fullMetadata: true});
const version = releaseAgeUtils.resolveTag(packageName, `latest`, {
tags: rootMetadata[`dist-tags`],
versions: Object.keys(rootMetadata.versions),
time: rootMetadata.time,
}, minimumReleaseAge);
metadata = rootMetadata.versions[version];
} else {
metadata = await fetchAsJson(packageName, `latest`);
}

const {version, dist: {integrity, signatures, shasum}} = metadata;

Expand All @@ -100,14 +115,20 @@ export async function fetchLatestStableVersion(packageName: string) {
}`;
}

export async function fetchAvailableTags(packageName: string) {
const metadata = await fetchAsJson(packageName);
return metadata[`dist-tags`];
export async function fetchTagVersion(packageName: string, tag: string) {
const minimumReleaseAge = releaseAgeUtils.getMinimumReleaseAge();
const metadata = await fetchAsJson(packageName, undefined, {fullMetadata: minimumReleaseAge > 0});
return releaseAgeUtils.resolveTag(packageName, tag, {
tags: metadata[`dist-tags`],
versions: minimumReleaseAge > 0 ? Object.keys(metadata.versions) : [],
time: metadata.time,
}, minimumReleaseAge);
}

export async function fetchAvailableVersions(packageName: string) {
const metadata = await fetchAsJson(packageName);
return Object.keys(metadata.versions);
const minimumReleaseAge = releaseAgeUtils.getMinimumReleaseAge();
const metadata = await fetchAsJson(packageName, undefined, {fullMetadata: minimumReleaseAge > 0});
return releaseAgeUtils.filterVersionsByAge(Object.keys(metadata.versions), metadata.time, minimumReleaseAge);
}

export async function fetchTarballURLAndSignature(packageName: string, version: string) {
Expand Down
64 changes: 64 additions & 0 deletions sources/releaseAgeUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {UsageError} from 'clipanion';
import semverParse from 'semver/functions/parse.js';
import semverRcompare from 'semver/functions/rcompare.js';

export function getMinimumReleaseAge(): number {
const value = process.env.COREPACK_MINIMUM_RELEASE_AGE;
if (value === undefined || value === ``)
return 0;

const minutes = Number(value);
if (!/^\d+$/.test(value) || !Number.isSafeInteger(minutes))
throw new UsageError(`COREPACK_MINIMUM_RELEASE_AGE must be a non-negative integer number of minutes`);

return minutes;
}

export function filterVersionsByAge(versions: Array<string>, time: Record<string, unknown> | null | undefined, minimumReleaseAge: number): Array<string> {
if (minimumReleaseAge === 0)
return versions;

const cutoff = Date.now() - minimumReleaseAge * 60_000;
return versions.filter(version => {
const published = time?.[version];
// Require an explicit timezone so eligibility doesn't depend on the host's
// timezone. Unknown or malformed timestamps must not bypass the gate.
if (typeof published !== `string` || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(published))
return false;

return Date.parse(published) <= cutoff;
});
}

export function resolveTag(packageName: string, tag: string, metadata: {
tags: Record<string, string>;
versions: Array<string>;
time?: Record<string, unknown> | null;
}, minimumReleaseAge: number): string {
if (!Object.hasOwn(metadata.tags, tag))
throw new UsageError(`Tag not found (${tag})`);

const taggedVersion = metadata.tags[tag];
if (minimumReleaseAge === 0)
return taggedVersion;

const target = semverParse(taggedVersion);
const candidates = filterVersionsByAge(metadata.versions, metadata.time, minimumReleaseAge).filter(version => {
const candidate = semverParse(version);
if (!target || !candidate || candidate.compare(target) > 0)
return false;

// Tags have no history in registry metadata. Never advance beyond the tag
// or switch a stable tag to a prerelease. For prerelease tags, stay within
// the same release and channel (e.g. 2.0.0-rc.*).
return target.prerelease.length === 0
? candidate.prerelease.length === 0
: candidate.major === target.major && candidate.minor === target.minor && candidate.patch === target.patch && candidate.prerelease[0] === target.prerelease[0];
});

const version = candidates.sort(semverRcompare)[0];
if (version === undefined)
throw new UsageError(`No ${packageName} release older than ${minimumReleaseAge} minutes is available for tag ${tag}`);

return version;
}
124 changes: 124 additions & 0 deletions tests/MinimumReleaseAge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {npath, ppath, xfs} from '@yarnpkg/fslib';
import {beforeEach, describe, expect, it} from 'vitest';

import {runCli} from './_runCli.ts';

describe(`COREPACK_MINIMUM_RELEASE_AGE`, () => {
beforeEach(async () => {
const home = await xfs.mktempPromise();
process.env.COREPACK_HOME = npath.fromPortablePath(home);
process.env.COREPACK_DEFAULT_TO_LATEST = `0`;
process.env.COREPACK_MINIMUM_RELEASE_AGE = `60`;
process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`;
process.env.TEST_INTEGRITY = `valid`;

return async () => {
await xfs.removePromise(home, {recursive: true});
};
});

it.each([`latest`, `^1.0.0`])(`installs the highest eligible version for pnpm@%s`, async range => {
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`install`, `-g`, `pnpm@${range}`], true)).resolves.toEqual({
exitCode: 0,
stdout: `Installing pnpm@1.9998.9998...\n`,
stderr: ``,
});
await expect(xfs.readJsonPromise(ppath.join(npath.toPortablePath(process.env.COREPACK_HOME!), `lastKnownGood.json`))).resolves.toEqual({pnpm: `1.9998.9998`});
});
});

it.each([`latest`, `^1.0.0`, `>=1.9998.9999`])(`fails cleanly when no version qualifies for pnpm@%s`, async range => {
process.env.COREPACK_MINIMUM_RELEASE_AGE = `4320`;
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`install`, `-g`, `pnpm@${range}`], true)).resolves.toEqual({
exitCode: 1,
stdout: expect.stringContaining(`Usage Error: No pnpm release older than 4320 minutes is available`),
stderr: ``,
});
});
});

it(`fails when only releases outside the requested range are old enough`, async () => {
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`install`, `-g`, `pnpm@>=1.9998.9999`], true)).resolves.toEqual({
exitCode: 1,
stdout: expect.stringContaining(`No pnpm release older than 60 minutes is available for range >=1.9998.9999`),
stderr: ``,
});
});
});

it(`allows an explicitly pinned new release`, async () => {
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`install`, `-g`, `pnpm@1.9998.9999`], true)).resolves.toEqual({
exitCode: 0,
stdout: `Installing pnpm@1.9998.9999...\n`,
stderr: ``,
});
});
});

it.each([undefined, ``, `0`])(`keeps current latest behavior when set to %j`, async value => {
if (value === undefined)
delete process.env.COREPACK_MINIMUM_RELEASE_AGE;
else
process.env.COREPACK_MINIMUM_RELEASE_AGE = value;

await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`install`, `-g`, `pnpm@latest`], true)).resolves.toEqual({
exitCode: 0,
stdout: `Installing pnpm@1.9998.9999...\n`,
stderr: ``,
});
});
});

it(`validates the setting before accessing the network`, async () => {
process.env.COREPACK_MINIMUM_RELEASE_AGE = `one day`;
process.env.COREPACK_ENABLE_NETWORK = `0`;
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`install`, `-g`, `pnpm@latest`], true)).resolves.toEqual({
exitCode: 1,
stdout: expect.stringContaining(`Usage Error: COREPACK_MINIMUM_RELEASE_AGE must be a non-negative integer number of minutes`),
stderr: ``,
});
});
});

it(`applies the gate to the implicit latest lookup and verifies the selected release`, async () => {
process.env.COREPACK_DEFAULT_TO_LATEST = `1`;
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toEqual({
exitCode: 0,
stdout: `pnpm: Hello from custom registry\n`,
stderr: ``,
});
await expect(xfs.readJsonPromise(ppath.join(npath.toPortablePath(process.env.COREPACK_HOME!), `lastKnownGood.json`))).resolves.toEqual({pnpm: expect.stringMatching(/^1\.9998\.9998\+sha512\./)});
});
});

it(`does not let a cached new release bypass range resolution`, async () => {
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`install`, `-g`, `pnpm@1.9998.9999`], true)).resolves.toMatchObject({exitCode: 0});
await expect(runCli(cwd, [`prepare`, `pnpm@^1.0.0`, `--activate`], true)).resolves.toEqual({
exitCode: 0,
stdout: `Preparing pnpm@^1.0.0 for immediate activation...\n`,
stderr: ``,
});
await expect(xfs.readJsonPromise(ppath.join(npath.toPortablePath(process.env.COREPACK_HOME!), `lastKnownGood.json`))).resolves.toEqual({pnpm: `1.9998.9998`});
});
});

it.each([`use`, `up`])(`applies the gate to corepack %s`, async command => {
await xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {packageManager: `pnpm@1.9998.9997`});
await expect(runCli(cwd, command === `use` ? [`use`, `pnpm@latest`] : [`up`], true)).resolves.toEqual({
exitCode: 0,
stdout: `Installing pnpm@1.9998.9998 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\.9998\+sha512\./)});
});
});
});
Loading