add ssh support (#163)

This commit is contained in:
eric sciple 2020-03-11 15:55:17 -04:00 committed by GitHub
parent 80602fafba
commit b2e6b7ed13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 837 additions and 58 deletions

@ -45,14 +45,40 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous
# Otherwise, defaults to `master`. # Otherwise, defaults to `master`.
ref: '' ref: ''
# Auth token used to fetch the repository. The token is stored in the local git # Personal access token (PAT) used to fetch the repository. The PAT is configured
# config, which enables your scripts to run authenticated git commands. The # with the local git config, which enables your scripts to run authenticated git
# post-job step removes the token from the git config. [Learn more about creating # commands. The post-job step removes the PAT.
# and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) #
# We recommend creating a service account with the least permissions necessary.
# Also when generating a new PAT, select the least scopes necessary.
#
# [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
#
# Default: ${{ github.token }} # Default: ${{ github.token }}
token: '' token: ''
# Whether to persist the token in the git config # SSH key used to fetch the repository. SSH key is configured with the local git
# config, which enables your scripts to run authenticated git commands. The
# post-job step removes the SSH key.
#
# We recommend creating a service account with the least permissions necessary.
#
# [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
ssh-key: ''
# Known hosts in addition to the user and global host key database. The public SSH
# keys for a host may be obtained using the utility `ssh-keyscan`. For example,
# `ssh-keyscan github.com`. The public key for github.com is always implicitly
# added.
ssh-known-hosts: ''
# Whether to perform strict host key checking. When true, adds the options
# `StrictHostKeyChecking=yes` and `CheckHostIP=no` to the SSH command line. Use
# the input `ssh-known-hosts` to configure additional hosts.
# Default: true
ssh-strict: ''
# Whether to configure the token or SSH key with the local git config
# Default: true # Default: true
persist-credentials: '' persist-credentials: ''
@ -73,6 +99,10 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous
# Whether to checkout submodules: `true` to checkout submodules or `recursive` to # Whether to checkout submodules: `true` to checkout submodules or `recursive` to
# recursively checkout submodules. # recursively checkout submodules.
#
# When the `ssh-key` input is not provided, SSH URLs beginning with
# `git@github.com:` are converted to HTTPS.
#
# Default: false # Default: false
submodules: '' submodules: ''
``` ```

@ -2,10 +2,13 @@ import * as core from '@actions/core'
import * as fs from 'fs' import * as fs from 'fs'
import * as gitAuthHelper from '../lib/git-auth-helper' import * as gitAuthHelper from '../lib/git-auth-helper'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as os from 'os'
import * as path from 'path' import * as path from 'path'
import * as stateHelper from '../lib/state-helper'
import {IGitCommandManager} from '../lib/git-command-manager' import {IGitCommandManager} from '../lib/git-command-manager'
import {IGitSourceSettings} from '../lib/git-source-settings' import {IGitSourceSettings} from '../lib/git-source-settings'
const isWindows = process.platform === 'win32'
const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper') const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper')
const originalRunnerTemp = process.env['RUNNER_TEMP'] const originalRunnerTemp = process.env['RUNNER_TEMP']
const originalHome = process.env['HOME'] const originalHome = process.env['HOME']
@ -16,9 +19,13 @@ let runnerTemp: string
let tempHomedir: string let tempHomedir: string
let git: IGitCommandManager & {env: {[key: string]: string}} let git: IGitCommandManager & {env: {[key: string]: string}}
let settings: IGitSourceSettings let settings: IGitSourceSettings
let sshPath: string
describe('git-auth-helper tests', () => { describe('git-auth-helper tests', () => {
beforeAll(async () => { beforeAll(async () => {
// SSH
sshPath = await io.which('ssh')
// Clear test workspace // Clear test workspace
await io.rmRF(testWorkspace) await io.rmRF(testWorkspace)
}) })
@ -32,6 +39,12 @@ describe('git-auth-helper tests', () => {
jest.spyOn(core, 'warning').mockImplementation(jest.fn()) jest.spyOn(core, 'warning').mockImplementation(jest.fn())
jest.spyOn(core, 'info').mockImplementation(jest.fn()) jest.spyOn(core, 'info').mockImplementation(jest.fn())
jest.spyOn(core, 'debug').mockImplementation(jest.fn()) jest.spyOn(core, 'debug').mockImplementation(jest.fn())
// Mock state helper
jest.spyOn(stateHelper, 'setSshKeyPath').mockImplementation(jest.fn())
jest
.spyOn(stateHelper, 'setSshKnownHostsPath')
.mockImplementation(jest.fn())
}) })
afterEach(() => { afterEach(() => {
@ -108,6 +121,52 @@ describe('git-auth-helper tests', () => {
} }
) )
const configureAuth_copiesUserKnownHosts =
'configureAuth copies user known hosts'
it(configureAuth_copiesUserKnownHosts, async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${configureAuth_copiesUserKnownHosts}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arange
await setup(configureAuth_copiesUserKnownHosts)
expect(settings.sshKey).toBeTruthy() // sanity check
// Mock fs.promises.readFile
const realReadFile = fs.promises.readFile
jest.spyOn(fs.promises, 'readFile').mockImplementation(
async (file: any, options: any): Promise<Buffer> => {
const userKnownHostsPath = path.join(
os.homedir(),
'.ssh',
'known_hosts'
)
if (file === userKnownHostsPath) {
return Buffer.from('some-domain.com ssh-rsa ABCDEF')
}
return await realReadFile(file, options)
}
)
// Act
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
// Assert known hosts
const actualSshKnownHostsPath = await getActualSshKnownHostsPath()
const actualSshKnownHostsContent = (
await fs.promises.readFile(actualSshKnownHostsPath)
).toString()
expect(actualSshKnownHostsContent).toMatch(
/some-domain\.com ssh-rsa ABCDEF/
)
expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/)
})
const configureAuth_registersBasicCredentialAsSecret = const configureAuth_registersBasicCredentialAsSecret =
'configureAuth registers basic credential as secret' 'configureAuth registers basic credential as secret'
it(configureAuth_registersBasicCredentialAsSecret, async () => { it(configureAuth_registersBasicCredentialAsSecret, async () => {
@ -129,6 +188,173 @@ describe('git-auth-helper tests', () => {
expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret) expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret)
}) })
const setsSshCommandEnvVarWhenPersistCredentialsFalse =
'sets SSH command env var when persist-credentials false'
it(setsSshCommandEnvVarWhenPersistCredentialsFalse, async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${setsSshCommandEnvVarWhenPersistCredentialsFalse}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arrange
await setup(setsSshCommandEnvVarWhenPersistCredentialsFalse)
settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
// Act
await authHelper.configureAuth()
// Assert git env var
const actualKeyPath = await getActualSshKeyPath()
const actualKnownHostsPath = await getActualSshKnownHostsPath()
const expectedSshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
actualKeyPath
)}" -o StrictHostKeyChecking=yes -o CheckHostIP=no -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
actualKnownHostsPath
)}"`
expect(git.setEnvironmentVariable).toHaveBeenCalledWith(
'GIT_SSH_COMMAND',
expectedSshCommand
)
// Asserty git config
const gitConfigLines = (await fs.promises.readFile(localGitConfigPath))
.toString()
.split('\n')
.filter(x => x)
expect(gitConfigLines).toHaveLength(1)
expect(gitConfigLines[0]).toMatch(/^http\./)
})
const configureAuth_setsSshCommandWhenPersistCredentialsTrue =
'sets SSH command when persist-credentials true'
it(configureAuth_setsSshCommandWhenPersistCredentialsTrue, async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${configureAuth_setsSshCommandWhenPersistCredentialsTrue}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arrange
await setup(configureAuth_setsSshCommandWhenPersistCredentialsTrue)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
// Act
await authHelper.configureAuth()
// Assert git env var
const actualKeyPath = await getActualSshKeyPath()
const actualKnownHostsPath = await getActualSshKnownHostsPath()
const expectedSshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
actualKeyPath
)}" -o StrictHostKeyChecking=yes -o CheckHostIP=no -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
actualKnownHostsPath
)}"`
expect(git.setEnvironmentVariable).toHaveBeenCalledWith(
'GIT_SSH_COMMAND',
expectedSshCommand
)
// Asserty git config
expect(git.config).toHaveBeenCalledWith(
'core.sshCommand',
expectedSshCommand
)
})
const configureAuth_writesExplicitKnownHosts = 'writes explicit known hosts'
it(configureAuth_writesExplicitKnownHosts, async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${configureAuth_writesExplicitKnownHosts}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arrange
await setup(configureAuth_writesExplicitKnownHosts)
expect(settings.sshKey).toBeTruthy() // sanity check
settings.sshKnownHosts = 'my-custom-host.com ssh-rsa ABC123'
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
// Act
await authHelper.configureAuth()
// Assert known hosts
const actualSshKnownHostsPath = await getActualSshKnownHostsPath()
const actualSshKnownHostsContent = (
await fs.promises.readFile(actualSshKnownHostsPath)
).toString()
expect(actualSshKnownHostsContent).toMatch(
/my-custom-host\.com ssh-rsa ABC123/
)
expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/)
})
const configureAuth_writesSshKeyAndImplicitKnownHosts =
'writes SSH key and implicit known hosts'
it(configureAuth_writesSshKeyAndImplicitKnownHosts, async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${configureAuth_writesSshKeyAndImplicitKnownHosts}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arrange
await setup(configureAuth_writesSshKeyAndImplicitKnownHosts)
expect(settings.sshKey).toBeTruthy() // sanity check
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
// Act
await authHelper.configureAuth()
// Assert SSH key
const actualSshKeyPath = await getActualSshKeyPath()
expect(actualSshKeyPath).toBeTruthy()
const actualSshKeyContent = (
await fs.promises.readFile(actualSshKeyPath)
).toString()
expect(actualSshKeyContent).toBe(settings.sshKey + '\n')
if (!isWindows) {
expect((await fs.promises.stat(actualSshKeyPath)).mode & 0o777).toBe(
0o600
)
}
// Assert known hosts
const actualSshKnownHostsPath = await getActualSshKnownHostsPath()
const actualSshKnownHostsContent = (
await fs.promises.readFile(actualSshKnownHostsPath)
).toString()
expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/)
})
const configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet =
'configureGlobalAuth configures URL insteadOf when SSH key not set'
it(configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet, async () => {
// Arrange
await setup(configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet)
settings.sshKey = ''
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
// Act
await authHelper.configureAuth()
await authHelper.configureGlobalAuth()
// Assert temporary global config
expect(git.env['HOME']).toBeTruthy()
const configContent = (
await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
).toString()
expect(
configContent.indexOf(`url.https://github.com/.insteadOf git@github.com`)
).toBeGreaterThanOrEqual(0)
})
const configureGlobalAuth_copiesGlobalGitConfig = const configureGlobalAuth_copiesGlobalGitConfig =
'configureGlobalAuth copies global git config' 'configureGlobalAuth copies global git config'
it(configureGlobalAuth_copiesGlobalGitConfig, async () => { it(configureGlobalAuth_copiesGlobalGitConfig, async () => {
@ -211,6 +437,67 @@ describe('git-auth-helper tests', () => {
} }
) )
const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeyNotSet =
'configureSubmoduleAuth configures token when persist credentials true and SSH key not set'
it(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeyNotSet,
async () => {
// Arrange
await setup(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeyNotSet
)
settings.sshKey = ''
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls
// Act
await authHelper.configureSubmoduleAuth()
// Assert
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3)
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
/unset-all.*insteadOf/
)
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/url.*insteadOf/)
}
)
const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet =
'configureSubmoduleAuth configures token when persist credentials true and SSH key set'
it(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet,
async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arrange
await setup(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet
)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls
// Act
await authHelper.configureSubmoduleAuth()
// Assert
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2)
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
/unset-all.*insteadOf/
)
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
}
)
const configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse = const configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse =
'configureSubmoduleAuth does not configure token when persist credentials false' 'configureSubmoduleAuth does not configure token when persist credentials false'
it( it(
@ -223,37 +510,135 @@ describe('git-auth-helper tests', () => {
settings.persistCredentials = false settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings) const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth() await authHelper.configureAuth()
;(git.submoduleForeach as jest.Mock<any, any>).mockClear() // reset calls const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls
// Act // Act
await authHelper.configureSubmoduleAuth() await authHelper.configureSubmoduleAuth()
// Assert // Assert
expect(git.submoduleForeach).not.toHaveBeenCalled() expect(mockSubmoduleForeach).toBeCalledTimes(1)
expect(mockSubmoduleForeach.mock.calls[0][0] as string).toMatch(
/unset-all.*insteadOf/
)
} }
) )
const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue = const configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet =
'configureSubmoduleAuth configures token when persist credentials true' 'configureSubmoduleAuth does not configure URL insteadOf when persist credentials true and SSH key set'
it( it(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue, configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet,
async () => { async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arrange // Arrange
await setup( await setup(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet
) )
const authHelper = gitAuthHelper.createAuthHelper(git, settings) const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth() await authHelper.configureAuth()
;(git.submoduleForeach as jest.Mock<any, any>).mockClear() // reset calls const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls
// Act // Act
await authHelper.configureSubmoduleAuth() await authHelper.configureSubmoduleAuth()
// Assert // Assert
expect(git.submoduleForeach).toHaveBeenCalledTimes(1) expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2)
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
/unset-all.*insteadOf/
)
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
} }
) )
const configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse =
'configureSubmoduleAuth removes URL insteadOf when persist credentials false'
it(
configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse,
async () => {
// Arrange
await setup(
configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse
)
settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls
// Act
await authHelper.configureSubmoduleAuth()
// Assert
expect(mockSubmoduleForeach).toBeCalledTimes(1)
expect(mockSubmoduleForeach.mock.calls[0][0] as string).toMatch(
/unset-all.*insteadOf/
)
}
)
const removeAuth_removesSshCommand = 'removeAuth removes SSH command'
it(removeAuth_removesSshCommand, async () => {
if (!sshPath) {
process.stdout.write(
`Skipped test "${removeAuth_removesSshCommand}". Executable 'ssh' not found in the PATH.\n`
)
return
}
// Arrange
await setup(removeAuth_removesSshCommand)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
let gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('core.sshCommand')).toBeGreaterThanOrEqual(
0
) // sanity check
const actualKeyPath = await getActualSshKeyPath()
expect(actualKeyPath).toBeTruthy()
await fs.promises.stat(actualKeyPath)
const actualKnownHostsPath = await getActualSshKnownHostsPath()
expect(actualKnownHostsPath).toBeTruthy()
await fs.promises.stat(actualKnownHostsPath)
// Act
await authHelper.removeAuth()
// Assert git config
gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('core.sshCommand')).toBeLessThan(0)
// Assert SSH key file
try {
await fs.promises.stat(actualKeyPath)
throw new Error('SSH key should have been deleted')
} catch (err) {
if (err.code !== 'ENOENT') {
throw err
}
}
// Assert known hosts file
try {
await fs.promises.stat(actualKnownHostsPath)
throw new Error('SSH known hosts should have been deleted')
} catch (err) {
if (err.code !== 'ENOENT') {
throw err
}
}
})
const removeAuth_removesToken = 'removeAuth removes token' const removeAuth_removesToken = 'removeAuth removes token'
it(removeAuth_removesToken, async () => { it(removeAuth_removesToken, async () => {
// Arrange // Arrange
@ -401,6 +786,36 @@ async function setup(testName: string): Promise<void> {
ref: 'refs/heads/master', ref: 'refs/heads/master',
repositoryName: 'my-repo', repositoryName: 'my-repo',
repositoryOwner: 'my-org', repositoryOwner: 'my-org',
repositoryPath: '' repositoryPath: '',
sshKey: sshPath ? 'some ssh private key' : '',
sshKnownHosts: '',
sshStrict: true
} }
} }
async function getActualSshKeyPath(): Promise<string> {
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
.sort()
.map(x => path.join(runnerTemp, x))
if (actualTempFiles.length === 0) {
return ''
}
expect(actualTempFiles).toHaveLength(2)
expect(actualTempFiles[0].endsWith('_known_hosts')).toBeFalsy()
return actualTempFiles[0]
}
async function getActualSshKnownHostsPath(): Promise<string> {
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
.sort()
.map(x => path.join(runnerTemp, x))
if (actualTempFiles.length === 0) {
return ''
}
expect(actualTempFiles).toHaveLength(2)
expect(actualTempFiles[1].endsWith('_known_hosts')).toBeTruthy()
expect(actualTempFiles[1].startsWith(actualTempFiles[0])).toBeTruthy()
return actualTempFiles[1]
}

@ -11,13 +11,42 @@ inputs:
event. Otherwise, defaults to `master`. event. Otherwise, defaults to `master`.
token: token:
description: > description: >
Auth token used to fetch the repository. The token is stored in the local Personal access token (PAT) used to fetch the repository. The PAT is configured
git config, which enables your scripts to run authenticated git commands. with the local git config, which enables your scripts to run authenticated git
The post-job step removes the token from the git config. [Learn more about commands. The post-job step removes the PAT.
creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
We recommend creating a service account with the least permissions necessary.
Also when generating a new PAT, select the least scopes necessary.
[Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
default: ${{ github.token }} default: ${{ github.token }}
ssh-key:
description: >
SSH key used to fetch the repository. SSH key is configured with the local
git config, which enables your scripts to run authenticated git commands.
The post-job step removes the SSH key.
We recommend creating a service account with the least permissions necessary.
[Learn more about creating and using
encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
ssh-known-hosts:
description: >
Known hosts in addition to the user and global host key database. The public
SSH keys for a host may be obtained using the utility `ssh-keyscan`. For example,
`ssh-keyscan github.com`. The public key for github.com is always implicitly added.
ssh-strict:
description: >
Whether to perform strict host key checking. When true, adds the options `StrictHostKeyChecking=yes`
and `CheckHostIP=no` to the SSH command line. Use the input `ssh-known-hosts` to
configure additional hosts.
default: true
persist-credentials: persist-credentials:
description: 'Whether to persist the token in the git config' description: 'Whether to configure the token or SSH key with the local git config'
default: true default: true
path: path:
description: 'Relative path under $GITHUB_WORKSPACE to place the repository' description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
@ -34,6 +63,10 @@ inputs:
description: > description: >
Whether to checkout submodules: `true` to checkout submodules or `recursive` to Whether to checkout submodules: `true` to checkout submodules or `recursive` to
recursively checkout submodules. recursively checkout submodules.
When the `ssh-key` input is not provided, SSH URLs beginning with `git@github.com:` are
converted to HTTPS.
default: false default: false
runs: runs:
using: node12 using: node12

142
dist/index.js vendored

@ -2621,6 +2621,14 @@ exports.IsPost = !!process.env['STATE_isPost'];
* The repository path for the POST action. The value is empty during the MAIN action. * The repository path for the POST action. The value is empty during the MAIN action.
*/ */
exports.RepositoryPath = process.env['STATE_repositoryPath'] || ''; exports.RepositoryPath = process.env['STATE_repositoryPath'] || '';
/**
* The SSH key path for the POST action. The value is empty during the MAIN action.
*/
exports.SshKeyPath = process.env['STATE_sshKeyPath'] || '';
/**
* The SSH known hosts path for the POST action. The value is empty during the MAIN action.
*/
exports.SshKnownHostsPath = process.env['STATE_sshKnownHostsPath'] || '';
/** /**
* Save the repository path so the POST action can retrieve the value. * Save the repository path so the POST action can retrieve the value.
*/ */
@ -2628,6 +2636,20 @@ function setRepositoryPath(repositoryPath) {
coreCommand.issueCommand('save-state', { name: 'repositoryPath' }, repositoryPath); coreCommand.issueCommand('save-state', { name: 'repositoryPath' }, repositoryPath);
} }
exports.setRepositoryPath = setRepositoryPath; exports.setRepositoryPath = setRepositoryPath;
/**
* Save the SSH key path so the POST action can retrieve the value.
*/
function setSshKeyPath(sshKeyPath) {
coreCommand.issueCommand('save-state', { name: 'sshKeyPath' }, sshKeyPath);
}
exports.setSshKeyPath = setSshKeyPath;
/**
* Save the SSH known hosts path so the POST action can retrieve the value.
*/
function setSshKnownHostsPath(sshKnownHostsPath) {
coreCommand.issueCommand('save-state', { name: 'sshKnownHostsPath' }, sshKnownHostsPath);
}
exports.setSshKnownHostsPath = setSshKnownHostsPath;
// Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic. // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic.
// This is necessary since we don't have a separate entry point. // This is necessary since we don't have a separate entry point.
if (!exports.IsPost) { if (!exports.IsPost) {
@ -5080,14 +5102,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
const assert = __importStar(__webpack_require__(357)); const assert = __importStar(__webpack_require__(357));
const core = __importStar(__webpack_require__(470)); const core = __importStar(__webpack_require__(470));
const exec = __importStar(__webpack_require__(986));
const fs = __importStar(__webpack_require__(747)); const fs = __importStar(__webpack_require__(747));
const io = __importStar(__webpack_require__(1)); const io = __importStar(__webpack_require__(1));
const os = __importStar(__webpack_require__(87)); const os = __importStar(__webpack_require__(87));
const path = __importStar(__webpack_require__(622)); const path = __importStar(__webpack_require__(622));
const regexpHelper = __importStar(__webpack_require__(528)); const regexpHelper = __importStar(__webpack_require__(528));
const stateHelper = __importStar(__webpack_require__(153));
const v4_1 = __importDefault(__webpack_require__(826)); const v4_1 = __importDefault(__webpack_require__(826));
const IS_WINDOWS = process.platform === 'win32'; const IS_WINDOWS = process.platform === 'win32';
const HOSTNAME = 'github.com'; const HOSTNAME = 'github.com';
const SSH_COMMAND_KEY = 'core.sshCommand';
function createAuthHelper(git, settings) { function createAuthHelper(git, settings) {
return new GitAuthHelper(git, settings); return new GitAuthHelper(git, settings);
} }
@ -5097,6 +5122,8 @@ class GitAuthHelper {
this.tokenConfigKey = `http.https://${HOSTNAME}/.extraheader`; this.tokenConfigKey = `http.https://${HOSTNAME}/.extraheader`;
this.insteadOfKey = `url.https://${HOSTNAME}/.insteadOf`; this.insteadOfKey = `url.https://${HOSTNAME}/.insteadOf`;
this.insteadOfValue = `git@${HOSTNAME}:`; this.insteadOfValue = `git@${HOSTNAME}:`;
this.sshKeyPath = '';
this.sshKnownHostsPath = '';
this.temporaryHomePath = ''; this.temporaryHomePath = '';
this.git = gitCommandManager; this.git = gitCommandManager;
this.settings = gitSourceSettings || {}; this.settings = gitSourceSettings || {};
@ -5111,6 +5138,7 @@ class GitAuthHelper {
// Remove possible previous values // Remove possible previous values
yield this.removeAuth(); yield this.removeAuth();
// Configure new values // Configure new values
yield this.configureSsh();
yield this.configureToken(); yield this.configureToken();
}); });
} }
@ -5150,8 +5178,10 @@ class GitAuthHelper {
yield this.configureToken(newGitConfigPath, true); yield this.configureToken(newGitConfigPath, true);
// Configure HTTPS instead of SSH // Configure HTTPS instead of SSH
yield this.git.tryConfigUnset(this.insteadOfKey, true); yield this.git.tryConfigUnset(this.insteadOfKey, true);
if (!this.settings.sshKey) {
yield this.git.config(this.insteadOfKey, this.insteadOfValue, true); yield this.git.config(this.insteadOfKey, this.insteadOfValue, true);
} }
}
catch (err) { catch (err) {
// Unset in case somehow written to the real global config // Unset in case somehow written to the real global config
core.info('Encountered an error when attempting to configure token. Attempting unconfigure.'); core.info('Encountered an error when attempting to configure token. Attempting unconfigure.');
@ -5162,27 +5192,29 @@ class GitAuthHelper {
} }
configureSubmoduleAuth() { configureSubmoduleAuth() {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
// Remove possible previous HTTPS instead of SSH
yield this.removeGitConfig(this.insteadOfKey, true);
if (this.settings.persistCredentials) { if (this.settings.persistCredentials) {
// Configure a placeholder value. This approach avoids the credential being captured // Configure a placeholder value. This approach avoids the credential being captured
// by process creation audit events, which are commonly logged. For more information, // by process creation audit events, which are commonly logged. For more information,
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
const commands = [ const output = yield this.git.submoduleForeach(`git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules);
`git config --local "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}"`,
`git config --local "${this.insteadOfKey}" "${this.insteadOfValue}"`,
`git config --local --show-origin --name-only --get-regexp remote.origin.url`
];
const output = yield this.git.submoduleForeach(commands.join(' && '), this.settings.nestedSubmodules);
// Replace the placeholder // Replace the placeholder
const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
for (const configPath of configPaths) { for (const configPath of configPaths) {
core.debug(`Replacing token placeholder in '${configPath}'`); core.debug(`Replacing token placeholder in '${configPath}'`);
this.replaceTokenPlaceholder(configPath); this.replaceTokenPlaceholder(configPath);
} }
// Configure HTTPS instead of SSH
if (!this.settings.sshKey) {
yield this.git.submoduleForeach(`git config --local '${this.insteadOfKey}' '${this.insteadOfValue}'`, this.settings.nestedSubmodules);
}
} }
}); });
} }
removeAuth() { removeAuth() {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
yield this.removeSsh();
yield this.removeToken(); yield this.removeToken();
}); });
} }
@ -5193,6 +5225,62 @@ class GitAuthHelper {
yield io.rmRF(this.temporaryHomePath); yield io.rmRF(this.temporaryHomePath);
}); });
} }
configureSsh() {
return __awaiter(this, void 0, void 0, function* () {
if (!this.settings.sshKey) {
return;
}
// Write key
const runnerTemp = process.env['RUNNER_TEMP'] || '';
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined');
const uniqueId = v4_1.default();
this.sshKeyPath = path.join(runnerTemp, uniqueId);
stateHelper.setSshKeyPath(this.sshKeyPath);
yield fs.promises.mkdir(runnerTemp, { recursive: true });
yield fs.promises.writeFile(this.sshKeyPath, this.settings.sshKey.trim() + '\n', { mode: 0o600 });
// Remove inherited permissions on Windows
if (IS_WINDOWS) {
const icacls = yield io.which('icacls.exe');
yield exec.exec(`"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`);
yield exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`);
}
// Write known hosts
const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts');
let userKnownHosts = '';
try {
userKnownHosts = (yield fs.promises.readFile(userKnownHostsPath)).toString();
}
catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
let knownHosts = '';
if (userKnownHosts) {
knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`;
}
if (this.settings.sshKnownHosts) {
knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`;
}
knownHosts += `# Begin implicitly added github.com\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n# End implicitly added github.com\n`;
this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`);
stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath);
yield fs.promises.writeFile(this.sshKnownHostsPath, knownHosts);
// Configure GIT_SSH_COMMAND
const sshPath = yield io.which('ssh', true);
let sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(this.sshKeyPath)}"`;
if (this.settings.sshStrict) {
sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no';
}
sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(this.sshKnownHostsPath)}"`;
core.info(`Temporarily overriding GIT_SSH_COMMAND=${sshCommand}`);
this.git.setEnvironmentVariable('GIT_SSH_COMMAND', sshCommand);
// Configure core.sshCommand
if (this.settings.persistCredentials) {
yield this.git.config(SSH_COMMAND_KEY, sshCommand);
}
});
}
configureToken(configPath, globalConfig) { configureToken(configPath, globalConfig) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
// Validate args // Validate args
@ -5223,21 +5311,50 @@ class GitAuthHelper {
yield fs.promises.writeFile(configPath, content); yield fs.promises.writeFile(configPath, content);
}); });
} }
removeSsh() {
return __awaiter(this, void 0, void 0, function* () {
// SSH key
const keyPath = this.sshKeyPath || stateHelper.SshKeyPath;
if (keyPath) {
try {
yield io.rmRF(keyPath);
}
catch (err) {
core.debug(err.message);
core.warning(`Failed to remove SSH key '${keyPath}'`);
}
}
// SSH known hosts
const knownHostsPath = this.sshKnownHostsPath || stateHelper.SshKnownHostsPath;
if (knownHostsPath) {
try {
yield io.rmRF(knownHostsPath);
}
catch (_a) {
// Intentionally empty
}
}
// SSH command
yield this.removeGitConfig(SSH_COMMAND_KEY);
});
}
removeToken() { removeToken() {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
// HTTP extra header // HTTP extra header
yield this.removeGitConfig(this.tokenConfigKey); yield this.removeGitConfig(this.tokenConfigKey);
}); });
} }
removeGitConfig(configKey) { removeGitConfig(configKey, submoduleOnly = false) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
if (!submoduleOnly) {
if ((yield this.git.configExists(configKey)) && if ((yield this.git.configExists(configKey)) &&
!(yield this.git.tryConfigUnset(configKey))) { !(yield this.git.tryConfigUnset(configKey))) {
// Load the config contents // Load the config contents
core.warning(`Failed to remove '${configKey}' from the git config`); core.warning(`Failed to remove '${configKey}' from the git config`);
} }
}
const pattern = regexpHelper.escape(configKey); const pattern = regexpHelper.escape(configKey);
yield this.git.submoduleForeach(`git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, true); yield this.git.submoduleForeach(`git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`, true);
}); });
} }
} }
@ -5680,7 +5797,9 @@ function getSource(settings) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
// Repository URL // Repository URL
core.info(`Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`); core.info(`Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`);
const repositoryUrl = `https://${hostname}/${encodeURIComponent(settings.repositoryOwner)}/${encodeURIComponent(settings.repositoryName)}`; const repositoryUrl = settings.sshKey
? `git@${hostname}:${encodeURIComponent(settings.repositoryOwner)}/${encodeURIComponent(settings.repositoryName)}.git`
: `https://${hostname}/${encodeURIComponent(settings.repositoryOwner)}/${encodeURIComponent(settings.repositoryName)}`;
// Remove conflicting file path // Remove conflicting file path
if (fsHelper.fileExistsSync(settings.repositoryPath)) { if (fsHelper.fileExistsSync(settings.repositoryPath)) {
yield io.rmRF(settings.repositoryPath); yield io.rmRF(settings.repositoryPath);
@ -13940,6 +14059,11 @@ function getInputs() {
core.debug(`recursive submodules = ${result.nestedSubmodules}`); core.debug(`recursive submodules = ${result.nestedSubmodules}`);
// Auth token // Auth token
result.authToken = core.getInput('token'); result.authToken = core.getInput('token');
// SSH
result.sshKey = core.getInput('ssh-key');
result.sshKnownHosts = core.getInput('ssh-known-hosts');
result.sshStrict =
(core.getInput('ssh-strict') || 'true').toUpperCase() === 'TRUE';
// Persist credentials // Persist credentials
result.persistCredentials = result.persistCredentials =
(core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE'; (core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE';

@ -13,6 +13,7 @@ import {IGitSourceSettings} from './git-source-settings'
const IS_WINDOWS = process.platform === 'win32' const IS_WINDOWS = process.platform === 'win32'
const HOSTNAME = 'github.com' const HOSTNAME = 'github.com'
const SSH_COMMAND_KEY = 'core.sshCommand'
export interface IGitAuthHelper { export interface IGitAuthHelper {
configureAuth(): Promise<void> configureAuth(): Promise<void>
@ -36,6 +37,8 @@ class GitAuthHelper {
private readonly tokenPlaceholderConfigValue: string private readonly tokenPlaceholderConfigValue: string
private readonly insteadOfKey: string = `url.https://${HOSTNAME}/.insteadOf` private readonly insteadOfKey: string = `url.https://${HOSTNAME}/.insteadOf`
private readonly insteadOfValue: string = `git@${HOSTNAME}:` private readonly insteadOfValue: string = `git@${HOSTNAME}:`
private sshKeyPath = ''
private sshKnownHostsPath = ''
private temporaryHomePath = '' private temporaryHomePath = ''
private tokenConfigValue: string private tokenConfigValue: string
@ -61,6 +64,7 @@ class GitAuthHelper {
await this.removeAuth() await this.removeAuth()
// Configure new values // Configure new values
await this.configureSsh()
await this.configureToken() await this.configureToken()
} }
@ -106,7 +110,9 @@ class GitAuthHelper {
// Configure HTTPS instead of SSH // Configure HTTPS instead of SSH
await this.git.tryConfigUnset(this.insteadOfKey, true) await this.git.tryConfigUnset(this.insteadOfKey, true)
if (!this.settings.sshKey) {
await this.git.config(this.insteadOfKey, this.insteadOfValue, true) await this.git.config(this.insteadOfKey, this.insteadOfValue, true)
}
} catch (err) { } catch (err) {
// Unset in case somehow written to the real global config // Unset in case somehow written to the real global config
core.info( core.info(
@ -118,17 +124,15 @@ class GitAuthHelper {
} }
async configureSubmoduleAuth(): Promise<void> { async configureSubmoduleAuth(): Promise<void> {
// Remove possible previous HTTPS instead of SSH
await this.removeGitConfig(this.insteadOfKey, true)
if (this.settings.persistCredentials) { if (this.settings.persistCredentials) {
// Configure a placeholder value. This approach avoids the credential being captured // Configure a placeholder value. This approach avoids the credential being captured
// by process creation audit events, which are commonly logged. For more information, // by process creation audit events, which are commonly logged. For more information,
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
const commands = [
`git config --local "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}"`,
`git config --local "${this.insteadOfKey}" "${this.insteadOfValue}"`,
`git config --local --show-origin --name-only --get-regexp remote.origin.url`
]
const output = await this.git.submoduleForeach( const output = await this.git.submoduleForeach(
commands.join(' && '), `git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`,
this.settings.nestedSubmodules this.settings.nestedSubmodules
) )
@ -139,10 +143,19 @@ class GitAuthHelper {
core.debug(`Replacing token placeholder in '${configPath}'`) core.debug(`Replacing token placeholder in '${configPath}'`)
this.replaceTokenPlaceholder(configPath) this.replaceTokenPlaceholder(configPath)
} }
// Configure HTTPS instead of SSH
if (!this.settings.sshKey) {
await this.git.submoduleForeach(
`git config --local '${this.insteadOfKey}' '${this.insteadOfValue}'`,
this.settings.nestedSubmodules
)
}
} }
} }
async removeAuth(): Promise<void> { async removeAuth(): Promise<void> {
await this.removeSsh()
await this.removeToken() await this.removeToken()
} }
@ -152,6 +165,77 @@ class GitAuthHelper {
await io.rmRF(this.temporaryHomePath) await io.rmRF(this.temporaryHomePath)
} }
private async configureSsh(): Promise<void> {
if (!this.settings.sshKey) {
return
}
// Write key
const runnerTemp = process.env['RUNNER_TEMP'] || ''
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
const uniqueId = uuid()
this.sshKeyPath = path.join(runnerTemp, uniqueId)
stateHelper.setSshKeyPath(this.sshKeyPath)
await fs.promises.mkdir(runnerTemp, {recursive: true})
await fs.promises.writeFile(
this.sshKeyPath,
this.settings.sshKey.trim() + '\n',
{mode: 0o600}
)
// Remove inherited permissions on Windows
if (IS_WINDOWS) {
const icacls = await io.which('icacls.exe')
await exec.exec(
`"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`
)
await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`)
}
// Write known hosts
const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
let userKnownHosts = ''
try {
userKnownHosts = (
await fs.promises.readFile(userKnownHostsPath)
).toString()
} catch (err) {
if (err.code !== 'ENOENT') {
throw err
}
}
let knownHosts = ''
if (userKnownHosts) {
knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`
}
if (this.settings.sshKnownHosts) {
knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`
}
knownHosts += `# Begin implicitly added github.com\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n# End implicitly added github.com\n`
this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`)
stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath)
await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts)
// Configure GIT_SSH_COMMAND
const sshPath = await io.which('ssh', true)
let sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
this.sshKeyPath
)}"`
if (this.settings.sshStrict) {
sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
}
sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
this.sshKnownHostsPath
)}"`
core.info(`Temporarily overriding GIT_SSH_COMMAND=${sshCommand}`)
this.git.setEnvironmentVariable('GIT_SSH_COMMAND', sshCommand)
// Configure core.sshCommand
if (this.settings.persistCredentials) {
await this.git.config(SSH_COMMAND_KEY, sshCommand)
}
}
private async configureToken( private async configureToken(
configPath?: string, configPath?: string,
globalConfig?: boolean globalConfig?: boolean
@ -198,12 +282,43 @@ class GitAuthHelper {
await fs.promises.writeFile(configPath, content) await fs.promises.writeFile(configPath, content)
} }
private async removeSsh(): Promise<void> {
// SSH key
const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
if (keyPath) {
try {
await io.rmRF(keyPath)
} catch (err) {
core.debug(err.message)
core.warning(`Failed to remove SSH key '${keyPath}'`)
}
}
// SSH known hosts
const knownHostsPath =
this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
if (knownHostsPath) {
try {
await io.rmRF(knownHostsPath)
} catch {
// Intentionally empty
}
}
// SSH command
await this.removeGitConfig(SSH_COMMAND_KEY)
}
private async removeToken(): Promise<void> { private async removeToken(): Promise<void> {
// HTTP extra header // HTTP extra header
await this.removeGitConfig(this.tokenConfigKey) await this.removeGitConfig(this.tokenConfigKey)
} }
private async removeGitConfig(configKey: string): Promise<void> { private async removeGitConfig(
configKey: string,
submoduleOnly: boolean = false
): Promise<void> {
if (!submoduleOnly) {
if ( if (
(await this.git.configExists(configKey)) && (await this.git.configExists(configKey)) &&
!(await this.git.tryConfigUnset(configKey)) !(await this.git.tryConfigUnset(configKey))
@ -211,10 +326,11 @@ class GitAuthHelper {
// Load the config contents // Load the config contents
core.warning(`Failed to remove '${configKey}' from the git config`) core.warning(`Failed to remove '${configKey}' from the git config`)
} }
}
const pattern = regexpHelper.escape(configKey) const pattern = regexpHelper.escape(configKey)
await this.git.submoduleForeach( await this.git.submoduleForeach(
`git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, `git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`,
true true
) )
} }

@ -18,7 +18,11 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
core.info( core.info(
`Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}` `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`
) )
const repositoryUrl = `https://${hostname}/${encodeURIComponent( const repositoryUrl = settings.sshKey
? `git@${hostname}:${encodeURIComponent(
settings.repositoryOwner
)}/${encodeURIComponent(settings.repositoryName)}.git`
: `https://${hostname}/${encodeURIComponent(
settings.repositoryOwner settings.repositoryOwner
)}/${encodeURIComponent(settings.repositoryName)}` )}/${encodeURIComponent(settings.repositoryName)}`

@ -10,5 +10,8 @@ export interface IGitSourceSettings {
submodules: boolean submodules: boolean
nestedSubmodules: boolean nestedSubmodules: boolean
authToken: string authToken: string
sshKey: string
sshKnownHosts: string
sshStrict: boolean
persistCredentials: boolean persistCredentials: boolean
} }

@ -112,6 +112,12 @@ export function getInputs(): IGitSourceSettings {
// Auth token // Auth token
result.authToken = core.getInput('token') result.authToken = core.getInput('token')
// SSH
result.sshKey = core.getInput('ssh-key')
result.sshKnownHosts = core.getInput('ssh-known-hosts')
result.sshStrict =
(core.getInput('ssh-strict') || 'true').toUpperCase() === 'TRUE'
// Persist credentials // Persist credentials
result.persistCredentials = result.persistCredentials =
(core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE' (core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE'

@ -59,13 +59,17 @@ function updateUsage(
// Constrain the width of the description // Constrain the width of the description
const width = 80 const width = 80
let description = input.description as string let description = (input.description as string)
.trimRight()
.replace(/\r\n/g, '\n') // Convert CR to LF
.replace(/ +/g, ' ') // Squash consecutive spaces
.replace(/ \n/g, '\n') // Squash space followed by newline
while (description) { while (description) {
// Longer than width? Find a space to break apart // Longer than width? Find a space to break apart
let segment: string = description let segment: string = description
if (description.length > width) { if (description.length > width) {
segment = description.substr(0, width + 1) segment = description.substr(0, width + 1)
while (!segment.endsWith(' ') && segment) { while (!segment.endsWith(' ') && !segment.endsWith('\n') && segment) {
segment = segment.substr(0, segment.length - 1) segment = segment.substr(0, segment.length - 1)
} }
@ -77,15 +81,30 @@ function updateUsage(
segment = description segment = description
} }
description = description.substr(segment.length) // Remaining // Check for newline
segment = segment.trimRight() // Trim the trailing space const newlineIndex = segment.indexOf('\n')
newReadme.push(` # ${segment}`) if (newlineIndex >= 0) {
segment = segment.substr(0, newlineIndex + 1)
}
// Append segment
newReadme.push(` # ${segment}`.trimRight())
// Remaining
description = description.substr(segment.length)
} }
// Input and default
if (input.default !== undefined) { if (input.default !== undefined) {
// Append blank line if description had paragraphs
if ((input.description as string).trimRight().match(/\n[ ]*\r?\n/)) {
newReadme.push(` #`)
}
// Default
newReadme.push(` # Default: ${input.default}`) newReadme.push(` # Default: ${input.default}`)
} }
// Input name
newReadme.push(` ${key}: ''`) newReadme.push(` ${key}: ''`)
firstInput = false firstInput = false

@ -11,6 +11,17 @@ export const IsPost = !!process.env['STATE_isPost']
export const RepositoryPath = export const RepositoryPath =
(process.env['STATE_repositoryPath'] as string) || '' (process.env['STATE_repositoryPath'] as string) || ''
/**
* The SSH key path for the POST action. The value is empty during the MAIN action.
*/
export const SshKeyPath = (process.env['STATE_sshKeyPath'] as string) || ''
/**
* The SSH known hosts path for the POST action. The value is empty during the MAIN action.
*/
export const SshKnownHostsPath =
(process.env['STATE_sshKnownHostsPath'] as string) || ''
/** /**
* Save the repository path so the POST action can retrieve the value. * Save the repository path so the POST action can retrieve the value.
*/ */
@ -22,6 +33,24 @@ export function setRepositoryPath(repositoryPath: string) {
) )
} }
/**
* Save the SSH key path so the POST action can retrieve the value.
*/
export function setSshKeyPath(sshKeyPath: string) {
coreCommand.issueCommand('save-state', {name: 'sshKeyPath'}, sshKeyPath)
}
/**
* Save the SSH known hosts path so the POST action can retrieve the value.
*/
export function setSshKnownHostsPath(sshKnownHostsPath: string) {
coreCommand.issueCommand(
'save-state',
{name: 'sshKnownHostsPath'},
sshKnownHostsPath
)
}
// Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic. // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic.
// This is necessary since we don't have a separate entry point. // This is necessary since we don't have a separate entry point.
if (!IsPost) { if (!IsPost) {