Compare commits

...

45 commits

Author SHA1 Message Date
Peter Evans
740d9850a7 add maintainer-can-modify input 2024-08-16 15:44:43 +01:00
Peter Evans
e7f5ea9fd9 use separate client for branch and pull operations 2024-08-16 12:26:38 +00:00
Peter Evans
66ddf90dac output retryafter for secondary rate limit 2024-08-15 15:24:58 +00:00
Peter Evans
fd3e742ffd default the operation output to none 2024-08-15 13:37:41 +00:00
Peter Evans
eea4f44785 fix head sha output 2024-08-15 10:16:22 +00:00
Peter Evans
5a9be5875b log outputs 2024-08-14 21:55:31 +00:00
Peter Evans
3d665e5aea output head sha and verified status 2024-08-14 21:50:59 +00:00
Peter Evans
bb1f2b1327 set default back to false 2024-08-14 21:06:45 +00:00
Peter Evans
1da4bbe67c add throttling 2024-08-14 21:10:21 +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 32174 additions and 8524 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`. | |
@ -74,6 +75,7 @@ All inputs are **optional**. If not set, sensible defaults will be used.
| `team-reviewers` | A comma or newline-separated list of GitHub teams to request a review from. Note that a `repo` scoped [PAT](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token), or equivalent [GitHub App permissions](docs/concepts-guidelines.md#authenticating-with-github-app-generated-tokens), are required. | | | `team-reviewers` | A comma or newline-separated list of GitHub teams to request a review from. Note that a `repo` scoped [PAT](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token), or equivalent [GitHub App permissions](docs/concepts-guidelines.md#authenticating-with-github-app-generated-tokens), are required. | |
| `milestone` | The number of the milestone to associate this pull request with. | | | `milestone` | The number of the milestone to associate this pull request with. | |
| `draft` | Create a [draft pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests#draft-pull-requests). It is not possible to change draft status after creation except through the web interface. | `false` | | `draft` | Create a [draft pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests#draft-pull-requests). It is not possible to change draft status after creation except through the web interface. | `false` |
| `maintainer-can-modify` | Indicates whether [maintainers can modify](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) the pull request. | `true` |
#### commit-message #### commit-message
@ -115,9 +117,10 @@ The following outputs can be used by subsequent workflow steps.
- `pull-request-number` - The pull request number. - `pull-request-number` - The pull request number.
- `pull-request-url` - The URL of the pull request. - `pull-request-url` - The URL of the pull request.
- `pull-request-operation` - The pull request operation performed by the action, `created`, `updated` or `closed`. - `pull-request-operation` - The pull request operation performed by the action, `created`, `updated`, `closed` or `none`.
- `pull-request-head-sha` - The commit SHA of the pull request branch. - `pull-request-head-sha` - The commit SHA of the pull request branch.
- `pull-request-branch` - The branch name of the pull request. - `pull-request-branch` - The branch name of the pull request.
- `pull-request-commits-verified` - Whether GitHub considers the signature of the branch's commits to be verified; `true` or `false`.
Step outputs can be accessed as in the following example. Step outputs can be accessed as in the following example.
Note that in order to read the step outputs the action step must have an id. Note that in order to read the step outputs the action step must have an id.

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: false
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'
@ -74,6 +77,9 @@ inputs:
draft: draft:
description: 'Create a draft pull request. It is not possible to change draft status after creation except through the web interface' description: 'Create a draft pull request. It is not possible to change draft status after creation except through the web interface'
default: false default: false
maintainer-can-modify:
description: 'Indicates whether maintainers can modify the pull request.'
default: true
outputs: outputs:
pull-request-number: pull-request-number:
description: 'The pull request number' description: 'The pull request number'

36377
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
@ -32,6 +33,7 @@ export interface Inputs {
teamReviewers: string[] teamReviewers: string[]
milestone: number milestone: number
draft: boolean draft: boolean
maintainerCanModify: boolean
} }
export async function createPullRequest(inputs: Inputs): Promise<void> { export async function createPullRequest(inputs: Inputs): Promise<void> {
@ -45,8 +47,9 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
core.startGroup('Determining the base and head repositories') core.startGroup('Determining the base and head repositories')
const baseRemote = gitConfigHelper.getGitRemote() const baseRemote = gitConfigHelper.getGitRemote()
// Init the GitHub client // Init the GitHub clients
const githubHelper = new GitHubHelper(baseRemote.hostname, inputs.token) const ghBranch = new GitHubHelper(baseRemote.hostname, inputs.gitToken)
const ghPull = new GitHubHelper(baseRemote.hostname, inputs.token)
// Determine the head repository; the target for the pull request branch // Determine the head repository; the target for the pull request branch
const branchRemoteName = inputs.pushToFork ? 'fork' : 'origin' const branchRemoteName = inputs.pushToFork ? 'fork' : 'origin'
const branchRepository = inputs.pushToFork const branchRepository = inputs.pushToFork
@ -57,11 +60,11 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
core.info( core.info(
`Checking if '${branchRepository}' is a fork of '${baseRemote.repository}'` `Checking if '${branchRepository}' is a fork of '${baseRemote.repository}'`
) )
const baseParentRepository = await githubHelper.getRepositoryParent( const baseParentRepository = await ghBranch.getRepositoryParent(
baseRemote.repository baseRemote.repository
) )
const branchParentRepository = const branchParentRepository =
await githubHelper.getRepositoryParent(branchRepository) await ghBranch.getRepositoryParent(branchRepository)
if (branchParentRepository == null) { if (branchParentRepository == null) {
throw new Error( throw new Error(
`Repository '${branchRepository}' is not a fork. Unable to continue.` `Repository '${branchRepository}' is not a fork. Unable to continue.`
@ -174,6 +177,12 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
) )
core.endGroup() core.endGroup()
// Action outputs
const outputs = new Map<string, string>()
outputs.set('pull-request-branch', inputs.branch)
outputs.set('pull-request-operation', 'none')
outputs.set('pull-request-commits-verified', 'false')
// Create or update the pull request branch // Create or update the pull request branch
core.startGroup('Create or update the pull request branch') core.startGroup('Create or update the pull request branch')
const result = await createOrUpdateBranch( const result = await createOrUpdateBranch(
@ -183,8 +192,12 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
inputs.branch, inputs.branch,
branchRemoteName, branchRemoteName,
inputs.signoff, inputs.signoff,
inputs.addPaths inputs.addPaths,
inputs.signCommits
) )
outputs.set('pull-request-head-sha', result.headSha)
// 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,38 +205,50 @@ 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)
]) const pushSignedCommitsResult = await ghBranch.pushSignedCommits(
result.branchCommits,
result.baseSha,
repoPath,
branchRepository,
inputs.branch
)
outputs.set('pull-request-head-sha', pushSignedCommitsResult.sha)
outputs.set(
'pull-request-commits-verified',
pushSignedCommitsResult.verified.toString()
)
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
core.startGroup('Create or update the pull request') core.startGroup('Create or update the pull request')
const pull = await githubHelper.createOrUpdatePullRequest( const pull = await ghPull.createOrUpdatePullRequest(
inputs, inputs,
baseRemote.repository, baseRemote.repository,
branchRepository branchRepository
) )
core.endGroup() outputs.set('pull-request-number', pull.number.toString())
outputs.set('pull-request-url', pull.html_url)
// Set outputs
core.startGroup('Setting outputs')
core.setOutput('pull-request-number', pull.number)
core.setOutput('pull-request-url', pull.html_url)
if (pull.created) { if (pull.created) {
core.setOutput('pull-request-operation', 'created') outputs.set('pull-request-operation', 'created')
} else if (result.action == 'updated') { } else if (result.action == 'updated') {
core.setOutput('pull-request-operation', 'updated') outputs.set('pull-request-operation', 'updated')
} }
core.setOutput('pull-request-head-sha', result.headSha)
core.setOutput('pull-request-branch', inputs.branch)
// Deprecated // Deprecated
core.exportVariable('PULL_REQUEST_NUMBER', pull.number) core.exportVariable('PULL_REQUEST_NUMBER', pull.number)
core.endGroup() core.endGroup()
@ -242,13 +267,18 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
branchRemoteName, branchRemoteName,
`refs/heads/${inputs.branch}` `refs/heads/${inputs.branch}`
]) ])
// Set outputs outputs.set('pull-request-operation', 'closed')
core.startGroup('Setting outputs')
core.setOutput('pull-request-operation', 'closed')
core.endGroup()
} }
} }
} }
// Set outputs
core.startGroup('Setting outputs')
for (const [key, value] of outputs) {
core.info(`${key} = ${value}`)
core.setOutput(key, value)
}
core.endGroup()
} catch (error) { } catch (error) {
core.setFailed(utils.getErrorMessage(error)) core.setFailed(utils.getErrorMessage(error))
} finally { } finally {

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,10 +1,16 @@
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_ALREADY_EXISTS = 'A pull request already exists for'
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 ERROR_PR_FORK_COLLAB = `Fork collab can't be granted by someone without permission`
const blobCreationLimit = pLimit(8)
interface Repository { interface Repository {
owner: string owner: string
@ -17,6 +23,18 @@ interface Pull {
created: boolean created: boolean
} }
interface CommitResponse {
sha: string
verified: 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 +48,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)
} }
@ -59,7 +78,8 @@ export class GitHubHelper {
head_repo: headRepository, head_repo: headRepository,
base: inputs.base, base: inputs.base,
body: inputs.body, body: inputs.body,
draft: inputs.draft draft: inputs.draft,
maintainer_can_modify: inputs.maintainerCanModify
}) })
core.info( core.info(
`Created pull request #${pull.number} (${headBranch} => ${inputs.base})` `Created pull request #${pull.number} (${headBranch} => ${inputs.base})`
@ -70,10 +90,17 @@ export class GitHubHelper {
created: true created: true
} }
} catch (e) { } catch (e) {
if ( const errorMessage = utils.getErrorMessage(e)
utils.getErrorMessage(e).includes(`A pull request already exists for`) if (errorMessage.includes(ERROR_PR_ALREADY_EXISTS)) {
) {
core.info(`A pull request already exists for ${headBranch}`) core.info(`A pull request already exists for ${headBranch}`)
} else if (errorMessage.includes(ERROR_PR_FORK_COLLAB)) {
core.warning(
'An attempt was made to create a pull request using a token that does not have write access to the head branch.'
)
core.warning(
`For this case, set input 'maintainer-can-modify' to 'false' to allow pull request creation.`
)
throw e
} else { } else {
throw e throw e
} }
@ -184,4 +211,121 @@ export class GitHubHelper {
return pull return pull
} }
async pushSignedCommits(
branchCommits: Commit[],
baseSha: string,
repoPath: string,
branchRepository: string,
branch: string
): Promise<CommitResponse> {
let headCommit: CommitResponse = {
sha: baseSha,
verified: false
}
for (const commit of branchCommits) {
headCommit = await this.createCommit(
commit,
[headCommit.sha],
repoPath,
branchRepository
)
}
await this.createOrUpdateRef(branchRepository, branch, headCommit.sha)
return headCommit
}
private async createCommit(
commit: Commit,
parents: string[],
repoPath: string,
branchRepository: string
): Promise<CommitResponse> {
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 {
sha: remoteCommit.sha,
verified: remoteCommit.verification.verified
}
}
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'),
@ -27,7 +28,8 @@ async function run(): Promise<void> {
reviewers: utils.getInputAsArray('reviewers'), reviewers: utils.getInputAsArray('reviewers'),
teamReviewers: utils.getInputAsArray('team-reviewers'), teamReviewers: utils.getInputAsArray('team-reviewers'),
milestone: Number(core.getInput('milestone')), milestone: Number(core.getInput('milestone')),
draft: core.getBooleanInput('draft') draft: core.getBooleanInput('draft'),
maintainerCanModify: core.getBooleanInput('maintainer-can-modify')
} }
core.debug(`Inputs: ${inspect(inputs)}`) core.debug(`Inputs: ${inspect(inputs)}`)

View file

@ -1,17 +1,38 @@
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: (retryAfter, options) => {
core.warning(
`Hit secondary rate limit for request ${options.method} ${options.url}`
)
core.warning(`Requests may be retried after ${retryAfter} seconds.`)
}
}
const proxyFetch = const proxyFetch =
(proxyUrl: string): typeof undiciFetch => (proxyUrl: string): typeof undiciFetch =>
(url, opts) => { (url, opts) => {
@ -24,7 +45,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'