From a7ced93abdc542bbcae9385dd28c3a33595bccb9 Mon Sep 17 00:00:00 2001 From: Golam Rashed Date: Wed, 14 May 2025 21:06:42 +1000 Subject: [PATCH 1/4] Add retry logic for runner creation and document new inputs - Introduce `create_retries` and `create_retry_delay` inputs to control retry attempts and delay for runner creation failures - Update README with new inputs and retry logic explanation - Refactor action.sh for improved readability and to support retry mechanism --- README.md | 6 + action.sh | 445 +++++++++++++++++++++++------------------------------ action.yml | 12 ++ 3 files changed, 211 insertions(+), 252 deletions(-) diff --git a/README.md b/README.md index 2335cd3..d8fd38b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,12 @@ jobs: | `server_type` | | Name of the Server type this Server should be created with. | `cx22` (Intel x86, 2 vCPU, 4GB RAM, 40GB SSD) | | `server_wait` | | Wait up to `server_wait` retries (10 sec each) for the Hetzner Cloud Server to start. | `30` (5 min) | | `ssh_key` | | SSH key ID (integer) which should be injected into the Server at creation time. | `null` | +| `create_retries` | | Number of retry attempts for runner creation if it fails. | `1` | +| `create_retry_delay`| | Delay in seconds between runner creation retry attempts. | `10` | + +## Retry Logic + +If runner creation fails due to transient errors (e.g., resource unavailability or network issues), the action will automatically retry up to `create_retries` times, waiting `create_retry_delay` seconds between attempts. ## Outputs diff --git a/action.sh b/action.sh index b8cf28e..2c71e34 100644 --- a/action.sh +++ b/action.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash # Copyright 2024 Nils Knieling. All Rights Reserved. # @@ -19,36 +19,35 @@ # Function to exit the script with a failure message function exit_with_failure() { - echo >&2 "FAILURE: $1" # Print error message to stderr - exit 1 + echo >&2 "FAILURE: $1" # Print error message to stderr + exit 1 } # Define required commands MY_COMMANDS=( - base64 - curl - cut - envsubst - jq + base64 + curl + cut + envsubst + jq ) # Check if required commands are available for MY_COMMAND in "${MY_COMMANDS[@]}"; do - if ! command -v "$MY_COMMAND" >/dev/null 2>&1; then - exit_with_failure "The command '$MY_COMMAND' was not found. Please install it." - fi + if ! command -v "$MY_COMMAND" >/dev/null 2>&1; then + exit_with_failure "The command '$MY_COMMAND' was not found. Please install it." + fi done # Check if files exist MY_FILES=( - "cloud-init.template.yml" - "create-server.template.json" - "install.sh" + "cloud-init.template.yml" + "create-server.template.json" + "install.sh" ) -# Check if required commands are available for MY_FILE in "${MY_FILES[@]}"; do - if [[ ! -f "$MY_FILE" ]]; then - exit_with_failure "The file '$MY_FILE' was not found!" - fi + if [[ ! -f "$MY_FILE" ]]; then + exit_with_failure "The file '$MY_FILE' was not found!" + fi done # @@ -60,250 +59,206 @@ done # When you specify an input, GitHub creates an environment variable for the input with the name INPUT_. # Set the Hetzner Cloud API token. -# Retrieves the value from the INPUT_HCLOUD_TOKEN environment variable. MY_HETZNER_TOKEN=${INPUT_HCLOUD_TOKEN} if [[ -z "$MY_HETZNER_TOKEN" ]]; then - exit_with_failure "Hetzner Cloud API token is not set." + exit_with_failure "Hetzner Cloud API token is not set." fi # Set the GitHub Personal Access Token (PAT). -# Retrieves the value from the INPUT_GITHUB_TOKEN environment variable. MY_GITHUB_TOKEN=${INPUT_GITHUB_TOKEN} if [[ -z "$MY_GITHUB_TOKEN" ]]; then - exit_with_failure "GitHub Personal Access Token (PAT) token is required!" + exit_with_failure "GitHub Personal Access Token (PAT) token is required!" fi # Set the GitHub repository name. -# This retrieves the value from the GITHUB_ACTION_REPOSITORY environment variable, -# which is automatically set in GitHub Actions workflows. -# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables MY_GITHUB_REPOSITORY=${GITHUB_REPOSITORY} if [[ -z "$MY_GITHUB_REPOSITORY" ]]; then - exit_with_failure "GitHub repository is required!" + exit_with_failure "GitHub repository is required!" fi -# Set the repository owner's account ID (used for Hetzner Cloud Server label). MY_GITHUB_REPOSITORY_OWNER_ID=${GITHUB_REPOSITORY_OWNER_ID:-"0"} -# Set The ID of the repository (used for Hetzner Cloud Server label). MY_GITHUB_REPOSITORY_ID=${GITHUB_REPOSITORY_ID:-"0"} # Specify here which mode you want to use (default: create): -# - create : Create a new runner -# - delete : Delete the previously created runner -# If INPUT_MODE is set, use its value; otherwise, use "create". MY_MODE=${INPUT_MODE:-"create"} if [[ "$MY_MODE" != "create" && "$MY_MODE" != "delete" ]]; then - exit_with_failure "Mode must be 'create' or 'delete'." + exit_with_failure "Mode must be 'create' or 'delete'." fi # Enable IPv4 (default: false) -# If INPUT_ENABLE_IPV4 is set, use its value; otherwise, use "false". MY_ENABLE_IPV4=${INPUT_ENABLE_IPV4:-"true"} if [[ "$MY_ENABLE_IPV4" != "true" && "$MY_ENABLE_IPV4" != "false" ]]; then - exit_with_failure "Enable IPv4 must be 'true' or 'false'." + exit_with_failure "Enable IPv4 must be 'true' or 'false'." fi # Enable IPv6 (default: true) -# If INPUT_ENABLE_IPV6 is set, use its value; otherwise, use "true". MY_ENABLE_IPV6=${INPUT_ENABLE_IPV6:-"true"} if [[ "$MY_ENABLE_IPV6" != "true" && "$MY_ENABLE_IPV6" != "false" ]]; then - exit_with_failure "Enable IPv6 must be 'true' or 'false'." + exit_with_failure "Enable IPv6 must be 'true' or 'false'." fi # Set the image to use for the instance (default: ubuntu-24.04) -# If INPUT_IMAGE is set, use its value; otherwise, use "ubuntu-24.04". MY_IMAGE=${INPUT_IMAGE:-"ubuntu-24.04"} -# Check allowed characters if [[ ! "$MY_IMAGE" =~ ^[a-zA-Z0-9\._-]{1,63}$ ]]; then - exit_with_failure "'$MY_IMAGE' is not a valid OS image name!" + exit_with_failure "'$MY_IMAGE' is not a valid OS image name!" fi # Set the location/region for the instance (default: nbg1) -# If INPUT_LOCATION is set, use its value; otherwise, use "nbg1". MY_LOCATION=${INPUT_LOCATION:-"nbg1"} # Set the name of the instance (default: gh-runner-$RANDOM) -# If INPUT_NAME is set, use its value; otherwise, generate a random name using "gh-runner-$RANDOM". MY_NAME=${INPUT_NAME:-"gh-runner-$RANDOM"} -# Check allowed characters if [[ ! "$MY_NAME" =~ ^[a-zA-Z0-9_-]{1,64}$ ]]; then - exit_with_failure "'$MY_NAME' is not a valid hostname or label!" + exit_with_failure "'$MY_NAME' is not a valid hostname or label!" fi if [[ "$MY_NAME" == "hetzner" ]]; then - exit_with_failure "'hetzner' is not allowed as hostname!" + exit_with_failure "'hetzner' is not allowed as hostname!" fi # Set the network for the instance (default: null) -# If INPUT_NETWORK is set, use its value; otherwise, use "null". MY_NETWORK=${INPUT_NETWORK:-"null"} -# Check if MY_NETWORK is an integer if [[ "$MY_NETWORK" != "null" && ! "$MY_NETWORK" =~ ^[0-9]+$ ]]; then - exit_with_failure "The network ID must be 'null' or an integer!" + exit_with_failure "The network ID must be 'null' or an integer!" fi # Set bash commands to run before the runner starts. -# If INPUT_PRE_RUNNER_SCRIPT is set, use its value; otherwise, use "". MY_PRE_RUNNER_SCRIPT=${INPUT_PRE_RUNNER_SCRIPT:-""} # Set the primary IPv4 address for the instance (default: null) -# If INPUT_PRIMARY_IPV4 is set, use its value; otherwise, use "null". MY_PRIMARY_IPV4=${INPUT_PRIMARY_IPV4:-"null"} -# Check if MY_PRIMARY_IPV4 is an integer if [[ "$MY_PRIMARY_IPV4" != "null" && ! "$MY_PRIMARY_IPV4" =~ ^[0-9]+$ ]]; then - exit_with_failure "The primary IPv4 ID must be 'null' or an integer!" + exit_with_failure "The primary IPv4 ID must be 'null' or an integer!" fi # Set the primary IPv6 address for the instance (default: null) -# If INPUT_PRIMARY_IPV6 is set, use its value; otherwise, use "null". MY_PRIMARY_IPV6=${INPUT_PRIMARY_IPV6:-"null"} -# Check if MY_PRIMARY_IPV6 is an integer if [[ "$MY_PRIMARY_IPV6" != "null" && ! "$MY_PRIMARY_IPV6" =~ ^[0-9]+$ ]]; then - exit_with_failure "The primary IPv6 ID must be 'null' or an integer!" + exit_with_failure "The primary IPv6 ID must be 'null' or an integer!" fi # Set the server type/instance type (default: cx22) -# If INPUT_SERVER_TYPE is set, use its value; otherwise, use "cx22". MY_SERVER_TYPE=${INPUT_SERVER_TYPE:-"cx22"} # Set maximal wait time (retries * 10 sec) for Hetzner Cloud Server (default: 30 [5 min]) -# If INPUT_SERVER_WAIT is set, use its value; otherwise, use "30". MY_SERVER_WAIT=${INPUT_SERVER_WAIT:-"30"} -# Check if MY_RUNNER_WAIT is an integer if [[ ! "$MY_SERVER_WAIT" =~ ^[0-9]+$ ]]; then - exit_with_failure "The maximum wait time (reties) for a running Hetzner Cloud Server must be an integer!" + exit_with_failure "The maximum wait time (reties) for a running Hetzner Cloud Server must be an integer!" fi # Set the SSH key to use for the instance (default: null) -# If INPUT_SSH_KEY is set, use its value; otherwise, use "null". MY_SSH_KEY=${INPUT_SSH_KEY:-"null"} -# Check if MY_SSH_KEY is an integer if [[ "$MY_SSH_KEY" != "null" && ! "$MY_SSH_KEY" =~ ^[0-9]+$ ]]; then - exit_with_failure "The SSH key ID must be 'null' or an integer!" + exit_with_failure "The SSH key ID must be 'null' or an integer!" fi # Set default GitHub Actions Runner installation directory (default: /actions-runner) -# If INPUT_RUNNER_DIR is set, its value is used. Otherwise, the default value "/actions-runner" is used. MY_RUNNER_DIR=${INPUT_RUNNER_DIR:-"/actions-runner"} -# Check allowed characters if [[ ! "$MY_RUNNER_DIR" =~ ^/([^/]+/)*[^/]+$ ]]; then - exit_with_failure "'$MY_RUNNER_DIR' is not a valid absolute directory path without a trailing slash!" + exit_with_failure "'$MY_RUNNER_DIR' is not a valid absolute directory path without a trailing slash!" +fi + +# Set runner creation retry parameters +MY_CREATE_RETRIES=${INPUT_CREATE_RETRIES:-1} +MY_CREATE_RETRY_DELAY=${INPUT_CREATE_RETRY_DELAY:-10} +if [[ ! "$MY_CREATE_RETRIES" =~ ^[0-9]+$ ]]; then + exit_with_failure "Runner creation retries must be an integer!" +fi +if [[ ! "$MY_CREATE_RETRY_DELAY" =~ ^[0-9]+$ ]]; then + exit_with_failure "Runner creation retry delay must be an integer!" fi # Set default GitHub Actions Runner version (default: latest) -# If INPUT_RUNNER_VERSION is set, its value is used. Otherwise, the default value "latest" is used. -# Releases: https://github.com/actions/runner/releases MY_RUNNER_VERSION=${INPUT_RUNNER_VERSION:-"latest"} -# Check allowed values if [[ "$MY_RUNNER_VERSION" != "latest" && "$MY_RUNNER_VERSION" != "skip" && ! "$MY_RUNNER_VERSION" =~ ^[0-9\.]{1,63}$ ]]; then - exit_with_failure "'$MY_RUNNER_VERSION' is not a valid GitHub Actions Runner version! Enter 'latest', 'skip' or the version without 'v'." + exit_with_failure "'$MY_RUNNER_VERSION' is not a valid GitHub Actions Runner version! Enter 'latest', 'skip' or the version without 'v'." fi # Set maximal wait time (retries * 10 sec) for GitHub Actions Runner registration (default: 30 [5 min]) -# If MY_RUNNER_WAIT is set, use its value; otherwise, use "30". MY_RUNNER_WAIT=${INPUT_RUNNER_WAIT:-"60"} -# Check if MY_RUNNER_WAIT is an integer if [[ ! "$MY_RUNNER_WAIT" =~ ^[0-9]+$ ]]; then - exit_with_failure "The maximum wait time (reties) for GitHub Action Runner registration must be an integer!" + exit_with_failure "The maximum wait time (reties) for GitHub Action Runner registration must be an integer!" fi # Set Hetzner Cloud Server ID MY_HETZNER_SERVER_ID=${INPUT_SERVER_ID} - # # DELETE # if [[ "$MY_MODE" == "delete" ]]; then - # Check if MY_HETZNER_SERVER_ID is an integer - if [[ ! "$MY_HETZNER_SERVER_ID" =~ ^[0-9]+$ ]]; then - exit_with_failure "Failed to get ID of the Hetzner Cloud Server!" - fi - - # Send a DELETE request to the Hetzner Cloud API to delete the server. - # https://docs.hetzner.cloud/#servers-delete-a-server - echo "Delete server..." - curl \ - -X DELETE \ - --fail-with-body \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ - "https://api.hetzner.cloud/v1/servers/$MY_HETZNER_SERVER_ID" \ - || exit_with_failure "Error deleting server!" - echo "Hetzner Cloud Server deleted successfully." - - # List self-hosted runners for repository - # https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository - echo "List self-hosted runners..." - curl -L \ - --fail-with-body \ - -o "github-runners.json" \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners" \ - || exit_with_failure "Failed to list GitHub Actions runners from repository!" - - MY_GITHUB_RUNNER_ID=$(jq -er ".runners[] | select(.name == \"$MY_NAME\") | .id" < "github-runners.json") - # Check if MY_GITHUB_RUNNER_ID is an integer - if [[ ! "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then - exit_with_failure "Failed to get ID of the GitHub Actions Runner!" - fi - - # Delete a self-hosted runner from repository - # https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#delete-a-self-hosted-runner-from-a-repository - echo "Delete GitHub Actions Runner..." - curl -L \ - -X DELETE \ - --fail-with-body \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners/${MY_GITHUB_RUNNER_ID}" \ - || exit_with_failure "Failed to delete GitHub Actions Runner from repository! Please delete manually: https://github.com/${MY_GITHUB_REPOSITORY}/settings/actions/runners" - echo "GitHub Actions Runner deleted successfully." - echo - echo "The Hetzner Cloud Server and its associated GitHub Actions Runner have been deleted successfully." - # Add GitHub Action job summary - # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary - echo "The Hetzner Cloud Server and its associated GitHub Actions Runner have been deleted successfully 🗑️" >> "$GITHUB_STEP_SUMMARY" - exit 0 + if [[ ! "$MY_HETZNER_SERVER_ID" =~ ^[0-9]+$ ]]; then + exit_with_failure "Failed to get ID of the Hetzner Cloud Server!" + fi + + echo "Delete server..." + curl \ + -X DELETE \ + --fail-with-body \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ + "https://api.hetzner.cloud/v1/servers/$MY_HETZNER_SERVER_ID" \ + || exit_with_failure "Error deleting server!" + echo "Hetzner Cloud Server deleted successfully." + + echo "List self-hosted runners..." + curl -L \ + --fail-with-body \ + -o "github-runners.json" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners" \ + || exit_with_failure "Failed to list GitHub Actions runners from repository!" + + MY_GITHUB_RUNNER_ID=$(jq -er ".runners[] | select(.name == \"$MY_NAME\") | .id" < "github-runners.json") + if [[ ! "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then + exit_with_failure "Failed to get ID of the GitHub Actions Runner!" + fi + + echo "Delete GitHub Actions Runner..." + curl -L \ + -X DELETE \ + --fail-with-body \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners/${MY_GITHUB_RUNNER_ID}" \ + || exit_with_failure "Failed to delete GitHub Actions Runner from repository! Please delete manually: https://github.com/${MY_GITHUB_REPOSITORY}/settings/actions/runners" + echo "GitHub Actions Runner deleted successfully." + echo + echo "The Hetzner Cloud Server and its associated GitHub Actions Runner have been deleted successfully." + echo "The Hetzner Cloud Server and its associated GitHub Actions Runner have been deleted successfully 🗑️" >> "$GITHUB_STEP_SUMMARY" + exit 0 fi # # CREATE # -# Create GitHub Actions registration token for registering a self-hosted runner to a repository -# https://docs.github.com/en/rest/actions/self-hosted-runners#create-a-registration-token-for-a-repository echo "Create GitHub Actions Runner registration token..." curl -L \ - -X "POST" \ - --fail-with-body \ - -o "registration-token.json" \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners/registration-token" \ - || exit_with_failure "Failed to retrieve GitHub Actions Runner registration token!" - -# Read the GitHub Runner registration token from a file (assuming valid JSON) + -X "POST" \ + --fail-with-body \ + -o "registration-token.json" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners/registration-token" \ + || exit_with_failure "Failed to retrieve GitHub Actions Runner registration token!" + MY_GITHUB_RUNNER_REGISTRATION_TOKEN=$(jq -er '.token' < "registration-token.json") -# Encode the contents of the "install.sh" and runner script into base64 -# BSD if [[ "$OSTYPE" == "darwin"* || "$OSTYPE" == "freebsd"* ]]; then - MY_INSTALL_SH_BASE64=$(base64 < "install.sh") - MY_PRE_RUNNER_SCRIPT_BASE64=$(echo "$MY_PRE_RUNNER_SCRIPT" | base64) -# GNU Core tools + MY_INSTALL_SH_BASE64=$(base64 < "install.sh") + MY_PRE_RUNNER_SCRIPT_BASE64=$(echo "$MY_PRE_RUNNER_SCRIPT" | base64) else - MY_INSTALL_SH_BASE64=$(base64 --wrap=0 < "install.sh") - MY_PRE_RUNNER_SCRIPT_BASE64=$(echo "$MY_PRE_RUNNER_SCRIPT" | base64 --wrap=0) + MY_INSTALL_SH_BASE64=$(base64 --wrap=0 < "install.sh") + MY_PRE_RUNNER_SCRIPT_BASE64=$(echo "$MY_PRE_RUNNER_SCRIPT" | base64 --wrap=0) fi -# Split repository into owner and repository name -MY_GITHUB_OWNER="${MY_GITHUB_REPOSITORY%/*}" # Extract the part before the last / -MY_GITHUB_REPO_NAME="${MY_GITHUB_REPOSITORY##*/}" # Extract the part after the last / -# Export environment variables for use in the cloud-init template +MY_GITHUB_OWNER="${MY_GITHUB_REPOSITORY%/*}" +MY_GITHUB_REPO_NAME="${MY_GITHUB_REPOSITORY##*/}" + export MY_GITHUB_OWNER export MY_GITHUB_REPO_NAME export MY_GITHUB_REPOSITORY @@ -313,151 +268,137 @@ export MY_NAME export MY_PRE_RUNNER_SCRIPT_BASE64 export MY_RUNNER_DIR export MY_RUNNER_VERSION -# Substitute environment variables in the cloud-init template and create the final cloud-init configuration + if [[ ! -f "cloud-init.template.yml" ]]; then - exit_with_failure "cloud-init.template.yml not found!" + exit_with_failure "cloud-init.template.yml not found!" fi envsubst < cloud-init.template.yml > cloud-init.yml -# Generate the create-server.json file by populating the create-server.template.json template with variables. -# This uses jq to construct a JSON object based on the template and provided arguments. -# Optimize values for valid labels: https://docs.hetzner.cloud/#labels echo "Generate server configuration..." jq -n \ - --arg location "$MY_LOCATION" \ - --arg runner_version "$MY_RUNNER_VERSION" \ - --arg github_owner_id "$MY_GITHUB_REPOSITORY_OWNER_ID" \ - --arg github_repo_id "$MY_GITHUB_REPOSITORY_ID" \ - --arg image "$MY_IMAGE" \ - --arg server_type "$MY_SERVER_TYPE" \ - --arg name "$MY_NAME" \ - --argjson enable_ipv4 "$MY_ENABLE_IPV4" \ - --argjson enable_ipv6 "$MY_ENABLE_IPV6" \ - --rawfile cloud_init_yml "cloud-init.yml" \ - -f create-server.template.json > create-server.json \ - || exit_with_failure "Failed to generate create-server.json!" -# Add the primary IPv4 address if available (not "null") + --arg location "$MY_LOCATION" \ + --arg runner_version "$MY_RUNNER_VERSION" \ + --arg github_owner_id "$MY_GITHUB_REPOSITORY_OWNER_ID" \ + --arg github_repo_id "$MY_GITHUB_REPOSITORY_ID" \ + --arg image "$MY_IMAGE" \ + --arg server_type "$MY_SERVER_TYPE" \ + --arg name "$MY_NAME" \ + --argjson enable_ipv4 "$MY_ENABLE_IPV4" \ + --argjson enable_ipv6 "$MY_ENABLE_IPV6" \ + --rawfile cloud_init_yml "cloud-init.yml" \ + -f create-server.template.json > create-server.json \ + || exit_with_failure "Failed to generate create-server.json!" + if [[ "$MY_PRIMARY_IPV4" != "null" ]]; then - cp create-server.json create-server-ipv4.json && \ - jq ".public_net.ipv4 = $MY_PRIMARY_IPV4" < create-server-ipv4.json > create-server.json && \ - echo "Primary IPv4 ID added to create-server.json." + cp create-server.json create-server-ipv4.json && \ + jq ".public_net.ipv4 = $MY_PRIMARY_IPV4" < create-server-ipv4.json > create-server.json && \ + echo "Primary IPv4 ID added to create-server.json." fi -# Add the primary IPv6 address if available (not "null") if [[ "$MY_PRIMARY_IPV6" != "null" ]]; then - cp create-server.json create-server-ipv6.json && \ - jq ".public_net.ipv6 = $MY_PRIMARY_IPV6" < create-server-ipv6.json > create-server.json && \ - echo "Primary IPv6 ID added to create-server.json." + cp create-server.json create-server-ipv6.json && \ + jq ".public_net.ipv6 = $MY_PRIMARY_IPV6" < create-server-ipv6.json > create-server.json && \ + echo "Primary IPv6 ID added to create-server.json." fi -# Add SSH key configuration to the create-server.json file if MY_SSH_KEY is not "null". if [[ "$MY_SSH_KEY" != "null" ]]; then - cp create-server.json create-server-ssh.json && \ - jq ".ssh_keys += [$MY_SSH_KEY]" < create-server-ssh.json > create-server.json && \ - echo "SSH key added to create-server.json." + cp create-server.json create-server-ssh.json && \ + jq ".ssh_keys += [$MY_SSH_KEY]" < create-server-ssh.json > create-server.json && \ + echo "SSH key added to create-server.json." fi -# Add network configuration to the create-server.json file if MY_NETWORK is not "null". if [[ "$MY_NETWORK" != "null" ]]; then - cp create-server.json create-server-network.json && \ - jq ".networks += [$MY_NETWORK]" < create-server-network.json > create-server.json && \ - echo "Network added to create-server.json." + cp create-server.json create-server-network.json && \ + jq ".networks += [$MY_NETWORK]" < create-server-network.json > create-server.json && \ + echo "Network added to create-server.json." fi -# Send a POST request to the Hetzner Cloud API to create a server. -# https://docs.hetzner.cloud/#servers-create-a-server -echo "Create server..." -if ! curl \ - -X POST \ - --fail-with-body \ - -o "servers.json" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ - -d @create-server.json \ - "https://api.hetzner.cloud/v1/servers"; then - cat "servers.json" - exit_with_failure "Failed to create Server in Hetzner Cloud!" -fi +echo "Create server with up to $MY_CREATE_RETRIES attempt(s)..." +CREATE_ATTEMPT=1 +while [[ $CREATE_ATTEMPT -le $MY_CREATE_RETRIES ]]; do + if curl \ + -X POST \ + --fail-with-body \ + -o "servers.json" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ + -d @create-server.json \ + "https://api.hetzner.cloud/v1/servers"; then + echo "Server created successfully on attempt $CREATE_ATTEMPT." + break + else + echo "Attempt $CREATE_ATTEMPT to create server failed." + cat "servers.json" + if [[ $CREATE_ATTEMPT -lt $MY_CREATE_RETRIES ]]; then + echo "Retrying in $MY_CREATE_RETRY_DELAY seconds..." + sleep "$MY_CREATE_RETRY_DELAY" + else + exit_with_failure "Failed to create Server in Hetzner Cloud after $MY_CREATE_RETRIES attempt(s)!" + fi + fi + CREATE_ATTEMPT=$((CREATE_ATTEMPT + 1)) +done -# Get the Hetzner Server ID from the JSON response (assuming valid JSON) MY_HETZNER_SERVER_ID=$(jq -er '.server.id' < "servers.json") - -# Check if MY_HETZNER_SERVER_ID is an integer if [[ ! "$MY_HETZNER_SERVER_ID" =~ ^[0-9]+$ ]]; then - exit_with_failure "Failed to get ID of the Hetzner Cloud Server!" + exit_with_failure "Failed to get ID of the Hetzner Cloud Server!" fi -# Set GitHub Action output -# https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ -#echo "::set-output name=label::$MY_NAME" -#echo "::set-output name=server_id::$MY_HETZNER_SERVER_ID" echo "label=$MY_NAME" >> "$GITHUB_OUTPUT" echo "server_id=$MY_HETZNER_SERVER_ID" >> "$GITHUB_OUTPUT" -# Wait for server MAX_RETRIES=$MY_SERVER_WAIT WAIT_SEC=10 RETRY_COUNT=0 echo "Wait for server..." while [[ $RETRY_COUNT -lt $MAX_RETRIES ]]; do - # Download and parse server status - # https://docs.hetzner.cloud/#servers-get-a-server - curl -s \ - -o "servers.json" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ - "https://api.hetzner.cloud/v1/servers/$MY_HETZNER_SERVER_ID" \ - || exit_with_failure "Failed to get status of the Hetzner Cloud Server!" - - MY_HETZNER_SERVER_STATUS=$(jq -er '.server.status' < "servers.json") - - # Check if server is running - if [[ "$MY_HETZNER_SERVER_STATUS" == "running" ]]; then - echo "Server is running." - break - fi - - RETRY_COUNT=$((RETRY_COUNT + 1)) # Increment retry counter - - echo "Server is not running yet. Waiting $WAIT_SEC seconds... (Attempt $RETRY_COUNT/$MAX_RETRIES)" - sleep "$WAIT_SEC" + curl -s \ + -o "servers.json" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ + "https://api.hetzner.cloud/v1/servers/$MY_HETZNER_SERVER_ID" \ + || exit_with_failure "Failed to get status of the Hetzner Cloud Server!" + + MY_HETZNER_SERVER_STATUS=$(jq -er '.server.status' < "servers.json") + + if [[ "$MY_HETZNER_SERVER_STATUS" == "running" ]]; then + echo "Server is running." + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "Server is not running yet. Waiting $WAIT_SEC seconds... (Attempt $RETRY_COUNT/$MAX_RETRIES)" + sleep "$WAIT_SEC" done if [[ "$MY_HETZNER_SERVER_STATUS" != "running" ]]; then - exit_with_failure "Failed to start Hetzner Cloud Server! Please check manually." + exit_with_failure "Failed to start Hetzner Cloud Server! Please check manually." fi -# Wait for GitHub Actions Runner registration MAX_RETRIES=$MY_RUNNER_WAIT RETRY_COUNT=0 echo "Wait for GitHub Actions Runner registration..." while [[ $RETRY_COUNT -lt $MAX_RETRIES ]]; do - # List self-hosted runners for repository - # https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository - curl -L -s \ - -o "github-runners.json" \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners" \ - || exit_with_failure "Failed to list GitHub Actions runners from repository!" - - MY_GITHUB_RUNNER_ID=$(jq -er ".runners[] | select(.name == \"$MY_NAME\") | .id" < "github-runners.json") - # Check if MY_GITHUB_RUNNER_ID is an integer - if [[ "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then - echo "GitHub Actions Runner registered." - break - fi - - RETRY_COUNT=$((RETRY_COUNT + 1)) # Increment retry counter - - echo "GitHub Actions Runner is not yet registered. Wait $WAIT_SEC seconds... (Attempt $RETRY_COUNT/$MAX_RETRIES)" - sleep "$WAIT_SEC" + curl -L -s \ + -o "github-runners.json" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners" \ + || exit_with_failure "Failed to list GitHub Actions runners from repository!" + + MY_GITHUB_RUNNER_ID=$(jq -er ".runners[] | select(.name == \"$MY_NAME\") | .id" < "github-runners.json") + if [[ "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then + echo "GitHub Actions Runner registered." + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "GitHub Actions Runner is not yet registered. Wait $WAIT_SEC seconds... (Attempt $RETRY_COUNT/$MAX_RETRIES)" + sleep "$WAIT_SEC" done if [[ ! "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then - exit_with_failure "GitHub Actions Runner is not registered. Please check installation manually." + exit_with_failure "GitHub Actions Runner is not registered. Please check installation manually." fi echo -echo "The Hetzner Cloud Server and its associated GitHub Actions Runner are ready for use." +echo "The Hetzner Cloud Server and its associated GitHub Actions Runner are ready for use." echo "Runner: https://github.com/${MY_GITHUB_REPOSITORY}/settings/actions/runners/${MY_GITHUB_RUNNER_ID}" -# Add GitHub Action job summary -# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary echo "The Hetzner Cloud Server and its associated [GitHub Actions Runner](https://github.com/${MY_GITHUB_REPOSITORY}/settings/actions/runners/${MY_GITHUB_RUNNER_ID}) are ready for use 🚀" >> "$GITHUB_STEP_SUMMARY" exit 0 diff --git a/action.yml b/action.yml index fe2fdeb..e16fd2e 100644 --- a/action.yml +++ b/action.yml @@ -102,6 +102,16 @@ inputs: Specifies bash commands to run before the GitHub Actions Runner starts. It's useful for installing dependencies with apt-get, dnf, zypper etc. required: false + create_retries: + description: >- + Number of retry attempts for runner creation if it fails (default: 1). + required: false + default: '1' + create_retry_delay: + description: >- + Delay in seconds between runner creation retry attempts (default: 10). + required: false + default: '10' outputs: label: @@ -142,3 +152,5 @@ runs: INPUT_SERVER_TYPE: ${{ inputs.server_type }} INPUT_SERVER_WAIT: ${{ inputs.server_wait }} INPUT_SSH_KEY: ${{ inputs.ssh_key }} + INPUT_CREATE_RETRIES: ${{ inputs.create_retries }} + INPUT_CREATE_RETRY_DELAY: ${{ inputs.create_retry_delay }} From b28fc8e0d0f0a42e36fe1bafdd9101e30c81560b Mon Sep 17 00:00:00 2001 From: Golam Rashed Date: Wed, 14 May 2025 21:12:15 +1000 Subject: [PATCH 2/4] Update action name to remove 'Cloud' from Hetzner reference Clarify the action name by shortening it to 'Self-Hosted GitHub Actions Runner on Hetzner' for consistency and simplicity. --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index e16fd2e..2c1b8e4 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,4 @@ -name: Self-Hosted GitHub Actions Runner on Hetzner Cloud +name: Self-Hosted GitHub Actions Runner on Hetzner description: A GitHub Action to automatically create Hetzner Cloud servers and register them as self-hosted GitHub Actions runners. author: Nils Knieling From 6e28425133a1faaf1e37d8f8ff16bf16435af5dd Mon Sep 17 00:00:00 2001 From: Golam Rashed Date: Wed, 14 May 2025 21:49:54 +1000 Subject: [PATCH 3/4] Update action name to specify Hetzner Cloud for clarity Clarify that the action is for Hetzner Cloud by updating the name field in action.yml. This helps distinguish it from other Hetzner services and improves discoverability. --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 2c1b8e4..e16fd2e 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,4 @@ -name: Self-Hosted GitHub Actions Runner on Hetzner +name: Self-Hosted GitHub Actions Runner on Hetzner Cloud description: A GitHub Action to automatically create Hetzner Cloud servers and register them as self-hosted GitHub Actions runners. author: Nils Knieling From 9836091a82663dcf8c6b6fda39bc205b4c99a0ff Mon Sep 17 00:00:00 2001 From: Golam Rashed Date: Wed, 14 May 2025 22:16:08 +1000 Subject: [PATCH 4/4] Fix shebang line encoding issue in action.sh Remove invisible BOM character from the shebang line to ensure proper script execution in Unix environments. --- action.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.sh b/action.sh index 2c71e34..672fbda 100644 --- a/action.sh +++ b/action.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash # Copyright 2024 Nils Knieling. All Rights Reserved. #