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;