From da5210572d42deb9d255fde80db17c975089fb1d Mon Sep 17 00:00:00 2001 From: Chris Riccomini Date: Fri, 13 Dec 2024 16:35:32 -0800 Subject: [PATCH 1/3] Add 'ssh-key' as an alternative to 'github-token' --- action-types.yml | 2 + action.yml | 5 +- src/git.ts | 88 +++++++++++++++++++++++++++-------- test/git.spec.ts | 119 ++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 188 insertions(+), 26 deletions(-) diff --git a/action-types.yml b/action-types.yml index e199a6627..a4ed9e1a1 100644 --- a/action-types.yml +++ b/action-types.yml @@ -26,6 +26,8 @@ inputs: type: string github-token: type: string + ssh-key: + type: string ref: type: string auto-push: diff --git a/action.yml b/action.yml index daa6cd343..1b11d5e25 100644 --- a/action.yml +++ b/action.yml @@ -28,7 +28,10 @@ inputs: required: true default: 'dev/bench' github-token: - description: 'GitHub API token to pull/push GitHub pages branch and deploy GitHub pages. For public repository, this must be personal access token for now. Please read README.md for more details' + description: 'GitHub API token to pull/push GitHub pages branch and deploy GitHub pages. For public repository, this must be personal access token for now. Please read README.md for more details. Not required if using ssh-key.' + required: false + ssh-key: + description: 'SSH private key for Git operations. Alternative to github-token. The key should have push access to the repository (usually as a deploy key).' required: false ref: description: 'optional Ref to use when finding commit' diff --git a/src/git.ts b/src/git.ts index 1a228ac6d..f60d4293e 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,8 +2,12 @@ import { exec } from '@actions/exec'; import * as core from '@actions/core'; import * as github from '@actions/github'; import { URL } from 'url'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; const DEFAULT_GITHUB_URL = 'https://github.com'; +const SSH_KEY_PATH = path.join(os.homedir(), '.ssh', 'github_action_key'); interface ExecResult { stdout: string; @@ -54,6 +58,45 @@ export function getServerName(repositoryUrl: string | undefined): string { return getServerUrlObj(repositoryUrl).hostname; } +function getCurrentRepoRemoteUrl(token: string | undefined): string { + const { repo, owner } = github.context.repo; + const serverName = getServerName(github.context.payload.repository?.html_url); + return getRepoRemoteUrl(token, `${serverName}/${owner}/${repo}`); +} + +function getRepoRemoteUrl(token: string | undefined, repoUrl: string): string { + if (!token) { + // Use SSH format when no token is provided + const [serverName, owner, repo] = repoUrl.split('/'); + return `git@${serverName}:${owner}/${repo}.git`; + } + return `https://x-access-token:${token}@${repoUrl}.git`; +} + +async function setupSshKey(): Promise { + const sshKey = core.getInput('ssh-key'); + if (!sshKey) { + return; + } + + // Ensure .ssh directory exists + const sshDir = path.dirname(SSH_KEY_PATH); + if (!fs.existsSync(sshDir)) { + fs.mkdirSync(sshDir, { recursive: true }); + } + + // Write SSH key + fs.writeFileSync(SSH_KEY_PATH, sshKey, { mode: 0o600 }); + + // Configure Git to use SSH key + await cmd( + [], + 'config', + 'core.sshCommand', + `ssh -i ${SSH_KEY_PATH} -o StrictHostKeyChecking=no`, + ); +} + export async function cmd(additionalGitOptions: string[], ...args: string[]): Promise { core.debug(`Executing Git: ${args.join(' ')}`); const serverUrl = getServerUrl(github.context.payload.repository?.html_url); @@ -73,24 +116,18 @@ export async function cmd(additionalGitOptions: string[], ...args: string[]): Pr return res.stdout; } -function getCurrentRepoRemoteUrl(token: string): string { - const { repo, owner } = github.context.repo; - const serverName = getServerName(github.context.payload.repository?.html_url); - return getRepoRemoteUrl(token, `${serverName}/${owner}/${repo}`); -} - -function getRepoRemoteUrl(token: string, repoUrl: string): string { - return `https://x-access-token:${token}@${repoUrl}.git`; -} - export async function push( - token: string, + token: string | undefined, repoUrl: string | undefined, branch: string, additionalGitOptions: string[] = [], ...options: string[] ): Promise { - core.debug(`Executing 'git push' to branch '${branch}' with token and options '${options.join(' ')}'`); + core.debug(`Executing 'git push' to branch '${branch}' with ${token ? 'token' : 'SSH key'} and options '${options.join(' ')}'`); + + if (!token) { + await setupSshKey(); + } const remote = repoUrl ? getRepoRemoteUrl(token, repoUrl) : getCurrentRepoRemoteUrl(token); let args = ['push', remote, `${branch}:${branch}`, '--no-verify']; @@ -107,9 +144,13 @@ export async function pull( additionalGitOptions: string[] = [], ...options: string[] ): Promise { - core.debug(`Executing 'git pull' to branch '${branch}' with token and options '${options.join(' ')}'`); + core.debug(`Executing 'git pull' on branch '${branch}' with ${token ? 'token' : 'SSH key'} and options '${options.join(' ')}'`); - const remote = token !== undefined ? getCurrentRepoRemoteUrl(token) : 'origin'; + if (!token) { + await setupSshKey(); + } + + const remote = getCurrentRepoRemoteUrl(token); let args = ['pull', remote, branch]; if (options.length > 0) { args = args.concat(options); @@ -124,10 +165,14 @@ export async function fetch( additionalGitOptions: string[] = [], ...options: string[] ): Promise { - core.debug(`Executing 'git fetch' for branch '${branch}' with token and options '${options.join(' ')}'`); + core.debug(`Executing 'git fetch' on branch '${branch}' with ${token ? 'token' : 'SSH key'} and options '${options.join(' ')}'`); + + if (!token) { + await setupSshKey(); + } - const remote = token !== undefined ? getCurrentRepoRemoteUrl(token) : 'origin'; - let args = ['fetch', remote, `${branch}:${branch}`]; + const remote = getCurrentRepoRemoteUrl(token); + let args = ['fetch', remote, branch]; if (options.length > 0) { args = args.concat(options); } @@ -136,13 +181,17 @@ export async function fetch( } export async function clone( - token: string, + token: string | undefined, ghRepository: string, baseDirectory: string, additionalGitOptions: string[] = [], ...options: string[] ): Promise { - core.debug(`Executing 'git clone' to directory '${baseDirectory}' with token and options '${options.join(' ')}'`); + core.debug(`Executing 'git clone' for repository '${ghRepository}' with ${token ? 'token' : 'SSH key'} and options '${options.join(' ')}'`); + + if (!token) { + await setupSshKey(); + } const remote = getRepoRemoteUrl(token, ghRepository); let args = ['clone', remote, baseDirectory]; @@ -152,6 +201,7 @@ export async function clone( return cmd(additionalGitOptions, ...args); } + export async function checkout( ghRef: string, additionalGitOptions: string[] = [], diff --git a/test/git.spec.ts b/test/git.spec.ts index 91e09b85b..9926c2b7f 100644 --- a/test/git.spec.ts +++ b/test/git.spec.ts @@ -1,5 +1,8 @@ import { deepStrictEqual as eq, notDeepStrictEqual as neq, strict as A } from 'assert'; -import { cmd, getServerUrl, pull, push, fetch } from '../src/git'; +import { cmd, getServerUrl, pull, push, fetch, clone } from '../src/git'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; interface ExecOptions { listeners: { @@ -55,6 +58,9 @@ const gitHubContext = { }; }; +const TEST_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----'; +const SSH_KEY_PATH = path.join(os.homedir(), '.ssh', 'github_action_key'); + jest.mock('@actions/exec', () => ({ exec: (c: string, a: string[], o: ExecOptions) => { fakedExec.lastArgs = [c, a, o]; @@ -73,12 +79,24 @@ jest.mock('@actions/core', () => ({ debug: () => { /* do nothing */ }, + getInput: (name: string) => { + if (name === 'ssh-key') { + return TEST_SSH_KEY; + } + return ''; + }, })); jest.mock('@actions/github', () => ({ get context() { return gitHubContext; }, })); +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + writeFileSync: jest.fn(), + existsSync: jest.fn(), + mkdirSync: jest.fn(), +})); const ok: (x: any) => asserts x = A.ok; const serverUrl = getServerUrl(gitHubContext.payload.repository?.html_url); @@ -96,10 +114,12 @@ describe('git', function () { jest.unmock('@actions/exec'); jest.unmock('@actions/core'); jest.unmock('@actions/github'); + jest.unmock('fs'); }); afterEach(function () { fakedExec.reset(); + jest.clearAllMocks(); }); describe('cmd()', function () { @@ -154,6 +174,33 @@ describe('git', function () { ]), ); }); + + it('runs `git push` with SSH key authentication', async function () { + const stdout = await push(undefined, undefined, 'my-branch', [], 'opt1', 'opt2'); + const args = fakedExec.lastArgs; + + // Verify SSH key setup + expect(fs.writeFileSync).toHaveBeenCalledWith( + SSH_KEY_PATH, + TEST_SSH_KEY, + { mode: 0o600 } + ); + + eq(stdout, 'this is test'); + ok(args); + eq(args[0], 'git'); + eq( + args[1], + userArgs.concat([ + 'push', + 'git@github.com:user/repo.git', + 'my-branch:my-branch', + '--no-verify', + 'opt1', + 'opt2', + ]), + ); + }); }); describe('pull()', function () { @@ -176,14 +223,30 @@ describe('git', function () { ); }); - it('runs `git pull` with given branch and options without token', async function () { + it('runs `git pull` with SSH key authentication', async function () { const stdout = await pull(undefined, 'my-branch', [], 'opt1', 'opt2'); const args = fakedExec.lastArgs; + // Verify SSH key setup + expect(fs.writeFileSync).toHaveBeenCalledWith( + SSH_KEY_PATH, + TEST_SSH_KEY, + { mode: 0o600 } + ); + eq(stdout, 'this is test'); ok(args); eq(args[0], 'git'); - eq(args[1], userArgs.concat(['pull', 'origin', 'my-branch', 'opt1', 'opt2'])); + eq( + args[1], + userArgs.concat([ + 'pull', + 'git@github.com:user/repo.git', + 'my-branch', + 'opt1', + 'opt2', + ]), + ); }); }); @@ -200,21 +263,65 @@ describe('git', function () { userArgs.concat([ 'fetch', 'https://x-access-token:this-is-token@github.com/user/repo.git', - 'my-branch:my-branch', + 'my-branch', 'opt1', 'opt2', ]), ); }); - it('runs `git fetch` with given branch and options without token', async function () { + it('runs `git fetch` with SSH key authentication', async function () { const stdout = await fetch(undefined, 'my-branch', [], 'opt1', 'opt2'); const args = fakedExec.lastArgs; + // Verify SSH key setup + expect(fs.writeFileSync).toHaveBeenCalledWith( + SSH_KEY_PATH, + TEST_SSH_KEY, + { mode: 0o600 } + ); + + eq(stdout, 'this is test'); + ok(args); + eq(args[0], 'git'); + eq( + args[1], + userArgs.concat([ + 'fetch', + 'git@github.com:user/repo.git', + 'my-branch', + 'opt1', + 'opt2', + ]), + ); + }); + }); + + describe('clone()', function () { + it('runs `git clone` with SSH key authentication', async function () { + const stdout = await clone(undefined, 'github.com/user/repo', 'dest-dir', [], 'opt1', 'opt2'); + const args = fakedExec.lastArgs; + + // Verify SSH key setup + expect(fs.writeFileSync).toHaveBeenCalledWith( + SSH_KEY_PATH, + TEST_SSH_KEY, + { mode: 0o600 } + ); + eq(stdout, 'this is test'); ok(args); eq(args[0], 'git'); - eq(args[1], userArgs.concat(['fetch', 'origin', 'my-branch:my-branch', 'opt1', 'opt2'])); + eq( + args[1], + userArgs.concat([ + 'clone', + 'git@github.com:user/repo.git', + 'dest-dir', + 'opt1', + 'opt2', + ]), + ); }); }); }); From 1f79ba201d0195dd210aea031333aec60e3e466d Mon Sep 17 00:00:00 2001 From: Chris Riccomini Date: Fri, 13 Dec 2024 16:43:48 -0800 Subject: [PATCH 2/3] Re-order funcs to lessen diff --- src/git.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/git.ts b/src/git.ts index f60d4293e..7c584a182 100644 --- a/src/git.ts +++ b/src/git.ts @@ -58,21 +58,6 @@ export function getServerName(repositoryUrl: string | undefined): string { return getServerUrlObj(repositoryUrl).hostname; } -function getCurrentRepoRemoteUrl(token: string | undefined): string { - const { repo, owner } = github.context.repo; - const serverName = getServerName(github.context.payload.repository?.html_url); - return getRepoRemoteUrl(token, `${serverName}/${owner}/${repo}`); -} - -function getRepoRemoteUrl(token: string | undefined, repoUrl: string): string { - if (!token) { - // Use SSH format when no token is provided - const [serverName, owner, repo] = repoUrl.split('/'); - return `git@${serverName}:${owner}/${repo}.git`; - } - return `https://x-access-token:${token}@${repoUrl}.git`; -} - async function setupSshKey(): Promise { const sshKey = core.getInput('ssh-key'); if (!sshKey) { @@ -116,6 +101,21 @@ export async function cmd(additionalGitOptions: string[], ...args: string[]): Pr return res.stdout; } +function getCurrentRepoRemoteUrl(token: string | undefined): string { + const { repo, owner } = github.context.repo; + const serverName = getServerName(github.context.payload.repository?.html_url); + return getRepoRemoteUrl(token, `${serverName}/${owner}/${repo}`); +} + +function getRepoRemoteUrl(token: string | undefined, repoUrl: string): string { + if (!token) { + // Use SSH format when no token is provided + const [serverName, owner, repo] = repoUrl.split('/'); + return `git@${serverName}:${owner}/${repo}.git`; + } + return `https://x-access-token:${token}@${repoUrl}.git`; +} + export async function push( token: string | undefined, repoUrl: string | undefined, From 490112c40ba061c16f68d88ae31469a72ac1f576 Mon Sep 17 00:00:00 2001 From: Chris Riccomini Date: Fri, 13 Dec 2024 16:50:13 -0800 Subject: [PATCH 3/3] Update readme --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 7dacb6c4d..d9bf18ffd 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,29 @@ option. alert-comment-cc-users: '@rhysd' ``` +### SSH Deploy Key Example + +This example shows how to use an SSH deploy key for authentication when deploying to GitHub Pages. In order +to use this, you need to add the [SSH deploy key to your organization secrets](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys). + +```yaml +- name: Store benchmark result + uses: benchmark-action/github-action-benchmark@v1 + with: + tool: 'cargo' + output-file-path: output.txt + external-data-json-path: ./cache/benchmark-data.json + fail-on-alert: true + # SSH private key for authentication + ssh-key: ${{ secrets.SSH_DEPLOY_KEY }} + # Enable alert commit comment + comment-on-alert: true + # Enable Job Summary for PRs + summary-always: true + # Mention @rhysd in the commit comment + alert-comment-cc-users: '@rhysd' +``` + ### Charts on GitHub Pages It is useful to see how the benchmark results changed on each change in time-series charts. This action @@ -532,6 +555,12 @@ which means there is no limit. If set to `true`, the workflow will skip fetching branch defined with the `gh-pages-branch` variable. +#### `ssh-key` (Optional) + +- Type: String +- Default: N/A + +SSH private key used for authentication when pushing to a repository. This key should be provided if you prefer to use SSH instead of a GitHub token for authentication. Ensure that the key is kept secure and is only accessible to authorized users. ### Action outputs