fix(config): honor NPM_CONFIG_USERCONFIG as a low-priority fallback (#11545)

* fix(config): honor NPM_CONFIG_USERCONFIG as a low-priority fallback

Restores compatibility with environments that point npm at a custom
.npmrc via NPM_CONFIG_USERCONFIG (e.g. actions/setup-node writing to
${runner.temp}/.npmrc), which silently broke after the v11 env var
prefix change. PNPM-prefixed env vars and npmrcAuthFile from the
global config.yaml continue to take precedence.

Closes #11539

* fix(config): treat empty NPM_CONFIG_USERCONFIG as unset

`??` accepts an empty string as a defined value, so an exported but
unset NPM_CONFIG_USERCONFIG would short-circuit the fallback chain and
make normalizePath('') resolve to process.cwd(). Mirror readEnvVar's
empty-string-to-undefined coercion via a readNpmEnvVar helper so the
fallback to ~/.npmrc works as expected.
This commit is contained in:
Zoltan Kochan
2026-05-08 16:00:38 +02:00
parent 781918f048
commit e9e876c3fb
3 changed files with 135 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Honor `NPM_CONFIG_USERCONFIG` (and its lowercase `npm_config_userconfig` form) as a low-priority fallback when locating the user-level `.npmrc`. This restores compatibility with environments that point npm at a custom auth file via that env var — most notably `actions/setup-node`, which writes registry credentials to `${runner.temp}/.npmrc` and exports `NPM_CONFIG_USERCONFIG` to reference it. Without this, GitHub Actions workflows using `actions/setup-node` to authenticate to private registries broke after upgrading to pnpm v11. PNPM-prefixed env vars and `npmrcAuthFile` from the global `config.yaml` continue to take precedence [#11539](https://github.com/pnpm/pnpm/issues/11539).

View File

@@ -221,12 +221,17 @@ export async function getConfig (opts: {
// also have to peek at the relevant env vars here in order for
// PNPM_CONFIG_NPMRC_AUTH_FILE / PNPM_CONFIG_USERCONFIG (and their lowercase
// equivalents) to actually decide which user-level .npmrc gets read.
// npm_config_userconfig is honored as a low-priority compatibility fallback
// so that environments that point npm at a custom .npmrc (e.g. actions/setup-node
// writing to ${runner.temp}/.npmrc) keep working without requiring users to
// rename the env var to its PNPM_CONFIG_* equivalent.
const globalYamlConfigForNpmrcAuthFile = await readWorkspaceManifest(configDir, GLOBAL_CONFIG_YAML_FILENAME)
const npmrcAuthFile = cliOptions['npmrc-auth-file'] as string | undefined
?? cliOptions.userconfig as string | undefined
?? readEnvVar(env, 'npmrc_auth_file')
?? readEnvVar(env, 'userconfig')
?? globalYamlConfigForNpmrcAuthFile?.npmrcAuthFile
?? readNpmEnvVar(env, 'userconfig')
const npmrcResult = loadNpmrcConfig({
cliOptions,
@@ -696,6 +701,14 @@ function readEnvVar (env: NodeJS.ProcessEnv, key: string): string | undefined {
return value !== '' ? value : undefined
}
// Same shape as readEnvVar but for the `npm_config_<key>` family. Used as a
// low-priority compatibility shim so that npm-style env vars (e.g.
// NPM_CONFIG_USERCONFIG written by actions/setup-node) keep working.
function readNpmEnvVar (env: NodeJS.ProcessEnv, key: string): string | undefined {
const value = env[`npm_config_${key}`] ?? env[`NPM_CONFIG_${key.toUpperCase()}`]
return value !== '' ? value : undefined
}
function getWantedPackageManager (manifest: ProjectManifest): { pm?: WantedPackageManager, warnings: string[] } {
const warnings: string[] = []
const pmFromDevEngines = parseDevEnginesPackageManager(manifest.devEngines)

View File

@@ -1217,6 +1217,91 @@ test('getConfig() prefers pnpm_config_userconfig over PNPM_CONFIG_USERCONFIG whe
expect(config.userConfig).toEqual({ registry: 'https://lower.example.test' })
})
// actions/setup-node writes auth to ${runner.temp}/.npmrc and sets NPM_CONFIG_USERCONFIG;
// pnpm honors it as a low-priority compatibility fallback for that flow.
test('getConfig() reads userconfig from NPM_CONFIG_USERCONFIG env var', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
NPM_CONFIG_USERCONFIG: path.resolve('user-home', '.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
test('getConfig() reads userconfig from npm_config_userconfig env var', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
npm_config_userconfig: path.resolve('user-home', '.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
test('getConfig() prefers PNPM_CONFIG_USERCONFIG over NPM_CONFIG_USERCONFIG when both are set', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', 'pnpm.npmrc'), 'registry = https://pnpm.example.test', 'utf-8')
fs.writeFileSync(path.resolve('user-home', 'npm.npmrc'), 'registry = https://npm.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
PNPM_CONFIG_USERCONFIG: path.resolve('user-home', 'pnpm.npmrc'),
NPM_CONFIG_USERCONFIG: path.resolve('user-home', 'npm.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://pnpm.example.test' })
})
// An empty NPM_CONFIG_USERCONFIG (e.g. `export NPM_CONFIG_USERCONFIG=`) must be
// treated as unset. Otherwise it short-circuits the fallback chain and resolves
// to the cwd, returning an empty/invalid auth config instead of ~/.npmrc.
test('getConfig() ignores an empty NPM_CONFIG_USERCONFIG and falls back to ~/.npmrc', async () => {
prepareEmpty()
const homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(path.resolve('user-home'))
try {
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://home.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
NPM_CONFIG_USERCONFIG: '',
npm_config_userconfig: '',
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://home.example.test' })
} finally {
homedirSpy.mockRestore()
}
})
test('getConfig() sets sideEffectsCacheRead and sideEffectsCacheWrite when side-effects-cache is set', async () => {
const { config } = await getConfig({
cliOptions: {
@@ -1869,6 +1954,37 @@ describe('global config.yaml', () => {
expect(config.httpsProxy).toBe('http://cli-proxy.example.com:7070')
})
// npmrcAuthFile in global config.yaml is a deliberate pnpm-native setting and should
// not be silently overridden by an ambient NPM_CONFIG_USERCONFIG (e.g. from a CI runner).
test('npmrcAuthFile from global config.yaml takes precedence over NPM_CONFIG_USERCONFIG', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', 'yaml.npmrc'), 'registry = https://yaml.example.test', 'utf-8')
fs.writeFileSync(path.resolve('user-home', 'npm.npmrc'), 'registry = https://npm.example.test', 'utf-8')
fs.mkdirSync('.config/pnpm', { recursive: true })
writeYamlFileSync('.config/pnpm/config.yaml', {
npmrcAuthFile: path.resolve('user-home', 'yaml.npmrc'),
})
process.env.XDG_CONFIG_HOME = path.resolve('.config')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
NPM_CONFIG_USERCONFIG: path.resolve('user-home', 'npm.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
workspaceDir: process.cwd(),
})
expect(config.userConfig).toEqual({ registry: 'https://yaml.example.test' })
})
})
test('proxy settings are still read from .npmrc', async () => {