From c2821134f15ac8845ee62f1ce4a60a2d511bb33d Mon Sep 17 00:00:00 2001 From: RahulGautamSingh <rahultesnik@gmail.com> Date: Mon, 19 Aug 2024 19:07:25 +0530 Subject: [PATCH] feat(npm): append corepack hashes when updating package managers (#30552) --- lib/modules/manager/npm/artifacts.spec.ts | 230 ++++++++++++++++++++++ lib/modules/manager/npm/artifacts.ts | 97 +++++++++ lib/modules/manager/npm/index.ts | 1 + lib/modules/manager/types.ts | 1 + 4 files changed, 329 insertions(+) create mode 100644 lib/modules/manager/npm/artifacts.spec.ts create mode 100644 lib/modules/manager/npm/artifacts.ts diff --git a/lib/modules/manager/npm/artifacts.spec.ts b/lib/modules/manager/npm/artifacts.spec.ts new file mode 100644 index 0000000000..7cb52f4dbf --- /dev/null +++ b/lib/modules/manager/npm/artifacts.spec.ts @@ -0,0 +1,230 @@ +import { join } from 'upath'; +import { + envMock, + mockExecAll, + mockExecSequence, +} from '../../../../test/exec-util'; +import { env, fs } from '../../../../test/util'; +import { GlobalConfig } from '../../../config/global'; +import type { RepoGlobalConfig } from '../../../config/types'; +import * as docker from '../../../util/exec/docker'; +import type { UpdateArtifactsConfig, Upgrade } from '../types'; +import { updateArtifacts } from '.'; + +jest.mock('../../../util/exec/env'); +jest.mock('../../../util/fs'); + +const adminConfig: RepoGlobalConfig = { + // `join` fixes Windows CI + localDir: join('/tmp/github/some/repo'), + cacheDir: join('/tmp/renovate/cache'), + containerbaseDir: join('/tmp/renovate/cache/containerbase'), +}; +const dockerAdminConfig = { + ...adminConfig, + binarySource: 'docker', + dockerSidecarImage: 'ghcr.io/containerbase/sidecar', +}; + +process.env.CONTAINERBASE = 'true'; + +const config: UpdateArtifactsConfig = {}; +const validDepUpdate = { + depName: 'pnpm', + depType: 'packageManager', + currentValue: + '8.15.5+sha256.4b4efa12490e5055d59b9b9fc9438b7d581a6b7af3b5675eb5c5f447cee1a589', + newVersion: '8.15.6', +} satisfies Upgrade<Record<string, unknown>>; + +describe('modules/manager/npm/artifacts', () => { + beforeEach(() => { + env.getChildProcessEnv.mockReturnValue({ + ...envMock.basic, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US', + }); + GlobalConfig.set(adminConfig); + docker.resetPrefetchedImages(); + }); + + it('returns null if no packageManager updates present', async () => { + const res = await updateArtifacts({ + packageFileName: 'flake.nix', + updatedDeps: [{ ...validDepUpdate, depName: 'xmldoc', depType: 'patch' }], + newPackageFileContent: 'some new content', + config, + }); + + expect(res).toBeNull(); + }); + + it('returns null if currentValue is undefined', async () => { + const res = await updateArtifacts({ + packageFileName: 'flake.nix', + updatedDeps: [{ ...validDepUpdate, currentValue: undefined }], + newPackageFileContent: 'some new content', + config, + }); + + expect(res).toBeNull(); + }); + + it('returns null if currentValue has no hash', async () => { + const res = await updateArtifacts({ + packageFileName: 'flake.nix', + updatedDeps: [{ ...validDepUpdate, currentValue: '8.15.5' }], + newPackageFileContent: 'some new content', + config, + }); + + expect(res).toBeNull(); + }); + + it('returns null if unchanged', async () => { + fs.readLocalFile.mockResolvedValueOnce('some content'); + const execSnapshots = mockExecAll(); + + const res = await updateArtifacts({ + packageFileName: 'package.json', + updatedDeps: [validDepUpdate], + newPackageFileContent: 'some content', + config: { ...config }, + }); + + expect(res).toBeNull(); + expect(execSnapshots).toMatchObject([{ cmd: 'corepack use pnpm@8.15.6' }]); + }); + + it('returns updated package.json', async () => { + fs.readLocalFile + .mockResolvedValueOnce('{}') // for node constraints + .mockResolvedValue('some new content'); // for updated package.json + const execSnapshots = mockExecAll(); + + const res = await updateArtifacts({ + packageFileName: 'package.json', + updatedDeps: [validDepUpdate], + newPackageFileContent: 'some content', + config: { ...config }, + }); + + expect(res).toEqual([ + { + file: { + contents: 'some new content', + path: 'package.json', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject([{ cmd: 'corepack use pnpm@8.15.6' }]); + }); + + it('supports docker mode', async () => { + GlobalConfig.set(dockerAdminConfig); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('some new content'); + + const res = await updateArtifacts({ + packageFileName: 'package.json', + updatedDeps: [validDepUpdate], + newPackageFileContent: 'some content', + config: { + ...config, + constraints: { node: '20.1.0', corepack: '0.29.3' }, + }, + }); + + expect(res).toEqual([ + { + file: { + contents: 'some new content', + path: 'package.json', + type: 'addition', + }, + }, + ]); + + expect(execSnapshots).toMatchObject([ + { cmd: 'docker pull ghcr.io/containerbase/sidecar' }, + { cmd: 'docker ps --filter name=renovate_sidecar -aq' }, + { + cmd: + 'docker run --rm --name=renovate_sidecar --label=renovate_child ' + + '-v "/tmp/github/some/repo":"/tmp/github/some/repo" ' + + '-v "/tmp/renovate/cache":"/tmp/renovate/cache" ' + + '-e CONTAINERBASE_CACHE_DIR ' + + '-w "/tmp/github/some/repo" ' + + 'ghcr.io/containerbase/sidecar ' + + 'bash -l -c "' + + 'install-tool node 20.1.0 ' + + '&& ' + + 'install-tool corepack 0.29.3 ' + + '&& ' + + 'corepack use pnpm@8.15.6' + + '"', + }, + ]); + }); + + it('supports install mode', async () => { + GlobalConfig.set({ ...adminConfig, binarySource: 'install' }); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('some new content'); + + const res = await updateArtifacts({ + packageFileName: 'package.json', + updatedDeps: [validDepUpdate], + newPackageFileContent: 'some content', + config: { + ...config, + constraints: { node: '20.1.0', corepack: '0.29.3' }, + }, + }); + + expect(res).toEqual([ + { + file: { + contents: 'some new content', + path: 'package.json', + type: 'addition', + }, + }, + ]); + + expect(execSnapshots).toMatchObject([ + { + cmd: 'install-tool node 20.1.0', + options: { cwd: '/tmp/github/some/repo' }, + }, + { cmd: 'install-tool corepack 0.29.3' }, + + { + cmd: 'corepack use pnpm@8.15.6', + options: { cwd: '/tmp/github/some/repo' }, + }, + ]); + }); + + it('catches errors', async () => { + const execSnapshots = mockExecSequence([new Error('exec error')]); + + const res = await updateArtifacts({ + packageFileName: 'package.json', + updatedDeps: [validDepUpdate], + newPackageFileContent: 'some content', + config: { + ...config, + constraints: { node: '20.1.0', corepack: '0.29.3' }, + }, + }); + + expect(res).toEqual([ + { + artifactError: { fileName: 'package.json', stderr: 'exec error' }, + }, + ]); + expect(execSnapshots).toMatchObject([{ cmd: 'corepack use pnpm@8.15.6' }]); + }); +}); diff --git a/lib/modules/manager/npm/artifacts.ts b/lib/modules/manager/npm/artifacts.ts new file mode 100644 index 0000000000..13522f5028 --- /dev/null +++ b/lib/modules/manager/npm/artifacts.ts @@ -0,0 +1,97 @@ +import upath from 'upath'; +import { logger } from '../../../logger'; +import { exec } from '../../../util/exec'; +import type { ExecOptions } from '../../../util/exec/types'; +import { readLocalFile } from '../../../util/fs'; +import { regEx } from '../../../util/regex'; +import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; +import { getNodeToolConstraint } from './post-update/node-version'; +import { lazyLoadPackageJson } from './post-update/utils'; + +// 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({ + packageFileName, + config, + updatedDeps, + newPackageFileContent: existingPackageFileContent, +}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> { + logger.debug(`npm.updateArtifacts(${packageFileName})`); + 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; + } + + // 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 lazyPkgJson = lazyLoadPackageJson(pkgFileDir); + const cmd = `corepack use ${depName}@${newVersion}`; + + const nodeConstraints = await getNodeToolConstraint( + config, + updatedDeps, + pkgFileDir, + lazyPkgJson, + ); + + const execOptions: ExecOptions = { + cwdFile: packageFileName, + toolConstraints: [ + nodeConstraints, + { + toolName: 'corepack', + constraint: config.constraints?.corepack, + }, + ], + docker: {}, + userConfiguredEnv: config.env, + }; + + 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, + }, + }, + ]; + } +} diff --git a/lib/modules/manager/npm/index.ts b/lib/modules/manager/npm/index.ts index 63e6982ca0..7be2df84de 100644 --- a/lib/modules/manager/npm/index.ts +++ b/lib/modules/manager/npm/index.ts @@ -11,6 +11,7 @@ export { updateLockedDependency, } from './update'; export { getRangeStrategy } from './range'; +export { updateArtifacts } from './artifacts'; export const supportsLockFileMaintenance = true; diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 112df67f56..4cd752def3 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -189,6 +189,7 @@ export interface ArtifactNotice { } export interface ArtifactError { + fileName?: string; lockFile?: string; stderr?: string; } -- GitLab