From b2e6b7ed13bcde9d37c9e3e6967cd3ecfd2807ad Mon Sep 17 00:00:00 2001 From: eric sciple Date: Wed, 11 Mar 2020 15:55:17 -0400 Subject: [PATCH] add ssh support (#163) --- README.md | 40 ++- __test__/git-auth-helper.test.ts | 433 ++++++++++++++++++++++++++++++- action.yml | 45 +++- dist/index.js | 152 ++++++++++- src/git-auth-helper.ts | 146 +++++++++-- src/git-source-provider.ts | 10 +- src/git-source-settings.ts | 3 + src/input-helper.ts | 6 + src/misc/generate-docs.ts | 31 ++- src/state-helper.ts | 29 +++ 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 => { + 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 + 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 + 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).mockClear() // reset calls + const mockSubmoduleForeach = git.submoduleForeach as jest.Mock + mockSubmoduleForeach.mockClear() // reset calls // Act await authHelper.configureSubmoduleAuth() // 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 = - 'configureSubmoduleAuth configures token when persist credentials true' + const configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet = + 'configureSubmoduleAuth does not configure URL insteadOf when persist credentials true and SSH key set' it( - configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue, + 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_configuresTokenWhenPersistCredentialsTrue + configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet ) const authHelper = gitAuthHelper.createAuthHelper(git, settings) await authHelper.configureAuth() - ;(git.submoduleForeach as jest.Mock).mockClear() // reset calls + const mockSubmoduleForeach = git.submoduleForeach as jest.Mock + mockSubmoduleForeach.mockClear() // reset calls // Act await authHelper.configureSubmoduleAuth() // 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 + 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' it(removeAuth_removesToken, async () => { // Arrange @@ -401,6 +786,36 @@ async function setup(testName: string): Promise { 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 { + 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 { + 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 @@ -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. */ 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. */ @@ -2628,6 +2636,20 @@ 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 @@ -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 { + // 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 { + await this.removeSsh() await this.removeToken() } @@ -152,6 +165,77 @@ class GitAuthHelper { await io.rmRF(this.temporaryHomePath) } + private async configureSsh(): Promise { + 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 { + // 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 { // HTTP extra header await this.removeGitConfig(this.tokenConfigKey) } - private async removeGitConfig(configKey: string): Promise { - 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 { + 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 { 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 @@ -11,6 +11,17 @@ export const IsPost = !!process.env['STATE_isPost'] 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. */ @@ -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) {