mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 10:09:50 +00:00
4220c0fe36
* refactor(manager): share the .npmrc host-rule wrapper between bun, deno and npm Adds `withNpmrcHostRules()` to the npm manager utils: it injects the host-rule lines into a directory's `.npmrc`, runs the callback, and restores the original `.npmrc` from a `finally` block. bun, deno, the corepack `updateArtifacts()` and the three lock file loops in `post-update/index.ts` now share it instead of repeating getNpmrcContent / updateNpmrcContent / resetNpmrcContent. Adds `readUpdatedBinaryLockFile()` to the shared manager utils for the `Buffer.compare` plus file-addition block that bun and deno both carried. Behaviour changes: - bun and deno now restore `.npmrc` when `exec()` throws. The reset previously ran only after a successful install, so a failed lock file update left the injected registry credentials in the working tree. Covered by "restores .npmrc when the install fails" in `bun/artifacts.spec.ts` and `deno/artifacts.spec.ts`. - The npm, yarn and pnpm loops in `post-update/index.ts` now restore `.npmrc` when the loop body throws an `ExternalHostError`, for the same reason. - deno reads `config.isLockFileMaintenance` instead of deriving the flag from `config.updateType === 'lockFileMaintenance'`. Grouped branches overwrite `updateType` while `isLockFileMaintenance` is set explicitly, so the flag is the reliable source. The deno specs were updated to set it. * refactor(manager): let updateLockFile() compare binary lock files A Buffer as the existing content makes the skeleton read, compare and return the new lock file as bytes, so bun and deno use it instead of a separate readUpdatedBinaryLockFile() helper. * refactor(manager/devbox): update the lock file through updateLockFile() devbox kept the same Buffer compare as bun and deno; the trace log before returning the lock file goes away with it. * fix(manager/devbox): type the command list explicitly The list is now only read inside the run() closure, where TypeScript cannot resolve an evolving array type, so tsc reported an implicit any.
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import { isEmptyArray, isNonEmptyObject, isString } from '@sindresorhus/is';
|
|
import { quote } from 'shlex';
|
|
import upath from 'upath';
|
|
import type { Scalar, YAMLSeq } from 'yaml';
|
|
import { isScalar, isSeq, parseDocument } from 'yaml';
|
|
import { logger } from '../../../logger/index.ts';
|
|
import { exec } from '../../../util/exec/index.ts';
|
|
import type { ExecOptions } from '../../../util/exec/types.ts';
|
|
import {
|
|
ensureCacheDir,
|
|
localPathExists,
|
|
readLocalFile,
|
|
writeLocalFile,
|
|
} from '../../../util/fs/index.ts';
|
|
import { coerceObject } from '../../../util/object.ts';
|
|
import { regEx } from '../../../util/regex.ts';
|
|
import { matchRegexOrGlob } from '../../../util/string-match.ts';
|
|
import type { UpdateArtifact, UpdateArtifactsResult } from '../types.ts';
|
|
import { resolveToolConstraint } from '../util.ts';
|
|
import { PNPM_CACHE_DIR, PNPM_STORE_DIR } from './constants.ts';
|
|
import { getNodeToolConstraint } from './post-update/node-version.ts';
|
|
import { processHostRules } from './post-update/rules.ts';
|
|
import { lazyLoadPackageJson } from './post-update/utils.ts';
|
|
import { withNpmrcHostRules } from './utils.ts';
|
|
|
|
// eg. 8.15.5+sha256.4b4efa12490e5055d59b9b9fc9438b7d581a6b7af3b5675eb5c5f447cee1a589
|
|
const versionWithHashRegString = '^(?<version>.*)\\+(?<hash>.*)';
|
|
|
|
// Execute 'corepack use' command for npm manager updates
|
|
// This step is necessary because Corepack recommends attaching a hash after the version
|
|
// The hash is generated only after running 'corepack use' and cannot be fetched from the npm registry
|
|
export async function updateArtifacts(
|
|
updateArtifactsConfig: UpdateArtifact,
|
|
): Promise<UpdateArtifactsResult[] | null> {
|
|
logger.debug(`npm.updateArtifacts(${updateArtifactsConfig.packageFileName})`);
|
|
let res: UpdateArtifactsResult[] = [];
|
|
res.push(
|
|
coerceObject(await handlePackageManagerUpdates(updateArtifactsConfig)),
|
|
);
|
|
res.push(coerceObject(await updatePnpmWorkspace(updateArtifactsConfig)));
|
|
|
|
res = res.filter(isNonEmptyObject);
|
|
if (res.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
async function handlePackageManagerUpdates(
|
|
updateArtifactsConfig: UpdateArtifact,
|
|
): Promise<UpdateArtifactsResult | null> {
|
|
const {
|
|
packageFileName,
|
|
config,
|
|
updatedDeps,
|
|
newPackageFileContent: existingPackageFileContent,
|
|
} = updateArtifactsConfig;
|
|
const packageManagerUpdate = updatedDeps.find(
|
|
(dep) => dep.depType === 'packageManager',
|
|
);
|
|
|
|
if (!packageManagerUpdate) {
|
|
logger.debug('No packageManager updates - returning null');
|
|
return null;
|
|
}
|
|
|
|
const { currentValue, depName, newVersion } = packageManagerUpdate;
|
|
|
|
// Execute 'corepack use' command only if the currentValue already has hash in it
|
|
if (!currentValue || !regEx(versionWithHashRegString).test(currentValue)) {
|
|
return null;
|
|
}
|
|
|
|
// write old updates before executing corepack update so that they are not removed from package file
|
|
await writeLocalFile(packageFileName, existingPackageFileContent);
|
|
|
|
// Asumming that corepack only needs to modify the package.json file in the root folder
|
|
// As it should not be regular practice to have different package managers in different workspaces
|
|
const pkgFileDir = upath.dirname(packageFileName);
|
|
const { additionalNpmrcContent } = processHostRules();
|
|
const lazyPkgJson = lazyLoadPackageJson(pkgFileDir);
|
|
const cmd = `corepack use ${quote(`${depName}@${newVersion}`)}`;
|
|
|
|
const nodeConstraints = await getNodeToolConstraint(
|
|
config,
|
|
updatedDeps,
|
|
pkgFileDir,
|
|
lazyPkgJson,
|
|
);
|
|
|
|
const pnpmConfigCacheDir = await ensureCacheDir(PNPM_CACHE_DIR);
|
|
const pnpmConfigStoreDir = await ensureCacheDir(PNPM_STORE_DIR);
|
|
const execOptions: ExecOptions = {
|
|
cwdFile: packageFileName,
|
|
extraEnv: {
|
|
// To make sure pnpm store location is consistent between "corepack use"
|
|
// here and the pnpm commands in ./post-update/pnpm.ts. Check
|
|
// ./post-update/pnpm.ts for more details.
|
|
npm_config_cache_dir: pnpmConfigCacheDir,
|
|
npm_config_store_dir: pnpmConfigStoreDir,
|
|
pnpm_config_cache_dir: pnpmConfigCacheDir,
|
|
pnpm_config_store_dir: pnpmConfigStoreDir,
|
|
},
|
|
toolConstraints: [
|
|
nodeConstraints,
|
|
{
|
|
toolName: 'corepack',
|
|
constraint: await resolveToolConstraint(config, 'corepack'),
|
|
},
|
|
],
|
|
docker: {},
|
|
};
|
|
|
|
return await withNpmrcHostRules(
|
|
pkgFileDir,
|
|
additionalNpmrcContent,
|
|
async () => {
|
|
try {
|
|
await exec(cmd, execOptions);
|
|
const newPackageFileContent = await readLocalFile(
|
|
packageFileName,
|
|
'utf8',
|
|
);
|
|
if (
|
|
!newPackageFileContent ||
|
|
existingPackageFileContent === newPackageFileContent
|
|
) {
|
|
return null;
|
|
}
|
|
logger.debug('Returning updated package.json');
|
|
return {
|
|
file: {
|
|
type: 'addition',
|
|
path: packageFileName,
|
|
contents: newPackageFileContent,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Error updating package.json');
|
|
return {
|
|
artifactError: {
|
|
fileName: packageFileName,
|
|
stderr: err.message,
|
|
},
|
|
};
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update the minimumReleaseAgeExclude setting in pnpm-workspace.yaml if needed
|
|
*/
|
|
async function updatePnpmWorkspace(
|
|
updateArtifactsConfig: UpdateArtifact,
|
|
): Promise<UpdateArtifactsResult | null> {
|
|
const upgrades = updateArtifactsConfig.updatedDeps.filter(
|
|
(u) => u.isVulnerabilityAlert,
|
|
);
|
|
// return early if no security updates are present
|
|
if (isEmptyArray(upgrades)) {
|
|
return null;
|
|
}
|
|
|
|
const pnpmLockFile = upgrades[0].managerData?.pnpmLockFile;
|
|
if (!isString(pnpmLockFile)) {
|
|
logger.debug(
|
|
'No pnpm shrinkwrap found, not attempting to update pnpm-workspace.yaml',
|
|
);
|
|
return null;
|
|
}
|
|
const lockFileDir = upath.dirname(pnpmLockFile);
|
|
const pnpmWorkspaceFilePath = upath.join(lockFileDir, 'pnpm-workspace.yaml');
|
|
|
|
if (!(await localPathExists(pnpmWorkspaceFilePath))) {
|
|
return null;
|
|
}
|
|
|
|
// use already-updated content when updating the workspace file itself
|
|
const packageFileContent =
|
|
updateArtifactsConfig.packageFileName === pnpmWorkspaceFilePath
|
|
? updateArtifactsConfig.newPackageFileContent
|
|
: (await readLocalFile(pnpmWorkspaceFilePath, 'utf8'))!;
|
|
const doc = parseDocument(packageFileContent);
|
|
|
|
if (!doc.get('minimumReleaseAge')) {
|
|
return null;
|
|
}
|
|
|
|
let updated = false;
|
|
|
|
for (const upgrade of upgrades) {
|
|
let excludeNode = doc.getIn(['minimumReleaseAgeExclude']) as YAMLSeq | null;
|
|
// v8 ignore next -- TODO: add test #40625
|
|
const newVersion = upgrade.newVersion ?? upgrade.newValue;
|
|
// For pnpm overrides with range selectors (e.g. "pkg@<=1.0.0"), depName contains
|
|
// the full key including the selector. Use packageName (bare package name) for
|
|
// minimumReleaseAgeExclude entries which require exact versions only.
|
|
const excludeDepName = upgrade.packageName ?? upgrade.depName;
|
|
|
|
/* v8 ignore if -- should not happen, adding for type narrowing*/
|
|
if (excludeNode && !isSeq(excludeNode)) {
|
|
return null;
|
|
}
|
|
|
|
if (!excludeNode) {
|
|
logger.debug('Adding new exclude block');
|
|
excludeNode = doc.createNode([]);
|
|
const newItem = doc.createNode(`${excludeDepName}@${newVersion}`);
|
|
newItem.commentBefore = ` Renovate security update: ${excludeDepName}@${newVersion}`;
|
|
excludeNode.items.push(newItem);
|
|
doc.set('minimumReleaseAgeExclude', excludeNode);
|
|
updated = true;
|
|
continue;
|
|
}
|
|
|
|
const {
|
|
item: matchedItem,
|
|
allExcluded,
|
|
malformed,
|
|
} = getMatchedItem(excludeDepName!, excludeNode.items);
|
|
|
|
if (allExcluded) {
|
|
// still clean up any malformed entries even when a wildcard covers the package
|
|
} else if (malformed && isScalar<string>(matchedItem)) {
|
|
logger.debug(
|
|
{ entry: matchedItem.value, excludeDepName, newVersion },
|
|
'Replacing malformed minimumReleaseAgeExclude entry',
|
|
);
|
|
matchedItem.value = `${excludeDepName}@${newVersion}`;
|
|
matchedItem.commentBefore = ` Renovate security update: ${excludeDepName}@${newVersion}`;
|
|
updated = true;
|
|
} else if (isScalar<string>(matchedItem)) {
|
|
// if we have a comment before the list, which includes the dependency
|
|
if (excludeNode?.commentBefore?.includes(`${excludeDepName}@`)) {
|
|
// and it doesn't already have the version included in it
|
|
if (
|
|
!minimumReleaseAgeExcludeIncludesDepNameAndVersion(
|
|
excludeNode.commentBefore,
|
|
excludeDepName,
|
|
newVersion,
|
|
)
|
|
) {
|
|
// then append it
|
|
|
|
// normalize value (no quote handling needed)
|
|
excludeNode.commentBefore = `${excludeNode.commentBefore} || ${newVersion}`;
|
|
updated = true;
|
|
}
|
|
}
|
|
// otherwise, if it's in our matched item's comment
|
|
else if (matchedItem.commentBefore) {
|
|
// add it
|
|
if (
|
|
!minimumReleaseAgeExcludeIncludesDepNameAndVersion(
|
|
matchedItem.commentBefore,
|
|
excludeDepName,
|
|
newVersion,
|
|
)
|
|
) {
|
|
// normalize value (no quote handling needed)
|
|
matchedItem.commentBefore = `${matchedItem.commentBefore} || ${newVersion}`;
|
|
updated = true;
|
|
}
|
|
} else {
|
|
matchedItem.commentBefore = ` Renovate security update: ${excludeDepName}@${newVersion}`;
|
|
updated = true;
|
|
}
|
|
|
|
if (
|
|
!minimumReleaseAgeExcludeIncludesDepNameAndVersion(
|
|
matchedItem.value,
|
|
excludeDepName,
|
|
newVersion,
|
|
)
|
|
) {
|
|
matchedItem.value = `${matchedItem.value} || ${newVersion}`;
|
|
updated = true;
|
|
}
|
|
} else {
|
|
// add new entry
|
|
const newItem = doc.createNode(`${excludeDepName}@${newVersion}`);
|
|
newItem.commentBefore = ` Renovate security update: ${excludeDepName}@${newVersion}`;
|
|
|
|
excludeNode.items.push(newItem);
|
|
updated = true;
|
|
}
|
|
|
|
// Remove any malformed entries for the same package left over from the prior bug
|
|
for (let i = excludeNode.items.length - 1; i >= 0; i--) {
|
|
const item = excludeNode.items[i];
|
|
if (
|
|
item !== matchedItem &&
|
|
isScalar(item) &&
|
|
isString(item.value) &&
|
|
item.value.startsWith(`${excludeDepName}@`) &&
|
|
!isValidMinimumReleaseAgeExcludeEntry(item.value, excludeDepName!)
|
|
) {
|
|
excludeNode.items.splice(i, 1);
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!updated) {
|
|
return null;
|
|
}
|
|
|
|
const newContent = doc.toString();
|
|
await writeLocalFile(pnpmWorkspaceFilePath, newContent);
|
|
|
|
return {
|
|
file: {
|
|
type: 'addition',
|
|
path: pnpmWorkspaceFilePath,
|
|
contents: newContent,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getMatchedItem(
|
|
depName: string,
|
|
items: unknown[],
|
|
): {
|
|
item: Scalar | null;
|
|
allExcluded: boolean;
|
|
malformed?: boolean;
|
|
} {
|
|
let malformedItem: Scalar | null = null;
|
|
|
|
for (const item of items) {
|
|
/* v8 ignore if -- should not happen */
|
|
if (!isScalar(item) || !isString(item.value)) {
|
|
continue;
|
|
}
|
|
|
|
if (item.value.startsWith(`${depName}@`)) {
|
|
if (isValidMinimumReleaseAgeExcludeEntry(item.value, depName)) {
|
|
return {
|
|
allExcluded: false,
|
|
item,
|
|
};
|
|
}
|
|
malformedItem ??= item;
|
|
continue;
|
|
}
|
|
|
|
if (item.value === depName || matchRegexOrGlob(depName, item.value)) {
|
|
return {
|
|
allExcluded: true,
|
|
item,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (malformedItem) {
|
|
return {
|
|
allExcluded: false,
|
|
item: malformedItem,
|
|
malformed: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
item: null,
|
|
allExcluded: false,
|
|
};
|
|
}
|
|
|
|
/** pnpm requires package@version entries without range selectors or extra @ in the version part */
|
|
function isValidMinimumReleaseAgeExcludeEntry(
|
|
value: string,
|
|
packageName: string,
|
|
): boolean {
|
|
return !value.slice(`${packageName}@`.length).includes('@');
|
|
}
|
|
|
|
/** determine whether a comment or a list item contains the depName at a given newVersion */
|
|
function minimumReleaseAgeExcludeIncludesDepNameAndVersion(
|
|
line: string,
|
|
depName: string | undefined,
|
|
newVersion: string | undefined,
|
|
): boolean {
|
|
if (line.includes(`${depName}@${newVersion}`)) {
|
|
return true;
|
|
}
|
|
|
|
if (line.includes(`|| ${newVersion}`)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|