diff --git a/.changeset/honor-npm-config-userconfig.md b/.changeset/honor-npm-config-userconfig.md new file mode 100644 index 0000000000..a97ca4ca98 --- /dev/null +++ b/.changeset/honor-npm-config-userconfig.md @@ -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). diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index c9f37f4704..7ab043bd52 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -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_` 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) diff --git a/config/reader/test/index.ts b/config/reader/test/index.ts index 8671b8f7e1..e6349ce8f7 100644 --- a/config/reader/test/index.ts +++ b/config/reader/test/index.ts @@ -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 () => {