name: Retry a flaky command description: >- Run a shell command, retrying on non-zero exit. For dependency installs (npm ci, uv sync) whose only failures are transient network/toolchain flakes — a node-gyp header fetch, a registry blip — so CI self-heals instead of needing a manual re-run. Can also capture stdout as a step output for commands whose result must be consumed by later steps. inputs: command: description: Shell command to run (and retry). required: true attempts: description: Max attempts before giving up. default: "3" delay: description: Seconds to wait between attempts. default: "10" working-directory: description: Directory to run in. default: "." outputs: stdout: description: Captured stdout from the successful attempt (empty if not needed). value: ${{ steps.retry.outputs.stdout }} runs: using: composite steps: - id: retry shell: bash working-directory: ${{ inputs.working-directory }} # command goes through env, never interpolated into the script body, so # a command with quotes/specials can't break or inject into the runner. env: _CMD: ${{ inputs.command }} _ATTEMPTS: ${{ inputs.attempts }} _DELAY: ${{ inputs.delay }} run: | set -uo pipefail _OUTFILE="$(mktemp)" trap 'rm -f "$_OUTFILE"' EXIT n=0 while :; do n=$((n + 1)) echo "::group::attempt $n/$_ATTEMPTS: $_CMD" # Run the command, capturing stdout to a temp file while still # streaming to the log. We redirect first, then tee the file to # stdout — this avoids pipefail + tee exit-code interactions that # can cause the if-branch to be skipped under set -e. if bash -c "$_CMD" > "$_OUTFILE"; then cat "$_OUTFILE" echo "::endgroup::" # Preserve newlines in the output via heredoc delimiter. { echo 'stdout<<__RETRY_STDOUT_EOF__' cat "$_OUTFILE" echo '__RETRY_STDOUT_EOF__' } >> "$GITHUB_OUTPUT" exit 0 fi echo "::endgroup::" if [ "$n" -ge "$_ATTEMPTS" ]; then echo "::error::failed after $n attempts: $_CMD" exit 1 fi echo "::warning::attempt $n failed; retrying in ${_DELAY}s: $_CMD" sleep "$_DELAY" done