mirror of
				https://github.com/actions/checkout.git
				synced 2025-10-30 18:33:59 +08:00 
			
		
		
		
	add ssh support (#163)
This commit is contained in:
		| @@ -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 | ||||
|     ) | ||||
|   } | ||||
|   | ||||
| @@ -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)) { | ||||
|   | ||||
| @@ -10,5 +10,8 @@ export interface IGitSourceSettings { | ||||
|   submodules: boolean | ||||
|   nestedSubmodules: boolean | ||||
|   authToken: string | ||||
|   sshKey: string | ||||
|   sshKnownHosts: string | ||||
|   sshStrict: boolean | ||||
|   persistCredentials: boolean | ||||
| } | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 eric sciple
					eric sciple