diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index c9c08f444481fcd81bad66ecfff26ab37e373f28..59ef0fc81c319e1b3f9bba651dea6859b5d29447 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -1833,6 +1833,14 @@ Normally when you set `rebaseWhen=auto` Renovate rebases any branch that's behin
 This behavior is no longer guaranteed when you enable `platformAutomerge` because the platform might automerge a branch which is not up-to-date.
 For example, GitHub might automerge a Renovate branch even if it's behind the base branch at the time.
 
+## platformCommit
+
+Supports only GitHub App mode and not when using Personal Access Tokens.
+
+To avoid errors, `gitAuthor` or `gitIgnoredAuthors` should be manually adjusted accordingly.
+
+The primary reason to use this option is because commits will then be signed automatically if authenticating as an app.
+
 ## postUpdateOptions
 
 - `gomodTidy`: Run `go mod tidy` after Go module updates. This is implicitly enabled for major module updates when `gomodUpdateImportPaths` is enabled
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index a9ce49196214ef8ebbbe39bff72f9978e87955a6..267b9b712815b8613ed774adf282c4cec90a13a4 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -2244,6 +2244,13 @@ const options: RenovateOptions[] = [
         'As this PR has been closed unmerged, Renovate will now ignore this update ({{{newValue}}}). You will still receive a PR once a newer version is released, so if you wish to permanently ignore this dependency, please add it to the `ignoreDeps` array of your renovate config.',
     },
   },
+  {
+    name: 'platformCommit',
+    description: `Use platform API to perform commits instead of using git directly.`,
+    type: 'boolean',
+    default: false,
+    supportedPlatforms: ['github'],
+  },
 ];
 
 export function getOptions(): RenovateOptions[] {
diff --git a/lib/platform/github/index.spec.ts b/lib/platform/github/index.spec.ts
index 6173790f6d21f863dd1795666091c6502283c7f3..ebc3fbb6d45df01374c9970fc6dc88df2709984a 100644
--- a/lib/platform/github/index.spec.ts
+++ b/lib/platform/github/index.spec.ts
@@ -2579,4 +2579,102 @@ describe('platform/github/index', () => {
       expect(httpMock.getTrace()).toMatchSnapshot();
     });
   });
+
+  describe('pushFiles', () => {
+    beforeEach(() => {
+      git.prepareCommit.mockImplementation(({ files }) =>
+        Promise.resolve({
+          parentCommitSha: '1234567',
+          commitSha: '7654321',
+          files,
+        })
+      );
+      git.fetchCommit.mockImplementation(() => Promise.resolve('0abcdef'));
+    });
+    it('returns null if pre-commit phase has failed', async () => {
+      const scope = httpMock.scope(githubApiHost);
+      initRepoMock(scope, 'some/repo');
+      git.prepareCommit.mockResolvedValueOnce(null);
+
+      await github.initRepo({ repository: 'some/repo', token: 'token' } as any);
+
+      const res = await github.commitFiles({
+        branchName: 'foo/bar',
+        files: [
+          { type: 'addition', path: 'foo.bar', contents: 'foobar' },
+          { type: 'deletion', path: 'baz' },
+          { type: 'deletion', path: 'qux' },
+        ],
+        message: 'Foobar',
+      });
+
+      expect(res).toBeNull();
+    });
+    it('returns null on REST error', async () => {
+      const scope = httpMock.scope(githubApiHost);
+      initRepoMock(scope, 'some/repo');
+      await github.initRepo({ repository: 'some/repo', token: 'token' } as any);
+      scope.post('/repos/some/repo/git/trees').replyWithError('unknown');
+
+      const res = await github.commitFiles({
+        branchName: 'foo/bar',
+        files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
+        message: 'Foobar',
+      });
+
+      expect(res).toBeNull();
+    });
+    it('commits and returns SHA string', async () => {
+      git.pushCommitToRenovateRef.mockResolvedValueOnce();
+      git.listCommitTree.mockResolvedValueOnce([]);
+      git.branchExists.mockReturnValueOnce(false);
+
+      const scope = httpMock.scope(githubApiHost);
+
+      initRepoMock(scope, 'some/repo');
+      await github.initRepo({ repository: 'some/repo', token: 'token' } as any);
+
+      scope
+        .post('/repos/some/repo/git/trees')
+        .reply(200, { sha: '111' })
+        .post('/repos/some/repo/git/commits')
+        .reply(200, { sha: '222' })
+        .post('/repos/some/repo/git/refs')
+        .reply(200);
+
+      const res = await github.commitFiles({
+        branchName: 'foo/bar',
+        files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
+        message: 'Foobar',
+      });
+
+      expect(res).toBe('0abcdef');
+    });
+    it('performs rebase', async () => {
+      git.pushCommitToRenovateRef.mockResolvedValueOnce();
+      git.listCommitTree.mockResolvedValueOnce([]);
+      git.branchExists.mockReturnValueOnce(true);
+
+      const scope = httpMock.scope(githubApiHost);
+
+      initRepoMock(scope, 'some/repo');
+      await github.initRepo({ repository: 'some/repo', token: 'token' } as any);
+
+      scope
+        .post('/repos/some/repo/git/trees')
+        .reply(200, { sha: '111' })
+        .post('/repos/some/repo/git/commits')
+        .reply(200, { sha: '222' })
+        .patch('/repos/some/repo/git/refs/heads/foo/bar')
+        .reply(200);
+
+      const res = await github.commitFiles({
+        branchName: 'foo/bar',
+        files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
+        message: 'Foobar',
+      });
+
+      expect(res).toBe('0abcdef');
+    });
+  });
 });
diff --git a/lib/platform/github/index.ts b/lib/platform/github/index.ts
index f7f854b0f2e1c6184f7393d4133f30a66c430f65..013eb31a2563a1c567169b4d3b8e34f3a68a9a2b 100644
--- a/lib/platform/github/index.ts
+++ b/lib/platform/github/index.ts
@@ -22,6 +22,12 @@ import { logger } from '../../logger';
 import { BranchStatus, PrState, VulnerabilityAlert } from '../../types';
 import { ExternalHostError } from '../../types/errors/external-host-error';
 import * as git from '../../util/git';
+import { listCommitTree, pushCommitToRenovateRef } from '../../util/git';
+import type {
+  CommitFilesConfig,
+  CommitResult,
+  CommitSha,
+} from '../../util/git/types';
 import * as hostRules from '../../util/host-rules';
 import * as githubHttp from '../../util/http/github';
 import { regEx } from '../../util/regex';
@@ -1719,3 +1725,73 @@ export async function getVulnerabilityAlerts(): Promise<VulnerabilityAlert[]> {
   }
   return alerts;
 }
+
+async function pushFiles(
+  { branchName, message }: CommitFilesConfig,
+  { parentCommitSha, commitSha }: CommitResult
+): Promise<CommitSha | null> {
+  try {
+    // Push the commit to GitHub using a custom ref
+    // The associated blobs will be pushed automatically
+    await pushCommitToRenovateRef(commitSha, branchName);
+    // Get all the blobs which the commit/tree points to
+    // The blob SHAs will be the same locally as on GitHub
+    const treeItems = await listCommitTree(commitSha);
+
+    // For reasons unknown, we need to recreate our tree+commit on GitHub
+    // Attempting to reuse the tree or commit SHA we pushed does not work
+    const treeRes = await githubApi.postJson<{ sha: string }>(
+      `/repos/${config.repository}/git/trees`,
+      { body: { tree: treeItems } }
+    );
+    const treeSha = treeRes.body.sha;
+
+    // Now we recreate the commit using the tree we recreated the step before
+    const commitRes = await githubApi.postJson<{ sha: string }>(
+      `/repos/${config.repository}/git/commits`,
+      { body: { message, tree: treeSha, parents: [parentCommitSha] } }
+    );
+    const remoteCommitSha = commitRes.body.sha;
+
+    // Create branch if it didn't already exist, update it otherwise
+    if (git.branchExists(branchName)) {
+      // This is the equivalent of a git force push
+      // We are using this REST API because the GraphQL API doesn't support force push
+      await githubApi.patchJson(
+        `/repos/${config.repository}/git/refs/heads/${branchName}`,
+        { body: { sha: remoteCommitSha, force: true } }
+      );
+    } else {
+      await githubApi.postJson(`/repos/${config.repository}/git/refs`, {
+        body: { ref: `refs/heads/${branchName}`, sha: remoteCommitSha },
+      });
+    }
+
+    return remoteCommitSha;
+  } catch (err) {
+    logger.debug({ branchName, err }, 'Platform-native commit: unknown error');
+    return null;
+  }
+}
+
+export async function commitFiles(
+  config: CommitFilesConfig
+): Promise<CommitSha | null> {
+  const commitResult = await git.prepareCommit(config); // Commit locally and don't push
+  if (!commitResult) {
+    const { branchName, files } = config;
+    logger.debug(
+      { branchName, files: files.map(({ path }) => path) },
+      `Platform-native commit: unable to prepare for commit`
+    );
+    return null;
+  }
+  // Perform the commits using REST API
+  const pushResult = await pushFiles(config, commitResult);
+  if (!pushResult) {
+    return null;
+  }
+  // Because the branch commit was done remotely via REST API, now we git fetch it locally.
+  // We also do this step when committing/pushing using local git tooling.
+  return git.fetchCommit(config);
+}
diff --git a/lib/util/git/index.ts b/lib/util/git/index.ts
index d8a3dc40cc2679708a70cdd09e4e22628af1c626..4396797a4d30c90d370db283a330d347ae68140b 100644
--- a/lib/util/git/index.ts
+++ b/lib/util/git/index.ts
@@ -787,6 +787,18 @@ async function handleCommitAuth(localDir: string): Promise<void> {
   await writeGitAuthor();
 }
 
+/**
+ *
+ * Prepare local branch with commit
+ *
+ * 0. Hard reset
+ * 1. Creates local branch with `origin/` prefix
+ * 2. Perform `git add` (respecting mode) and `git remove` for each file
+ * 3. Perform commit
+ * 4. Check whether resulting commit is empty or not (due to .gitignore)
+ * 5. If not empty, return commit info for further processing
+ *
+ */
 export async function prepareCommit({
   branchName,
   files,
diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts
index 9273873442d4568528d2cf015acbec186397e2af..039763ba384dea2fdcf7efd7030d8852d39e87dc 100644
--- a/lib/util/http/github.ts
+++ b/lib/util/http/github.ts
@@ -17,6 +17,7 @@ import { regEx } from '../regex';
 import { parseLinkHeader } from '../url';
 import type { GotLegacyError } from './legacy';
 import type {
+  GraphqlOptions,
   HttpPostOptions,
   HttpResponse,
   InternalHttpOptions,
@@ -159,15 +160,6 @@ function handleGotError(
   return err;
 }
 
-interface GraphqlOptions {
-  variables?: Record<string, string | number | null>;
-  paginate?: boolean;
-  count?: number;
-  limit?: number;
-  cursor?: string | null;
-  acceptHeader?: string;
-}
-
 interface GraphqlPaginatedContent<T = unknown> {
   nodes: T[];
   edges: T[];
diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts
index 7b55da4c95721639eb294848a5522711e8cd0e52..b7f378961af573238badb865e129e841dbccb6cf 100644
--- a/lib/util/http/types.ts
+++ b/lib/util/http/types.ts
@@ -33,6 +33,19 @@ export interface RequestStats {
 
 export type OutgoingHttpHeaders = Record<string, string | string[] | undefined>;
 
+export interface GraphqlVariables {
+  [k: string]: unknown;
+}
+
+export interface GraphqlOptions {
+  variables?: GraphqlVariables;
+  paginate?: boolean;
+  count?: number;
+  limit?: number;
+  cursor?: string | null;
+  acceptHeader?: string;
+}
+
 export interface HttpOptions {
   body?: any;
   username?: string;