Read-only mirror — the public repositories of the Forgejo instance, refreshed every 10 minutes. The live instance (fg.dieschoenewolke.de) is reachable only over the VPN; clone here with https://git.dieschoenewolke.de/git/<owner>/<repo>.git.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoreric sciple <ericsciple@users.noreply.github.com>2020-03-11 15:55:17 -0400
committerGitHub <noreply@github.com>2020-03-11 15:55:17 -0400
commit1b406b27feed694fb3a05a638c92124514baa6ea (patch)
tree05a8d7cd5c1748d2e83782e43edb2c274882ea90
parentad8315eb967265115b59c3b9dc1516f8ac5917ba (diff)
add ssh support (#163)
-rw-r--r--README.md40
-rw-r--r--__test__/git-auth-helper.test.ts433
-rw-r--r--action.yml45
-rw-r--r--dist/index.js152
-rw-r--r--src/git-auth-helper.ts146
-rw-r--r--src/git-source-provider.ts10
-rw-r--r--src/git-source-settings.ts3
-rw-r--r--src/input-helper.ts6
-rw-r--r--src/misc/generate-docs.ts31
-rw-r--r--src/state-helper.ts29
10 files changed, 837 insertions, 58 deletions
diff --git a/README.md b/README.md
index 9233a25..df3355c 100644
--- a/README.md
+++ b/README.md
@@ -45,14 +45,40 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous
# Otherwise, defaults to `master`.
ref: ''
- # Auth token used to fetch the repository. The token is stored in the local git
- # config, which enables your scripts to run authenticated git commands. The
- # post-job step removes the token from the git config. [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)
+ # Personal access token (PAT) used to fetch the repository. The PAT is configured
+ # with the local git config, which enables your scripts to run authenticated git
+ # commands. The post-job step removes the PAT.
+ #
+ # 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 }}
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
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
# recursively checkout submodules.
+ #
+ # When the `ssh-key` input is not provided, SSH URLs beginning with
+ # `git@github.com:` are converted to HTTPS.
+ #
# Default: false
submodules: ''
```
diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts
index 68926f2..dc03ab3 100644
--- a/__test__/git-auth-helper.test.ts
+++ b/__test__/git-auth-helper.test.ts
@@ -2,10 +2,13 @@ import * as core from '@actions/core'
import * as fs from 'fs'
import * as gitAuthHelper from '../lib/git-auth-helper'
import * as io from '@actions/io'
+import * as os from 'os'
import * as path from 'path'
+import * as stateHelper from '../lib/state-helper'
import {IGitCommandManager} from '../lib/git-command-manager'
import {IGitSourceSettings} from '../lib/git-source-settings'
+const isWindows = process.platform === 'win32'
const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper')
const originalRunnerTemp = process.env['RUNNER_TEMP']
const originalHome = process.env['HOME']
@@ -16,9 +19,13 @@ let runnerTemp: string
let tempHomedir: string
let git: IGitCommandManager & {env: {[key: string]: string}}
let settings: IGitSourceSettings
+let sshPath: string
describe('git-auth-helper tests', () => {
beforeAll(async () => {
+ // SSH
+ sshPath = await io.which('ssh')
+
// Clear test workspace
await io.rmRF(testWorkspace)
})
@@ -32,6 +39,12 @@ describe('git-auth-helper tests', () => {
jest.spyOn(core, 'warning').mockImplementation(jest.fn())
jest.spyOn(core, 'info').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(() => {
@@ -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 =
'configureAuth registers basic credential as secret'
it(configureAuth_registersBasicCredentialAsSecret, async () => {
@@ -129,6 +188,173 @@ describe('git-auth-helper tests', () => {
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 =
'configureGlobalAuth copies global git config'
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 =
'configureSubmoduleAuth does not configure token when persist credentials false'
it(
@@ -223,37 +510,135 @@ describe('git-auth-helper tests', () => {
settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
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
+ await authHelper.configureSubmoduleAuth()
+
+ // Assert
+ expect(mockSubmoduleForeach).toBeCalledTimes(1)
+ expect(mockSubmoduleForeach.mock.calls[0][0] as string).toMatch(
+ /unset-all.*insteadOf/
+ )
+ }
+ )
+
+ const configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet =
+ 'configureSubmoduleAuth does not configure URL insteadOf when persist credentials true and SSH key set'
+ it(
+ configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet,
+ async () => {
+ if (!sshPath) {
+ process.stdout.write(
+ `Skipped test "${configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet}". Executable 'ssh' not found in the PATH.\n`
+ )
+ return
+ }
+
+ // Arrange
+ await setup(
+ configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet
+ )
+ 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(git.submoduleForeach).not.toHaveBeenCalled()
+ 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_configuresTokenWhenPersistCredentialsTrue =
- 'configureSubmoduleAuth configures token when persist credentials true'
+ const configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse =
+ 'configureSubmoduleAuth removes URL insteadOf when persist credentials false'
it(
- configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue,
+ configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse,
async () => {
// Arrange
await setup(
- configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue
+ configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse
)
+ settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
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
await authHelper.configureSubmoduleAuth()
// Assert
- expect(git.submoduleForeach).toHaveBeenCalledTimes(1)
+ 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'
it(removeAuth_removesToken, async () => {
// Arrange
@@ -401,6 +786,36 @@ async function setup(testName: string): Promise<void> {
ref: 'refs/heads/master',
repositoryName: 'my-repo',
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]
}
diff --git a/action.yml b/action.yml
index a411037..7d5412d 100644
--- a/action.yml
+++ b/action.yml
@@ -1,6 +1,6 @@
name: 'Checkout'
description: 'Checkout a Git repository at a particular version'
-inputs:
+inputs:
repository:
description: 'Repository name with owner. For example, actions/checkout'
default: ${{ github.repository }}
@@ -11,13 +11,42 @@ inputs:
event. Otherwise, defaults to `master`.
token:
description: >
- Auth token used to fetch the repository. The token is stored in the local
- git config, which enables your scripts to run authenticated git commands.
- The post-job step removes the token from the git config. [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)
+ Personal access token (PAT) used to fetch the repository. The PAT is configured
+ with the local git config, which enables your scripts to run authenticated git
+ commands. The post-job step removes the PAT.
+
+
+ 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 }}
+ 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:
- 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
path:
description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
@@ -34,6 +63,10 @@ inputs:
description: >
Whether to checkout submodules: `true` to checkout submodules or `recursive` to
recursively checkout submodules.
+
+
+ When the `ssh-key` input is not provided, SSH URLs beginning with `git@github.com:` are
+ converted to HTTPS.
default: false
runs:
using: node12
diff --git a/dist/index.js b/dist/index.js
index 3082946..706f035 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2622,12 +2622,34 @@ exports.IsPost = !!process.env['STATE_isPost'];
*/
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.
*/
function setRepositoryPath(repositoryPath) {
coreCommand.issueCommand('save-state', { name: 'repositoryPath' }, repositoryPath);
}
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.
// This is necessary since we don't have a separate entry point.
if (!exports.IsPost) {
@@ -5080,14 +5102,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
Object.defineProperty(exports, "__esModule", { value: true });
const assert = __importStar(__webpack_require__(357));
const core = __importStar(__webpack_require__(470));
+const exec = __importStar(__webpack_require__(986));
const fs = __importStar(__webpack_require__(747));
const io = __importStar(__webpack_require__(1));
const os = __importStar(__webpack_require__(87));
const path = __importStar(__webpack_require__(622));
const regexpHelper = __importStar(__webpack_require__(528));
+const stateHelper = __importStar(__webpack_require__(153));
const v4_1 = __importDefault(__webpack_require__(826));
const IS_WINDOWS = process.platform === 'win32';
const HOSTNAME = 'github.com';
+const SSH_COMMAND_KEY = 'core.sshCommand';
function createAuthHelper(git, settings) {
return new GitAuthHelper(git, settings);
}
@@ -5097,6 +5122,8 @@ class GitAuthHelper {
this.tokenConfigKey = `http.https://${HOSTNAME}/.extraheader`;
this.insteadOfKey = `url.https://${HOSTNAME}/.insteadOf`;
this.insteadOfValue = `git@${HOSTNAME}:`;
+ this.sshKeyPath = '';
+ this.sshKnownHostsPath = '';
this.temporaryHomePath = '';
this.git = gitCommandManager;
this.settings = gitSourceSettings || {};
@@ -5111,6 +5138,7 @@ class GitAuthHelper {
// Remove possible previous values
yield this.removeAuth();
// Configure new values
+ yield this.configureSsh();
yield this.configureToken();
});
}
@@ -5150,7 +5178,9 @@ class GitAuthHelper {
yield this.configureToken(newGitConfigPath, true);
// Configure HTTPS instead of SSH
yield this.git.tryConfigUnset(this.insteadOfKey, true);
- yield this.git.config(this.insteadOfKey, this.insteadOfValue, true);
+ if (!this.settings.sshKey) {
+ yield this.git.config(this.insteadOfKey, this.insteadOfValue, true);
+ }
}
catch (err) {
// Unset in case somehow written to the real global config
@@ -5162,27 +5192,29 @@ class GitAuthHelper {
}
configureSubmoduleAuth() {
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) {
// Configure a placeholder value. This approach avoids the credential being captured
// 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
- 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 = yield this.git.submoduleForeach(commands.join(' && '), this.settings.nestedSubmodules);
+ 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);
// Replace the placeholder
const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
for (const configPath of configPaths) {
core.debug(`Replacing token placeholder in '${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() {
return __awaiter(this, void 0, void 0, function* () {
+ yield this.removeSsh();
yield this.removeToken();
});
}
@@ -5193,6 +5225,62 @@ class GitAuthHelper {
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) {
return __awaiter(this, void 0, void 0, function* () {
// Validate args
@@ -5223,21 +5311,50 @@ class GitAuthHelper {
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() {
return __awaiter(this, void 0, void 0, function* () {
// HTTP extra header
yield this.removeGitConfig(this.tokenConfigKey);
});
}
- removeGitConfig(configKey) {
+ removeGitConfig(configKey, submoduleOnly = false) {
return __awaiter(this, void 0, void 0, function* () {
- if ((yield this.git.configExists(configKey)) &&
- !(yield this.git.tryConfigUnset(configKey))) {
- // Load the config contents
- core.warning(`Failed to remove '${configKey}' from the git config`);
+ if (!submoduleOnly) {
+ if ((yield this.git.configExists(configKey)) &&
+ !(yield this.git.tryConfigUnset(configKey))) {
+ // Load the config contents
+ core.warning(`Failed to remove '${configKey}' from the git config`);
+ }
}
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* () {
// Repository URL
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
if (fsHelper.fileExistsSync(settings.repositoryPath)) {
yield io.rmRF(settings.repositoryPath);
@@ -13940,6 +14059,11 @@ function getInputs() {
core.debug(`recursive submodules = ${result.nestedSubmodules}`);
// Auth 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
result.persistCredentials =
(core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE';
diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts
index 3f36ff8..7418c7c 100644
--- a/src/git-auth-helper.ts
+++ b/src/git-auth-helper.ts
@@ -13,6 +13,7 @@ import {IGitSourceSettings} from './git-source-settings'
const IS_WINDOWS = process.platform === 'win32'
const HOSTNAME = 'github.com'
+const SSH_COMMAND_KEY = 'core.sshCommand'
export interface IGitAuthHelper {
configureAuth(): Promise<void>
@@ -36,6 +37,8 @@ class GitAuthHelper {
private readonly tokenPlaceholderConfigValue: string
private readonly insteadOfKey: string = `url.https://${HOSTNAME}/.insteadOf`
private readonly insteadOfValue: string = `git@${HOSTNAME}:`
+ private sshKeyPath = ''
+ private sshKnownHostsPath = ''
private temporaryHomePath = ''
private tokenConfigValue: string
@@ -61,6 +64,7 @@ class GitAuthHelper {
await this.removeAuth()
// Configure new values
+ await this.configureSsh()
await this.configureToken()
}
@@ -106,7 +110,9 @@ class GitAuthHelper {
// Configure HTTPS instead of SSH
await this.git.tryConfigUnset(this.insteadOfKey, true)
- await this.git.config(this.insteadOfKey, this.insteadOfValue, true)
+ if (!this.settings.sshKey) {
+ await this.git.config(this.insteadOfKey, this.insteadOfValue, true)
+ }
} catch (err) {
// Unset in case somehow written to the real global config
core.info(
@@ -118,17 +124,15 @@ class GitAuthHelper {
}
async configureSubmoduleAuth(): Promise<void> {
+ // Remove possible previous HTTPS instead of SSH
+ await this.removeGitConfig(this.insteadOfKey, true)
+
if (this.settings.persistCredentials) {
// Configure a placeholder value. This approach avoids the credential being captured
// 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
- 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(
- commands.join(' && '),
+ `git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`,
this.settings.nestedSubmodules
)
@@ -139,10 +143,19 @@ class GitAuthHelper {
core.debug(`Replacing token placeholder in '${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> {
+ await this.removeSsh()
await this.removeToken()
}
@@ -152,6 +165,77 @@ class GitAuthHelper {
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(
configPath?: string,
globalConfig?: boolean
@@ -198,23 +282,55 @@ class GitAuthHelper {
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> {
// HTTP extra header
await this.removeGitConfig(this.tokenConfigKey)
}
- private async removeGitConfig(configKey: string): Promise<void> {
- if (
- (await this.git.configExists(configKey)) &&
- !(await this.git.tryConfigUnset(configKey))
- ) {
- // Load the config contents
- core.warning(`Failed to remove '${configKey}' from the git config`)
+ private async removeGitConfig(
+ configKey: string,
+ submoduleOnly: boolean = false
+ ): Promise<void> {
+ if (!submoduleOnly) {
+ if (
+ (await this.git.configExists(configKey)) &&
+ !(await this.git.tryConfigUnset(configKey))
+ ) {
+ // Load the config contents
+ core.warning(`Failed to remove '${configKey}' from the git config`)
+ }
}
const pattern = regexpHelper.escape(configKey)
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
)
}
diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts
index 90f97c9..7ce9fb7 100644
--- a/src/git-source-provider.ts
+++ b/src/git-source-provider.ts
@@ -18,9 +18,13 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
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
if (fsHelper.fileExistsSync(settings.repositoryPath)) {
diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts
index e411fad..04d548c 100644
--- a/src/git-source-settings.ts
+++ b/src/git-source-settings.ts
@@ -10,5 +10,8 @@ export interface IGitSourceSettings {
submodules: boolean
nestedSubmodules: boolean
authToken: string
+ sshKey: string
+ sshKnownHosts: string
+ sshStrict: boolean
persistCredentials: boolean
}
diff --git a/src/input-helper.ts b/src/input-helper.ts
index 3769350..11a1ab6 100644
--- a/src/input-helper.ts
+++ b/src/input-helper.ts
@@ -112,6 +112,12 @@ export function getInputs(): IGitSourceSettings {
// Auth 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
result.persistCredentials =
(core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE'
diff --git a/src/misc/generate-docs.ts b/src/misc/generate-docs.ts
index 884d606..defda5a 100644
--- a/src/misc/generate-docs.ts
+++ b/src/misc/generate-docs.ts
@@ -59,13 +59,17 @@ function updateUsage(
// Constrain the width of the description
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) {
// Longer than width? Find a space to break apart
let segment: string = description
if (description.length > width) {
segment = description.substr(0, width + 1)
- while (!segment.endsWith(' ') && segment) {
+ while (!segment.endsWith(' ') && !segment.endsWith('\n') && segment) {
segment = segment.substr(0, segment.length - 1)
}
@@ -77,15 +81,30 @@ function updateUsage(
segment = description
}
- description = description.substr(segment.length) // Remaining
- segment = segment.trimRight() // Trim the trailing space
- newReadme.push(` # ${segment}`)
+ // Check for newline
+ const newlineIndex = segment.indexOf('\n')
+ 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) {
+ // 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}`)
}
+
+ // Input name
newReadme.push(` ${key}: ''`)
firstInput = false
diff --git a/src/state-helper.ts b/src/state-helper.ts
index da15d86..3c657b1 100644
--- a/src/state-helper.ts
+++ b/src/state-helper.ts
@@ -12,6 +12,17 @@ export const RepositoryPath =
(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.
*/
export function setRepositoryPath(repositoryPath: string) {
@@ -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.
// This is necessary since we don't have a separate entry point.
if (!IsPost) {