Native SSH Key Support (#569)

* SSH Key Support 🔑

* Update ssh.ts

* Update src/ssh.ts

Co-authored-by: Axel Hecht <axel@pike.org>

* README fixes/etc

* Unit Tests & README

* ssh key

* Update README.md

* Update ssh.test.ts

* Update ssh.test.ts

* Update ssh.test.ts

* Update ssh.test.ts

* Update ssh.test.ts

* Update ssh.test.ts

* Update integration.yml

Co-authored-by: Axel Hecht <axel@pike.org>
This commit is contained in:
James Ives 2021-01-21 09:08:31 -05:00 committed by GitHub
parent e00d6bfda7
commit 64eb7112e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 224 additions and 47 deletions

View File

@ -90,6 +90,30 @@ jobs:
# Deploys using an SSH key. # Deploys using an SSH key.
integration-ssh: integration-ssh:
needs: integration-container
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Build and Deploy
uses: JamesIves/github-pages-deploy-action@releases/v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}
branch: gh-pages
folder: integration
target-folder: cat/montezuma3
- name: Cleanup Generated Branch
uses: dawidd6/action-delete-branch@v2.0.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branches: gh-pages
# Deploys using an SSH key.
integration-ssh-third-party-client:
needs: integration-container needs: integration-container
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -109,7 +133,7 @@ jobs:
ssh: true ssh: true
branch: gh-pages branch: gh-pages
folder: integration folder: integration
target-folder: cat/montezuma3 target-folder: cat/montezuma4
- name: Cleanup Generated Branch - name: Cleanup Generated Branch
uses: dawidd6/action-delete-branch@v2.0.1 uses: dawidd6/action-delete-branch@v2.0.1
@ -131,15 +155,10 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install SSH Client
uses: webfactory/ssh-agent@v0.4.1
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- name: Build and Deploy - name: Build and Deploy
uses: JamesIves/github-pages-deploy-action@releases/v4 uses: JamesIves/github-pages-deploy-action@releases/v4
with: with:
ssh: true ssh-key: ${{ secrets.DEPLOY_KEY }}
branch: gh-pages branch: gh-pages
folder: integration folder: integration
target-folder: cat/montezuma4 target-folder: cat/montezuma4

View File

@ -132,7 +132,7 @@ By default the action does not need any token configuration and uses the provide
| Key | Value Information | Type | Required | | Key | Value Information | Type | Required |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -------- | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -------- |
| `token` | This option defaults to the repository scoped GitHub Token. However if you need more permissions for things such as deploying to another repository, you can add a Personal Access Token (PAT) here. This should be stored in the `secrets / with` menu **as a secret**. We reccomend using a service account with the least permissions neccersary and recommend when generating a new PAT that you select the least permission scopes neccersary. [Learn more about creating and using encrypted secrets here.](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | **No** | | `token` | This option defaults to the repository scoped GitHub Token. However if you need more permissions for things such as deploying to another repository, you can add a Personal Access Token (PAT) here. This should be stored in the `secrets / with` menu **as a secret**. We reccomend using a service account with the least permissions neccersary and recommend when generating a new PAT that you select the least permission scopes neccersary. [Learn more about creating and using encrypted secrets here.](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | **No** |
| `ssh` | You can configure the action to deploy using SSH by setting this option to `true`. For more information on how to add your ssh key pair please refer to the [Using a Deploy Key section of this README](https://github.com/JamesIves/github-pages-deploy-action/tree/dev#using-an-ssh-deploy-key-). | `with` | **No** | | `ssh-key` | You can configure the action to deploy using SSH by setting this option to a private SSH key stored **as a secret**. It can also be set to `true` to use an existing SSH client configuration. For more detailed information on how to add your ssh key pair please refer to the [Using a Deploy Key section of this README](https://github.com/JamesIves/github-pages-deploy-action/tree/dev#using-an-ssh-deploy-key-). | `with` | **No** |
#### Optional Choices #### Optional Choices
@ -176,20 +176,15 @@ ssh-keygen -t rsa -m pem -b 4096 -C "youremailhere@example.com" -N ""
Once you've generated the key pair you must add the contents of the public key within your repository's [deploy keys menu](https://developer.github.com/v3/guides/managing-deploy-keys/). You can find this option by going to `Settings > Deploy Keys`, you can name the public key whatever you want, but you **do** need to give it write access. Afterwards add the contents of the private key to the `Settings > Secrets` menu as `DEPLOY_KEY`. Once you've generated the key pair you must add the contents of the public key within your repository's [deploy keys menu](https://developer.github.com/v3/guides/managing-deploy-keys/). You can find this option by going to `Settings > Deploy Keys`, you can name the public key whatever you want, but you **do** need to give it write access. Afterwards add the contents of the private key to the `Settings > Secrets` menu as `DEPLOY_KEY`.
With this configured you must add the `ssh-agent` step to your workflow and set `ssh` to `true` within the deploy action. There are several SSH actions available on the [GitHub marketplace](https://github.com/marketplace?type=actions) for you to choose from. With this configured you can then set the `ssh-key` part of the action to your private key stored as a secret.
```yml ```yml
- name: Install SSH Client 🔑
uses: webfactory/ssh-agent@v0.4.1
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- name: Deploy 🚀 - name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@3.7.1 uses: JamesIves/github-pages-deploy-action@3.7.1
with: with:
ssh: true
branch: gh-pages branch: gh-pages
folder: site folder: site
ssh-key: ${{ secrets.DEPLOY_KEY }}
``` ```
<details><summary>You can view a full example of this here.</summary> <details><summary>You can view a full example of this here.</summary>
@ -215,11 +210,6 @@ jobs:
npm install npm install
npm run build npm run build
- name: Install SSH Client 🔑
uses: webfactory/ssh-agent@v0.4.1 # This step installs the ssh client into the workflow run. There's many options available for this on the action marketplace.
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- name: Deploy 🚀 - name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@3.7.1 uses: JamesIves/github-pages-deploy-action@3.7.1
with: with:
@ -229,12 +219,14 @@ jobs:
clean-exclude: | clean-exclude: |
special-file.txt special-file.txt
some/*.txt some/*.txt
ssh: true # SSH must be set to true so the deploy action knows which protocol to deploy with. ssh-key: ${{ secrets.DEPLOY_KEY }}
``` ```
</p> </p>
</details> </details>
Alternatively if you've already configured the SSH client within a previous step you can set the `ssh-key` option to `true` to allow it to deploy using an existing SSH client. Instead of adjusting the client configuration it will simply switch to using GitHub's SSH endpoints.
--- ---
### Operating System Support 💿 ### Operating System Support 💿

View File

@ -60,6 +60,7 @@ describe('main', () => {
folder: 'assets', folder: 'assets',
branch: 'branch', branch: 'branch',
token: '123', token: '123',
sshKey: true,
pusher: { pusher: {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
@ -77,7 +78,7 @@ describe('main', () => {
folder: 'assets', folder: 'assets',
branch: 'branch', branch: 'branch',
token: null, token: null,
ssh: null, sshKey: null,
pusher: { pusher: {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'

103
__tests__/ssh.test.ts Normal file
View File

@ -0,0 +1,103 @@
import {mkdirP} from '@actions/io'
import {appendFileSync} from 'fs'
import {action, TestFlag} from '../src/constants'
import {execute} from '../src/execute'
import {configureSSH} from '../src/ssh'
const originalAction = JSON.stringify(action)
jest.mock('fs', () => ({
appendFileSync: jest.fn(),
existsSync: jest.fn()
}))
jest.mock('@actions/io', () => ({
rmRF: jest.fn(),
mkdirP: jest.fn()
}))
jest.mock('@actions/core', () => ({
setFailed: jest.fn(),
getInput: jest.fn(),
setOutput: jest.fn(),
isDebug: jest.fn(),
info: jest.fn()
}))
jest.mock('../src/execute', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
execute: jest.fn()
}))
describe('configureSSH', () => {
afterEach(() => {
Object.assign(action, JSON.parse(originalAction))
})
it('should skip client configuration if sshKey is set to true', async () => {
Object.assign(action, {
silent: false,
folder: 'assets',
branch: 'branch',
sshKey: true,
pusher: {
name: 'asd',
email: 'as@cat'
},
isTest: TestFlag.HAS_CHANGED_FILES
})
await configureSSH(action)
expect(execute).toBeCalledTimes(0)
expect(mkdirP).toBeCalledTimes(0)
expect(appendFileSync).toBeCalledTimes(0)
})
it('should configure the ssh client if a key is defined', async () => {
Object.assign(action, {
silent: false,
folder: 'assets',
branch: 'branch',
sshKey: '?=-----BEGIN 123 456\n 789',
pusher: {
name: 'asd',
email: 'as@cat'
},
isTest: TestFlag.HAS_CHANGED_FILES
})
await configureSSH(action)
expect(execute).toBeCalledTimes(4)
expect(mkdirP).toBeCalledTimes(1)
expect(appendFileSync).toBeCalledTimes(2)
})
it('should throw if something errors', async () => {
;(execute as jest.Mock).mockImplementationOnce(() => {
throw new Error('Mocked throw')
})
Object.assign(action, {
silent: false,
folder: 'assets',
branch: 'branch',
sshKey: 'real_key',
pusher: {
name: 'asd',
email: 'as@cat'
},
isTest: TestFlag.HAS_CHANGED_FILES
})
try {
await configureSSH(action)
} catch (error) {
expect(error.message).toBe(
'The ssh client configuration encountered an error: Mocked throw ❌'
)
}
})
})

View File

@ -38,7 +38,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: null, token: null,
ssh: true, sshKey: 'real_token',
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -51,7 +51,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: '123', token: '123',
ssh: null, sshKey: null,
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -64,7 +64,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: null, token: null,
ssh: null, sshKey: null,
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -80,7 +80,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: null, token: null,
ssh: true, sshKey: 'real_token',
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -96,7 +96,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: '123', token: '123',
ssh: null, sshKey: null,
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -155,7 +155,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: null, token: null,
ssh: null, sshKey: null,
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -168,7 +168,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: '/home/user/repo/build', folder: '/home/user/repo/build',
token: null, token: null,
ssh: null, sshKey: null,
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -181,7 +181,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: './build', folder: './build',
token: null, token: null,
ssh: null, sshKey: null,
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }
@ -194,7 +194,7 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: '~/repo/build', folder: '~/repo/build',
token: null, token: null,
ssh: null, sshKey: null,
silent: false, silent: false,
isTest: TestFlag.NONE isTest: TestFlag.NONE
} }

View File

@ -8,8 +8,14 @@ branding:
icon: 'git-commit' icon: 'git-commit'
color: 'orange' color: 'orange'
inputs: inputs:
ssh: ssh-key:
description: 'You can configure the action to deploy using SSH by setting this option to true. More more information on how to add your ssh key pair please refer to the Using a Deploy Key section of this README.' description: >
This option allows you to define a private SSH key to be used in conjunction with a repository deployment key to deploy using SSH.
The private key should be stored in the `secrets / with` menu **as a secret**. The public should be stored in the repositories deployment
keys menu and be given write access.
Alternatively you can set this field to `true` to enable SSH endpoints for deployment without configuring the ssh client. This can be useful if you've
already setup the SSH client using another package or action in a previous step.
required: false required: false
token: token:

View File

@ -41,8 +41,8 @@ export interface ActionInterface {
singleCommit?: boolean | null singleCommit?: boolean | null
/** Determines if the action should run in silent mode or not. */ /** Determines if the action should run in silent mode or not. */
silent: boolean silent: boolean
/** Set to true if you're using an ssh client in your build step. */ /** Defines an SSH private key that can be used during deployment. This can also be set to true to use SSH deployment endpoints if you've already configured the SSH client outside of this package. */
ssh?: boolean | null sshKey?: string | boolean | null
/** If you'd like to push the contents of the deployment folder into a specific directory on the deployment branch you can specify it here. */ /** If you'd like to push the contents of the deployment folder into a specific directory on the deployment branch you can specify it here. */
targetFolder?: string targetFolder?: string
/** Deployment token. */ /** Deployment token. */
@ -65,10 +65,11 @@ export interface NodeActionInterface {
token?: string | null token?: string | null
/** Determines if the action should run in silent mode or not. */ /** Determines if the action should run in silent mode or not. */
silent: boolean silent: boolean
/** Set to true if you're using an ssh client in your build step. */ /** Defines an SSH private key that can be used during deployment. This can also be set to true to use SSH deployment endpoints if you've already configured the SSH client outside of this package. */
ssh?: boolean | null sshKey?: string | boolean | null
/** The folder where your deployment project lives. */ /** The folder where your deployment project lives. */
workspace: string workspace: string
/** Determines test scenarios the action is running in. */
isTest: TestFlag isTest: TestFlag
} }
@ -113,9 +114,12 @@ export const action: ActionInterface = {
silent: !isNullOrUndefined(getInput('silent')) silent: !isNullOrUndefined(getInput('silent'))
? getInput('silent').toLowerCase() === 'true' ? getInput('silent').toLowerCase() === 'true'
: false, : false,
ssh: !isNullOrUndefined(getInput('ssh')) sshKey: isNullOrUndefined(getInput('ssh-key'))
? getInput('ssh').toLowerCase() === 'true' ? false
: false, : !isNullOrUndefined(getInput('ssh-key')) &&
getInput('ssh-key').toLowerCase() === 'true'
? true
: getInput('ssh-key'),
targetFolder: getInput('target-folder'), targetFolder: getInput('target-folder'),
workspace: process.env.GITHUB_WORKSPACE || '' workspace: process.env.GITHUB_WORKSPACE || ''
} }
@ -123,7 +127,7 @@ export const action: ActionInterface = {
/** Types for the required action parameters. */ /** Types for the required action parameters. */
export type RequiredActionParameters = Pick< export type RequiredActionParameters = Pick<
ActionInterface, ActionInterface,
'token' | 'ssh' | 'branch' | 'folder' | 'isTest' 'token' | 'sshKey' | 'branch' | 'folder' | 'isTest'
> >
/** Status codes for the action. */ /** Status codes for the action. */

View File

@ -1,9 +1,10 @@
import {exportVariable, info, setFailed, setOutput} from '@actions/core' import {exportVariable, info, setFailed, setOutput} from '@actions/core'
import {ActionInterface, Status, NodeActionInterface} from './constants' import {ActionInterface, NodeActionInterface, Status} from './constants'
import {deploy, init} from './git' import {deploy, init} from './git'
import {configureSSH} from './ssh'
import { import {
generateFolderPath,
checkParameters, checkParameters,
generateFolderPath,
generateRepositoryPath, generateRepositoryPath,
generateTokenType generateTokenType
} from './util' } from './util'
@ -43,6 +44,10 @@ export default async function run(
settings.repositoryPath = generateRepositoryPath(settings) settings.repositoryPath = generateRepositoryPath(settings)
settings.tokenType = generateTokenType(settings) settings.tokenType = generateTokenType(settings)
if (settings.sshKey) {
await configureSSH(settings)
}
await init(settings) await init(settings)
status = await deploy(settings) status = await deploy(settings)
} catch (error) { } catch (error) {

47
src/ssh.ts Normal file
View File

@ -0,0 +1,47 @@
import {info} from '@actions/core'
import {mkdirP} from '@actions/io'
import {appendFileSync} from 'fs'
import {ActionInterface} from './constants'
import {execute} from './execute'
import {suppressSensitiveInformation} from './util'
export async function configureSSH(action: ActionInterface): Promise<void> {
try {
if (typeof action.sshKey === 'string') {
const sshDirectory = `${process.env['HOME']}/.ssh`
const sshKnownHostsDirectory = `${sshDirectory}/known_hosts`
// SSH fingerprints provided by GitHub: https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/githubs-ssh-key-fingerprints
const sshGitHubKnownHostRsa =
'\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n'
const sshGitHubKnownHostDss =
'\ngithub.com ssh-dss AAAAB3NzaC1kc3MAAACBANGFW2P9xlGU3zWrymJgI/lKo//ZW2WfVtmbsUZJ5uyKArtlQOT2+WRhcg4979aFxgKdcsqAYW3/LS1T2km3jYW/vr4Uzn+dXWODVk5VlUiZ1HFOHf6s6ITcZvjvdbp6ZbpM+DuJT7Bw+h5Fx8Qt8I16oCZYmAPJRtu46o9C2zk1AAAAFQC4gdFGcSbp5Gr0Wd5Ay/jtcldMewAAAIATTgn4sY4Nem/FQE+XJlyUQptPWMem5fwOcWtSXiTKaaN0lkk2p2snz+EJvAGXGq9dTSWHyLJSM2W6ZdQDqWJ1k+cL8CARAqL+UMwF84CR0m3hj+wtVGD/J4G5kW2DBAf4/bqzP4469lT+dF2FRQ2L9JKXrCWcnhMtJUvua8dvnwAAAIB6C4nQfAA7x8oLta6tT+oCk2WQcydNsyugE8vLrHlogoWEicla6cWPk7oXSspbzUcfkjN3Qa6e74PhRkc7JdSdAlFzU3m7LMkXo1MHgkqNX8glxWNVqBSc0YRdbFdTkL0C6gtpklilhvuHQCdbgB3LBAikcRkDp+FCVkUgPC/7Rw==\n'
info(`Configuring SSH client… 🔑`)
await mkdirP(sshDirectory)
appendFileSync(sshKnownHostsDirectory, sshGitHubKnownHostRsa)
appendFileSync(sshKnownHostsDirectory, sshGitHubKnownHostDss)
// Initializes SSH agent.
await execute(`ssh-agent`, sshDirectory, action.silent)
// Adds the SSH key to the agent.
action.sshKey.split(/(?=-----BEGIN)/).map(async line => {
await execute(`ssh-add - ${line.trim()}\n`, sshDirectory, action.silent)
})
await execute(`ssh-add -l`, sshDirectory, action.silent)
} else {
info(`Skipping SSH client configuration… ⌚`)
}
} catch (error) {
throw new Error(
`The ssh client configuration encountered an error: ${suppressSensitiveInformation(
error.message,
action
)} `
)
}
}

View File

@ -1,6 +1,6 @@
import {isDebug} from '@actions/core'
import {existsSync} from 'fs' import {existsSync} from 'fs'
import path from 'path' import path from 'path'
import {isDebug} from '@actions/core'
import {ActionInterface, RequiredActionParameters} from './constants' import {ActionInterface, RequiredActionParameters} from './constants'
/* Replaces all instances of a match in a string. */ /* Replaces all instances of a match in a string. */
@ -13,11 +13,11 @@ export const isNullOrUndefined = (value: any): boolean =>
/* Generates a token type used for the action. */ /* Generates a token type used for the action. */
export const generateTokenType = (action: ActionInterface): string => export const generateTokenType = (action: ActionInterface): string =>
action.ssh ? 'SSH Deploy Key' : action.token ? 'Deploy Token' : '…' action.sshKey ? 'SSH Deploy Key' : action.token ? 'Deploy Token' : '…'
/* Generates a the repository path used to make the commits. */ /* Generates a the repository path used to make the commits. */
export const generateRepositoryPath = (action: ActionInterface): string => export const generateRepositoryPath = (action: ActionInterface): string =>
action.ssh action.sshKey
? `git@github.com:${action.repositoryName}` ? `git@github.com:${action.repositoryName}`
: `https://${`x-access-token:${action.token}`}@github.com/${ : `https://${`x-access-token:${action.token}`}@github.com/${
action.repositoryName action.repositoryName
@ -46,7 +46,7 @@ const hasRequiredParameters = <K extends keyof RequiredActionParameters>(
/* Verifies the action has the required parameters to run, otherwise throw an error. */ /* Verifies the action has the required parameters to run, otherwise throw an error. */
export const checkParameters = (action: ActionInterface): void => { export const checkParameters = (action: ActionInterface): void => {
if (!hasRequiredParameters(action, ['token', 'ssh'])) { if (!hasRequiredParameters(action, ['token', 'sshKey'])) {
throw new Error( throw new Error(
'No deployment token/method was provided. You must provide the action with either a Personal Access Token or the GitHub Token secret in order to deploy. If you wish to use an ssh deploy token then you must set SSH to true.' 'No deployment token/method was provided. You must provide the action with either a Personal Access Token or the GitHub Token secret in order to deploy. If you wish to use an ssh deploy token then you must set SSH to true.'
) )