Compare commits

...

37 commits

Author SHA1 Message Date
Peter Evans
b8ff24664a add throttling 2024-08-14 21:09:55 +01:00
Peter Evans
d93a919a26 update docs 2024-08-14 17:17:10 +01:00
Peter Evans
a2d4746d68 fix capital letter 2024-08-14 17:17:10 +01:00
Peter Evans
2c262e8e92 update docs for commit signing 2024-08-14 17:17:10 +01:00
Peter Evans
7b7dc5777f update readme link 2024-08-14 17:17:10 +01:00
Peter Evans
c1be170c86 remove unused code 2024-08-14 17:17:10 +01:00
Peter Evans
822f3b39c1 only build commits when feature enabled 2024-08-14 17:17:10 +01:00
Peter Evans
197e74c6e1 limit blob creation concurrency 2024-08-14 17:17:09 +01:00
Peter Evans
c7909f9b04 add executable mode file to test 2024-08-14 17:17:09 +01:00
Peter Evans
491f77f4d6 fix format and cleanup 2024-08-14 17:17:09 +01:00
Peter Evans
93858f721d debug commit verification 2024-08-14 17:17:09 +01:00
Peter Evans
2668dc956a debug commit verification 2024-08-14 17:17:09 +01:00
Peter Evans
b0303827bb try fix base tree 2024-08-14 17:17:09 +01:00
Peter Evans
90b04fe25b force push 2024-08-14 17:17:08 +01:00
Peter Evans
2707da835d fix check for branch existence 2024-08-14 17:17:08 +01:00
Peter Evans
e4c51477d1 try rest api route 2024-08-14 17:17:08 +01:00
Peter Evans
0a237f343d use source mode for deleted files 2024-08-14 17:17:08 +01:00
Peter Evans
77c6c11180 build branch commits 2024-08-14 17:17:07 +01:00
Peter Evans
477c78c3f2 fix format 2024-08-14 17:17:07 +01:00
Peter Evans
7f459482cc add function to get commit detail 2024-08-14 17:17:07 +01:00
Peter Evans
018afb52b6 build file changes even when there is no diff 2024-08-14 17:17:07 +01:00
Peter Evans
3a7a677a14 refactor graphql code into github helper class 2024-08-14 17:17:07 +01:00
Peter Evans
74416df758 add build file changes test for binary files 2024-08-14 17:17:07 +01:00
Peter Evans
7575ead361 add tests for building file changes 2024-08-14 17:17:07 +01:00
Peter Evans
3d409de49f Try refactor of file changes 2024-08-14 17:17:06 +01:00
Peter Evans
743dcd81f7 remove commented code 2024-08-14 17:17:06 +01:00
Peter Evans
0f72e35b7f try to fix head repo 2024-08-14 17:17:06 +01:00
Peter Evans
4a3e69b7f7 fix filepath when using path input 2024-08-14 17:17:06 +01:00
Peter Evans
525f1f0028 disable linter for debug code 2024-08-14 17:17:05 +01:00
Peter Evans
36bba202e3 debug payload without contents 2024-08-14 17:17:05 +01:00
Peter Evans
3563849c8a read to buffer not string and use non-legacy method to base64 2024-08-14 17:17:05 +01:00
Peter Evans
6c03c11aff add debug lines 2024-08-14 17:17:05 +01:00
Peter Evans
27642d5a9e sign commits by default for testing 2024-08-14 17:17:05 +01:00
Peter Evans
136db6a783 shift setting the base to before the push 2024-08-14 17:17:05 +01:00
Peter Evans
43d45f2e88 fix eslint and lint errors 2024-08-14 17:17:04 +01:00
Peter Evans
24bfe8de6b formatting 2024-08-14 17:16:38 +01:00
Ravi
22fb2d9a65 Add support for signed commits (#3055) 2024-08-14 17:16:38 +01:00
17 changed files with 32061 additions and 8476 deletions

View file

@ -65,6 +65,7 @@ All inputs are **optional**. If not set, sensible defaults will be used.
| `branch-suffix` | The branch suffix type when using the alternative branching strategy. Valid values are `random`, `timestamp` and `short-commit-hash`. See [Alternative strategy](#alternative-strategy---always-create-a-new-pull-request-branch) for details. | | | `branch-suffix` | The branch suffix type when using the alternative branching strategy. Valid values are `random`, `timestamp` and `short-commit-hash`. See [Alternative strategy](#alternative-strategy---always-create-a-new-pull-request-branch) for details. | |
| `base` | Sets the pull request base branch. | Defaults to the branch checked out in the workflow. | | `base` | Sets the pull request base branch. | Defaults to the branch checked out in the workflow. |
| `push-to-fork` | A fork of the checked-out parent repository to which the pull request branch will be pushed. e.g. `owner/repo-fork`. The pull request will be created to merge the fork's branch into the parent's base. See [push pull request branches to a fork](docs/concepts-guidelines.md#push-pull-request-branches-to-a-fork) for details. | | | `push-to-fork` | A fork of the checked-out parent repository to which the pull request branch will be pushed. e.g. `owner/repo-fork`. The pull request will be created to merge the fork's branch into the parent's base. See [push pull request branches to a fork](docs/concepts-guidelines.md#push-pull-request-branches-to-a-fork) for details. | |
| `sign-commits` | Sign commits as `github-actions[bot]` when using `GITHUB_TOKEN`, or your own bot when using [GitHub App tokens](docs/concepts-guidelines.md#authenticating-with-github-app-generated-tokens). See [commit signing](docs/concepts-guidelines.md#commit-signature-verification-for-bots) for details. | `false` |
| `title` | The title of the pull request. | `Changes by create-pull-request action` | | `title` | The title of the pull request. | `Changes by create-pull-request action` |
| `body` | The body of the pull request. | `Automated changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action` | | `body` | The body of the pull request. | `Automated changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action` |
| `body-path` | The path to a file containing the pull request body. Takes precedence over `body`. | | | `body-path` | The path to a file containing the pull request body. Takes precedence over `body`. | |

View file

@ -1,7 +1,8 @@
import { import {
createOrUpdateBranch, createOrUpdateBranch,
tryFetch, tryFetch,
getWorkingBaseAndType getWorkingBaseAndType,
buildBranchCommits
} from '../lib/create-or-update-branch' } from '../lib/create-or-update-branch'
import * as fs from 'fs' import * as fs from 'fs'
import {GitCommandManager} from '../lib/git-command-manager' import {GitCommandManager} from '../lib/git-command-manager'
@ -229,6 +230,77 @@ describe('create-or-update-branch tests', () => {
expect(workingBaseType).toEqual('commit') expect(workingBaseType).toEqual('commit')
}) })
it('tests buildBranchCommits with no diff', async () => {
await git.checkout(BRANCH, BASE)
const branchCommits = await buildBranchCommits(git, BASE, BRANCH)
expect(branchCommits.length).toEqual(0)
})
it('tests buildBranchCommits with addition and modification', async () => {
await git.checkout(BRANCH, BASE)
await createChanges()
const UNTRACKED_EXE_FILE = 'a/script.sh'
const filepath = path.join(REPO_PATH, UNTRACKED_EXE_FILE)
await fs.promises.writeFile(filepath, '#!/usr/bin/env bash', {mode: 0o755})
await git.exec(['add', '-A'])
await git.commit(['-m', 'Test changes'])
const branchCommits = await buildBranchCommits(git, BASE, BRANCH)
expect(branchCommits.length).toEqual(1)
expect(branchCommits[0].subject).toEqual('Test changes')
expect(branchCommits[0].changes.length).toEqual(3)
expect(branchCommits[0].changes).toEqual([
{mode: '100755', path: UNTRACKED_EXE_FILE, status: 'A'},
{mode: '100644', path: TRACKED_FILE, status: 'M'},
{mode: '100644', path: UNTRACKED_FILE, status: 'A'}
])
})
it('tests buildBranchCommits with addition and deletion', async () => {
await git.checkout(BRANCH, BASE)
await createChanges()
const TRACKED_FILE_NEW_PATH = 'c/tracked-file.txt'
const filepath = path.join(REPO_PATH, TRACKED_FILE_NEW_PATH)
await fs.promises.mkdir(path.dirname(filepath), {recursive: true})
await fs.promises.rename(path.join(REPO_PATH, TRACKED_FILE), filepath)
await git.exec(['add', '-A'])
await git.commit(['-m', 'Test changes'])
const branchCommits = await buildBranchCommits(git, BASE, BRANCH)
expect(branchCommits.length).toEqual(1)
expect(branchCommits[0].subject).toEqual('Test changes')
expect(branchCommits[0].changes.length).toEqual(3)
expect(branchCommits[0].changes).toEqual([
{mode: '100644', path: TRACKED_FILE, status: 'D'},
{mode: '100644', path: UNTRACKED_FILE, status: 'A'},
{mode: '100644', path: TRACKED_FILE_NEW_PATH, status: 'A'}
])
})
it('tests buildBranchCommits with multiple commits', async () => {
await git.checkout(BRANCH, BASE)
for (let i = 0; i < 3; i++) {
await createChanges()
await git.exec(['add', '-A'])
await git.commit(['-m', `Test changes ${i}`])
}
const branchCommits = await buildBranchCommits(git, BASE, BRANCH)
expect(branchCommits.length).toEqual(3)
for (let i = 0; i < 3; i++) {
expect(branchCommits[i].subject).toEqual(`Test changes ${i}`)
expect(branchCommits[i].changes.length).toEqual(2)
const untrackedFileStatus = i == 0 ? 'A' : 'M'
expect(branchCommits[i].changes).toEqual([
{mode: '100644', path: TRACKED_FILE, status: 'M'},
{mode: '100644', path: UNTRACKED_FILE, status: untrackedFileStatus}
])
}
})
it('tests no changes resulting in no new branch being created', async () => { it('tests no changes resulting in no new branch being created', async () => {
const commitMessage = uuidv4() const commitMessage = uuidv4()
const result = await createOrUpdateBranch( const result = await createOrUpdateBranch(

View file

@ -13,7 +13,7 @@ git daemon --verbose --enable=receive-pack --base-path=/git/remote --export-all
# Give the daemon time to start # Give the daemon time to start
sleep 2 sleep 2
# Create a local clone and make an initial commit # Create a local clone and make initial commits
mkdir -p /git/local/repos mkdir -p /git/local/repos
git clone git://127.0.0.1/repos/test-base.git /git/local/repos/test-base git clone git://127.0.0.1/repos/test-base.git /git/local/repos/test-base
cd /git/local/repos/test-base cd /git/local/repos/test-base
@ -22,6 +22,10 @@ git config --global user.name "Your Name"
echo "#test-base" > README.md echo "#test-base" > README.md
git add . git add .
git commit -m "initial commit" git commit -m "initial commit"
echo "#test-base :sparkles:" > README.md
git add .
git commit -m "add sparkles" -m "Change description:
- updates README.md to add sparkles to the title"
git push -u git push -u
git log -1 --pretty=oneline git log -1 --pretty=oneline
git config --global --unset user.email git config --global --unset user.email

View file

@ -0,0 +1,26 @@
import {GitCommandManager, Commit} from '../lib/git-command-manager'
const REPO_PATH = '/git/local/repos/test-base'
describe('git-command-manager integration tests', () => {
let git: GitCommandManager
beforeAll(async () => {
git = await GitCommandManager.create(REPO_PATH)
await git.checkout('main')
})
it('tests getCommit', async () => {
const parent = await git.getCommit('HEAD^')
const commit = await git.getCommit('HEAD')
expect(parent.subject).toEqual('initial commit')
expect(parent.changes).toEqual([
{mode: '100644', status: 'A', path: 'README.md'}
])
expect(commit.subject).toEqual('add sparkles')
expect(commit.parents[0]).toEqual(parent.sha)
expect(commit.changes).toEqual([
{mode: '100644', status: 'M', path: 'README.md'}
])
})
})

View file

@ -7,7 +7,6 @@ const extraheaderConfigKey = 'http.https://127.0.0.1/.extraheader'
describe('git-config-helper integration tests', () => { describe('git-config-helper integration tests', () => {
let git: GitCommandManager let git: GitCommandManager
let gitConfigHelper: GitConfigHelper
beforeAll(async () => { beforeAll(async () => {
git = await GitCommandManager.create(REPO_PATH) git = await GitCommandManager.create(REPO_PATH)

View file

@ -51,6 +51,9 @@ inputs:
A fork of the checked out parent repository to which the pull request branch will be pushed. A fork of the checked out parent repository to which the pull request branch will be pushed.
e.g. `owner/repo-fork`. e.g. `owner/repo-fork`.
The pull request will be created to merge the fork's branch into the parent's base. The pull request will be created to merge the fork's branch into the parent's base.
sign-commits:
description: 'Sign commits as `github-actions[bot]` when using `GITHUB_TOKEN`, or your own bot when using GitHub App tokens.'
default: true
title: title:
description: 'The title of the pull request.' description: 'The title of the pull request.'
default: 'Changes by create-pull-request action' default: 'Changes by create-pull-request action'

36309
dist/index.js vendored

File diff suppressed because one or more lines are too long

View file

@ -16,7 +16,9 @@ This document covers terminology, how the action works, general usage guidelines
- [Push using SSH (deploy keys)](#push-using-ssh-deploy-keys) - [Push using SSH (deploy keys)](#push-using-ssh-deploy-keys)
- [Push pull request branches to a fork](#push-pull-request-branches-to-a-fork) - [Push pull request branches to a fork](#push-pull-request-branches-to-a-fork)
- [Authenticating with GitHub App generated tokens](#authenticating-with-github-app-generated-tokens) - [Authenticating with GitHub App generated tokens](#authenticating-with-github-app-generated-tokens)
- [GPG commit signature verification](#gpg-commit-signature-verification) - [Commit signing](#commit-signing)
- [Commit signature verification for bots](#commit-signature-verification-for-bots)
- [GPG commit signature verification](#gpg-commit-signature-verification)
- [Running in a container or on self-hosted runners](#running-in-a-container-or-on-self-hosted-runners) - [Running in a container or on self-hosted runners](#running-in-a-container-or-on-self-hosted-runners)
## Terminology ## Terminology
@ -260,17 +262,17 @@ GitHub App generated tokens are more secure than using a PAT because GitHub App
4. Set secrets on your repository containing the GitHub App ID, and the private key you created in step 2. e.g. `APP_ID`, `APP_PRIVATE_KEY`. 4. Set secrets on your repository containing the GitHub App ID, and the private key you created in step 2. e.g. `APP_ID`, `APP_PRIVATE_KEY`.
5. The following example workflow shows how to use [tibdex/github-app-token](https://github.com/tibdex/github-app-token) to generate a token for use with this action. 5. The following example workflow shows how to use [actions/create-github-app-token](https://github.com/actions/create-github-app-token) to generate a token for use with this action.
```yaml ```yaml
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: tibdex/github-app-token@v1 - uses: actions/create-github-app-token@v1
id: generate-token id: generate-token
with: with:
app_id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Make changes to pull request here # Make changes to pull request here
@ -280,7 +282,54 @@ GitHub App generated tokens are more secure than using a PAT because GitHub App
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
``` ```
### GPG commit signature verification ### Commit signing
[Commit signature verification](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) is a feature where GitHub will mark signed commits as "verified" to give confidence that changes are from a trusted source. Some organizations require commit signing, and enforce it with branch protection rules.
The action supports two methods to sign commits, [commit signature verification for bots](#commit-signature-verification-for-bots), and [GPG commit signature verification](#gpg-commit-signature-verification).
#### Commit signature verification for bots
The action can sign commits as `github-actions[bot]` when using the repository's default `GITHUB_TOKEN`, or your own bot when using [GitHub App tokens](#authenticating-with-github-app-generated-tokens).
> [!IMPORTANT]
> - When setting `sign-commits: true` the action will ignore the `committer` and `author` inputs.
> - If you attempt to use a [Personal Access Token (PAT)](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) the action will create the pull request, but commits will not be signed. Commit signing is only supported with bot generated tokens.
In this example the `token` input is not supplied, so the action will use the repository's default `GITHUB_TOKEN`. This will sign commits as `github-actions[bot]`.
```yaml
steps:
- uses: actions/checkout@v4
# Make changes to pull request here
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
sign-commits: true
```
In this example, the `token` input is generated using a GitHub App. This will sign commits as `<application-name>[bot]`.
```yaml
steps:
- uses: actions/checkout@v4
- uses: actions/create-github-app-token@v1
id: generate-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Make changes to pull request here
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ steps.generate-token.outputs.token }}
sign-commits: true
```
#### GPG commit signature verification
The action can use GPG to sign commits with a GPG key that you generate yourself. The action can use GPG to sign commits with a GPG key that you generate yourself.

3762
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -31,9 +31,11 @@
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1", "@actions/exec": "^1.1.1",
"@octokit/core": "^4.2.4", "@octokit/core": "^6.1.2",
"@octokit/plugin-paginate-rest": "^5.0.1", "@octokit/plugin-paginate-rest": "^11.3.3",
"@octokit/plugin-rest-endpoint-methods": "^6.8.1", "@octokit/plugin-rest-endpoint-methods": "^13.2.4",
"@octokit/plugin-throttling": "^9.3.1",
"p-limit": "^6.1.0",
"proxy-from-env": "^1.1.0", "proxy-from-env": "^1.1.0",
"undici": "^6.19.7", "undici": "^6.19.7",
"uuid": "^9.0.1" "uuid": "^9.0.1"
@ -41,7 +43,8 @@
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^18.19.44", "@types/node": "^18.19.44",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0",
"@vercel/ncc": "^0.38.1", "@vercel/ncc": "^0.38.1",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
@ -55,6 +58,6 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"ts-jest": "^29.2.4", "ts-jest": "^29.2.4",
"typescript": "^4.9.5" "typescript": "^5.5.4"
} }
} }

View file

@ -1,5 +1,5 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {GitCommandManager} from './git-command-manager' import {GitCommandManager, Commit} from './git-command-manager'
import {v4 as uuidv4} from 'uuid' import {v4 as uuidv4} from 'uuid'
const CHERRYPICK_EMPTY = const CHERRYPICK_EMPTY =
@ -47,6 +47,24 @@ export async function tryFetch(
} }
} }
export async function buildBranchCommits(
git: GitCommandManager,
base: string,
branch: string
): Promise<Commit[]> {
const output = await git.exec(['log', '--format=%H', `${base}..${branch}`])
const shas = output.stdout
.split('\n')
.filter(x => x !== '')
.reverse()
const commits: Commit[] = []
for (const sha of shas) {
const commit = await git.getCommit(sha)
commits.push(commit)
}
return commits
}
// Return the number of commits that branch2 is ahead of branch1 // Return the number of commits that branch2 is ahead of branch1
async function commitsAhead( async function commitsAhead(
git: GitCommandManager, git: GitCommandManager,
@ -114,7 +132,9 @@ interface CreateOrUpdateBranchResult {
action: string action: string
base: string base: string
hasDiffWithBase: boolean hasDiffWithBase: boolean
baseSha: string
headSha: string headSha: string
branchCommits: Commit[]
} }
export async function createOrUpdateBranch( export async function createOrUpdateBranch(
@ -124,7 +144,8 @@ export async function createOrUpdateBranch(
branch: string, branch: string,
branchRemoteName: string, branchRemoteName: string,
signoff: boolean, signoff: boolean,
addPaths: string[] addPaths: string[],
signCommits: boolean = false
): Promise<CreateOrUpdateBranchResult> { ): Promise<CreateOrUpdateBranchResult> {
// Get the working base. // Get the working base.
// When a ref, it may or may not be the actual base. // When a ref, it may or may not be the actual base.
@ -144,7 +165,9 @@ export async function createOrUpdateBranch(
action: 'none', action: 'none',
base: base, base: base,
hasDiffWithBase: false, hasDiffWithBase: false,
headSha: '' baseSha: '',
headSha: '',
branchCommits: []
} }
// Save the working base changes to a temporary branch // Save the working base changes to a temporary branch
@ -289,8 +312,15 @@ export async function createOrUpdateBranch(
result.hasDiffWithBase = await isAhead(git, base, branch) result.hasDiffWithBase = await isAhead(git, base, branch)
} }
// Get the pull request branch SHA // Get the base and head SHAs
result.headSha = await git.revParse('HEAD') result.baseSha = await git.revParse(base)
result.headSha = await git.revParse(branch)
// NOTE: This could always be built and returned. Maybe remove when there is confidence in buildBranchCommits.
if (signCommits) {
// Build the branch commits
result.branchCommits = await buildBranchCommits(git, base, branch)
}
// Delete the temporary branch // Delete the temporary branch
await git.exec(['branch', '--delete', '--force', tempBranch]) await git.exec(['branch', '--delete', '--force', tempBranch])

View file

@ -23,6 +23,7 @@ export interface Inputs {
branchSuffix: string branchSuffix: string
base: string base: string
pushToFork: string pushToFork: string
signCommits: boolean
title: string title: string
body: string body: string
bodyPath: string bodyPath: string
@ -183,8 +184,11 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
inputs.branch, inputs.branch,
branchRemoteName, branchRemoteName,
inputs.signoff, inputs.signoff,
inputs.addPaths inputs.addPaths,
inputs.signCommits
) )
// Set the base. It would have been '' if not specified as an input
inputs.base = result.base
core.endGroup() core.endGroup()
if (['created', 'updated'].includes(result.action)) { if (['created', 'updated'].includes(result.action)) {
@ -192,17 +196,31 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
core.startGroup( core.startGroup(
`Pushing pull request branch to '${branchRemoteName}/${inputs.branch}'` `Pushing pull request branch to '${branchRemoteName}/${inputs.branch}'`
) )
await git.push([ if (inputs.signCommits) {
'--force-with-lease', // Create signed commits via the GitHub API
branchRemoteName, const stashed = await git.stashPush(['--include-untracked'])
`${inputs.branch}:refs/heads/${inputs.branch}` await git.checkout(inputs.branch)
]) await githubHelper.pushSignedCommits(
result.branchCommits,
result.baseSha,
repoPath,
branchRepository,
inputs.branch
)
await git.checkout('-')
if (stashed) {
await git.stashPop()
}
} else {
await git.push([
'--force-with-lease',
branchRemoteName,
`${inputs.branch}:refs/heads/${inputs.branch}`
])
}
core.endGroup() core.endGroup()
} }
// Set the base. It would have been '' if not specified as an input
inputs.base = result.base
if (result.hasDiffWithBase) { if (result.hasDiffWithBase) {
// Create or update the pull request // Create or update the pull request
core.startGroup('Create or update the pull request') core.startGroup('Create or update the pull request')

View file

@ -5,6 +5,19 @@ import * as path from 'path'
const tagsRefSpec = '+refs/tags/*:refs/tags/*' const tagsRefSpec = '+refs/tags/*:refs/tags/*'
export type Commit = {
sha: string
tree: string
parents: string[]
subject: string
body: string
changes: {
mode: string
status: 'A' | 'M' | 'D'
path: string
}[]
}
export class GitCommandManager { export class GitCommandManager {
private gitPath: string private gitPath: string
private workingDirectory: string private workingDirectory: string
@ -138,6 +151,43 @@ export class GitCommandManager {
await this.exec(args) await this.exec(args)
} }
async getCommit(ref: string): Promise<Commit> {
const endOfBody = '###EOB###'
const output = await this.exec([
'show',
'--raw',
'--cc',
'--diff-filter=AMD',
`--format=%H%n%T%n%P%n%s%n%b%n${endOfBody}`,
ref
])
const lines = output.stdout.split('\n')
const endOfBodyIndex = lines.lastIndexOf(endOfBody)
const detailLines = lines.slice(0, endOfBodyIndex)
return <Commit>{
sha: detailLines[0],
tree: detailLines[1],
parents: detailLines[2].split(' '),
subject: detailLines[3],
body: detailLines.slice(4, endOfBodyIndex).join('\n'),
changes: lines.slice(endOfBodyIndex + 2, -1).map(line => {
const change = line.match(
/^:(\d{6}) (\d{6}) \w{7} \w{7} ([AMD])\s+(.*)$/
)
if (change) {
return {
mode: change[3] === 'D' ? change[1] : change[2],
status: change[3],
path: change[4]
}
} else {
throw new Error(`Unexpected line format: ${line}`)
}
})
}
}
async getConfigValue(configKey: string, configValue = '.'): Promise<string> { async getConfigValue(configKey: string, configValue = '.'): Promise<string> {
const output = await this.exec([ const output = await this.exec([
'config', 'config',

View file

@ -1,11 +1,15 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {Inputs} from './create-pull-request' import {Inputs} from './create-pull-request'
import {Octokit, OctokitOptions} from './octokit-client' import {Commit} from './git-command-manager'
import {Octokit, OctokitOptions, throttleOptions} from './octokit-client'
import pLimit from 'p-limit'
import * as utils from './utils' import * as utils from './utils'
const ERROR_PR_REVIEW_TOKEN_SCOPE = const ERROR_PR_REVIEW_TOKEN_SCOPE =
'Validation Failed: "Could not resolve to a node with the global id of' 'Validation Failed: "Could not resolve to a node with the global id of'
const blobCreationLimit = pLimit(8)
interface Repository { interface Repository {
owner: string owner: string
repo: string repo: string
@ -17,6 +21,13 @@ interface Pull {
created: boolean created: boolean
} }
type TreeObject = {
path: string
mode: '100644' | '100755' | '040000' | '160000' | '120000'
sha: string | null
type: 'blob'
}
export class GitHubHelper { export class GitHubHelper {
private octokit: InstanceType<typeof Octokit> private octokit: InstanceType<typeof Octokit>
@ -30,6 +41,7 @@ export class GitHubHelper {
} else { } else {
options.baseUrl = 'https://api.github.com' options.baseUrl = 'https://api.github.com'
} }
options.throttle = throttleOptions
this.octokit = new Octokit(options) this.octokit = new Octokit(options)
} }
@ -184,4 +196,114 @@ export class GitHubHelper {
return pull return pull
} }
async pushSignedCommits(
branchCommits: Commit[],
baseSha: string,
repoPath: string,
branchRepository: string,
branch: string
): Promise<void> {
let headSha = baseSha
for (const commit of branchCommits) {
headSha = await this.createCommit(
commit,
[headSha],
repoPath,
branchRepository
)
}
await this.createOrUpdateRef(branchRepository, branch, headSha)
}
private async createCommit(
commit: Commit,
parents: string[],
repoPath: string,
branchRepository: string
): Promise<string> {
const repository = this.parseRepository(branchRepository)
let treeSha = commit.tree
if (commit.changes.length > 0) {
core.info(`Creating tree objects for local commit ${commit.sha}`)
const treeObjects = await Promise.all(
commit.changes.map(async ({path, mode, status}) => {
let sha: string | null = null
if (status === 'A' || status === 'M') {
core.info(`Creating blob for file '${path}'`)
const {data: blob} = await blobCreationLimit(() =>
this.octokit.rest.git.createBlob({
...repository,
content: utils.readFileBase64([repoPath, path]),
encoding: 'base64'
})
)
sha = blob.sha
}
return <TreeObject>{
path,
mode,
sha,
type: 'blob'
}
})
)
core.info(`Creating tree for local commit ${commit.sha}`)
const {data: tree} = await this.octokit.rest.git.createTree({
...repository,
base_tree: parents[0],
tree: treeObjects
})
treeSha = tree.sha
core.info(`Created tree ${treeSha} for local commit ${commit.sha}`)
}
const {data: remoteCommit} = await this.octokit.rest.git.createCommit({
...repository,
parents: parents,
tree: treeSha,
message: `${commit.subject}\n\n${commit.body}`
})
core.info(
`Created commit ${remoteCommit.sha} for local commit ${commit.sha}`
)
core.info(
`Commit verified: ${remoteCommit.verification.verified}; reason: ${remoteCommit.verification.reason}`
)
return remoteCommit.sha
}
private async createOrUpdateRef(
branchRepository: string,
branch: string,
newHead: string
) {
const repository = this.parseRepository(branchRepository)
const branchExists = await this.octokit.rest.repos
.getBranch({
...repository,
branch: branch
})
.then(
() => true,
() => false
)
if (branchExists) {
core.info(`Branch ${branch} exists; Updating ref`)
await this.octokit.rest.git.updateRef({
...repository,
sha: newHead,
ref: `heads/${branch}`,
force: true
})
} else {
core.info(`Branch ${branch} does not exist; Creating ref`)
await this.octokit.rest.git.createRef({
...repository,
sha: newHead,
ref: `refs/heads/${branch}`
})
}
}
} }

View file

@ -19,6 +19,7 @@ async function run(): Promise<void> {
branchSuffix: core.getInput('branch-suffix'), branchSuffix: core.getInput('branch-suffix'),
base: core.getInput('base'), base: core.getInput('base'),
pushToFork: core.getInput('push-to-fork'), pushToFork: core.getInput('push-to-fork'),
signCommits: core.getBooleanInput('sign-commits'),
title: core.getInput('title'), title: core.getInput('title'),
body: core.getInput('body'), body: core.getInput('body'),
bodyPath: core.getInput('body-path'), bodyPath: core.getInput('body-path'),

View file

@ -1,17 +1,37 @@
import {Octokit as Core} from '@octokit/core' import * as core from '@actions/core'
import {Octokit as OctokitCore} from '@octokit/core'
import {paginateRest} from '@octokit/plugin-paginate-rest' import {paginateRest} from '@octokit/plugin-paginate-rest'
import {restEndpointMethods} from '@octokit/plugin-rest-endpoint-methods' import {restEndpointMethods} from '@octokit/plugin-rest-endpoint-methods'
import {throttling} from '@octokit/plugin-throttling'
import {getProxyForUrl} from 'proxy-from-env' import {getProxyForUrl} from 'proxy-from-env'
import {ProxyAgent, fetch as undiciFetch} from 'undici' import {ProxyAgent, fetch as undiciFetch} from 'undici'
export {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods' export {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods'
// eslint-disable-next-line import/no-unresolved
export {OctokitOptions} from '@octokit/core/dist-types/types' export {OctokitOptions} from '@octokit/core/dist-types/types'
export const Octokit = Core.plugin( export const Octokit = OctokitCore.plugin(
paginateRest, paginateRest,
restEndpointMethods, restEndpointMethods,
throttling,
autoProxyAgent autoProxyAgent
) )
export const throttleOptions = {
onRateLimit: (retryAfter, options, _, retryCount) => {
core.debug(`Hit rate limit for request ${options.method} ${options.url}`)
// Retries twice for a total of three attempts
if (retryCount < 2) {
core.debug(`Retrying after ${retryAfter} seconds!`)
return true
}
},
onSecondaryRateLimit: (_, options) => {
core.warning(
`Hit secondary rate limit for request ${options.method} ${options.url}`
)
}
}
const proxyFetch = const proxyFetch =
(proxyUrl: string): typeof undiciFetch => (proxyUrl: string): typeof undiciFetch =>
(url, opts) => { (url, opts) => {
@ -24,7 +44,7 @@ const proxyFetch =
} }
// Octokit plugin to support the standard environment variables http_proxy, https_proxy and no_proxy // Octokit plugin to support the standard environment variables http_proxy, https_proxy and no_proxy
function autoProxyAgent(octokit: Core) { function autoProxyAgent(octokit: OctokitCore) {
octokit.hook.before('request', options => { octokit.hook.before('request', options => {
const proxy = getProxyForUrl(options.baseUrl) const proxy = getProxyForUrl(options.baseUrl)
if (proxy) { if (proxy) {

View file

@ -126,6 +126,10 @@ export function readFile(path: string): string {
return fs.readFileSync(path, 'utf-8') return fs.readFileSync(path, 'utf-8')
} }
export function readFileBase64(pathParts: string[]): string {
return fs.readFileSync(path.resolve(...pathParts)).toString('base64')
}
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
function hasErrorCode(error: any): error is {code: string} { function hasErrorCode(error: any): error is {code: string} {
return typeof (error && error.code) === 'string' return typeof (error && error.code) === 'string'