From 0e0d34e929f86adece92011bfe67675c91abcd90 Mon Sep 17 00:00:00 2001 From: Eran Markus Date: Fri, 6 Nov 2026 19:21:58 +0200 Subject: [PATCH] fix: retry npm cache lookup with --force on EBADDEVENGINES npm checks devEngines.packageManager from the workspace package.json even for `npm config get cache`, so setup-node fails before the workflow can install the required package manager (monorepos with pnpm at the root, or projects that require a newer npm than the one bundled with Node). Retry the lookup once with --force only when npm reports EBADDEVENGINES; the normal path is unchanged. Fixes #1553 Co-Authored-By: Claude Opus 5.5 --- __tests__/cache-utils.test.ts | 73 +++++++++++++++++++++++++++++++++++ dist/cache-save/index.js | 19 ++++++++- dist/setup/index.js | 19 ++++++++- src/cache-utils.ts | 25 +++++++++--- 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/__tests__/cache-utils.test.ts b/__tests__/cache-utils.test.ts index da5703dfc..8ed6335b7 100644 --- a/__tests__/cache-utils.test.ts +++ b/__tests__/cache-utils.test.ts @@ -158,6 +158,79 @@ describe('cache-utils', () => { }); }); + describe('npm getCacheFolderPath', () => { + const devEnginesError = [ + 'npm error code EBADDEVENGINES', + 'npm error EBADDEVENGINES Invalid name "pnpm" does not match "npm" for "packageManager"' + ].join('\n'); + + function mockExecResultOnce(result: { + stdout?: string; + stderr?: string; + exitCode: number; + }) { + getExecOutputSpy.mockImplementationOnce(async () => ({ + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.exitCode + })); + } + + it('returns the cache path without --force when npm config succeeds', async () => { + mockExecResultOnce({stdout: '/home/runner/.npm', exitCode: 0}); + + await expect( + supportedPackageManagers.npm.getCacheFolderPath() + ).resolves.toBe('/home/runner/.npm'); + expect(getExecOutputSpy).toHaveBeenCalledTimes(1); + expect(getExecOutputSpy).toHaveBeenCalledWith( + 'npm config get cache', + undefined, + expect.anything() + ); + }); + + it('retries with --force when npm rejects devEngines', async () => { + mockExecResultOnce({stderr: devEnginesError, exitCode: 1}); + mockExecResultOnce({stdout: '/home/runner/.npm', exitCode: 0}); + + await expect( + supportedPackageManagers.npm.getCacheFolderPath() + ).resolves.toBe('/home/runner/.npm'); + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + expect(getExecOutputSpy).toHaveBeenNthCalledWith( + 2, + 'npm config get cache --force', + undefined, + expect.anything() + ); + expect(info).toHaveBeenCalledWith( + expect.stringContaining( + 'retrying the npm cache path lookup with --force' + ) + ); + }); + + it('does not retry on other npm errors', async () => { + mockExecResultOnce({stderr: 'npm error code ENOENT', exitCode: 1}); + + await expect( + supportedPackageManagers.npm.getCacheFolderPath() + ).rejects.toThrow('npm error code ENOENT'); + expect(getExecOutputSpy).toHaveBeenCalledTimes(1); + }); + + it('surfaces the error when the --force retry also fails', async () => { + mockExecResultOnce({stderr: devEnginesError, exitCode: 1}); + mockExecResultOnce({stderr: 'npm error code EFORCEFAILED', exitCode: 1}); + + await expect( + supportedPackageManagers.npm.getCacheFolderPath() + ).rejects.toThrow('npm error code EFORCEFAILED'); + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + }); + }); + it('isCacheFeatureAvailable for GHES is false', () => { isFeatureAvailable.mockImplementation(() => false); process.env['GITHUB_SERVER_URL'] = 'https://www.test.com'; diff --git a/dist/cache-save/index.js b/dist/cache-save/index.js index ab2494937..5478f94ae 100644 --- a/dist/cache-save/index.js +++ b/dist/cache-save/index.js @@ -92228,7 +92228,7 @@ const supportedPackageManagers = { npm: { name: 'npm', lockFilePatterns: ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'], - getCacheFolderPath: () => getCommandOutputNotEmpty('npm config get cache', 'Could not get npm cache folder path') + getCacheFolderPath: () => getNpmCacheFolderPath() }, pnpm: { name: 'pnpm', @@ -92268,6 +92268,23 @@ const getCommandOutputNotEmpty = async (toolCommand, error, cwd) => { } return stdOut; }; +const getNpmCacheFolderPath = async () => { + const error = 'Could not get npm cache folder path'; + try { + return await getCommandOutputNotEmpty('npm config get cache', error); + } + catch (err) { + // npm checks devEngines.packageManager from the package.json in the + // workspace even for `npm config get`, so the cache lookup fails before + // the workflow had a chance to install the required package manager. + // `--force` turns that check into a warning and still honours .npmrc. + if (!err.message.includes('EBADDEVENGINES')) { + throw err; + } + info("The installed npm doesn't satisfy devEngines.packageManager in package.json, retrying the npm cache path lookup with --force"); + return getCommandOutputNotEmpty('npm config get cache --force', error); + } +}; const getPackageManagerInfo = async (packageManager) => { if (packageManager === 'npm') { return supportedPackageManagers.npm; diff --git a/dist/setup/index.js b/dist/setup/index.js index 0b1ed0349..d829ab009 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -97622,7 +97622,7 @@ const supportedPackageManagers = { npm: { name: 'npm', lockFilePatterns: ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'], - getCacheFolderPath: () => getCommandOutputNotEmpty('npm config get cache', 'Could not get npm cache folder path') + getCacheFolderPath: () => getNpmCacheFolderPath() }, pnpm: { name: 'pnpm', @@ -97662,6 +97662,23 @@ const getCommandOutputNotEmpty = async (toolCommand, error, cwd) => { } return stdOut; }; +const getNpmCacheFolderPath = async () => { + const error = 'Could not get npm cache folder path'; + try { + return await getCommandOutputNotEmpty('npm config get cache', error); + } + catch (err) { + // npm checks devEngines.packageManager from the package.json in the + // workspace even for `npm config get`, so the cache lookup fails before + // the workflow had a chance to install the required package manager. + // `--force` turns that check into a warning and still honours .npmrc. + if (!err.message.includes('EBADDEVENGINES')) { + throw err; + } + core_info("The installed npm doesn't satisfy devEngines.packageManager in package.json, retrying the npm cache path lookup with --force"); + return getCommandOutputNotEmpty('npm config get cache --force', error); + } +}; const getPackageManagerInfo = async (packageManager) => { if (packageManager === 'npm') { return supportedPackageManagers.npm; diff --git a/src/cache-utils.ts b/src/cache-utils.ts index ed3dc83d7..87ca7f03e 100644 --- a/src/cache-utils.ts +++ b/src/cache-utils.ts @@ -21,11 +21,7 @@ export const supportedPackageManagers: SupportedPackageManagers = { npm: { name: 'npm', lockFilePatterns: ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'], - getCacheFolderPath: () => - getCommandOutputNotEmpty( - 'npm config get cache', - 'Could not get npm cache folder path' - ) + getCacheFolderPath: () => getNpmCacheFolderPath() }, pnpm: { name: 'pnpm', @@ -98,6 +94,25 @@ export const getCommandOutputNotEmpty = async ( return stdOut; }; +const getNpmCacheFolderPath = async (): Promise => { + const error = 'Could not get npm cache folder path'; + try { + return await getCommandOutputNotEmpty('npm config get cache', error); + } catch (err) { + // npm checks devEngines.packageManager from the package.json in the + // workspace even for `npm config get`, so the cache lookup fails before + // the workflow had a chance to install the required package manager. + // `--force` turns that check into a warning and still honours .npmrc. + if (!(err as Error).message.includes('EBADDEVENGINES')) { + throw err; + } + core.info( + "The installed npm doesn't satisfy devEngines.packageManager in package.json, retrying the npm cache path lookup with --force" + ); + return getCommandOutputNotEmpty('npm config get cache --force', error); + } +}; + export const getPackageManagerInfo = async (packageManager: string) => { if (packageManager === 'npm') { return supportedPackageManagers.npm;