Compare commits

...

18 commits

Author SHA1 Message Date
Peter Evans
714e9be377 handle updates by updating the branch ref 2024-08-05 15:13:34 +00:00
Peter Evans
35dbaca7d5 build file changes even when there is no diff 2024-08-02 16:38:58 +00:00
Peter Evans
8101ce8ff4 refactor graphql code into github helper class 2024-08-02 16:24:55 +00:00
Peter Evans
a636b8113c add build file changes test for binary files 2024-08-02 16:00:46 +00:00
Peter Evans
0db6c3cf7b add tests for building file changes 2024-07-31 21:28:37 +00:00
Peter Evans
36e042a736 Try refactor of file changes 2024-07-31 17:54:05 +01:00
Peter Evans
6284ea5854 remove commented code 2024-07-31 17:54:04 +01:00
Peter Evans
03266d3789 try to fix head repo 2024-07-31 17:54:04 +01:00
Peter Evans
404696dda5 fix filepath when using path input 2024-07-31 17:54:04 +01:00
Peter Evans
0e209053e0 disable linter for debug code 2024-07-31 17:54:04 +01:00
Peter Evans
548d90536f debug payload without contents 2024-07-31 17:54:04 +01:00
Peter Evans
3e7e19f0eb read to buffer not string and use non-legacy method to base64 2024-07-31 17:54:04 +01:00
Peter Evans
7c0b09154e add debug lines 2024-07-31 17:54:04 +01:00
Peter Evans
9a6173b25c sign commits by default for testing 2024-07-31 17:54:04 +01:00
Peter Evans
13c3ab4d5e shift setting the base to before the push 2024-07-31 17:54:03 +01:00
Peter Evans
081c241f6e fix eslint and lint errors 2024-07-31 17:54:03 +01:00
Peter Evans
5bb83f1307 formatting 2024-07-31 17:53:18 +01:00
Ravi
70815fee7e Add support for signed commits (#3055) 2024-07-31 17:53:18 +01:00
12 changed files with 27053 additions and 2311 deletions

View file

@ -74,6 +74,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` |
| `sign-commit` | Sign the commit as bot [refer: [Signature verification for bots](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#signature-verification-for-bots)]. This can be useful if your repo or org has enforced commit-signing. | `false` |
#### commit-message #### commit-message

View file

@ -1,7 +1,8 @@
import { import {
createOrUpdateBranch, createOrUpdateBranch,
tryFetch, tryFetch,
getWorkingBaseAndType getWorkingBaseAndType,
buildBranchFileChanges
} 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,80 @@ describe('create-or-update-branch tests', () => {
expect(workingBaseType).toEqual('commit') expect(workingBaseType).toEqual('commit')
}) })
it('tests buildBranchFileChanges with no diff', async () => {
await git.checkout(BRANCH, BASE)
const branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
expect(branchFileChanges.additions.length).toEqual(0)
expect(branchFileChanges.deletions.length).toEqual(0)
})
it('tests buildBranchFileChanges with addition and modification', async () => {
await git.checkout(BRANCH, BASE)
const changes = await createChanges()
await git.exec(['add', '-A'])
await git.commit(['-m', 'Test changes'])
const branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
expect(branchFileChanges.additions).toEqual([
{
path: TRACKED_FILE,
contents: Buffer.from(changes.tracked, 'binary').toString('base64')
},
{
path: UNTRACKED_FILE,
contents: Buffer.from(changes.untracked, 'binary').toString('base64')
}
])
expect(branchFileChanges.deletions.length).toEqual(0)
})
it('tests buildBranchFileChanges with addition and deletion', async () => {
await git.checkout(BRANCH, BASE)
const changes = 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 branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
expect(branchFileChanges.additions).toEqual([
{
path: UNTRACKED_FILE,
contents: Buffer.from(changes.untracked, 'binary').toString('base64')
},
{
path: TRACKED_FILE_NEW_PATH,
contents: Buffer.from(changes.tracked, 'binary').toString('base64')
}
])
expect(branchFileChanges.deletions).toEqual([{path: TRACKED_FILE}])
})
it('tests buildBranchFileChanges with binary files', async () => {
await git.checkout(BRANCH, BASE)
const filename = 'c/untracked-binary-file'
const filepath = path.join(REPO_PATH, filename)
const binaryData = Buffer.from([0x00, 0xff, 0x10, 0x20])
await fs.promises.mkdir(path.dirname(filepath), {recursive: true})
await fs.promises.writeFile(filepath, binaryData)
await git.exec(['add', '-A'])
await git.commit(['-m', 'Test changes'])
const branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
expect(branchFileChanges.additions).toEqual([
{
path: filename,
contents: binaryData.toString('base64')
}
])
expect(branchFileChanges.deletions.length).toEqual(0)
})
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

@ -74,6 +74,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
sign-commit:
description: 'Sign the commit as github-actions bot (and as custom app if a different github-token is provided)'
default: true
outputs: outputs:
pull-request-number: pull-request-number:
description: 'The pull request number' description: 'The pull request number'

25379
dist/index.js vendored

File diff suppressed because one or more lines are too long

3601
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,8 @@
"@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": "^4.2.4",
"@octokit/graphql": "^8.1.1",
"@octokit/graphql-schema": "^15.25.0",
"@octokit/plugin-paginate-rest": "^5.0.1", "@octokit/plugin-paginate-rest": "^5.0.1",
"@octokit/plugin-rest-endpoint-methods": "^6.8.1", "@octokit/plugin-rest-endpoint-methods": "^6.8.1",
"proxy-from-env": "^1.1.0", "proxy-from-env": "^1.1.0",
@ -41,7 +43,8 @@
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^18.19.42", "@types/node": "^18.19.42",
"@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.3", "ts-jest": "^29.2.3",
"typescript": "^4.9.5" "typescript": "^5.5.4"
} }
} }

View file

@ -1,6 +1,7 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {GitCommandManager} from './git-command-manager' import {GitCommandManager} from './git-command-manager'
import {v4 as uuidv4} from 'uuid' import {v4 as uuidv4} from 'uuid'
import * as utils from './utils'
const CHERRYPICK_EMPTY = const CHERRYPICK_EMPTY =
'The previous cherry-pick is now empty, possibly due to conflict resolution.' 'The previous cherry-pick is now empty, possibly due to conflict resolution.'
@ -47,6 +48,38 @@ export async function tryFetch(
} }
} }
export async function buildBranchFileChanges(
git: GitCommandManager,
base: string,
branch: string
): Promise<BranchFileChanges> {
const branchFileChanges: BranchFileChanges = {
additions: [],
deletions: []
}
const changedFiles = await git.getChangedFiles([
'--diff-filter=AM',
`${base}..${branch}`
])
const deletedFiles = await git.getChangedFiles([
'--diff-filter=D',
`${base}..${branch}`
])
const repoPath = git.getWorkingDirectory()
for (const file of changedFiles) {
branchFileChanges.additions!.push({
path: file,
contents: utils.readFileBase64([repoPath, file])
})
}
for (const file of deletedFiles) {
branchFileChanges.deletions!.push({
path: file
})
}
return branchFileChanges
}
// 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,
@ -110,11 +143,22 @@ function splitLines(multilineString: string): string[] {
.filter(x => x !== '') .filter(x => x !== '')
} }
export interface BranchFileChanges {
additions: {
path: string
contents: string
}[]
deletions: {
path: string
}[]
}
interface CreateOrUpdateBranchResult { interface CreateOrUpdateBranchResult {
action: string action: string
base: string base: string
hasDiffWithBase: boolean hasDiffWithBase: boolean
headSha: string headSha: string
branchFileChanges?: BranchFileChanges
} }
export async function createOrUpdateBranch( export async function createOrUpdateBranch(
@ -289,6 +333,9 @@ export async function createOrUpdateBranch(
result.hasDiffWithBase = await isAhead(git, base, branch) result.hasDiffWithBase = await isAhead(git, base, branch)
} }
// Build the branch file changes
result.branchFileChanges = await buildBranchFileChanges(git, base, branch)
// Get the pull request branch SHA // Get the pull request branch SHA
result.headSha = await git.revParse('HEAD') result.headSha = await git.revParse('HEAD')

View file

@ -32,6 +32,7 @@ export interface Inputs {
teamReviewers: string[] teamReviewers: string[]
milestone: number milestone: number
draft: boolean draft: boolean
signCommit: boolean
} }
export async function createPullRequest(inputs: Inputs): Promise<void> { export async function createPullRequest(inputs: Inputs): Promise<void> {
@ -185,6 +186,8 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
inputs.signoff, inputs.signoff,
inputs.addPaths inputs.addPaths
) )
// 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 +195,24 @@ 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.signCommit) {
'--force-with-lease', await githubHelper.pushSignedCommit(
branchRemoteName, branchRepository,
`${inputs.branch}:refs/heads/${inputs.branch}` inputs.branch,
]) inputs.base,
inputs.commitMessage,
result.branchFileChanges
)
} 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

@ -166,6 +166,15 @@ export class GitCommandManager {
return output.exitCode === 1 return output.exitCode === 1
} }
async getChangedFiles(options?: string[]): Promise<string[]> {
const args = ['diff', '--name-only']
if (options) {
args.push(...options)
}
const output = await this.exec(args)
return output.stdout.split('\n').filter(filename => filename != '')
}
async isDirty(untracked: boolean, pathspec?: string[]): Promise<boolean> { async isDirty(untracked: boolean, pathspec?: string[]): Promise<boolean> {
const pathspecArgs = pathspec ? ['--', ...pathspec] : [] const pathspecArgs = pathspec ? ['--', ...pathspec] : []
// Check untracked changes // Check untracked changes

View file

@ -1,6 +1,13 @@
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 {Octokit, OctokitOptions} from './octokit-client'
import type {
Repository as TempRepository,
Ref,
Commit,
FileChanges
} from '@octokit/graphql-schema'
import {BranchFileChanges} from './create-or-update-branch'
import * as utils from './utils' import * as utils from './utils'
const ERROR_PR_REVIEW_TOKEN_SCOPE = const ERROR_PR_REVIEW_TOKEN_SCOPE =
@ -184,4 +191,204 @@ export class GitHubHelper {
return pull return pull
} }
async pushSignedCommit(
branchRepository: string,
branch: string,
base: string,
commitMessage: string,
branchFileChanges?: BranchFileChanges
): Promise<void> {
core.info(`Use API to push a signed commit`)
const [repoOwner, repoName] = branchRepository.split('/')
core.debug(`repoOwner: '${repoOwner}', repoName: '${repoName}'`)
const refQuery = `
query GetRefId($repoName: String!, $repoOwner: String!, $branchName: String!) {
repository(owner: $repoOwner, name: $repoName){
id
ref(qualifiedName: $branchName){
id
name
prefix
target{
id
oid
commitUrl
commitResourcePath
abbreviatedOid
}
}
},
}
`
let branchRef = await this.octokit.graphql<{repository: TempRepository}>(
refQuery,
{
repoOwner: repoOwner,
repoName: repoName,
branchName: branch
}
)
core.debug(
`Fetched information for branch '${branch}' - '${JSON.stringify(branchRef)}'`
)
const branchExists = branchRef.repository.ref != null
// if the branch does not exist, then first we need to create the branch from base
if (!branchExists) {
core.debug(`Branch does not exist - '${branch}'`)
branchRef = await this.octokit.graphql<{repository: TempRepository}>(
refQuery,
{
repoOwner: repoOwner,
repoName: repoName,
branchName: base
}
)
core.debug(
`Fetched information for base branch '${base}' - '${JSON.stringify(branchRef)}'`
)
core.info(
`Creating new branch '${branch}' from '${base}', with ref '${JSON.stringify(branchRef.repository.ref!.target!.oid)}'`
)
if (branchRef.repository.ref != null) {
core.debug(`Send request for creating new branch`)
const newBranchMutation = `
mutation CreateNewBranch($branchName: String!, $oid: GitObjectID!, $repoId: ID!) {
createRef(input: {
name: $branchName,
oid: $oid,
repositoryId: $repoId
}) {
ref {
id
name
prefix
}
}
}
`
const newBranch = await this.octokit.graphql<{createRef: {ref: Ref}}>(
newBranchMutation,
{
repoId: branchRef.repository.id,
oid: branchRef.repository.ref.target!.oid,
branchName: 'refs/heads/' + branch
}
)
core.debug(
`Created new branch '${branch}': '${JSON.stringify(newBranch.createRef.ref)}'`
)
}
}
core.info(
`Hash ref of branch '${branch}' is '${JSON.stringify(branchRef.repository.ref!.target!.oid)}'`
)
const fileChanges = <FileChanges>{
additions: branchFileChanges!.additions,
deletions: branchFileChanges!.deletions
}
const pushCommitMutation = `
mutation PushCommit(
$repoNameWithOwner: String!,
$branchName: String!,
$headOid: GitObjectID!,
$commitMessage: String!,
$fileChanges: FileChanges
) {
createCommitOnBranch(input: {
branch: {
repositoryNameWithOwner: $repoNameWithOwner,
branchName: $branchName,
}
fileChanges: $fileChanges
message: {
headline: $commitMessage
}
expectedHeadOid: $headOid
}){
clientMutationId
ref{
id
name
prefix
}
commit{
id
abbreviatedOid
oid
}
}
}
`
const pushCommitVars = {
branchName: branch,
repoNameWithOwner: repoOwner + '/' + repoName,
headOid: branchRef.repository.ref!.target!.oid,
commitMessage: commitMessage,
fileChanges: fileChanges
}
const pushCommitVarsWithoutContents = {
...pushCommitVars,
fileChanges: {
...pushCommitVars.fileChanges,
additions: pushCommitVars.fileChanges.additions?.map(addition => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {contents, ...rest} = addition
return rest
})
}
}
core.debug(
`Push commit with payload: '${JSON.stringify(pushCommitVarsWithoutContents)}'`
)
const commit = await this.octokit.graphql<{
createCommitOnBranch: {ref: Ref; commit: Commit}
}>(pushCommitMutation, pushCommitVars)
core.debug(`Pushed commit - '${JSON.stringify(commit)}'`)
core.info(
`Pushed commit with hash - '${commit.createCommitOnBranch.commit.oid}' on branch - '${commit.createCommitOnBranch.ref.name}'`
)
if (branchExists) {
// The branch existed so update the branch ref to point to the new commit
// This is the same behavior as force pushing the branch
core.info(
`Updating branch '${branch}' to commit '${commit.createCommitOnBranch.commit.oid}'`
)
const updateBranchMutation = `
mutation UpdateBranch($branchId: ID!, $commitOid: GitObjectID!) {
updateRef(input: {
refId: $branchId,
oid: $commitOid,
force: true
}) {
ref {
id
name
prefix
}
}
}
`
const updatedBranch = await this.octokit.graphql<{updateRef: {ref: Ref}}>(
updateBranchMutation,
{
branchId: branchRef.repository.ref!.id,
commitOid: commit.createCommitOnBranch.commit.oid
}
)
core.debug(`Updated branch - '${JSON.stringify(updatedBranch)}'`)
}
}
} }

View file

@ -27,7 +27,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'),
signCommit: core.getBooleanInput('sign-commit')
} }
core.debug(`Inputs: ${inspect(inputs)}`) core.debug(`Inputs: ${inspect(inputs)}`)

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'