From 768e84121e9874ccc6e8579fd36ec31f89de1b28 Mon Sep 17 00:00:00 2001 From: whyujjwal <138800022+whyujjwal@users.noreply.github.com> Date: Fri, 22 May 2026 17:09:08 +0000 Subject: [PATCH] fix: reuse ssh agent across repeated action steps --- dist/index.js | 40 +++++++++++++++++++----- package.json | 1 + src/index.ts | 51 ++++++++++++++++++++++++++----- tests/ssh.test.mjs | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 tests/ssh.test.mjs diff --git a/dist/index.js b/dist/index.js index fe6c47f..9add6b5 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6,6 +6,7 @@ import * as fs$1 from "fs"; import { constants, promises } from "fs"; import * as path$1 from "path"; import * as events from "events"; +import { fileURLToPath } from "node:url"; import "child_process"; import "timers"; import * as process$1 from "node:process"; @@ -36632,25 +36633,49 @@ var { VERSION, YAML, argv, dotenv, echo, expBackoff, fetch, fs, glob, globby, mi //#endregion //#region src/index.ts $.verbose = true; -(async function main() { +var DEFAULT_AUTH_SOCK = "/tmp/ssh-auth.sock"; +async function main() { try { await ssh(); await dep(); } catch (err) { setFailed(err instanceof Error ? err.message : String(err)); } -})(); +} +async function sshAgentIsReachable(authSock) { + try { + await $({ env: { + ...process.env, + SSH_AUTH_SOCK: authSock + } })`ssh-add -l`; + return true; + } catch (err) { + return (err instanceof Error ? err.message : String(err)).includes("The agent has no identities."); + } +} +async function ensureSshAgent(authSock) { + if (await sshAgentIsReachable(authSock)) { + exportVariable("SSH_AUTH_SOCK", authSock); + return; + } + if (fs.existsSync(authSock)) fs.rmSync(authSock, { force: true }); + const agentPid = (await $`ssh-agent -a ${authSock}`).stdout.match(/SSH_AGENT_PID=(\d+)/)?.[1]; + exportVariable("SSH_AUTH_SOCK", authSock); + if (agentPid !== void 0) exportVariable("SSH_AGENT_PID", agentPid); +} async function ssh() { if (getBooleanInput("skip-ssh-setup")) return; const sshHomeDir = `${process.env["HOME"]}/.ssh`; if (!fs.existsSync(sshHomeDir)) fs.mkdirSync(sshHomeDir); - const authSock = "/tmp/ssh-auth.sock"; - await $`ssh-agent -a ${authSock}`; - exportVariable("SSH_AUTH_SOCK", authSock); + const authSock = process.env["SSH_AUTH_SOCK"] || DEFAULT_AUTH_SOCK; + await ensureSshAgent(authSock); let privateKey = getInput("private-key"); if (privateKey !== "") { privateKey = privateKey.replace(/\r/g, "").trim() + "\n"; - const p = $`ssh-add -`; + const p = $({ env: { + ...process.env, + SSH_AUTH_SOCK: authSock + } })`ssh-add -`; p.stdin.write(privateKey); p.stdin.end(); await p; @@ -36731,5 +36756,6 @@ async function dep() { setFailed(`Failed: dep ${cmd}`); } } +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); //#endregion -export {}; +export { dep, main, ssh }; diff --git a/package.json b/package.json index c773d4d..90b87db 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "build": "vite build", "typecheck": "tsc --noEmit", + "test": "npm run build && node --test tests/*.test.mjs", "format": "prettier --write .", "format:check": "prettier --check ." }, diff --git a/src/index.ts b/src/index.ts index 3864887..df54f96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import * as core from '@actions/core' import { $, fs, cd } from 'zx' +import { fileURLToPath } from 'node:url' $.verbose = true @@ -13,16 +14,47 @@ interface DeployerManifestEntry { url: string } -void (async function main(): Promise { +const DEFAULT_AUTH_SOCK = '/tmp/ssh-auth.sock' + +export async function main(): Promise { try { await ssh() await dep() } catch (err) { core.setFailed(err instanceof Error ? err.message : String(err)) } -})() +} + +async function sshAgentIsReachable(authSock: string): Promise { + try { + await $({ env: { ...process.env, SSH_AUTH_SOCK: authSock } })`ssh-add -l` + return true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return message.includes('The agent has no identities.') + } +} + +async function ensureSshAgent(authSock: string): Promise { + if (await sshAgentIsReachable(authSock)) { + core.exportVariable('SSH_AUTH_SOCK', authSock) + return + } -async function ssh(): Promise { + if (fs.existsSync(authSock)) { + fs.rmSync(authSock, { force: true }) + } + + const result = await $`ssh-agent -a ${authSock}` + const agentPid = result.stdout.match(/SSH_AGENT_PID=(\d+)/)?.[1] + + core.exportVariable('SSH_AUTH_SOCK', authSock) + if (agentPid !== undefined) { + core.exportVariable('SSH_AGENT_PID', agentPid) + } +} + +export async function ssh(): Promise { if (core.getBooleanInput('skip-ssh-setup')) { return } @@ -33,14 +65,13 @@ async function ssh(): Promise { fs.mkdirSync(sshHomeDir) } - const authSock = '/tmp/ssh-auth.sock' - await $`ssh-agent -a ${authSock}` - core.exportVariable('SSH_AUTH_SOCK', authSock) + const authSock = process.env['SSH_AUTH_SOCK'] || DEFAULT_AUTH_SOCK + await ensureSshAgent(authSock) let privateKey = core.getInput('private-key') if (privateKey !== '') { privateKey = privateKey.replace(/\r/g, '').trim() + '\n' - const p = $`ssh-add -` + const p = $({ env: { ...process.env, SSH_AUTH_SOCK: authSock } })`ssh-add -` p.stdin.write(privateKey) p.stdin.end() await p @@ -62,7 +93,7 @@ async function ssh(): Promise { } } -async function dep(): Promise { +export async function dep(): Promise { let bin = core.getInput('deployer-binary') const subDirectory = core.getInput('sub-directory').trim() @@ -167,3 +198,7 @@ async function dep(): Promise { core.setFailed(`Failed: dep ${cmd}`) } } + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + void main() +} diff --git a/tests/ssh.test.mjs b/tests/ssh.test.mjs new file mode 100644 index 0000000..25536f9 --- /dev/null +++ b/tests/ssh.test.mjs @@ -0,0 +1,76 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync, spawnSync } from 'node:child_process' + +import { ssh } from '../dist/index.js' + +function restoreEnv(snapshot) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} + +test('ssh setup reuses an existing agent across repeated action runs', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deployphp-action-')) + const homeDir = path.join(tmpDir, 'home') + const keyPath = path.join(tmpDir, 'id_ed25519') + const authSock = path.join(tmpDir, 'ssh-auth.sock') + fs.mkdirSync(homeDir, { recursive: true }) + execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath], { + stdio: 'ignore', + }) + + const envSnapshot = { + HOME: process.env.HOME, + SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK, + SSH_AGENT_PID: process.env.SSH_AGENT_PID, + 'INPUT_SKIP-SSH-SETUP': process.env['INPUT_SKIP-SSH-SETUP'], + 'INPUT_PRIVATE-KEY': process.env['INPUT_PRIVATE-KEY'], + } + + process.env.HOME = homeDir + process.env.SSH_AUTH_SOCK = authSock + delete process.env.SSH_AGENT_PID + process.env['INPUT_SKIP-SSH-SETUP'] = 'false' + process.env['INPUT_PRIVATE-KEY'] = fs.readFileSync(keyPath, 'utf8') + + try { + await ssh() + let listed = spawnSync('ssh-add', ['-l'], { + env: { ...process.env, SSH_AUTH_SOCK: authSock }, + encoding: 'utf8', + }) + assert.equal(listed.status, 0, listed.stderr) + const firstPid = process.env.SSH_AGENT_PID + assert.ok(firstPid, 'expected SSH_AGENT_PID to be exported') + + await ssh() + listed = spawnSync('ssh-add', ['-l'], { + env: { ...process.env, SSH_AUTH_SOCK: authSock }, + encoding: 'utf8', + }) + assert.equal(listed.status, 0, listed.stderr) + assert.equal(process.env.SSH_AGENT_PID, firstPid) + assert.ok(fs.existsSync(authSock), 'expected SSH agent socket to remain available') + } finally { + if (process.env.SSH_AGENT_PID) { + spawnSync('ssh-agent', ['-k'], { + env: { + ...process.env, + SSH_AUTH_SOCK: authSock, + SSH_AGENT_PID: process.env.SSH_AGENT_PID, + }, + stdio: 'ignore', + }) + } + restoreEnv(envSnapshot) + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +})