Repository: renovatebot/renovate
Pull request #35521: fix(config): merge globalExtends before default config
Author: login: csmuller
Created: 2025-04-24T09:01:35Z | Merged: 2025-05-05T09:52:19Z

## Changes

Moves globalExtends resolving/merging earlier so that it occurs before defaults merging.

## Context

- https://github.com/renovatebot/renovate/discussions/32307

## Documentation (please check one with an [x])

- [ ] I have updated the documentation, or
- [x] No documentation update is required

## How I've tested my work (please select one)

I have verified these changes via:

- [ ] Code inspection only, or
- [ ] Newly added/modified unit tests, or
- [ ] No unit tests but ran on a real repository, or
- [x] Both unit tests + ran on a real repository


Diff:
diff --git a/lib/workers/global/config/parse/index.spec.ts b/lib/workers/global/config/parse/index.spec.ts
index 4a9a59c29a8..6ff7ff7d907 100644
--- a/lib/workers/global/config/parse/index.spec.ts
+++ b/lib/workers/global/config/parse/index.spec.ts
@@ -1,9 +1,11 @@
 import upath from 'upath';
 import * as _decrypt from '../../../../config/decrypt';
+import { CONFIG_PRESETS_INVALID } from '../../../../constants/error-messages';
 import { getCustomEnv } from '../../../../util/env';
 import { getParentDir, readSystemFile } from '../../../../util/fs';
 import getArgv from './__fixtures__/argv';
 import * as _hostRulesFromEnv from './host-rules-from-env';
+import * as httpMock from '~test/http-mock';
 
 vi.mock('../../../../modules/datasource/npm');
 vi.mock('../../../../util/fs');
@@ -170,6 +172,54 @@ describe('workers/global/config/parse/index', () => {
       expect(parsed).toContainEntries([['dryRun', 'full']]);
     });
 
+    it('resolves global presets', async () => {
+      // The remote preset defined in globalExtends of the config file
+      httpMock
+        .scope('http://example.com/')
+        .get('/config.json')
+        .reply(200, { repositories: ['g/r1', 'g/r2'], druRun: 'full' });
+
+      // Mock the default config file to return a preset
+      const defaultConfig = upath.join(__dirname, '__fixtures__/default.js');
+      vi.doMock(defaultConfig, () => ({
+        default: {
+          globalExtends: ['http://example.com/config.json', ':pinVersions'],
+          dryRun: 'extract',
+        },
+      }));
+
+      const parsedConfig = await configParser.parseConfigs(
+        defaultEnv,
+        defaultArgv,
+      );
+      vi.doUnmock(defaultConfig);
+
+      // Remote preset in globalExtends should be resolved
+      expect(parsedConfig).toContainEntries([
+        ['repositories', ['g/r1', 'g/r2']],
+      ]);
+      // :pinVersion in globalExtends should be resolved
+      expect(parsedConfig).toContainEntries([['rangeStrategy', 'pin']]);
+      // `globalExtends` should be an empty array after merging
+      expect(parsedConfig).toContainEntries([['globalExtends', []]]);
+      // `dryRun` from globalExtends should be overwritten with value defined in config file
+      expect(parsedConfig).toContainEntries([['dryRun', 'extract']]);
+    });
+
+    it('throws exception if global presets cannot be resolved', async () => {
+      httpMock
+        .scope('http://example.com/')
+        .get('/config.json')
+        .reply(404, 'Not Found');
+
+      await expect(
+        configParser.resolveGlobalExtends([
+          'http://example.com/config.json',
+          ':pinVersions',
+        ]),
+      ).rejects.toThrow(CONFIG_PRESETS_INVALID);
+    });
+
     it('cli dryRun replaced to full', async () => {
       defaultArgv = defaultArgv.concat(['--dry-run']);
       const parsed = await configParser.parseConfigs(defaultEnv, defaultArgv);
diff --git a/lib/workers/global/config/parse/index.ts b/lib/workers/global/config/parse/index.ts
index 54c4f8383b8..b9d175aa6e7 100644
--- a/lib/workers/global/config/parse/index.ts
+++ b/lib/workers/global/config/parse/index.ts
@@ -1,9 +1,11 @@
 import is from '@sindresorhus/is';
 import { setPrivateKeys } from '../../../../config/decrypt';
 import * as defaultsParser from '../../../../config/defaults';
+import { resolveConfigPresets } from '../../../../config/presets';
 import { applySecretsToConfig } from '../../../../config/secrets';
 import type { AllConfig } from '../../../../config/types';
 import { mergeChildConfig } from '../../../../config/utils';
+import { CONFIG_PRESETS_INVALID } from '../../../../constants/error-messages';
 import { logger, setContext } from '../../../../logger';
 import { detectAllGlobalConfig } from '../../../../modules/manager';
 import { coerceArray } from '../../../../util/array';
@@ -17,6 +19,21 @@ import * as envParser from './env';
 import * as fileParser from './file';
 import { hostRulesFromEnv } from './host-rules-from-env';
 
+export async function resolveGlobalExtends(
+  globalExtends: string[],
+  ignorePresets?: string[],
+): Promise<AllConfig> {
+  try {
+    // Make a "fake" config to pass to resolveConfigPresets and resolve globalPresets
+    const config = { extends: globalExtends, ignorePresets };
+    const resolvedConfig = await resolveConfigPresets(config);
+    return resolvedConfig;
+  } catch (err) {
+    logger.error({ err }, 'Error resolving config preset');
+    throw new Error(CONFIG_PRESETS_INVALID);
+  }
+}
+
 export async function parseConfigs(
   env: NodeJS.ProcessEnv,
   argv: string[],
@@ -34,6 +51,18 @@ export async function parseConfigs(
 
   config = await codespaces.setConfig(config);
 
+  let resolvedGlobalExtends: AllConfig | undefined;
+
+  if (is.nonEmptyArray(config?.globalExtends)) {
+    // resolve global presets immediately
+    resolvedGlobalExtends = await resolveGlobalExtends(
+      config.globalExtends,
+      config.ignorePresets,
+    );
+    config = mergeChildConfig(resolvedGlobalExtends, config);
+    delete config.globalExtends;
+  }
+
   const combinedConfig = config;
 
   config = mergeChildConfig(defaultConfig, config);
@@ -78,6 +107,7 @@ export async function parseConfigs(
   logger.debug({ config: fileConfig }, 'File config');
   logger.debug({ config: cliConfig }, 'CLI config');
   logger.debug({ config: envConfig }, 'Env config');
+  logger.debug({ config: resolvedGlobalExtends }, 'Resolved global extends');
   logger.debug({ config: combinedConfig }, 'Combined config');
 
   if (config.detectGlobalManagerConfig) {
diff --git a/lib/workers/global/index.spec.ts b/lib/workers/global/index.spec.ts
index 4506329fb86..f70f48c9e1a 100644
--- a/lib/workers/global/index.spec.ts
+++ b/lib/workers/global/index.spec.ts
@@ -1,8 +1,6 @@
 import { ERROR, WARN } from 'bunyan';
 import fs from 'fs-extra';
 import { GlobalConfig } from '../../config/global';
-import * as _presets from '../../config/presets';
-import { CONFIG_PRESETS_INVALID } from '../../constants/error-messages';
 import { DockerDatasource } from '../../modules/datasource/docker';
 import * as platform from '../../modules/platform';
 import * as secrets from '../../util/sanitize';
@@ -15,7 +13,6 @@ import type { RenovateConfig } from '~test/util';
 
 vi.mock('../repository');
 vi.mock('../../util/fs');
-vi.mock('../../config/presets');
 
 vi.mock('fs-extra', async () => {
   const realFs = await vi.importActual<typeof fs>('fs-extra');
@@ -38,9 +35,6 @@ vi.mock('fs-extra', async () => {
 // TODO: why do we need git here?
 vi.unmock('../../util/git');
 
-// imports are readonly
-const presets = vi.mocked(_presets);
-
 const addSecretForSanitizing = vi.spyOn(secrets, 'addSecretForSanitizing');
 const parseConfigs = vi.spyOn(configParser, 'parseConfigs');
 const initPlatform = vi.spyOn(platform, 'initPlatform');
@@ -94,31 +88,6 @@ describe('workers/global/index', () => {
     expect(addSecretForSanitizing).toHaveBeenCalledTimes(2);
   });
 
-  it('resolves global presets immediately', async () => {
-    parseConfigs.mockResolvedValueOnce({
-      repositories: [],
-      globalExtends: [':pinVersions'],
-      hostRules: [{ matchHost: 'github.com', token: 'abc123' }],
-    });
-    presets.resolveConfigPresets.mockResolvedValueOnce({});
-    await expect(globalWorker.start()).resolves.toBe(0);
-    expect(presets.resolveConfigPresets).toHaveBeenCalledWith({
-      extends: [':pinVersions'],
-    });
-    expect(parseConfigs).toHaveBeenCalledTimes(1);
-  });
-
-  it('throws if global presets could not be resolved', async () => {
-    presets.resolveConfigPresets.mockImplementationOnce(() => {
-      throw new Error('some-error');
-    });
-    await expect(
-      globalWorker.resolveGlobalExtends(['some-preset']),
-    ).rejects.toThrow(CONFIG_PRESETS_INVALID);
-    expect(presets.resolveConfigPresets).toHaveBeenCalled();
-    expect(parseConfigs).not.toHaveBeenCalled();
-  });
-
   it('handles zero repos', async () => {
     parseConfigs.mockResolvedValueOnce({
       baseDir: '/tmp/base',
diff --git a/lib/workers/global/index.ts b/lib/workers/global/index.ts
index 7887e8ec086..28f601e2300 100644
--- a/lib/workers/global/index.ts
+++ b/lib/workers/global/index.ts
@@ -4,7 +4,6 @@ import fs from 'fs-extra';
 import semver from 'semver';
 import upath from 'upath';
 import * as configParser from '../../config';
-import { mergeChildConfig } from '../../config';
 import { GlobalConfig } from '../../config/global';
 import { resolveConfigPresets } from '../../config/presets';
 import { validateConfigSecrets } from '../../config/secrets';
@@ -94,21 +93,6 @@ export async function validatePresets(config: AllConfig): Promise<void> {
   }
 }
 
-export async function resolveGlobalExtends(
-  globalExtends: string[],
-  ignorePresets?: string[],
-): Promise<AllConfig> {
-  try {
-    // Make a "fake" config to pass to resolveConfigPresets and resolve globalPresets
-    const config = { extends: globalExtends, ignorePresets };
-    const resolvedConfig = await resolveConfigPresets(config);
-    return resolvedConfig;
-  } catch (err) {
-    logger.error({ err }, 'Error resolving config preset');
-    throw new Error(CONFIG_PRESETS_INVALID);
-  }
-}
-
 export async function start(): Promise<number> {
   // istanbul ignore next
   if (regexEngineStatus.type === 'available') {
@@ -134,16 +118,6 @@ export async function start(): Promise<number> {
     await instrument('config', async () => {
       // read global config from file, env and cli args
       config = await getGlobalConfig();
-      if (is.nonEmptyArray(config?.globalExtends)) {
-        // resolve global presets immediately
-        config = mergeChildConfig(
-          await resolveGlobalExtends(
-            config.globalExtends,
-            config.ignorePresets,
-          ),
-          config,
-        );
-      }
 
       // Set allowedHeaders and userAgent in case hostRules headers are configured in file config
       GlobalConfig.set({


Reviews:
user: login: rarkins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T10:11:13Z
user: login: rarkins
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T10:12:20Z
user: login: rarkins
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T05:12:33Z
user: login: WojasKrk
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-02T05:54:39Z
user: login: rarkins
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T08:03:43Z
user: login: viceice
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-02T09:22:04Z
user: login: viceice
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T09:23:39Z
user: login: rarkins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T09:38:27Z
user: login: csmuller
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T12:51:56Z
user: login: rarkins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T16:05:09Z
user: login: csmuller
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T09:14:32Z
user: login: rarkins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T09:46:06Z
user: login: rarkins
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T09:46:18Z

Review comments:
diff_hunk: @@ -165,6 +166,33 @@ describe('workers/global/config/parse/index', () => {
       expect(parsed).toContainEntries([['dryRun', 'full']]);
     });
 
+    it('resolves global presets', async () => {
+      vi.doMock('../../../../../config.js', () => ({
+        default: {
+          globalExtends: ['http://example.com/config.json', ':pinVersions'],
+          dryRun: 'extract', // This should overwrite the one from the globalExtends
+        },
+      }));
+      httpMock
+        .scope('http://example.com/')
+        .get('/config.json')
+        .reply(200, { repositories: ['g/r1', 'g/r2'], druRun: 'full' });
path: lib/workers/global/config/parse/index.spec.ts
user: login: rarkins
body: ```suggestion
        .reply(200, { repositories: ['g/r1', 'g/r2'], dryRun: 'full' });
```
created_at: 2025-04-24T10:11:12Z
author_association: CONTRIBUTOR
path: lib/workers/global/config/parse/__fixtures__/config-global-extends.js
user: login: rarkins
body: Convert this to an inline string in the test, instead of a separate file
created_at: 2025-05-02T05:12:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,6 +166,33 @@ describe('workers/global/config/parse/index', () => {
       expect(parsed).toContainEntries([['dryRun', 'full']]);
     });
 
+    it('resolves global presets', async () => {
+      vi.doMock('../../../../../config.js', () => ({
+        default: {
+          globalExtends: ['http://example.com/config.json', ':pinVersions'],
+          dryRun: 'extract', // This should overwrite the one from the globalExtends
+        },
+      }));
+      httpMock
+        .scope('http://example.com/')
+        .get('/config.json')
+        .reply(200, { repositories: ['g/r1', 'g/r2'], druRun: 'full' });
path: lib/workers/global/config/parse/index.spec.ts
user: login: WojasKrk
body: Ok
created_at: 2025-05-02T05:54:39Z
author_association: NONE
diff_hunk: @@ -33,6 +50,18 @@ export async function parseConfigs(
 
   config = await codespaces.setConfig(config);
 
+  let resolvedGlobalExtends: AllConfig | undefined;
+
+  if (is.nonEmptyArray(config?.globalExtends)) {
+    // resolve global presets immediately
+    resolvedGlobalExtends = await resolveGlobalExtends(
path: lib/workers/global/config/parse/index.ts
user: login: viceice
body: we don't have any global config yet, so things like host rules are not applied while resolving 
created_at: 2025-05-02T09:23:36Z
author_association: MEMBER
diff_hunk: @@ -33,6 +50,18 @@ export async function parseConfigs(
 
   config = await codespaces.setConfig(config);
 
+  let resolvedGlobalExtends: AllConfig | undefined;
+
+  if (is.nonEmptyArray(config?.globalExtends)) {
+    // resolve global presets immediately
+    resolvedGlobalExtends = await resolveGlobalExtends(
path: lib/workers/global/config/parse/index.ts
user: login: rarkins
body: So would private repos for global extends not work, even if on the same endpoint? We should maybe test this
created_at: 2025-05-02T09:38:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +50,18 @@ export async function parseConfigs(
 
   config = await codespaces.setConfig(config);
 
+  let resolvedGlobalExtends: AllConfig | undefined;
+
+  if (is.nonEmptyArray(config?.globalExtends)) {
+    // resolve global presets immediately
+    resolvedGlobalExtends = await resolveGlobalExtends(
path: lib/workers/global/config/parse/index.ts
user: login: csmuller
body: When quickly checking, I don't see any authentication happening in the preset-fetching code, but maybe I'm mistaken.
For us it probably always worked because in our internal network the repo with the presets is not private.

Either way, it looks like this was problem even before the `globalExtends` bug came up, which we are trying to fix here. 
created_at: 2025-05-02T12:51:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +50,18 @@ export async function parseConfigs(
 
   config = await codespaces.setConfig(config);
 
+  let resolvedGlobalExtends: AllConfig | undefined;
+
+  if (is.nonEmptyArray(config?.globalExtends)) {
+    // resolve global presets immediately
+    resolvedGlobalExtends = await resolveGlobalExtends(
path: lib/workers/global/config/parse/index.ts
user: login: rarkins
body: If globalExtends previously didn't use auth then we can merge this then
created_at: 2025-05-02T16:05:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +50,18 @@ export async function parseConfigs(
 
   config = await codespaces.setConfig(config);
 
+  let resolvedGlobalExtends: AllConfig | undefined;
+
+  if (is.nonEmptyArray(config?.globalExtends)) {
+    // resolve global presets immediately
+    resolvedGlobalExtends = await resolveGlobalExtends(
path: lib/workers/global/config/parse/index.ts
user: login: csmuller
body: Here's why I think that the host rules were not considered for resolving global presets even before this PR: https://github.com/renovatebot/renovate/blob/e09764fceceb55cbea88ab8653039e1e7aa8b974/lib/workers/global/index.ts#L139-L154
`globalInitialize(config)` is called only after `resolveGlobalExtends(...)` is called. The `globalInitialize` method contains the [initialization](https://github.com/renovatebot/renovate/blob/6946d5b0ecab7706f2eb804beb82b1a6c98a7510/lib/workers/global/initialize.ts#L85) of the host rules.
created_at: 2025-05-05T09:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +50,18 @@ export async function parseConfigs(
 
   config = await codespaces.setConfig(config);
 
+  let resolvedGlobalExtends: AllConfig | undefined;
+
+  if (is.nonEmptyArray(config?.globalExtends)) {
+    // resolve global presets immediately
+    resolvedGlobalExtends = await resolveGlobalExtends(
path: lib/workers/global/config/parse/index.ts
user: login: rarkins
body: Confirmed via our _existing_ docs:

![image](https://github.com/user-attachments/assets/8c96d88c-d950-49e2-b46f-4d674ff73667)

created_at: 2025-05-05T09:46:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: csmuller
created_at: 2025-04-25T05:55:05Z
body: The tests behave differently on GitHub Actions than when run locally. Apparently, the mock that is set up with `httpMock` doesn't get used. Thus, the test fails and `httpMock` complains about an unused mock.
@rarkins Does this ring a bell? If not, then I guess I'll have to mock the call to a remote preset differently.
author_association: CONTRIBUTOR
user: login: rarkins
created_at: 2025-04-25T05:57:01Z
body: Actually the reason I paused before testing was because wasn't sure if http mocking had changed when we switched to vitest. @viceice any tips?
author_association: CONTRIBUTOR
user: login: csmuller
created_at: 2025-04-30T14:54:44Z
body: @rarkins I replaced the `httpMock` with a mock of `getPreset` in `/lib/config/presets/http`.
I assume the tests will tests run smoothly now. 
author_association: CONTRIBUTOR
user: login: CLAassistant
created_at: 2025-05-01T06:57:43Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/renovatebot/renovate?pullRequest=35521) <br/>All committers have signed the CLA.
author_association: NONE
user: login: csmuller
created_at: 2025-05-01T07:13:14Z
body: Ok, another try. Actually, the problem seems to be the mock of the config.js. I replaced that with a real config file in the fixtures. Let's see if this time it works independently of where the tests run 🙄
Sorry, I know I shouldn't, but had to force push because of wrong setting of the commit author.
author_association: CONTRIBUTOR
user: login: renovate-release
created_at: 2025-05-05T10:09:45Z
body: :tada: This PR is included in version 40.3.3 :tada:

The release is available on:
- [GitHub release](https://github.com/renovatebot/renovate/releases/tag/40.3.3)
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/renovate/v/40.3.3)
- `40.3.3`

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE