From c672036ff266b766f1093f5ca6cc4dbd4555037b Mon Sep 17 00:00:00 2001 From: james-tindal <10291002+james-tindal@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:23:32 +0300 Subject: [PATCH 1/2] feat: optional retry on ssh connection failure --- action.yml | 8 ++++ entrypoint.sh | 106 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/action.yml b/action.yml index 7027751..46865d9 100644 --- a/action.yml +++ b/action.yml @@ -84,6 +84,12 @@ inputs: version: description: | The version of drone-ssh to use. + retry_attempts: + description: "Number of retry attempts after the initial SSH command fails." + default: "0" + retry_delay: + description: "Delay between retry attempts" + default: "0s" outputs: stdout: @@ -138,6 +144,8 @@ runs: INPUT_SYNC: ${{ inputs.sync }} INPUT_CAPTURE_STDOUT: ${{ inputs.capture_stdout }} INPUT_CURL_INSECURE: ${{ inputs.curl_insecure }} + INPUT_RETRY_ATTEMPTS: ${{ inputs.retry_attempts }} + INPUT_RETRY_DELAY: ${{ inputs.retry_delay }} DRONE_SSH_VERSION: ${{ inputs.version }} branding: diff --git a/entrypoint.sh b/entrypoint.sh index 123c26d..d57cdd8 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -20,6 +20,104 @@ function log_error() { exit "$2" } +function validate_non_negative_integer() { + local name="$1" + local value="$2" + + if [[ ! "${value}" =~ ^[0-9]+$ ]]; then + log_error "${name} must be a non-negative integer, got: ${value}" 1 + fi +} + +function is_retryable_connection_failure() { + local output="$1" + + [[ "${output}" =~ dial[[:space:]]+tcp ]] || \ + [[ "${output}" =~ i/o[[:space:]]+timeout ]] || \ + [[ "${output}" =~ connection[[:space:]]+refused ]] || \ + [[ "${output}" =~ connection[[:space:]]+reset ]] || \ + [[ "${output}" =~ connection[[:space:]]+timed[[:space:]]+out ]] || \ + [[ "${output}" =~ no[[:space:]]+route[[:space:]]+to[[:space:]]+host ]] || \ + [[ "${output}" =~ network[[:space:]]+is[[:space:]]+unreachable ]] || \ + [[ "${output}" =~ no[[:space:]]+such[[:space:]]+host ]] || \ + [[ "${output}" =~ ssh:[[:space:]]+handshake[[:space:]]+failed ]] +} + +function run_ssh_command() { + local status=0 + local stderr="" + + if [[ "${INPUT_CAPTURE_STDOUT}" == 'true' ]]; then + echo 'stdout<> "${GITHUB_OUTPUT}" + exec 3>&1 + set +e + stderr="$( + { + "${TARGET}" "$@" 1> >(tee -a "${GITHUB_OUTPUT}" >&3) + } 2>&1 | tee /dev/stderr + )" + status="${PIPESTATUS[0]}" + set -e + exec 3>&- + echo 'EOF' >> "${GITHUB_OUTPUT}" + RUN_SSH_OUTPUT="${stderr}" + return "${status}" + else + exec 3>&1 + set +e + stderr="$( + { + "${TARGET}" "$@" 1>&3 + } 2>&1 | tee /dev/stderr + )" + status="${PIPESTATUS[0]}" + set -e + exec 3>&- + RUN_SSH_OUTPUT="${stderr}" + return "${status}" + fi +} + +function run_ssh_command_with_retry() { + local retries="${INPUT_RETRY_ATTEMPTS:-0}" + local delay="${INPUT_RETRY_DELAY:-0}" + local max_attempts + local attempt=1 + local status=0 + RUN_SSH_OUTPUT="" + + validate_non_negative_integer "retry_attempts" "${retries}" + + max_attempts=$((retries + 1)) + + while true; do + if (( retries > 0 )); then + echo "SSH command attempt ${attempt}/${max_attempts}" + fi + + run_ssh_command "$@" + status="$?" + + if (( status == 0 )); then + return 0 + fi + + if (( attempt >= max_attempts )); then + return "${status}" + fi + + if ! is_retryable_connection_failure "${RUN_SSH_OUTPUT}"; then + return "${status}" + fi + + attempt=$((attempt + 1)) + + if [[ "${delay}" != "0" && "${delay}" != "0s" ]]; then + sleep "${delay}" + fi + done +} + function detect_client_info() { CLIENT_PLATFORM="${SSH_CLIENT_OS:-$(uname -s | tr '[:upper:]' '[:lower:]')}" CLIENT_ARCH="${SSH_CLIENT_ARCH:-$(uname -m)}" @@ -70,10 +168,4 @@ if ! "${TARGET}" --version; then log_error "Failed to execute ${TARGET} --version. The binary may be corrupted." "${ERR_VERSION_CHECK_FAILED}" fi echo "=======================================" -if [[ "${INPUT_CAPTURE_STDOUT}" == 'true' ]]; then - echo 'stdout<> "${GITHUB_OUTPUT}" - "${TARGET}" "$@" | tee -a "${GITHUB_OUTPUT}" - echo 'EOF' >> "${GITHUB_OUTPUT}" -else - "${TARGET}" "$@" -fi +run_ssh_command_with_retry "$@" From 2168c3d3c7b4c8cd3a8abf2f0f3da0d300e934d7 Mon Sep 17 00:00:00 2001 From: james-tindal <10291002+james-tindal@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:27:57 +0300 Subject: [PATCH 2/2] docs: document retry feature --- README.md | 18 ++++++++++++++++++ README.zh-cn.md | 18 ++++++++++++++++++ README.zh-tw.md | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/README.md b/README.md index 6f9a36e..469cea2 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ These parameters control the commands executed on the remote host and related be | curl_insecure | Allow curl to connect to SSL sites without certificates | false | | capture_stdout | Capture standard output from commands as action output | false | | version | drone-ssh binary version. If not specified, the latest version will be used. | | +| retry_attempts | Number of retries after an SSH connection failure | 0 | +| retry_delay | Delay between retry attempts | 0s | --- @@ -309,6 +311,22 @@ This section covers common and advanced usage patterns, including multi-host, pr script_path: scripts/script.sh ``` +### Retry SSH connection failures + +Use `retry_attempts` to retry transient SSH connection failures, such as `dial tcp ...: i/o timeout`. Retries are disabled by default. Remote command failures are not retried. + +```yaml +- name: Retry transient SSH connection failures + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY }} + retry_attempts: 3 + retry_delay: 5s + script: whoami +``` + ### Multiple hosts ```diff diff --git a/README.zh-cn.md b/README.zh-cn.md index 2b2d4ee..5f3ee78 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -99,6 +99,8 @@ | curl_insecure | 允许 curl 连接无证书的 SSL 站点 | false | | capture_stdout | 捕获命令的标准输出作为 Action 输出 | false | | version | drone-ssh 二进制版本,未指定时使用最新版本 | | +| retry_attempts | SSH 连接失败后的重试次数 | 0 | +| retry_delay | 每次重试之间的延迟 | 0s | --- @@ -309,6 +311,22 @@ ssh-keygen -t ed25519 -a 200 -C "your_email@example.com" script_path: scripts/script.sh ``` +### 重试 SSH 连接失败 + +使用 `retry_attempts` 重试临时 SSH 连接失败,例如 `dial tcp ...: i/o timeout`。默认不重试。远程命令失败不会重试。 + +```yaml +- name: 重试临时 SSH 连接失败 + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY }} + retry_attempts: 3 + retry_delay: 5s + script: whoami +``` + ### 多主机 ```diff diff --git a/README.zh-tw.md b/README.zh-tw.md index f37f5ec..2b2f3df 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -99,6 +99,8 @@ | curl_insecure | 允許 curl 連線無憑證的 SSL 網站 | false | | capture_stdout | 擷取指令的標準輸出作為 Action 輸出 | false | | version | drone-ssh 執行檔版本,未指定時使用最新版本 | | +| retry_attempts | SSH 連線失敗後的重試次數 | 0 | +| retry_delay | 每次重試之間的延遲 | 0s | --- @@ -309,6 +311,22 @@ ssh-keygen -t ed25519 -a 200 -C "your_email@example.com" script_path: scripts/script.sh ``` +### 重試 SSH 連線失敗 + +使用 `retry_attempts` 重試暫時性 SSH 連線失敗,例如 `dial tcp ...: i/o timeout`。預設不重試。遠端指令失敗不會重試。 + +```yaml +- name: 重試暫時性 SSH 連線失敗 + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY }} + retry_attempts: 3 + retry_delay: 5s + script: whoami +``` + ### 多主機 ```diff