diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 008fdbc69452752db2f1ae5a40e17f958368104c..c7857be4a46835621ea39589bc8e8ab610a8f0a9 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -417,6 +417,31 @@ If enabled, all issues created by Renovate are set as confidential, even in a pu
 !!! note
     This option is applicable to GitLab only.
 
+## configMigration
+
+If enabled, Renovate will raise a pull request if config file migration is needed.
+
+We're adding new features to Renovate bot often.
+Most times you can keep using your Renovate config and benefit from the new features right away.
+But sometimes you need to change your Renovate configuration.
+To help you with this, Renovate will create config migration pull requests.
+
+Example:
+
+After we changed the [`baseBranches`](https://docs.renovatebot.com/configuration-options/#basebranches) feature, the Renovate configuration migration pull request would make this change:
+
+```diff
+{
+- "baseBranch": "main"
++ "baseBranches": ["main"]
+}
+```
+
+<!-- prettier-ignore -->
+!!! info
+    This feature writes plain JSON for `.json` files, and JSON5 for `.json5` files.
+    JSON5 content can potentially be down leveled (`.json` files) and all comments will be removed.
+
 ## configWarningReuseIssue
 
 Renovate's default behavior is to reuse/reopen a single Config Warning issue in each repository so as to keep the "noise" down.
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index 9b1692ed1fde63699b0aeff43fb68d5a2c00a2eb..af5c1e2f3eaeaf3b34de09715fad496203080529 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -123,6 +123,13 @@ const options: RenovateOptions[] = [
     globalOnly: true,
     cli: false,
   },
+  {
+    name: 'configMigration',
+    description: 'Enable this to get config migration PRs when needed.',
+    stage: 'repository',
+    type: 'boolean',
+    default: false,
+  },
   {
     name: 'productLinks',
     description: 'Links which are used in PRs, issues and comments.',
diff --git a/lib/config/types.ts b/lib/config/types.ts
index 9d8e742462cd3ffac161980f795591df4430ba1d..d21e6292a9469ecac2d9fcbfad31fda6a7c84064 100644
--- a/lib/config/types.ts
+++ b/lib/config/types.ts
@@ -186,6 +186,7 @@ export interface RenovateConfig
     RenovateSharedConfig,
     UpdateConfig<PackageRule>,
     AssigneesAndReviewersConfig,
+    ConfigMigration,
     Record<string, unknown> {
   depName?: string;
   baseBranches?: string[];
@@ -431,6 +432,10 @@ export interface PackageRuleInputConfig extends Record<string, unknown> {
   packageRules?: (PackageRule & PackageRuleInputConfig)[];
 }
 
+export interface ConfigMigration {
+  configMigration?: boolean;
+}
+
 export interface MigratedConfig {
   isMigrated: boolean;
   migratedConfig: RenovateConfig;
diff --git a/lib/util/template/index.ts b/lib/util/template/index.ts
index c4717f6fe3aa5f522db88b69e78a96998df9aa96..6fedb3617249dd654a6b5458ad83a390d2767179 100644
--- a/lib/util/template/index.ts
+++ b/lib/util/template/index.ts
@@ -143,6 +143,9 @@ const prBodyFields = [
   'table',
   'notes',
   'changelogs',
+  'hasWarningsErrors',
+  'errors',
+  'warnings',
   'configDescription',
   'controls',
   'footer',
diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json
new file mode 100644
index 0000000000000000000000000000000000000000..9febe12a31cb7fd5bb5827e94bd6afbb82544b01
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json
@@ -0,0 +1,4 @@
+{
+  "filename": "renovate.json",
+  "content": "{\n  \"extends\": [\n    \":separateMajorReleases\",\n    \":prImmediately\",\n    \":renovatePrefix\",\n    \":semanticPrefixFixDepsChoreOthers\",\n    \":updateNotScheduled\",\n    \":automergeDisabled\",\n    \":maintainLockFilesDisabled\",\n    \":autodetectPinVersions\",\n    \"group:monorepos\"\n  ],\n  \"onboarding\": false,\n  \"rangeStrategy\": \"replace\",\n  \"semanticCommits\": \"enabled\",\n  \"timezone\": \"US/Central\",\n  \"baseBranches\": [\n    \"main\"\n  ]\n}\n"
+}
diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json5 b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json5
new file mode 100644
index 0000000000000000000000000000000000000000..0540d1f8ccb642c452f4a5cbe808c36b7596bbf9
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json5
@@ -0,0 +1,4 @@
+{
+  "filename": "renovate.json5",
+  "content": "{\n  extends: [\n    ':separateMajorReleases',\n    ':prImmediately',\n    ':renovatePrefix',\n    ':semanticPrefixFixDepsChoreOthers',\n    ':updateNotScheduled',\n    ':automergeDisabled',\n    ':maintainLockFilesDisabled',\n    ':autodetectPinVersions',\n    'group:monorepos',\n  ],\n  onboarding: false,\n  rangeStrategy: 'replace',\n  semanticCommits: 'enabled',\n  timezone: 'US/Central',\n  baseBranches: [\n    'main',\n  ],\n}\n"
+}
diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/migrated.json b/lib/workers/repository/config-migration/branch/__fixtures__/migrated.json
new file mode 100644
index 0000000000000000000000000000000000000000..2fd26a80d99e64d17f340d5ede3f7d5c9a267bd1
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/__fixtures__/migrated.json
@@ -0,0 +1,20 @@
+{
+  "extends": [
+    ":separateMajorReleases",
+    ":prImmediately",
+    ":renovatePrefix",
+    ":semanticPrefixFixDepsChoreOthers",
+    ":updateNotScheduled",
+    ":automergeDisabled",
+    ":maintainLockFilesDisabled",
+    ":autodetectPinVersions",
+    "group:monorepos"
+  ],
+  "onboarding": false,
+  "rangeStrategy": "replace",
+  "semanticCommits": "enabled",
+  "timezone": "US/Central",
+  "baseBranches": [
+    "main"
+  ]
+}
diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json
new file mode 100644
index 0000000000000000000000000000000000000000..c7a91ea389e3b2d6cbd61d2cbe26903ac700167c
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json
@@ -0,0 +1,19 @@
+{
+  "extends": [
+    ":separateMajorReleases",
+    ":prImmediately",
+    ":renovatePrefix",
+    ":semanticPrefixFixDepsChoreOthers",
+    ":updateNotScheduled",
+    ":automergeDisabled",
+    ":maintainLockFilesDisabled",
+    ":autodetectPinVersions",
+    "group:monorepos",
+    "helpers:oddIsUnstablePackages"
+  ],
+  "onboarding": false,
+  "pinVersions": false,
+  "semanticCommits": true,
+  "timezone": "US/Central",
+  "baseBranch": "main"
+}
diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json5 b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json5
new file mode 100644
index 0000000000000000000000000000000000000000..4bcc234e4a0669a18daacc07ff0a8aeb95aa98e1
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json5
@@ -0,0 +1,19 @@
+{
+  extends: [
+    ':separateMajorReleases',
+    ':prImmediately',
+    ':renovatePrefix',
+    ':semanticPrefixFixDepsChoreOthers',
+    ':updateNotScheduled',
+    ':automergeDisabled',
+    ':maintainLockFilesDisabled',
+    ':autodetectPinVersions',
+    'group:monorepos',
+    'helpers:oddIsUnstablePackages'
+  ],
+  onboarding: false,
+  pinVersions: false,
+  semanticCommits: true,
+  timezone: 'US/Central',
+  baseBranch: 'main' // thats a comment
+}
diff --git a/lib/workers/repository/config-migration/branch/commit-message.ts b/lib/workers/repository/config-migration/branch/commit-message.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0046bac9a0a4633a6c918a4583eeee90191a6ecb
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/commit-message.ts
@@ -0,0 +1,46 @@
+import type { RenovateConfig } from '../../../../config/types';
+import * as template from '../../../../util/template';
+import type { CommitMessage } from '../../model/commit-message';
+import { CommitMessageFactory } from '../../model/commit-message-factory';
+
+export class ConfigMigrationCommitMessageFactory {
+  private readonly config: RenovateConfig;
+
+  private readonly configFile: string;
+
+  constructor(config: RenovateConfig, configFile: string) {
+    this.config = config;
+    this.configFile = configFile;
+  }
+
+  create(): CommitMessage {
+    const { commitMessage } = this.config;
+
+    this.config.commitMessageAction =
+      this.config.commitMessageAction === 'Update'
+        ? ''
+        : this.config.commitMessageAction;
+
+    this.config.commitMessageTopic =
+      this.config.commitMessageTopic === 'dependency {{depName}}'
+        ? `Migrate config ${this.configFile}`
+        : this.config.commitMessageTopic;
+
+    this.config.commitMessageExtra = '';
+    this.config.semanticCommitScope = 'config';
+
+    const commitMessageFactory = new CommitMessageFactory(this.config);
+    const commit = commitMessageFactory.create();
+
+    if (commitMessage) {
+      commit.subject = template.compile(commitMessage, {
+        ...this.config,
+        commitMessagePrefix: '',
+      });
+    } else {
+      commit.subject = `Migrate config ${this.configFile}`;
+    }
+
+    return commit;
+  }
+}
diff --git a/lib/workers/repository/config-migration/branch/create.spec.ts b/lib/workers/repository/config-migration/branch/create.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9b599b34d380d6ddde9702197bfab02a6b539569
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/create.spec.ts
@@ -0,0 +1,179 @@
+import { Fixtures } from '../../../../../test/fixtures';
+import { RenovateConfig, getConfig, platform } from '../../../../../test/util';
+import { commitFiles } from '../../../../util/git';
+import { createConfigMigrationBranch } from './create';
+import type { MigratedData } from './migrated-data';
+
+jest.mock('../../../../util/git');
+
+describe('workers/repository/config-migration/branch/create', () => {
+  const raw = Fixtures.getJson('./renovate.json');
+  const indent = '  ';
+  const renovateConfig = JSON.stringify(raw, undefined, indent) + '\n';
+  const filename = 'renovate.json';
+
+  let config: RenovateConfig;
+  let migratedConfigData: MigratedData;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    config = getConfig();
+    migratedConfigData = { content: renovateConfig, filename };
+  });
+
+  describe('createConfigMigrationBranch', () => {
+    it('applies the default commit message', async () => {
+      await createConfigMigrationBranch(config, migratedConfigData);
+      expect(commitFiles).toHaveBeenCalledWith({
+        branchName: 'renovate/migrate-config',
+        files: [
+          {
+            type: 'addition',
+            path: 'renovate.json',
+            contents: renovateConfig,
+          },
+        ],
+        message: 'Migrate config renovate.json',
+        platformCommit: false,
+      });
+    });
+
+    it('commits via platform', async () => {
+      config.platformCommit = true;
+
+      await createConfigMigrationBranch(config, migratedConfigData);
+
+      expect(platform.commitFiles).toHaveBeenCalledWith({
+        branchName: 'renovate/migrate-config',
+        files: [
+          {
+            type: 'addition',
+            path: 'renovate.json',
+            contents: renovateConfig,
+          },
+        ],
+        message: 'Migrate config renovate.json',
+        platformCommit: true,
+      });
+    });
+
+    it('applies supplied commit message', async () => {
+      const message = 'We can migrate config if we want to, or we can not';
+
+      config.commitMessage = message;
+
+      await createConfigMigrationBranch(config, migratedConfigData);
+
+      expect(commitFiles).toHaveBeenCalledWith({
+        branchName: 'renovate/migrate-config',
+        files: [
+          {
+            type: 'addition',
+            path: 'renovate.json',
+            contents: renovateConfig,
+          },
+        ],
+        message: message,
+        platformCommit: false,
+      });
+    });
+
+    describe('applies the commitMessagePrefix value', () => {
+      it('to the default commit message', async () => {
+        config.commitMessagePrefix = 'PREFIX:';
+        config.commitMessage = '';
+
+        const message = `PREFIX: migrate config renovate.json`;
+        await createConfigMigrationBranch(config, migratedConfigData);
+
+        expect(commitFiles).toHaveBeenCalledWith({
+          branchName: 'renovate/migrate-config',
+          files: [
+            {
+              type: 'addition',
+              path: 'renovate.json',
+              contents: renovateConfig,
+            },
+          ],
+          message: message,
+          platformCommit: false,
+        });
+      });
+
+      it('to the supplied commit message prefix, topic & action', async () => {
+        const prefix = 'PREFIX:';
+        const topic = 'thats a topic';
+        const action = 'action';
+
+        const message = `${prefix} ${action} ${topic}`;
+
+        config.commitMessagePrefix = prefix;
+        config.commitMessageTopic = topic;
+        config.commitMessageAction = action;
+
+        await createConfigMigrationBranch(config, migratedConfigData);
+
+        expect(commitFiles).toHaveBeenCalledWith({
+          branchName: 'renovate/migrate-config',
+          files: [
+            {
+              type: 'addition',
+              path: 'renovate.json',
+              contents: renovateConfig,
+            },
+          ],
+          message: message,
+          platformCommit: false,
+        });
+      });
+    });
+
+    describe('applies semanticCommit prefix', () => {
+      it('to the default commit message', async () => {
+        const prefix = 'chore(config)';
+        const message = `${prefix}: migrate config renovate.json`;
+
+        config.semanticCommits = 'enabled';
+
+        await createConfigMigrationBranch(config, migratedConfigData);
+
+        expect(commitFiles).toHaveBeenCalledWith({
+          branchName: 'renovate/migrate-config',
+          files: [
+            {
+              type: 'addition',
+              path: 'renovate.json',
+              contents: renovateConfig,
+            },
+          ],
+          message: message,
+          platformCommit: false,
+        });
+      });
+
+      it('to the supplied commit message topic', async () => {
+        const prefix = 'chore(config)';
+        const topic = 'supplied topic';
+        const message = `${prefix}: ${topic}`;
+
+        config.semanticCommits = 'enabled';
+        config.commitMessageTopic = topic;
+
+        await createConfigMigrationBranch(config, migratedConfigData);
+
+        expect(commitFiles).toHaveBeenCalledWith({
+          branchName: 'renovate/migrate-config',
+          files: [
+            {
+              type: 'addition',
+              path: 'renovate.json',
+              contents: renovateConfig,
+            },
+          ],
+          message: message,
+          platformCommit: false,
+        });
+      });
+    });
+  });
+});
diff --git a/lib/workers/repository/config-migration/branch/create.ts b/lib/workers/repository/config-migration/branch/create.ts
new file mode 100644
index 0000000000000000000000000000000000000000..92d684a2f72c9cda6415df24a34491a61d97e2bb
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/create.ts
@@ -0,0 +1,43 @@
+import { GlobalConfig } from '../../../../config/global';
+import type { RenovateConfig } from '../../../../config/types';
+import { logger } from '../../../../logger';
+import { commitAndPush } from '../../../../modules/platform/commit';
+import { getMigrationBranchName } from '../common';
+import { ConfigMigrationCommitMessageFactory } from './commit-message';
+import type { MigratedData } from './migrated-data';
+
+export function createConfigMigrationBranch(
+  config: Partial<RenovateConfig>,
+  migratedConfigData: MigratedData
+): Promise<string | null> {
+  logger.debug('createConfigMigrationBranch()');
+  const contents = migratedConfigData.content;
+  const configFileName = migratedConfigData.filename;
+  logger.debug('Creating config migration branch');
+
+  const commitMessageFactory = new ConfigMigrationCommitMessageFactory(
+    config,
+    configFileName
+  );
+
+  const commitMessage = commitMessageFactory.create();
+
+  // istanbul ignore if
+  if (GlobalConfig.get('dryRun')) {
+    logger.info('DRY-RUN: Would commit files to config migration branch');
+    return Promise.resolve(null);
+  }
+
+  return commitAndPush({
+    branchName: getMigrationBranchName(config),
+    files: [
+      {
+        type: 'addition',
+        path: configFileName,
+        contents,
+      },
+    ],
+    message: commitMessage.toString(),
+    platformCommit: !!config.platformCommit,
+  });
+}
diff --git a/lib/workers/repository/config-migration/branch/index.spec.ts b/lib/workers/repository/config-migration/branch/index.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d7b21af6e20b8c6057430bcc85a40627065a4f35
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/index.spec.ts
@@ -0,0 +1,98 @@
+import { mock } from 'jest-mock-extended';
+import { Fixtures } from '../../../../../test/fixtures';
+import {
+  RenovateConfig,
+  getConfig,
+  git,
+  mockedFunction,
+  platform,
+} from '../../../../../test/util';
+import { GlobalConfig } from '../../../../config/global';
+import { logger } from '../../../../logger';
+import type { Pr } from '../../../../modules/platform';
+import { createConfigMigrationBranch } from './create';
+import type { MigratedData } from './migrated-data';
+import { rebaseMigrationBranch } from './rebase';
+import { checkConfigMigrationBranch } from '.';
+
+jest.mock('./migrated-data');
+jest.mock('./rebase');
+jest.mock('./create');
+jest.mock('../../../../util/git');
+
+const migratedData: MigratedData = Fixtures.getJson('./migrated-data.json');
+
+describe('workers/repository/config-migration/branch/index', () => {
+  describe('checkConfigMigrationBranch', () => {
+    let config: RenovateConfig;
+
+    beforeEach(() => {
+      GlobalConfig.set({
+        dryRun: null,
+      });
+      jest.resetAllMocks();
+      config = getConfig();
+      config.branchPrefix = 'some/';
+    });
+
+    it('Exits when Migration is not needed', async () => {
+      await expect(
+        checkConfigMigrationBranch(config, null)
+      ).resolves.toBeNull();
+      expect(logger.debug).toHaveBeenCalledWith(
+        'checkConfigMigrationBranch() Config does not need migration'
+      );
+    });
+
+    it('Updates migration branch & refresh PR', async () => {
+      platform.getBranchPr.mockResolvedValue(mock<Pr>());
+      // platform.refreshPr is undefined as it is an optional function
+      // declared as: refreshPr?(number: number): Promise<void>;
+      platform.refreshPr = jest.fn().mockResolvedValueOnce(null);
+      mockedFunction(rebaseMigrationBranch).mockResolvedValueOnce('committed');
+      const res = await checkConfigMigrationBranch(config, migratedData);
+      expect(res).toBe(`${config.branchPrefix}migrate-config`);
+      expect(git.checkoutBranch).toHaveBeenCalledTimes(1);
+      expect(git.commitFiles).toHaveBeenCalledTimes(0);
+      expect(logger.debug).toHaveBeenCalledWith(
+        'Config Migration PR already exists'
+      );
+    });
+
+    it('Dry runs update migration branch', async () => {
+      GlobalConfig.set({
+        dryRun: 'full',
+      });
+      platform.getBranchPr.mockResolvedValueOnce(mock<Pr>());
+      mockedFunction(rebaseMigrationBranch).mockResolvedValueOnce('committed');
+      const res = await checkConfigMigrationBranch(config, migratedData);
+      expect(res).toBe(`${config.branchPrefix}migrate-config`);
+      expect(git.checkoutBranch).toHaveBeenCalledTimes(0);
+      expect(git.commitFiles).toHaveBeenCalledTimes(0);
+    });
+
+    it('Creates migration PR', async () => {
+      mockedFunction(createConfigMigrationBranch).mockResolvedValueOnce(
+        'committed'
+      );
+      const res = await checkConfigMigrationBranch(config, migratedData);
+      expect(res).toBe(`${config.branchPrefix}migrate-config`);
+      expect(git.checkoutBranch).toHaveBeenCalledTimes(1);
+      expect(git.commitFiles).toHaveBeenCalledTimes(0);
+      expect(logger.debug).toHaveBeenCalledWith('Need to create migration PR');
+    });
+
+    it('Dry runs create migration PR', async () => {
+      GlobalConfig.set({
+        dryRun: 'full',
+      });
+      mockedFunction(createConfigMigrationBranch).mockResolvedValueOnce(
+        'committed'
+      );
+      const res = await checkConfigMigrationBranch(config, migratedData);
+      expect(res).toBe(`${config.branchPrefix}migrate-config`);
+      expect(git.checkoutBranch).toHaveBeenCalledTimes(0);
+      expect(git.commitFiles).toHaveBeenCalledTimes(0);
+    });
+  });
+});
diff --git a/lib/workers/repository/config-migration/branch/index.ts b/lib/workers/repository/config-migration/branch/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8e66b83e361a84e18ca04146349c567f72c0f52d
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/index.ts
@@ -0,0 +1,46 @@
+import { GlobalConfig } from '../../../../config/global';
+import type { RenovateConfig } from '../../../../config/types';
+import { logger } from '../../../../logger';
+import { platform } from '../../../../modules/platform';
+import { checkoutBranch } from '../../../../util/git';
+import { getMigrationBranchName } from '../common';
+import { createConfigMigrationBranch } from './create';
+import type { MigratedData } from './migrated-data';
+import { rebaseMigrationBranch } from './rebase';
+
+export async function checkConfigMigrationBranch(
+  config: RenovateConfig,
+  migratedConfigData: MigratedData
+): Promise<string | null> {
+  logger.debug('checkConfigMigrationBranch()');
+  if (!migratedConfigData) {
+    logger.debug('checkConfigMigrationBranch() Config does not need migration');
+    return null;
+  }
+  const configMigrationBranch = getMigrationBranchName(config);
+  if (await migrationPrExists(configMigrationBranch)) {
+    logger.debug('Config Migration PR already exists');
+    await rebaseMigrationBranch(config, migratedConfigData);
+
+    if (platform.refreshPr) {
+      const configMigrationPr = await platform.getBranchPr(
+        configMigrationBranch
+      );
+      if (configMigrationPr) {
+        await platform.refreshPr(configMigrationPr.number);
+      }
+    }
+  } else {
+    logger.debug('Config Migration PR does not exist');
+    logger.debug('Need to create migration PR');
+    await createConfigMigrationBranch(config, migratedConfigData);
+  }
+  if (!GlobalConfig.get('dryRun')) {
+    await checkoutBranch(configMigrationBranch);
+  }
+  return configMigrationBranch;
+}
+
+export async function migrationPrExists(branchName: string): Promise<boolean> {
+  return !!(await platform.getBranchPr(branchName));
+}
diff --git a/lib/workers/repository/config-migration/branch/migrated-data.spec.ts b/lib/workers/repository/config-migration/branch/migrated-data.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e0aee9ab31826642b78a3c2a4182e2df7edd391a
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/migrated-data.spec.ts
@@ -0,0 +1,113 @@
+import detectIndent from 'detect-indent';
+import { Fixtures } from '../../../../../test/fixtures';
+import { mockedFunction } from '../../../../../test/util';
+
+import { migrateConfig } from '../../../../config/migration';
+import { readLocalFile } from '../../../../util/fs';
+import { detectRepoFileConfig } from '../../init/merge';
+import { MigratedDataFactory } from './migrated-data';
+
+jest.mock('../../../../config/migration');
+jest.mock('../../../../util/fs');
+jest.mock('../../init/merge');
+jest.mock('detect-indent');
+
+const rawNonMigrated = Fixtures.get('./renovate.json');
+const rawNonMigratedJson5 = Fixtures.get('./renovate.json5');
+const migratedData = Fixtures.getJson('./migrated-data.json');
+const migratedDataJson5 = Fixtures.getJson('./migrated-data.json5');
+const migratedConfigObj = Fixtures.getJson('./migrated.json');
+
+describe('workers/repository/config-migration/branch/migrated-data', () => {
+  describe('MigratedDataFactory.getAsync', () => {
+    beforeEach(() => {
+      jest.resetAllMocks();
+      mockedFunction(detectIndent).mockReturnValue({
+        type: 'space',
+        amount: 2,
+        indent: '  ',
+      });
+      mockedFunction(detectRepoFileConfig).mockResolvedValue({
+        configFileName: 'renovate.json',
+      });
+      mockedFunction(readLocalFile).mockResolvedValue(rawNonMigrated);
+      mockedFunction(migrateConfig).mockReturnValue({
+        isMigrated: true,
+        migratedConfig: migratedConfigObj,
+      });
+    });
+
+    it('Calls getAsync a first when migration not needed', async () => {
+      mockedFunction(migrateConfig).mockReturnValueOnce({
+        isMigrated: false,
+        migratedConfig: null,
+      });
+      await expect(MigratedDataFactory.getAsync()).resolves.toBeNull();
+    });
+
+    it('Calls getAsync a first time to initialize the factory', async () => {
+      await expect(MigratedDataFactory.getAsync()).resolves.toEqual(
+        migratedData
+      );
+      expect(detectRepoFileConfig).toHaveBeenCalledTimes(1);
+    });
+
+    it('Calls getAsync a second time to get the saved data from before', async () => {
+      await expect(MigratedDataFactory.getAsync()).resolves.toEqual(
+        migratedData
+      );
+      expect(detectRepoFileConfig).toHaveBeenCalledTimes(0);
+    });
+
+    describe('MigratedData class', () => {
+      it('gets the filename from the class instance', async () => {
+        const data = await MigratedDataFactory.getAsync();
+        expect(data.filename).toBe('renovate.json');
+      });
+
+      it('gets the content from the class instance', async () => {
+        const data = await MigratedDataFactory.getAsync();
+        expect(data.content).toBe(migratedData.content);
+      });
+    });
+
+    it('Resets the factory and gets a new value', async () => {
+      MigratedDataFactory.reset();
+      await expect(MigratedDataFactory.getAsync()).resolves.toEqual(
+        migratedData
+      );
+    });
+
+    it('Resets the factory and gets a new value with default indentation', async () => {
+      mockedFunction(detectIndent).mockReturnValueOnce({
+        type: null,
+        amount: 0,
+        indent: null,
+      });
+      MigratedDataFactory.reset();
+      await expect(MigratedDataFactory.getAsync()).resolves.toEqual(
+        migratedData
+      );
+    });
+
+    it('Migrate a JSON5 config file', async () => {
+      mockedFunction(detectRepoFileConfig).mockResolvedValueOnce({
+        configFileName: 'renovate.json5',
+      });
+      mockedFunction(readLocalFile).mockResolvedValueOnce(rawNonMigratedJson5);
+      MigratedDataFactory.reset();
+      await expect(MigratedDataFactory.getAsync()).resolves.toEqual(
+        migratedDataJson5
+      );
+    });
+
+    it('Returns nothing due to fs error', async () => {
+      mockedFunction(detectRepoFileConfig).mockResolvedValueOnce({
+        configFileName: null,
+      });
+      mockedFunction(readLocalFile).mockRejectedValueOnce(null);
+      MigratedDataFactory.reset();
+      await expect(MigratedDataFactory.getAsync()).resolves.toBeNull();
+    });
+  });
+});
diff --git a/lib/workers/repository/config-migration/branch/migrated-data.ts b/lib/workers/repository/config-migration/branch/migrated-data.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9739d940f586ed6301be910531b074980ad59b79
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/migrated-data.ts
@@ -0,0 +1,76 @@
+import detectIndent from 'detect-indent';
+import JSON5 from 'json5';
+import { migrateConfig } from '../../../../config/migration';
+import { logger } from '../../../../logger';
+import { readLocalFile } from '../../../../util/fs';
+import { detectRepoFileConfig } from '../../init/merge';
+
+export interface MigratedData {
+  content: string;
+  filename: string;
+}
+
+export class MigratedDataFactory {
+  // singleton
+  private static data: MigratedData | null;
+
+  public static async getAsync(): Promise<MigratedData | null> {
+    if (this.data) {
+      return this.data;
+    }
+    const migrated = await this.build();
+
+    if (!migrated) {
+      return null;
+    }
+
+    this.data = migrated;
+    return this.data;
+  }
+
+  public static reset(): void {
+    this.data = null;
+  }
+
+  private static async build(): Promise<MigratedData | null> {
+    let res: MigratedData | null = null;
+    try {
+      const rc = await detectRepoFileConfig();
+      const configFileParsed = rc?.configFileParsed || {};
+
+      // get migrated config
+      const { isMigrated, migratedConfig } = migrateConfig(configFileParsed);
+      if (!isMigrated) {
+        return null;
+      }
+
+      delete migratedConfig.errors;
+      delete migratedConfig.warnings;
+
+      const filename = rc.configFileName ?? '';
+      const raw = await readLocalFile(filename, 'utf8');
+
+      // indent defaults to 2 spaces
+      const indent = detectIndent(raw).indent ?? '  ';
+      let content: string;
+
+      if (filename.endsWith('.json5')) {
+        content = JSON5.stringify(migratedConfig, undefined, indent);
+      } else {
+        content = JSON.stringify(migratedConfig, undefined, indent);
+      }
+
+      if (!content.endsWith('\n')) {
+        content += '\n';
+      }
+
+      res = { content, filename };
+    } catch (err) {
+      logger.debug(
+        err,
+        'MigratedDataFactory.getAsync() Error initializing renovate MigratedData'
+      );
+    }
+    return res;
+  }
+}
diff --git a/lib/workers/repository/config-migration/branch/rebase.spec.ts b/lib/workers/repository/config-migration/branch/rebase.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ccdbf3a5f9272d9d6bf38e32515227af5bd68727
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/rebase.spec.ts
@@ -0,0 +1,76 @@
+import { Fixtures } from '../../../../../test/fixtures';
+import {
+  RenovateConfig,
+  defaultConfig,
+  git,
+  platform,
+} from '../../../../../test/util';
+import { GlobalConfig } from '../../../../config/global';
+import type { MigratedData } from './migrated-data';
+import { rebaseMigrationBranch } from './rebase';
+
+jest.mock('../../../../util/git');
+
+describe('workers/repository/config-migration/branch/rebase', () => {
+  beforeAll(() => {
+    GlobalConfig.set({
+      localDir: '',
+    });
+  });
+
+  describe('rebaseMigrationBranch()', () => {
+    const raw = Fixtures.getJson('./renovate.json');
+    const indent = '  ';
+    const renovateConfig = JSON.stringify(raw, undefined, indent) + '\n';
+    const filename = 'renovate.json';
+
+    let config: RenovateConfig;
+    let migratedConfigData: MigratedData;
+
+    beforeEach(() => {
+      jest.resetAllMocks();
+      GlobalConfig.reset();
+      migratedConfigData = { content: renovateConfig, filename };
+      config = {
+        ...defaultConfig,
+        repository: 'some/repo',
+      };
+    });
+
+    it('does not rebase modified branch', async () => {
+      git.isBranchModified.mockResolvedValueOnce(true);
+      await rebaseMigrationBranch(config, migratedConfigData);
+      expect(git.commitFiles).toHaveBeenCalledTimes(0);
+    });
+
+    it('does nothing if branch is up to date', async () => {
+      git.getFile
+        .mockResolvedValueOnce(renovateConfig)
+        .mockResolvedValueOnce(renovateConfig);
+      await rebaseMigrationBranch(config, migratedConfigData);
+      expect(git.commitFiles).toHaveBeenCalledTimes(0);
+    });
+
+    it('rebases migration branch', async () => {
+      git.isBranchStale.mockResolvedValueOnce(true);
+      await rebaseMigrationBranch(config, migratedConfigData);
+      expect(git.commitFiles).toHaveBeenCalledTimes(1);
+    });
+
+    it('does not rebases migration branch when in dryRun is on', async () => {
+      GlobalConfig.set({
+        dryRun: 'full',
+      });
+      git.isBranchStale.mockResolvedValueOnce(true);
+      await rebaseMigrationBranch(config, migratedConfigData);
+      expect(git.commitFiles).toHaveBeenCalledTimes(0);
+    });
+
+    it('rebases via platform', async () => {
+      config.platformCommit = true;
+      git.isBranchStale.mockResolvedValueOnce(true);
+      await rebaseMigrationBranch(config, migratedConfigData);
+      expect(platform.commitFiles).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/lib/workers/repository/config-migration/branch/rebase.ts b/lib/workers/repository/config-migration/branch/rebase.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b5a3a320f34d9d9aa1d192fc4bbfa60cbbdac23e
--- /dev/null
+++ b/lib/workers/repository/config-migration/branch/rebase.ts
@@ -0,0 +1,52 @@
+import { GlobalConfig } from '../../../../config/global';
+import type { RenovateConfig } from '../../../../config/types';
+import { logger } from '../../../../logger';
+import { commitAndPush } from '../../../../modules/platform/commit';
+import { getFile, isBranchModified, isBranchStale } from '../../../../util/git';
+import { getMigrationBranchName } from '../common';
+import { ConfigMigrationCommitMessageFactory } from './commit-message';
+import type { MigratedData } from './migrated-data';
+
+export async function rebaseMigrationBranch(
+  config: RenovateConfig,
+  migratedConfigData: MigratedData
+): Promise<string | null> {
+  logger.debug('Checking if migration branch needs rebasing');
+  const branchName = getMigrationBranchName(config);
+  if (await isBranchModified(branchName)) {
+    logger.debug('Migration branch has been edited and cannot be rebased');
+    return null;
+  }
+  const configFileName = migratedConfigData.filename;
+  const contents = migratedConfigData.content;
+  const existingContents = await getFile(configFileName, branchName);
+  if (contents === existingContents && !(await isBranchStale(branchName))) {
+    logger.debug('Migration branch is up to date');
+    return null;
+  }
+  logger.debug('Rebasing migration branch');
+
+  if (GlobalConfig.get('dryRun')) {
+    logger.info('DRY-RUN: Would rebase files in migration branch');
+    return null;
+  }
+
+  const commitMessageFactory = new ConfigMigrationCommitMessageFactory(
+    config,
+    configFileName
+  );
+  const commitMessage = commitMessageFactory.create();
+
+  return commitAndPush({
+    branchName,
+    files: [
+      {
+        type: 'addition',
+        path: configFileName,
+        contents,
+      },
+    ],
+    message: commitMessage.toString(),
+    platformCommit: !!config.platformCommit,
+  });
+}
diff --git a/lib/workers/repository/config-migration/common.ts b/lib/workers/repository/config-migration/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..62704c3dbdb5082ac503eea8ac809e338b8b5175
--- /dev/null
+++ b/lib/workers/repository/config-migration/common.ts
@@ -0,0 +1,8 @@
+import type { RenovateConfig } from '../../../config/types';
+import * as template from '../../../util/template';
+
+const migrationBranchTemplate = '{{{branchPrefix}}}migrate-config';
+
+export function getMigrationBranchName(config: RenovateConfig): string {
+  return template.compile(migrationBranchTemplate, config);
+}
diff --git a/lib/workers/repository/config-migration/pr/__fixtures__/migrated-data.json b/lib/workers/repository/config-migration/pr/__fixtures__/migrated-data.json
new file mode 100644
index 0000000000000000000000000000000000000000..a3b883a2f5ff97373f5700e944543662d5ef7274
--- /dev/null
+++ b/lib/workers/repository/config-migration/pr/__fixtures__/migrated-data.json
@@ -0,0 +1,4 @@
+{
+  "configFileName": "renovate.json",
+  "migratedContent": "{\n  \"extends\": [\n    \":separateMajorReleases\",\n    \":prImmediately\",\n    \":renovatePrefix\",\n    \":semanticPrefixFixDepsChoreOthers\",\n    \":updateNotScheduled\",\n    \":automergeDisabled\",\n    \":maintainLockFilesDisabled\",\n    \":autodetectPinVersions\",\n    \"group:monorepos\"\n  ],\n  \"onboarding\": false,\n  \"rangeStrategy\": \"replace\",\n  \"semanticCommits\": \"enabled\",\n  \"timezone\": \"US/Central\",\n  \"baseBranches\": [\n    \"main\"\n  ]\n}\n"
+}
diff --git a/lib/workers/repository/config-migration/pr/__snapshots__/index.spec.ts.snap b/lib/workers/repository/config-migration/pr/__snapshots__/index.spec.ts.snap
new file mode 100644
index 0000000000000000000000000000000000000000..dcbcd3daed29acfe9c3a9c5614fea749d13ce138
--- /dev/null
+++ b/lib/workers/repository/config-migration/pr/__snapshots__/index.spec.ts.snap
@@ -0,0 +1,92 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR for JSON5 config file 1`] = `
+"Config migration needed, merge this PR to update your Renovate configuration file.
+
+
+
+#### [PLEASE NOTE](https://docs.renovatebot.com/configuration-options#configmigration): JSON5 config file migrated! All comments & trailing commas were removed.
+---
+#### Migration completed successfully, No errors or warnings found.
+---
+
+
+❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
+If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).
+
+---
+
+This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
+"
+`;
+
+exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR with empty footer and header 1`] = `
+"
+
+Config migration needed, merge this PR to update your Renovate configuration file.
+
+
+
+
+---
+#### Migration completed successfully, No errors or warnings found.
+---
+
+
+❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
+If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).
+
+---
+
+
+"
+`;
+
+exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR with footer and header using templating 1`] = `
+"This is a header for platform:github
+
+Config migration needed, merge this PR to update your Renovate configuration file.
+
+
+
+
+---
+#### Migration completed successfully, No errors or warnings found.
+---
+
+
+❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
+If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).
+
+---
+
+And this is a footer for repository:test baseBranch:some-branch
+"
+`;
+
+exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR with footer and header with trailing and leading newlines 1`] = `
+"
+
+This should not be the first line of the PR
+
+Config migration needed, merge this PR to update your Renovate configuration file.
+
+
+
+
+---
+#### Migration completed successfully, No errors or warnings found.
+---
+
+
+❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
+If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).
+
+---
+
+There should be several empty lines at the end of the PR
+
+
+
+"
+`;
diff --git a/lib/workers/repository/config-migration/pr/errors-warnings.spec.ts b/lib/workers/repository/config-migration/pr/errors-warnings.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3069e6461eb618cb4badc5ecc55953b293c1351b
--- /dev/null
+++ b/lib/workers/repository/config-migration/pr/errors-warnings.spec.ts
@@ -0,0 +1,57 @@
+import { RenovateConfig, getConfig } from '../../../../../test/util';
+import { getErrors, getWarnings } from './errors-warnings';
+
+describe('workers/repository/config-migration/pr/errors-warnings', () => {
+  let config: RenovateConfig;
+
+  beforeEach(() => {
+    jest.resetAllMocks();
+    config = getConfig();
+  });
+
+  describe('getWarnings()', () => {
+    it('returns warning text', () => {
+      config.warnings = [
+        {
+          topic: 'WARNING',
+          message: 'Something went wrong',
+        },
+      ];
+      const res = getWarnings(config);
+      expect(res).toMatchInlineSnapshot(`
+        "
+        # Warnings (1)
+
+        Please correct - or verify that you can safely ignore - these warnings before you merge this PR.
+
+        -   \`WARNING\`: Something went wrong
+
+        ---
+        "
+      `);
+    });
+  });
+
+  describe('getErrors()', () => {
+    it('returns error text', () => {
+      config.errors = [
+        {
+          topic: 'Error',
+          message: 'An error occurred',
+        },
+      ];
+      const res = getErrors(config);
+      expect(res).toMatchInlineSnapshot(`
+        "
+        # Errors (1)
+
+        Renovate has found errors that you should fix (in this branch) before finishing this PR.
+
+        -   \`Error\`: An error occurred
+
+        ---
+        "
+      `);
+    });
+  });
+});
diff --git a/lib/workers/repository/config-migration/pr/errors-warnings.ts b/lib/workers/repository/config-migration/pr/errors-warnings.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6506ec784de29042c8c9a6c3de7907fbe0e25166
--- /dev/null
+++ b/lib/workers/repository/config-migration/pr/errors-warnings.ts
@@ -0,0 +1,28 @@
+import type { RenovateConfig } from '../../../../config/types';
+
+export function getWarnings(config: RenovateConfig): string {
+  if (!config.warnings?.length) {
+    return '';
+  }
+  let warningText = `\n# Warnings (${config?.warnings.length})\n\n`;
+  warningText += `Please correct - or verify that you can safely ignore - these warnings before you merge this PR.\n\n`;
+  config.warnings.forEach((w) => {
+    warningText += `-   \`${w.topic}\`: ${w.message}\n`;
+  });
+  warningText += '\n---\n';
+  return warningText;
+}
+
+export function getErrors(config: RenovateConfig): string {
+  let errorText = '';
+  if (!config.errors?.length) {
+    return '';
+  }
+  errorText = `\n# Errors (${config.errors.length})\n\n`;
+  errorText += `Renovate has found errors that you should fix (in this branch) before finishing this PR.\n\n`;
+  config.errors.forEach((e) => {
+    errorText += `-   \`${e.topic}\`: ${e.message}\n`;
+  });
+  errorText += '\n---\n';
+  return errorText;
+}
diff --git a/lib/workers/repository/config-migration/pr/index.spec.ts b/lib/workers/repository/config-migration/pr/index.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..18abc84bb32808d94b0d77c0079aff5f4b91d6d3
--- /dev/null
+++ b/lib/workers/repository/config-migration/pr/index.spec.ts
@@ -0,0 +1,244 @@
+import type { RequestError, Response } from 'got';
+import { mock } from 'jest-mock-extended';
+import { Fixtures } from '../../../../../test/fixtures';
+import {
+  RenovateConfig,
+  getConfig,
+  git,
+  partial,
+  platform,
+} from '../../../../../test/util';
+import { GlobalConfig } from '../../../../config/global';
+import { logger } from '../../../../logger';
+import type { Pr } from '../../../../modules/platform';
+import { hashBody } from '../../../../modules/platform/pr-body';
+import type { MigratedData } from '../branch/migrated-data';
+import { ensureConfigMigrationPr } from '.';
+
+jest.mock('../../../../util/git');
+
+describe('workers/repository/config-migration/pr/index', () => {
+  const spy = jest.spyOn(platform, 'massageMarkdown');
+  const { configFileName, migratedContent } = Fixtures.getJson(
+    './migrated-data.json'
+  );
+  const migratedData: MigratedData = {
+    content: migratedContent,
+    filename: configFileName,
+  };
+  let config: RenovateConfig;
+
+  beforeEach(() => {
+    GlobalConfig.set({
+      dryRun: null,
+    });
+    jest.resetAllMocks();
+    config = {
+      ...getConfig(),
+      configMigration: true,
+      defaultBranch: 'main',
+      errors: [],
+      warnings: [],
+      description: [],
+    };
+  });
+
+  describe('ensureConfigMigrationPr()', () => {
+    beforeEach(() => {
+      spy.mockImplementation((input) => input);
+      platform.createPr.mockResolvedValueOnce(partial<Pr>({}));
+    });
+
+    let createPrBody: string;
+    let hash: string;
+
+    it('creates PR', async () => {
+      await ensureConfigMigrationPr(config, migratedData);
+      expect(platform.getBranchPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr).toHaveBeenCalledTimes(1);
+      createPrBody = platform.createPr.mock.calls[0][0].prBody;
+    });
+
+    it('creates PR with default PR title', async () => {
+      await ensureConfigMigrationPr(
+        { ...config, onboardingPrTitle: null },
+        migratedData
+      );
+      expect(platform.getBranchPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr).toHaveBeenCalledTimes(1);
+      createPrBody = platform.createPr.mock.calls[0][0].prBody;
+    });
+
+    it('Founds an open PR and as it is up to date and returns', async () => {
+      hash = hashBody(createPrBody);
+      platform.getBranchPr.mockResolvedValueOnce(
+        mock<Pr>({ bodyStruct: { hash } })
+      );
+      await ensureConfigMigrationPr(config, migratedData);
+      expect(platform.updatePr).toHaveBeenCalledTimes(0);
+      expect(platform.createPr).toHaveBeenCalledTimes(0);
+    });
+
+    it('Founds an open PR and updates it', async () => {
+      platform.getBranchPr.mockResolvedValueOnce(
+        mock<Pr>({ bodyStruct: { hash: '' } })
+      );
+      await ensureConfigMigrationPr(config, migratedData);
+      expect(platform.updatePr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr).toHaveBeenCalledTimes(0);
+    });
+
+    it('Founds a closed PR and exit', async () => {
+      platform.getBranchPr.mockResolvedValueOnce(null);
+      platform.findPr.mockResolvedValueOnce(
+        mock<Pr>({
+          title: 'Config Migration',
+        })
+      );
+      await ensureConfigMigrationPr(config, migratedData);
+      expect(platform.updatePr).toHaveBeenCalledTimes(0);
+      expect(platform.createPr).toHaveBeenCalledTimes(0);
+      expect(logger.debug).toHaveBeenCalledWith(
+        'Found closed migration PR, exiting...'
+      );
+    });
+
+    it('Dry runs and does not update out of date PR', async () => {
+      GlobalConfig.set({
+        dryRun: 'full',
+      });
+      platform.getBranchPr.mockResolvedValueOnce(
+        mock<Pr>({ bodyStruct: { hash: '' } })
+      );
+      await ensureConfigMigrationPr(config, migratedData);
+      expect(platform.updatePr).toHaveBeenCalledTimes(0);
+      expect(platform.createPr).toHaveBeenCalledTimes(0);
+      expect(logger.debug).toHaveBeenCalledWith('Found open migration PR');
+      expect(logger.debug).not.toHaveBeenLastCalledWith(
+        `does not need updating`
+      );
+      expect(logger.info).toHaveBeenLastCalledWith(
+        'DRY-RUN: Would update migration PR'
+      );
+    });
+
+    it('Creates PR in dry run mode', async () => {
+      GlobalConfig.set({
+        dryRun: 'full',
+      });
+      await ensureConfigMigrationPr(config, migratedData);
+      expect(platform.getBranchPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr).toHaveBeenCalledTimes(0);
+      expect(logger.info).toHaveBeenLastCalledWith(
+        'DRY-RUN: Would create migration PR'
+      );
+    });
+
+    it('creates PR with labels', async () => {
+      await ensureConfigMigrationPr(
+        {
+          ...config,
+          labels: ['label'],
+          addLabels: ['label', 'additional-label'],
+        },
+        migratedData
+      );
+      expect(platform.createPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr.mock.calls[0][0].labels).toEqual([
+        'label',
+        'additional-label',
+      ]);
+    });
+
+    it('creates PR with empty footer and header', async () => {
+      await ensureConfigMigrationPr(
+        {
+          ...config,
+          prHeader: '',
+          prFooter: '',
+        },
+        migratedData
+      );
+      expect(platform.createPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot();
+    });
+
+    it('creates PR for JSON5 config file', async () => {
+      await ensureConfigMigrationPr(config, {
+        content: migratedContent,
+        filename: 'renovate.json5',
+      });
+      expect(platform.createPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot();
+    });
+
+    it('creates PR with footer and header with trailing and leading newlines', async () => {
+      await ensureConfigMigrationPr(
+        {
+          ...config,
+          prHeader: '\r\r\nThis should not be the first line of the PR',
+          prFooter:
+            'There should be several empty lines at the end of the PR\r\n\n\n',
+        },
+        migratedData
+      );
+      expect(platform.createPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot();
+    });
+
+    it('creates PR with footer and header using templating', async () => {
+      config.baseBranch = 'some-branch';
+      config.repository = 'test';
+      await ensureConfigMigrationPr(
+        {
+          ...config,
+          prHeader: 'This is a header for platform:{{platform}}',
+          prFooter:
+            'And this is a footer for repository:{{repository}} baseBranch:{{baseBranch}}',
+        },
+        migratedData
+      );
+      expect(platform.createPr).toHaveBeenCalledTimes(1);
+      expect(platform.createPr.mock.calls[0][0].prBody).toMatch(
+        /platform:github/
+      );
+      expect(platform.createPr.mock.calls[0][0].prBody).toMatch(
+        /repository:test/
+      );
+      expect(platform.createPr.mock.calls[0][0].prBody).toMatch(
+        /baseBranch:some-branch/
+      );
+      expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot();
+    });
+  });
+
+  describe('ensureConfigMigrationPr() throws', () => {
+    const response = partial<Response>({ statusCode: 422 });
+    const err = partial<RequestError>({ response });
+
+    beforeEach(() => {
+      jest.resetAllMocks();
+      GlobalConfig.reset();
+      git.deleteBranch.mockResolvedValue();
+    });
+
+    it('throws when trying to create a new PR', async () => {
+      platform.createPr.mockRejectedValueOnce(err);
+      await expect(ensureConfigMigrationPr(config, migratedData)).toReject();
+      expect(git.deleteBranch).toHaveBeenCalledTimes(0);
+    });
+
+    it('deletes branch when PR already exists but cannot find it', async () => {
+      err.response.body = {
+        errors: [{ message: 'A pull request already exists' }],
+      };
+      platform.createPr.mockRejectedValue(err);
+      await expect(ensureConfigMigrationPr(config, migratedData)).toResolve();
+      expect(logger.warn).toHaveBeenCalledWith(
+        { err },
+        'Migration PR already exists but cannot find it. It was probably created by a different user.'
+      );
+      expect(git.deleteBranch).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/lib/workers/repository/config-migration/pr/index.ts b/lib/workers/repository/config-migration/pr/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3c62ab5852cb37c294fdab19ce955eb2038cf397
--- /dev/null
+++ b/lib/workers/repository/config-migration/pr/index.ts
@@ -0,0 +1,151 @@
+import is from '@sindresorhus/is';
+import { GlobalConfig } from '../../../../config/global';
+import type { RenovateConfig } from '../../../../config/types';
+import { logger } from '../../../../logger';
+import { platform } from '../../../../modules/platform';
+import { hashBody } from '../../../../modules/platform/pr-body';
+import { PrState } from '../../../../types';
+import { emojify } from '../../../../util/emoji';
+import { deleteBranch } from '../../../../util/git';
+import * as template from '../../../../util/template';
+import { joinUrlParts } from '../../../../util/url';
+import { getPlatformPrOptions } from '../../update/pr';
+import { prepareLabels } from '../../update/pr/labels';
+import { addParticipants } from '../../update/pr/participants';
+import type { MigratedData } from '../branch/migrated-data';
+import { getMigrationBranchName } from '../common';
+import { getErrors, getWarnings } from './errors-warnings';
+
+export async function ensureConfigMigrationPr(
+  config: RenovateConfig,
+  migratedConfigData: MigratedData
+): Promise<void> {
+  logger.debug('ensureConfigMigrationPr()');
+  const docsLink = joinUrlParts(
+    config.productLinks?.documentation ?? '',
+    'configuration-options/#configmigration'
+  );
+  const branchName = getMigrationBranchName(config);
+  const prTitle = config.onboardingPrTitle ?? 'Config Migration';
+  const existingPr = await platform.getBranchPr(branchName);
+  const closedPr = await platform.findPr({
+    branchName,
+    prTitle,
+    state: PrState.Closed,
+  });
+  const filename = migratedConfigData.filename;
+  logger.debug('Filling in config migration PR template');
+  let prTemplate = `Config migration needed, merge this PR to update your Renovate configuration file.\n\n`;
+  prTemplate += emojify(
+    `
+
+${
+  filename.endsWith('.json5')
+    ? `#### [PLEASE NOTE](${docsLink}): ` +
+      `JSON5 config file migrated! All comments & trailing commas were removed.`
+    : ''
+}
+---
+{{#if hasWarningsErrors}}
+{{{warnings}}}
+{{{errors}}}
+{{else}}
+#### Migration completed successfully, No errors or warnings found.
+{{/if}}
+---
+
+
+:question: Got questions? Check out Renovate's [Docs](${
+      config.productLinks?.documentation
+    }), particularly the Getting Started section.
+If you need any further assistance then you can also [request help here](${
+      config.productLinks?.help
+    }).
+`
+  );
+  const warnings = getWarnings(config);
+  const errors = getErrors(config);
+  const hasWarningsErrors = warnings || errors;
+  let prBody = prTemplate;
+  prBody = template.compile(prBody, {
+    warnings,
+    errors,
+    hasWarningsErrors,
+  });
+  if (is.string(config.prHeader)) {
+    prBody = `${template.compile(config.prHeader, config)}\n\n${prBody}`;
+  }
+  if (is.string(config.prFooter)) {
+    prBody = `${prBody}\n---\n\n${template.compile(config.prFooter, config)}\n`;
+  }
+  logger.trace({ prBody }, 'prBody');
+
+  prBody = platform.massageMarkdown(prBody);
+
+  if (existingPr) {
+    logger.debug('Found open migration PR');
+    // Check if existing PR needs updating
+    const prBodyHash = hashBody(prBody);
+    if (existingPr.bodyStruct?.hash === prBodyHash) {
+      logger.debug({ pr: existingPr.number }, `Does not need updating`);
+      return;
+    }
+    // PR must need updating
+    if (GlobalConfig.get('dryRun')) {
+      logger.info('DRY-RUN: Would update migration PR');
+    } else {
+      await platform.updatePr({
+        number: existingPr.number,
+        prTitle: existingPr.title,
+        prBody,
+      });
+      logger.info({ pr: existingPr.number }, 'Migration PR updated');
+    }
+    return;
+  }
+  if (
+    [config.onboardingPrTitle, 'Config Migration'].includes(closedPr?.title)
+  ) {
+    logger.debug('Found closed migration PR, exiting...');
+    return;
+  }
+  logger.debug('Creating migration PR');
+  const labels = prepareLabels(config);
+  try {
+    if (GlobalConfig.get('dryRun')) {
+      logger.info('DRY-RUN: Would create migration PR');
+    } else {
+      const pr = await platform.createPr({
+        sourceBranch: branchName,
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
+        targetBranch: config.defaultBranch!,
+        prTitle,
+        prBody,
+        labels,
+        platformOptions: getPlatformPrOptions({
+          ...config,
+          automerge: false,
+        }),
+      });
+      logger.info({ pr: pr?.number }, 'Migration PR created');
+      if (pr) {
+        await addParticipants(config, pr);
+      }
+    }
+  } catch (err) {
+    if (
+      err.response?.statusCode === 422 &&
+      err.response?.body?.errors?.[0]?.message?.startsWith(
+        'A pull request already exists'
+      )
+    ) {
+      logger.warn(
+        { err },
+        'Migration PR already exists but cannot find it. It was probably created by a different user.'
+      );
+      await deleteBranch(branchName);
+      return;
+    }
+    throw err;
+  }
+}
diff --git a/lib/workers/repository/finalise/index.ts b/lib/workers/repository/finalise/index.ts
index 1f239204c183e2ca31ba6b1c9436bcdf163b4a58..bcc6f79e0fc7f0e8b550f81c862473b984b94c20 100644
--- a/lib/workers/repository/finalise/index.ts
+++ b/lib/workers/repository/finalise/index.ts
@@ -3,6 +3,9 @@ import { logger } from '../../../logger';
 import { platform } from '../../../modules/platform';
 import * as repositoryCache from '../../../util/cache/repository';
 import { clearRenovateRefs } from '../../../util/git';
+import { checkConfigMigrationBranch } from '../config-migration/branch';
+import { MigratedDataFactory } from '../config-migration/branch/migrated-data';
+import { ensureConfigMigrationPr } from '../config-migration/pr';
 import { PackageFiles } from '../package-files';
 import { pruneStaleBranches } from './prune';
 import { runRenovateRepoStats } from './repository-statistics';
@@ -12,6 +15,18 @@ export async function finaliseRepo(
   config: RenovateConfig,
   branchList: string[]
 ): Promise<void> {
+  if (config.configMigration) {
+    const migratedConfigData = await MigratedDataFactory.getAsync();
+    const migrationBranch = await checkConfigMigrationBranch(
+      config,
+      migratedConfigData
+    ); // null if migration not needed
+    if (migrationBranch) {
+      branchList.push(migrationBranch);
+      await ensureConfigMigrationPr(config, migratedConfigData);
+    }
+    MigratedDataFactory.reset();
+  }
   await repositoryCache.saveCache();
   await pruneStaleBranches(config, branchList);
   await platform.ensureIssueClosing(
diff --git a/tsconfig.strict.json b/tsconfig.strict.json
index 2516c1b9392735c2fc52ba9ac24a0559a9a43aa9..05ca0fd10bfe8680ee10d268ac8ae8cf197558a2 100644
--- a/tsconfig.strict.json
+++ b/tsconfig.strict.json
@@ -51,6 +51,7 @@
     "lib/workers/repository/errors-warnings.ts",
     "lib/workers/repository/onboarding/pr/index.ts",
     "lib/workers/repository/onboarding/pr/pr-list.ts",
+    "lib/workers/repository/config-migration/pr/index.ts",
     "lib/workers/repository/process/deprecated.ts",
     "lib/workers/repository/process/extract-update.ts",
     "lib/workers/repository/process/fetch.ts",